mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
399e3573c1
Trip members can rate a place 1-5 stars; the place shows the average, with a who-voted custom tooltip and a star-sort toggle in the places sidebar. Saved collection places are ratable too (plus a min-rating filter), and ratings travel with a place copied between a trip and a collection — carrying only the votes of users who belong to both sides. Adds a full Collections MCP surface (list/get, CRUD lists + places, status, rate_collection_place, labels, sharing, copy-to-trip) under a new collections:read/collections:write scope, plus rate_place for trip places; rating aggregates flow through the existing place outputs. Mobile trip + collection place sheets gain the rating row. New UI strings translated across all 22 locales.
1023 lines
51 KiB
TypeScript
1023 lines
51 KiB
TypeScript
import fs from 'fs';
|
|
import path from 'path';
|
|
import { db, canAccessTrip } from '../db/database';
|
|
import { broadcastToUser } from '../websocket';
|
|
import { checkPermission } from './permissions';
|
|
import { reclaimPlaceImage } from './placeImage';
|
|
import type {
|
|
Collection,
|
|
CollectionDetailResponse,
|
|
CollectionListResponse,
|
|
CollectionMember,
|
|
CollectionMembership,
|
|
CollectionPlace,
|
|
CollectionLink,
|
|
CollectionCreateRequest,
|
|
CollectionUpdateRequest,
|
|
CollectionSavePlaceRequest,
|
|
CollectionSaveResult,
|
|
CollectionCopyToTripRequest,
|
|
CollectionStatus,
|
|
CollectionLabel,
|
|
} from '@trek/shared';
|
|
|
|
/** Links are stored as a JSON TEXT column; parse on read, stringify on write. */
|
|
function parseLinks(raw: unknown): CollectionLink[] | undefined {
|
|
if (typeof raw !== 'string' || !raw) return undefined;
|
|
try {
|
|
const v = JSON.parse(raw);
|
|
return Array.isArray(v) ? (v as CollectionLink[]) : undefined;
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
function serializeLinks(links: CollectionLink[] | undefined): string | null {
|
|
return links && links.length ? JSON.stringify(links) : null;
|
|
}
|
|
|
|
/**
|
|
* Reclaim a replaced cover file. Path-confined to uploads/covers (mirrors
|
|
* tripService.deleteOldCover — kept local so this service doesn't pull the
|
|
* trips import graph). Collection + trip covers share the same directory.
|
|
*/
|
|
function deleteOldCollectionCover(coverImage: string | null | undefined): void {
|
|
if (!coverImage) return;
|
|
const coversDir = path.resolve(__dirname, '../../uploads/covers');
|
|
const resolved = path.resolve(path.join(coversDir, path.basename(coverImage)));
|
|
if (resolved.startsWith(coversDir + path.sep) && fs.existsSync(resolved)) fs.unlinkSync(resolved);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Errors — thrown as plain Errors carrying a status; TrekExceptionFilter maps
|
|
// `err.status` → that HTTP code with an `{ error: message }` body.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function httpError(status: number, message: string): never {
|
|
const err = new Error(message) as Error & { status: number };
|
|
err.status = status;
|
|
throw err;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Visibility — a user may see/edit a collection if they own it OR are an
|
|
// accepted member. Every read/write goes through assertAccess.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export function accessibleCollectionIds(userId: number): number[] {
|
|
const rows = db.prepare(`
|
|
SELECT id FROM collections WHERE owner_id = ?
|
|
UNION
|
|
SELECT collection_id FROM collection_members WHERE user_id = ? AND status = 'accepted'
|
|
`).all(userId, userId) as { id: number }[];
|
|
return rows.map(r => r.id);
|
|
}
|
|
|
|
function isVisible(userId: number, collectionId: number): boolean {
|
|
const row = db.prepare(`
|
|
SELECT 1 FROM collections WHERE id = ? AND owner_id = ?
|
|
UNION
|
|
SELECT 1 FROM collection_members WHERE collection_id = ? AND user_id = ? AND status = 'accepted'
|
|
LIMIT 1
|
|
`).get(collectionId, userId, collectionId, userId);
|
|
return !!row;
|
|
}
|
|
|
|
export function assertAccess(userId: number, collectionId: number): void {
|
|
if (!isVisible(userId, collectionId)) httpError(404, 'Collection not found');
|
|
}
|
|
|
|
export function isOwner(userId: number, collectionId: number): boolean {
|
|
const row = db.prepare('SELECT 1 FROM collections WHERE id = ? AND owner_id = ?').get(collectionId, userId);
|
|
return !!row;
|
|
}
|
|
|
|
export type EffectiveRole = 'owner' | 'admin' | 'editor' | 'viewer' | null;
|
|
/** The viewer's effective permission on a list: owner (full), or their accepted
|
|
* member role, or null when they have no access. */
|
|
export function roleOf(userId: number, collectionId: number): EffectiveRole {
|
|
if (isOwner(userId, collectionId)) return 'owner';
|
|
const row = db.prepare("SELECT role FROM collection_members WHERE collection_id = ? AND user_id = ? AND status = 'accepted'")
|
|
.get(collectionId, userId) as { role: string } | undefined;
|
|
if (!row) return null;
|
|
return row.role === 'admin' || row.role === 'viewer' ? row.role : 'editor';
|
|
}
|
|
/** Add/edit a place — owner, admin or editor. 404 hides lists you can't see,
|
|
* 403 for a read-only (viewer) member. */
|
|
export function assertCanEdit(userId: number, collectionId: number): void {
|
|
const r = roleOf(userId, collectionId);
|
|
if (r === null) httpError(404, 'Collection not found');
|
|
if (r === 'viewer') httpError(403, 'You have read-only access to this list');
|
|
}
|
|
/** Delete a place — owner or admin only. */
|
|
export function assertCanDelete(userId: number, collectionId: number): void {
|
|
const r = roleOf(userId, collectionId);
|
|
if (r === null) httpError(404, 'Collection not found');
|
|
if (r !== 'owner' && r !== 'admin') httpError(403, 'Only an admin can delete places from this list');
|
|
}
|
|
|
|
function ownerOf(collectionId: number): number {
|
|
const row = db.prepare('SELECT owner_id FROM collections WHERE id = ?').get(collectionId) as { owner_id: number } | undefined;
|
|
if (!row) httpError(404, 'Collection not found');
|
|
return row.owner_id;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Hydration helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
interface PlaceRow extends CollectionPlace {
|
|
category_name?: string | null;
|
|
category_color?: string | null;
|
|
category_icon?: string | null;
|
|
}
|
|
|
|
function loadTagsByCollectionPlaceIds(placeIds: number[]): Record<number, { id: number; name: string; color: string }[]> {
|
|
const out: Record<number, { id: number; name: string; color: string }[]> = {};
|
|
if (placeIds.length === 0) return out;
|
|
const placeholders = placeIds.map(() => '?').join(',');
|
|
const rows = db.prepare(`
|
|
SELECT cpt.collection_place_id AS pid, t.id, t.name, t.color
|
|
FROM collection_place_tags cpt
|
|
JOIN tags t ON t.id = cpt.tag_id
|
|
WHERE cpt.collection_place_id IN (${placeholders})
|
|
`).all(...placeIds) as { pid: number; id: number; name: string; color: string }[];
|
|
for (const r of rows) {
|
|
if (!out[r.pid]) out[r.pid] = [];
|
|
out[r.pid].push({ id: r.id, name: r.name, color: r.color });
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/** A list's own label definitions, in display order. */
|
|
function loadLabelsByCollection(collectionId: number): CollectionLabel[] {
|
|
return db.prepare(
|
|
'SELECT id, collection_id, name, color, sort_order FROM collection_labels WHERE collection_id = ? ORDER BY sort_order, id',
|
|
).all(collectionId) as CollectionLabel[];
|
|
}
|
|
|
|
/** Assigned label ids per place, batched (mirrors loadTagsByCollectionPlaceIds). */
|
|
function loadLabelIdsByPlaceIds(placeIds: number[]): Record<number, number[]> {
|
|
const out: Record<number, number[]> = {};
|
|
if (placeIds.length === 0) return out;
|
|
const placeholders = placeIds.map(() => '?').join(',');
|
|
const rows = db.prepare(
|
|
`SELECT collection_place_id AS pid, label_id FROM collection_place_labels WHERE collection_place_id IN (${placeholders})`,
|
|
).all(...placeIds) as { pid: number; label_id: number }[];
|
|
for (const r of rows) {
|
|
if (!out[r.pid]) out[r.pid] = [];
|
|
out[r.pid].push(r.label_id);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/** Per-voter rating rows (#1435), batched (mirrors loadTagsByCollectionPlaceIds). */
|
|
function loadRatingsByCollectionPlaceIds(placeIds: number[]): Record<number, { user_id: number; username: string; avatar: string | null; rating: number }[]> {
|
|
const out: Record<number, { user_id: number; username: string; avatar: string | null; rating: number }[]> = {};
|
|
if (placeIds.length === 0) return out;
|
|
const rows = db.prepare(`
|
|
SELECT cpr.collection_place_id AS pid, cpr.user_id, u.username, u.avatar, cpr.rating
|
|
FROM collection_place_ratings cpr
|
|
JOIN users u ON u.id = cpr.user_id
|
|
WHERE cpr.collection_place_id IN (${placeIds.map(() => '?').join(',')})
|
|
ORDER BY cpr.created_at
|
|
`).all(...placeIds) as { pid: number; user_id: number; username: string; avatar: string | null; rating: number }[];
|
|
for (const { pid, ...rest } of rows) {
|
|
if (!out[pid]) out[pid] = [];
|
|
out[pid].push(rest);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function hydratePlaces(rows: PlaceRow[]): CollectionPlace[] {
|
|
const ids = rows.map(r => r.id);
|
|
const tagsByPlace = loadTagsByCollectionPlaceIds(ids);
|
|
const labelsByPlace = loadLabelIdsByPlaceIds(ids);
|
|
const ratingsByPlace = loadRatingsByCollectionPlaceIds(ids);
|
|
return rows.map(r => {
|
|
const { category_name, category_color, category_icon, ...rest } = r;
|
|
const ratings = ratingsByPlace[r.id] || [];
|
|
return {
|
|
...rest,
|
|
links: parseLinks((r as { links?: unknown }).links),
|
|
category: r.category_id
|
|
? { id: r.category_id, name: category_name ?? '', color: category_color ?? null, icon: category_icon ?? null }
|
|
: undefined,
|
|
tags: tagsByPlace[r.id] || [],
|
|
label_ids: labelsByPlace[r.id] || [],
|
|
ratings,
|
|
rating_avg: ratings.length > 0 ? ratings.reduce((s, x) => s + x.rating, 0) / ratings.length : null,
|
|
rating_count: ratings.length,
|
|
} as CollectionPlace;
|
|
});
|
|
}
|
|
|
|
function getPlaceById(placeId: number): CollectionPlace {
|
|
const row = db.prepare(`
|
|
SELECT cp.*, c.name AS category_name, c.color AS category_color, c.icon AS category_icon
|
|
FROM collection_places cp
|
|
LEFT JOIN categories c ON cp.category_id = c.id
|
|
WHERE cp.id = ?
|
|
`).get(placeId) as PlaceRow | undefined;
|
|
if (!row) httpError(404, 'Place not found');
|
|
return hydratePlaces([row])[0];
|
|
}
|
|
|
|
function collectionIdOfPlace(placeId: number): number {
|
|
const row = db.prepare('SELECT collection_id FROM collection_places WHERE id = ?').get(placeId) as { collection_id: number } | undefined;
|
|
if (!row) httpError(404, 'Place not found');
|
|
return row.collection_id;
|
|
}
|
|
|
|
function buildMembers(collectionId: number): CollectionMember[] {
|
|
const owner = db.prepare(`
|
|
SELECT u.id AS user_id, u.username, u.email, u.avatar
|
|
FROM collections col JOIN users u ON u.id = col.owner_id
|
|
WHERE col.id = ?
|
|
`).get(collectionId) as Omit<CollectionMember, 'status' | 'is_owner'> | undefined;
|
|
const members = db.prepare(`
|
|
SELECT u.id AS user_id, u.username, u.email, u.avatar, cm.status, cm.role
|
|
FROM collection_members cm JOIN users u ON u.id = cm.user_id
|
|
WHERE cm.collection_id = ?
|
|
ORDER BY u.username
|
|
`).all(collectionId) as Omit<CollectionMember, 'is_owner'>[];
|
|
const result: CollectionMember[] = [];
|
|
if (owner) result.push({ ...owner, status: 'accepted', role: 'admin', is_owner: true });
|
|
for (const m of members) result.push({ ...m, is_owner: false });
|
|
return result;
|
|
}
|
|
|
|
function getCollectionRow(id: number): Collection {
|
|
const col = db.prepare('SELECT * FROM collections WHERE id = ?').get(id) as (Collection & { links?: unknown }) | undefined;
|
|
if (!col) httpError(404, 'Collection not found');
|
|
const placeCount = (db.prepare('SELECT COUNT(*) AS n FROM collection_places WHERE collection_id = ?').get(id) as { n: number }).n;
|
|
return { ...col, links: parseLinks(col.links), place_count: placeCount, members: buildMembers(id) };
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Lists CRUD
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export function listCollections(userId: number): CollectionListResponse {
|
|
const ids = accessibleCollectionIds(userId);
|
|
const collections: Collection[] = ids
|
|
.map(id => {
|
|
const col = getCollectionRow(id);
|
|
return { ...col, is_owner: col.owner_id === userId };
|
|
})
|
|
.sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0) || a.id - b.id);
|
|
|
|
const incomingInvites = (db.prepare(`
|
|
SELECT cm.collection_id, c.name, u.id AS from_id, u.username AS from_username
|
|
FROM collection_members cm
|
|
JOIN collections c ON c.id = cm.collection_id
|
|
JOIN users u ON u.id = c.owner_id
|
|
WHERE cm.user_id = ? AND cm.status = 'pending'
|
|
`).all(userId) as { collection_id: number; name: string; from_id: number; from_username: string }[])
|
|
.map(r => ({ collection_id: r.collection_id, name: r.name, from: { id: r.from_id, username: r.from_username } }));
|
|
|
|
return { collections, incomingInvites };
|
|
}
|
|
|
|
export function getCollection(userId: number, id: number): CollectionDetailResponse {
|
|
assertAccess(userId, id);
|
|
const collection = getCollectionRow(id);
|
|
const rows = db.prepare(`
|
|
SELECT cp.*, c.name AS category_name, c.color AS category_color, c.icon AS category_icon
|
|
FROM collection_places cp
|
|
LEFT JOIN categories c ON cp.category_id = c.id
|
|
WHERE cp.collection_id = ?
|
|
ORDER BY cp.sort_order, cp.created_at
|
|
`).all(id) as PlaceRow[];
|
|
return {
|
|
collection: { ...collection, is_owner: collection.owner_id === userId, labels: loadLabelsByCollection(id) },
|
|
places: hydratePlaces(rows),
|
|
};
|
|
}
|
|
|
|
export function createCollection(userId: number, body: CollectionCreateRequest): Collection {
|
|
const max = (db.prepare('SELECT COALESCE(MAX(sort_order), -1) AS m FROM collections WHERE owner_id = ?').get(userId) as { m: number }).m;
|
|
const result = db.prepare(`
|
|
INSERT INTO collections (owner_id, name, description, color, icon, cover_image, links, sort_order)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
`).run(
|
|
userId,
|
|
body.name,
|
|
body.description ?? null,
|
|
body.color ?? '#6366f1',
|
|
body.icon ?? 'Bookmark',
|
|
body.cover_image ?? null,
|
|
serializeLinks(body.links),
|
|
max + 1,
|
|
);
|
|
const col = getCollectionRow(Number(result.lastInsertRowid));
|
|
return { ...col, is_owner: true };
|
|
}
|
|
|
|
export function updateCollection(userId: number, id: number, body: CollectionUpdateRequest, socketId?: string): Collection {
|
|
assertCanEdit(userId, id);
|
|
const updates: string[] = [];
|
|
const params: (string | number | null)[] = [];
|
|
if (body.name !== undefined) { updates.push('name = ?'); params.push(body.name); }
|
|
if (body.description !== undefined) { updates.push('description = ?'); params.push(body.description ?? null); }
|
|
if (body.color !== undefined) { updates.push('color = ?'); params.push(body.color ?? null); }
|
|
if (body.icon !== undefined) { updates.push('icon = ?'); params.push(body.icon ?? null); }
|
|
if (body.cover_image !== undefined) { updates.push('cover_image = ?'); params.push(body.cover_image ?? null); }
|
|
if (body.links !== undefined) { updates.push('links = ?'); params.push(serializeLinks(body.links)); }
|
|
if (body.sort_order !== undefined) { updates.push('sort_order = ?'); params.push(body.sort_order); }
|
|
if (updates.length > 0) {
|
|
updates.push("updated_at = CURRENT_TIMESTAMP");
|
|
params.push(id);
|
|
db.prepare(`UPDATE collections SET ${updates.join(', ')} WHERE id = ?`).run(...params);
|
|
}
|
|
notifyCollectionUsers(id, socketId, 'collections:updated');
|
|
const col = getCollectionRow(id);
|
|
return { ...col, is_owner: col.owner_id === userId };
|
|
}
|
|
|
|
/** Set (or clear) a list's cover image, reclaiming the previous file. */
|
|
export function setCollectionCover(userId: number, id: number, coverUrl: string | null, socketId?: string): Collection {
|
|
assertCanEdit(userId, id);
|
|
const prev = (db.prepare('SELECT cover_image FROM collections WHERE id = ?').get(id) as { cover_image: string | null } | undefined)?.cover_image ?? null;
|
|
db.prepare('UPDATE collections SET cover_image = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(coverUrl, id);
|
|
if (prev && prev !== coverUrl) deleteOldCollectionCover(prev);
|
|
notifyCollectionUsers(id, socketId, 'collections:updated');
|
|
const col = getCollectionRow(id);
|
|
return { ...col, is_owner: col.owner_id === userId };
|
|
}
|
|
|
|
export function deleteCollection(userId: number, id: number): void {
|
|
assertAccess(userId, id);
|
|
if (!isOwner(userId, id)) httpError(403, 'Only the owner can delete this list');
|
|
|
|
// Snapshot recipients BEFORE the cascade wipes collection_members.
|
|
const accepted = (db.prepare("SELECT user_id FROM collection_members WHERE collection_id = ? AND status = 'accepted'").all(id) as { user_id: number }[]).map(r => r.user_id);
|
|
const pending = (db.prepare("SELECT user_id FROM collection_members WHERE collection_id = ? AND status = 'pending'").all(id) as { user_id: number }[]).map(r => r.user_id);
|
|
|
|
db.prepare('DELETE FROM collections WHERE id = ?').run(id); // CASCADE drops members + places + tags
|
|
|
|
[...new Set([...accepted, ...pending])]
|
|
.filter(uid => uid !== userId)
|
|
.forEach(uid => broadcastToUser(uid, { type: 'collections:deleted', collectionId: id }));
|
|
}
|
|
|
|
export function reorderCollections(userId: number, orderedIds: number[]): void {
|
|
const visible = new Set(accessibleCollectionIds(userId));
|
|
const stmt = db.prepare('UPDATE collections SET sort_order = ? WHERE id = ?');
|
|
orderedIds.forEach((cid, index) => {
|
|
if (visible.has(cid)) stmt.run(index, cid);
|
|
});
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Dedup (collection-scoped ports of placeService helpers)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const COORD_DEDUP_TOLERANCE = 0.0001; // ≈ 11 m
|
|
|
|
interface DedupSet {
|
|
names: Set<string>;
|
|
coords: Array<{ lat: number; lng: number }>;
|
|
}
|
|
|
|
function buildDedupSet(collectionId: number): DedupSet {
|
|
const rows = db.prepare('SELECT name, lat, lng FROM collection_places WHERE collection_id = ?').all(collectionId) as Array<{
|
|
name: string | null; lat: number | null; lng: number | null;
|
|
}>;
|
|
const names = new Set<string>();
|
|
const coords: Array<{ lat: number; lng: number }> = [];
|
|
for (const row of rows) {
|
|
if (row.name) names.add(row.name.trim().toLowerCase());
|
|
else if (row.lat != null && row.lng != null) coords.push({ lat: row.lat, lng: row.lng });
|
|
}
|
|
return { names, coords };
|
|
}
|
|
|
|
function isCollectionPlaceDuplicate(
|
|
candidate: { name: string | null | undefined; lat: number | null | undefined; lng: number | null | undefined },
|
|
dedup: DedupSet,
|
|
): boolean {
|
|
const normalizedName = candidate.name?.trim().toLowerCase();
|
|
if (normalizedName) return dedup.names.has(normalizedName);
|
|
if (candidate.lat != null && candidate.lng != null) {
|
|
return dedup.coords.some(c =>
|
|
Math.abs(c.lat - candidate.lat!) <= COORD_DEDUP_TOLERANCE &&
|
|
Math.abs(c.lng - candidate.lng!) <= COORD_DEDUP_TOLERANCE,
|
|
);
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function findDuplicateCollectionPlace(
|
|
collectionId: number,
|
|
candidate: { name: string | null | undefined; lat: number | null | undefined; lng: number | null | undefined },
|
|
): { id: number; name: string } | null {
|
|
const normalizedName = candidate.name?.trim().toLowerCase();
|
|
if (normalizedName) {
|
|
const dup = db.prepare(`
|
|
SELECT id, name FROM collection_places
|
|
WHERE collection_id = ? AND lower(trim(name)) = ?
|
|
ORDER BY id ASC LIMIT 1
|
|
`).get(collectionId, normalizedName) as { id: number; name: string } | undefined;
|
|
if (dup) return dup;
|
|
}
|
|
if (candidate.lat != null && candidate.lng != null) {
|
|
return (db.prepare(`
|
|
SELECT id, name FROM collection_places
|
|
WHERE collection_id = ? AND lat IS NOT NULL AND lng IS NOT NULL
|
|
AND abs(lat - ?) <= ? AND abs(lng - ?) <= ?
|
|
ORDER BY id ASC LIMIT 1
|
|
`).get(collectionId, candidate.lat, COORD_DEDUP_TOLERANCE, candidate.lng, COORD_DEDUP_TOLERANCE) as { id: number; name: string } | undefined) || null;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Saved places CRUD
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function attachTags(collectionPlaceId: number, tagIds: number[] | undefined): void {
|
|
if (!tagIds || tagIds.length === 0) return;
|
|
const stmt = db.prepare('INSERT OR IGNORE INTO collection_place_tags (collection_place_id, tag_id) VALUES (?, ?)');
|
|
for (const tid of tagIds) stmt.run(collectionPlaceId, tid);
|
|
}
|
|
|
|
/** Owner + accepted members — the users whose votes may live in this list. */
|
|
function collectionMemberIds(collectionId: number): Set<number> {
|
|
const ids = new Set<number>([ownerOf(collectionId)]);
|
|
const rows = db.prepare("SELECT user_id FROM collection_members WHERE collection_id = ? AND status = 'accepted'").all(collectionId) as { user_id: number }[];
|
|
rows.forEach(r => ids.add(r.user_id));
|
|
return ids;
|
|
}
|
|
|
|
/**
|
|
* Carry star votes (#1435) from a trip place into a freshly saved collection
|
|
* place. Only votes by members of the target collection come along (the saver
|
|
* is always a member) — other trip members' opinions stay in the trip.
|
|
*/
|
|
function copyTripRatings(sourcePlaceId: number, collectionPlaceId: number, collectionId: number): void {
|
|
const eligible = collectionMemberIds(collectionId);
|
|
const rows = db.prepare('SELECT user_id, rating FROM place_ratings WHERE place_id = ?').all(sourcePlaceId) as { user_id: number; rating: number }[];
|
|
const ins = db.prepare('INSERT OR IGNORE INTO collection_place_ratings (collection_place_id, user_id, rating) VALUES (?, ?, ?)');
|
|
for (const r of rows) {
|
|
if (eligible.has(r.user_id)) ins.run(collectionPlaceId, r.user_id, r.rating);
|
|
}
|
|
}
|
|
|
|
export function savePlace(userId: number, body: CollectionSavePlaceRequest, socketId?: string): CollectionSaveResult {
|
|
assertCanEdit(userId, body.collection_id);
|
|
|
|
if (!body.force) {
|
|
const dup = findDuplicateCollectionPlace(body.collection_id, { name: body.name, lat: body.lat, lng: body.lng });
|
|
if (dup) return { duplicate: true, duplicateOf: dup };
|
|
}
|
|
|
|
const ownerId = ownerOf(body.collection_id);
|
|
const result = db.prepare(`
|
|
INSERT INTO collection_places (
|
|
collection_id, owner_id, saved_by, name, description, lat, lng, address,
|
|
category_id, price, currency, notes, image_url, google_place_id, google_ftid,
|
|
osm_id, website, phone, status, source_trip_id, source_place_id, links
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
`).run(
|
|
body.collection_id, ownerId, userId,
|
|
body.name, body.description ?? null, body.lat ?? null, body.lng ?? null, body.address ?? null,
|
|
body.category_id ?? null, body.price ?? null, body.currency ?? null, body.notes ?? null,
|
|
body.image_url ?? null, body.google_place_id ?? null, body.google_ftid ?? null,
|
|
body.osm_id ?? null, body.website ?? null, body.phone ?? null,
|
|
body.status ?? 'idea', body.source_trip_id ?? null, body.source_place_id ?? null,
|
|
serializeLinks(body.links),
|
|
);
|
|
|
|
const placeId = Number(result.lastInsertRowid);
|
|
attachTags(placeId, body.tag_ids);
|
|
// Carry trip ratings ONLY when the caller can actually see the source place.
|
|
// source_place_id/source_trip_id are raw client input, so verify trip access +
|
|
// that the place lives in that trip before reading place_ratings — otherwise a
|
|
// member could harvest co-members' votes on places in trips they cannot access
|
|
// (mirrors the canAccessTrip gate in saveFromTripPlace).
|
|
if (
|
|
body.source_place_id && body.source_trip_id &&
|
|
canAccessTrip(body.source_trip_id, userId) &&
|
|
db.prepare('SELECT 1 FROM places WHERE id = ? AND trip_id = ?').get(body.source_place_id, body.source_trip_id)
|
|
) {
|
|
copyTripRatings(body.source_place_id, placeId, body.collection_id);
|
|
}
|
|
notifyCollectionUsers(body.collection_id, socketId, 'collections:updated');
|
|
return { place: getPlaceById(placeId) };
|
|
}
|
|
|
|
export function saveFromTripPlace(
|
|
userId: number, collectionId: number, tripId: number, placeId: number, force?: boolean,
|
|
): CollectionSaveResult {
|
|
assertCanEdit(userId, collectionId);
|
|
if (!canAccessTrip(tripId, userId)) httpError(404, 'Trip not found');
|
|
|
|
const place = db.prepare('SELECT * FROM places WHERE id = ? AND trip_id = ?').get(placeId, tripId) as Record<string, unknown> | undefined;
|
|
if (!place) httpError(404, 'Place not found');
|
|
|
|
return savePlace(userId, {
|
|
collection_id: collectionId,
|
|
name: place.name as string,
|
|
description: (place.description as string | null) ?? null,
|
|
lat: (place.lat as number | null) ?? null,
|
|
lng: (place.lng as number | null) ?? null,
|
|
address: (place.address as string | null) ?? null,
|
|
category_id: (place.category_id as number | null) ?? null,
|
|
price: (place.price as number | null) ?? null,
|
|
currency: (place.currency as string | null) ?? null,
|
|
notes: (place.notes as string | null) ?? null,
|
|
image_url: (place.image_url as string | null) ?? null,
|
|
google_place_id: (place.google_place_id as string | null) ?? null,
|
|
google_ftid: (place.google_ftid as string | null) ?? null,
|
|
osm_id: (place.osm_id as string | null) ?? null,
|
|
website: (place.website as string | null) ?? null,
|
|
phone: (place.phone as string | null) ?? null,
|
|
source_trip_id: tripId,
|
|
source_place_id: placeId,
|
|
force,
|
|
});
|
|
}
|
|
|
|
/** Bulk copy of several trip places into a list in one shot — one access check,
|
|
* one WS notify (vs saving each place individually). Mirrors saveFromTripPlace's
|
|
* field mapping + dedup; skips duplicates unless force. Status starts at 'idea'. */
|
|
export function saveFromTripPlaces(
|
|
userId: number, collectionId: number, tripId: number, placeIds: number[], force?: boolean,
|
|
): { copied: number; skipped: { id: number; name: string }[] } {
|
|
assertCanEdit(userId, collectionId);
|
|
if (!canAccessTrip(tripId, userId)) httpError(404, 'Trip not found');
|
|
|
|
const ownerId = ownerOf(collectionId);
|
|
const insert = db.prepare(`
|
|
INSERT INTO collection_places (
|
|
collection_id, owner_id, saved_by, name, description, lat, lng, address,
|
|
category_id, price, currency, notes, image_url, google_place_id, google_ftid,
|
|
osm_id, website, phone, status, source_trip_id, source_place_id, links
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'idea', ?, ?, NULL)
|
|
`);
|
|
let copied = 0;
|
|
const skipped: { id: number; name: string }[] = [];
|
|
for (const placeId of placeIds) {
|
|
const p = db.prepare('SELECT * FROM places WHERE id = ? AND trip_id = ?').get(placeId, tripId) as Record<string, unknown> | undefined;
|
|
if (!p) continue;
|
|
const name = p.name as string;
|
|
const lat = (p.lat as number | null) ?? null;
|
|
const lng = (p.lng as number | null) ?? null;
|
|
if (!force && findDuplicateCollectionPlace(collectionId, { name, lat, lng })) {
|
|
skipped.push({ id: placeId, name });
|
|
continue;
|
|
}
|
|
const res = insert.run(
|
|
collectionId, ownerId, userId,
|
|
name, (p.description as string | null) ?? null, lat, lng, (p.address as string | null) ?? null,
|
|
(p.category_id as number | null) ?? null, (p.price as number | null) ?? null, (p.currency as string | null) ?? null, (p.notes as string | null) ?? null,
|
|
(p.image_url as string | null) ?? null, (p.google_place_id as string | null) ?? null, (p.google_ftid as string | null) ?? null,
|
|
(p.osm_id as string | null) ?? null, (p.website as string | null) ?? null, (p.phone as string | null) ?? null,
|
|
tripId, placeId,
|
|
);
|
|
copyTripRatings(placeId, Number(res.lastInsertRowid), collectionId);
|
|
copied++;
|
|
}
|
|
if (copied > 0) notifyCollectionUsers(collectionId, undefined, 'collections:updated');
|
|
return { copied, skipped };
|
|
}
|
|
|
|
export function updatePlace(userId: number, placeId: number, body: import('@trek/shared').CollectionPlaceUpdateRequest, socketId?: string): CollectionPlace {
|
|
const currentCollection = collectionIdOfPlace(placeId);
|
|
assertCanEdit(userId, currentCollection);
|
|
|
|
// Capture the previous thumbnail so a replaced/cleared custom upload (#1136)
|
|
// can be reclaimed once nothing references it any more.
|
|
const prevImage = body.image_url !== undefined
|
|
? (db.prepare('SELECT image_url FROM collection_places WHERE id = ?').get(placeId) as { image_url: string | null } | undefined)?.image_url ?? null
|
|
: null;
|
|
|
|
const updates: string[] = [];
|
|
const params: (string | number | null)[] = [];
|
|
if (body.name !== undefined) { updates.push('name = ?'); params.push(body.name); }
|
|
if (body.description !== undefined) { updates.push('description = ?'); params.push(body.description ?? null); }
|
|
if (body.notes !== undefined) { updates.push('notes = ?'); params.push(body.notes ?? null); }
|
|
if (body.status !== undefined) { updates.push('status = ?'); params.push(body.status); }
|
|
if (body.category_id !== undefined) { updates.push('category_id = ?'); params.push(body.category_id ?? null); }
|
|
if (body.image_url !== undefined) { updates.push('image_url = ?'); params.push(body.image_url ?? null); }
|
|
if (body.links !== undefined) { updates.push('links = ?'); params.push(serializeLinks(body.links)); }
|
|
|
|
let movedTo: number | null = null;
|
|
if (body.collection_id !== undefined && body.collection_id !== currentCollection) {
|
|
assertCanEdit(userId, body.collection_id);
|
|
updates.push('collection_id = ?'); params.push(body.collection_id);
|
|
updates.push('owner_id = ?'); params.push(ownerOf(body.collection_id));
|
|
movedTo = body.collection_id;
|
|
}
|
|
|
|
if (updates.length > 0) {
|
|
updates.push("updated_at = CURRENT_TIMESTAMP");
|
|
params.push(placeId);
|
|
db.prepare(`UPDATE collection_places SET ${updates.join(', ')} WHERE id = ?`).run(...params);
|
|
}
|
|
|
|
if (body.tag_ids !== undefined) {
|
|
db.prepare('DELETE FROM collection_place_tags WHERE collection_place_id = ?').run(placeId);
|
|
attachTags(placeId, body.tag_ids);
|
|
}
|
|
|
|
// Labels are collection-scoped: a move invalidates the source list's labels;
|
|
// a provided label_ids set replaces them against the (target) collection.
|
|
if (movedTo) db.prepare('DELETE FROM collection_place_labels WHERE collection_place_id = ?').run(placeId);
|
|
if (body.label_ids !== undefined) setPlaceLabels(placeId, movedTo ?? currentCollection, body.label_ids);
|
|
|
|
if (body.image_url !== undefined && prevImage !== (body.image_url ?? null)) {
|
|
reclaimPlaceImage(prevImage);
|
|
}
|
|
|
|
notifyCollectionUsers(currentCollection, socketId, 'collections:updated');
|
|
if (movedTo) notifyCollectionUsers(movedTo, socketId, 'collections:updated');
|
|
return getPlaceById(placeId);
|
|
}
|
|
|
|
export function setStatus(userId: number, placeId: number, status: CollectionStatus, socketId?: string): CollectionPlace {
|
|
const collectionId = collectionIdOfPlace(placeId);
|
|
assertCanEdit(userId, collectionId);
|
|
db.prepare("UPDATE collection_places SET status = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?").run(status, placeId);
|
|
notifyCollectionUsers(collectionId, socketId, 'collections:updated');
|
|
return getPlaceById(placeId);
|
|
}
|
|
|
|
/**
|
|
* Set (1-5) or clear (null) the user's own star vote on a saved place (#1435).
|
|
* Gated on assertAccess, not assertCanEdit — a vote is the member's personal
|
|
* opinion, so read-only viewers get to cast one too.
|
|
*/
|
|
export function setRating(userId: number, placeId: number, rating: number | null, socketId?: string): CollectionPlace {
|
|
const collectionId = collectionIdOfPlace(placeId);
|
|
assertAccess(userId, collectionId);
|
|
if (rating === null) {
|
|
db.prepare('DELETE FROM collection_place_ratings WHERE collection_place_id = ? AND user_id = ?').run(placeId, userId);
|
|
} else {
|
|
db.prepare(`
|
|
INSERT INTO collection_place_ratings (collection_place_id, user_id, rating) VALUES (?, ?, ?)
|
|
ON CONFLICT(collection_place_id, user_id) DO UPDATE SET rating = excluded.rating
|
|
`).run(placeId, userId, rating);
|
|
}
|
|
notifyCollectionUsers(collectionId, socketId, 'collections:updated');
|
|
return getPlaceById(placeId);
|
|
}
|
|
|
|
export function deletePlace(userId: number, placeId: number, socketId?: string): void {
|
|
const collectionId = collectionIdOfPlace(placeId);
|
|
assertCanDelete(userId, collectionId);
|
|
const image = (db.prepare('SELECT image_url FROM collection_places WHERE id = ?').get(placeId) as { image_url: string | null } | undefined)?.image_url ?? null;
|
|
db.prepare('DELETE FROM collection_places WHERE id = ?').run(placeId); // CASCADE drops tags. NO photo-cache reclaim.
|
|
reclaimPlaceImage(image);
|
|
notifyCollectionUsers(collectionId, socketId, 'collections:updated');
|
|
}
|
|
|
|
export function deletePlacesMany(userId: number, ids: number[], socketId?: string): number[] {
|
|
const deleted: number[] = [];
|
|
const touched = new Set<number>();
|
|
const images: (string | null)[] = [];
|
|
for (const id of ids) {
|
|
const collectionId = collectionIdOfPlace(id);
|
|
assertCanDelete(userId, collectionId);
|
|
images.push((db.prepare('SELECT image_url FROM collection_places WHERE id = ?').get(id) as { image_url: string | null } | undefined)?.image_url ?? null);
|
|
db.prepare('DELETE FROM collection_places WHERE id = ?').run(id);
|
|
deleted.push(id);
|
|
touched.add(collectionId);
|
|
}
|
|
for (const image of images) reclaimPlaceImage(image);
|
|
touched.forEach(cid => notifyCollectionUsers(cid, socketId, 'collections:updated'));
|
|
return deleted;
|
|
}
|
|
|
|
/** Set (or clear) a saved place's custom thumbnail, reclaiming the previous upload. */
|
|
export function setPlaceImage(userId: number, placeId: number, imageUrl: string | null, socketId?: string): CollectionPlace {
|
|
const collectionId = collectionIdOfPlace(placeId);
|
|
assertCanEdit(userId, collectionId);
|
|
const prev = (db.prepare('SELECT image_url FROM collection_places WHERE id = ?').get(placeId) as { image_url: string | null } | undefined)?.image_url ?? null;
|
|
db.prepare('UPDATE collection_places SET image_url = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(imageUrl, placeId);
|
|
if (prev !== imageUrl) reclaimPlaceImage(prev);
|
|
notifyCollectionUsers(collectionId, socketId, 'collections:updated');
|
|
return getPlaceById(placeId);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Copy to trip
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export function copyToTrip(userId: number, body: CollectionCopyToTripRequest): { copied: number; skipped: { id: number; name: string }[] } {
|
|
const trip = canAccessTrip(body.trip_id, userId) as { id: number; user_id: number } | undefined;
|
|
if (!trip) httpError(404, 'Trip not found');
|
|
const role = (db.prepare('SELECT role FROM users WHERE id = ?').get(userId) as { role: string } | undefined)?.role ?? 'user';
|
|
if (!checkPermission('place_edit', role, trip.user_id, userId, trip.user_id !== userId)) {
|
|
httpError(403, 'Not allowed to edit this trip');
|
|
}
|
|
|
|
// Votes only travel for users who are members of THIS trip (owner + members),
|
|
// so copying never surfaces a collection member with no tie to the trip.
|
|
// Symmetric with copyTripRatings' collection-member filter (#1435).
|
|
const tripMemberIds = new Set<number>([trip.user_id]);
|
|
for (const r of db.prepare('SELECT user_id FROM trip_members WHERE trip_id = ?').all(body.trip_id) as { user_id: number }[]) {
|
|
tripMemberIds.add(r.user_id);
|
|
}
|
|
|
|
// Visibility on every SOURCE place — no cross-user exfiltration via copy.
|
|
const sources: Array<{ id: number; name: string; description: string | null; lat: number | null; lng: number | null;
|
|
address: string | null; category_id: number | null; price: number | null; currency: string | null;
|
|
notes: string | null; image_url: string | null; google_place_id: string | null; google_ftid: string | null;
|
|
osm_id: string | null; website: string | null; phone: string | null; collection_id: number }> = [];
|
|
for (const pid of body.place_ids) {
|
|
const row = db.prepare(`
|
|
SELECT id, collection_id, name, description, lat, lng, address, category_id, price, currency,
|
|
notes, image_url, google_place_id, google_ftid, osm_id, website, phone
|
|
FROM collection_places WHERE id = ?
|
|
`).get(pid) as (typeof sources)[number] | undefined;
|
|
if (!row) httpError(404, 'Place not found');
|
|
assertAccess(userId, row.collection_id);
|
|
sources.push(row);
|
|
}
|
|
|
|
// Trip dedup set (mirrors placeService.buildDedupSet).
|
|
const existing = db.prepare('SELECT name, lat, lng FROM places WHERE trip_id = ?').all(body.trip_id) as Array<{ name: string | null; lat: number | null; lng: number | null }>;
|
|
const dedup: DedupSet = { names: new Set(), coords: [] };
|
|
for (const r of existing) {
|
|
if (r.name) dedup.names.add(r.name.trim().toLowerCase());
|
|
else if (r.lat != null && r.lng != null) dedup.coords.push({ lat: r.lat, lng: r.lng });
|
|
}
|
|
|
|
const insertPlace = db.prepare(`
|
|
INSERT INTO places (trip_id, name, description, lat, lng, address, category_id, price,
|
|
currency, notes, image_url, google_place_id, google_ftid, website, phone, osm_id)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
`);
|
|
const insertTag = db.prepare('INSERT OR IGNORE INTO place_tags (place_id, tag_id) VALUES (?, ?)');
|
|
const insertRating = db.prepare('INSERT OR IGNORE INTO place_ratings (place_id, user_id, rating) VALUES (?, ?, ?)');
|
|
|
|
let copied = 0;
|
|
const skipped: { id: number; name: string }[] = [];
|
|
for (const s of sources) {
|
|
if (!body.force && isCollectionPlaceDuplicate({ name: s.name, lat: s.lat, lng: s.lng }, dedup)) {
|
|
skipped.push({ id: s.id, name: s.name });
|
|
continue;
|
|
}
|
|
const res = insertPlace.run(
|
|
body.trip_id, s.name, s.description, s.lat, s.lng, s.address, s.category_id, s.price,
|
|
s.currency, s.notes, s.image_url, s.google_place_id, s.google_ftid, s.website, s.phone, s.osm_id,
|
|
);
|
|
const newPlaceId = Number(res.lastInsertRowid);
|
|
const tagIds = db.prepare('SELECT tag_id FROM collection_place_tags WHERE collection_place_id = ?').all(s.id) as { tag_id: number }[];
|
|
for (const t of tagIds) insertTag.run(newPlaceId, t.tag_id);
|
|
// Ratings travel into the trip too (#1435), but only for trip members — a
|
|
// collection voter who isn't on the trip stays out of it. Trip members keep
|
|
// voting there; nothing is mirrored back.
|
|
const votes = db.prepare('SELECT user_id, rating FROM collection_place_ratings WHERE collection_place_id = ?').all(s.id) as { user_id: number; rating: number }[];
|
|
for (const v of votes) if (tripMemberIds.has(v.user_id)) insertRating.run(newPlaceId, v.user_id, v.rating);
|
|
|
|
if (s.name) dedup.names.add(s.name.trim().toLowerCase());
|
|
else if (s.lat != null && s.lng != null) dedup.coords.push({ lat: s.lat, lng: s.lng });
|
|
copied++;
|
|
}
|
|
return { copied, skipped };
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Library-wide membership lookup (inspector indicator)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export function findMembership(
|
|
userId: number,
|
|
query: { google_place_id?: string; google_ftid?: string; name?: string; lat?: number; lng?: number },
|
|
): CollectionMembership {
|
|
const ids = accessibleCollectionIds(userId);
|
|
if (ids.length === 0) return { saved: false, lists: [] };
|
|
const placeholders = ids.map(() => '?').join(',');
|
|
|
|
const conditions: string[] = [];
|
|
const params: (string | number)[] = [...ids];
|
|
if (query.google_place_id) { conditions.push('cp.google_place_id = ?'); params.push(query.google_place_id); }
|
|
if (query.google_ftid) { conditions.push('cp.google_ftid = ?'); params.push(query.google_ftid); }
|
|
// Coordinate proximity is the location signal. A bare NAME match is deliberately
|
|
// NOT a condition on its own — "Starbucks" (or any repeated name) would otherwise
|
|
// false-positive the inspector's "already saved" bookmark. When coords are given
|
|
// the name still effectively matches via the same-location row below; without an
|
|
// id or coords there is nothing strong enough to claim it's the same place.
|
|
if (query.lat != null && query.lng != null) {
|
|
conditions.push('(cp.lat IS NOT NULL AND cp.lng IS NOT NULL AND abs(cp.lat - ?) <= ? AND abs(cp.lng - ?) <= ?)');
|
|
params.push(query.lat, COORD_DEDUP_TOLERANCE, query.lng, COORD_DEDUP_TOLERANCE);
|
|
}
|
|
if (conditions.length === 0) return { saved: false, lists: [] };
|
|
|
|
const rows = db.prepare(`
|
|
SELECT cp.id AS place_id, cp.collection_id, c.name
|
|
FROM collection_places cp
|
|
JOIN collections c ON c.id = cp.collection_id
|
|
WHERE cp.collection_id IN (${placeholders}) AND (${conditions.join(' OR ')})
|
|
`).all(...params) as { place_id: number; collection_id: number; name: string }[];
|
|
|
|
return { saved: rows.length > 0, lists: rows.map(r => ({ collection_id: r.collection_id, name: r.name, place_id: r.place_id })) };
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// WebSocket notify
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export function notifyCollectionUsers(collectionId: number, excludeSid: string | undefined, event = 'collections:updated'): void {
|
|
const owner = db.prepare('SELECT owner_id FROM collections WHERE id = ?').get(collectionId) as { owner_id: number } | undefined;
|
|
if (!owner) return;
|
|
const userIds = [owner.owner_id];
|
|
const members = db.prepare("SELECT user_id FROM collection_members WHERE collection_id = ? AND status = 'accepted'").all(collectionId) as { user_id: number }[];
|
|
members.forEach(m => userIds.push(m.user_id));
|
|
userIds.forEach(id => broadcastToUser(id, { type: event, collectionId }, excludeSid));
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Labels — per-collection custom labels. Managing + assigning both require edit
|
|
// rights (owner/admin/editor); filtering is a read available to every member.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const MAX_LABELS_PER_COLLECTION = 50;
|
|
|
|
function collectionIdOfLabel(labelId: number): number {
|
|
const row = db.prepare('SELECT collection_id FROM collection_labels WHERE id = ?').get(labelId) as { collection_id: number } | undefined;
|
|
if (!row) httpError(404, 'Label not found');
|
|
return row.collection_id;
|
|
}
|
|
|
|
function getLabelById(labelId: number): CollectionLabel {
|
|
return db.prepare('SELECT id, collection_id, name, color, sort_order FROM collection_labels WHERE id = ?').get(labelId) as CollectionLabel;
|
|
}
|
|
|
|
/** Replace a place's label assignments, keeping only labels of `collectionId`. */
|
|
function setPlaceLabels(placeId: number, collectionId: number, labelIds: number[]): void {
|
|
db.prepare('DELETE FROM collection_place_labels WHERE collection_place_id = ?').run(placeId);
|
|
if (labelIds.length === 0) return;
|
|
const valid = new Set(loadLabelsByCollection(collectionId).map(l => l.id));
|
|
const stmt = db.prepare('INSERT OR IGNORE INTO collection_place_labels (collection_place_id, label_id) VALUES (?, ?)');
|
|
for (const id of labelIds) if (valid.has(id)) stmt.run(placeId, id);
|
|
}
|
|
|
|
export function createLabel(userId: number, collectionId: number, name: string, color?: string, socketId?: string): CollectionLabel {
|
|
assertCanEdit(userId, collectionId);
|
|
const trimmed = name.trim();
|
|
if (!trimmed) httpError(400, 'Label name is required');
|
|
const count = (db.prepare('SELECT COUNT(*) AS n FROM collection_labels WHERE collection_id = ?').get(collectionId) as { n: number }).n;
|
|
if (count >= MAX_LABELS_PER_COLLECTION) httpError(400, `A list can have at most ${MAX_LABELS_PER_COLLECTION} labels`);
|
|
if (db.prepare('SELECT 1 FROM collection_labels WHERE collection_id = ? AND lower(name) = lower(?)').get(collectionId, trimmed)) {
|
|
httpError(409, 'A label with this name already exists');
|
|
}
|
|
const nextSort = (db.prepare('SELECT COALESCE(MAX(sort_order), -1) AS m FROM collection_labels WHERE collection_id = ?').get(collectionId) as { m: number }).m + 1;
|
|
const res = db.prepare('INSERT INTO collection_labels (collection_id, name, color, sort_order) VALUES (?, ?, ?, ?)')
|
|
.run(collectionId, trimmed, color ?? '#6366f1', nextSort);
|
|
notifyCollectionUsers(collectionId, socketId, 'collections:updated');
|
|
return getLabelById(Number(res.lastInsertRowid));
|
|
}
|
|
|
|
export function updateLabel(userId: number, labelId: number, body: { name?: string; color?: string; sort_order?: number }, socketId?: string): CollectionLabel {
|
|
const collectionId = collectionIdOfLabel(labelId);
|
|
assertCanEdit(userId, collectionId);
|
|
const updates: string[] = [];
|
|
const params: (string | number)[] = [];
|
|
if (body.name !== undefined) {
|
|
const trimmed = body.name.trim();
|
|
if (!trimmed) httpError(400, 'Label name is required');
|
|
if (db.prepare('SELECT 1 FROM collection_labels WHERE collection_id = ? AND lower(name) = lower(?) AND id != ?').get(collectionId, trimmed, labelId)) {
|
|
httpError(409, 'A label with this name already exists');
|
|
}
|
|
updates.push('name = ?'); params.push(trimmed);
|
|
}
|
|
if (body.color !== undefined) { updates.push('color = ?'); params.push(body.color); }
|
|
if (body.sort_order !== undefined) { updates.push('sort_order = ?'); params.push(body.sort_order); }
|
|
if (updates.length > 0) {
|
|
params.push(labelId);
|
|
db.prepare(`UPDATE collection_labels SET ${updates.join(', ')} WHERE id = ?`).run(...params);
|
|
}
|
|
notifyCollectionUsers(collectionId, socketId, 'collections:updated');
|
|
return getLabelById(labelId);
|
|
}
|
|
|
|
export function deleteLabel(userId: number, labelId: number, socketId?: string): void {
|
|
const collectionId = collectionIdOfLabel(labelId);
|
|
assertCanEdit(userId, collectionId);
|
|
db.prepare('DELETE FROM collection_labels WHERE id = ?').run(labelId); // CASCADE clears place assignments
|
|
notifyCollectionUsers(collectionId, socketId, 'collections:updated');
|
|
}
|
|
|
|
/** Bulk add (or remove) one or more labels across a selection of places.
|
|
* Places are grouped by list so each list is permission-checked once, and only
|
|
* labels that belong to that list are applied. */
|
|
export function assignLabels(userId: number, labelIds: number[], placeIds: number[], remove: boolean, socketId?: string): { changed: number } {
|
|
const byCollection = new Map<number, number[]>();
|
|
for (const pid of placeIds) {
|
|
const cid = collectionIdOfPlace(pid);
|
|
if (!byCollection.has(cid)) byCollection.set(cid, []);
|
|
byCollection.get(cid)!.push(pid);
|
|
}
|
|
let changed = 0;
|
|
for (const [cid, pids] of byCollection) {
|
|
assertCanEdit(userId, cid);
|
|
const valid = new Set(loadLabelsByCollection(cid).map(l => l.id));
|
|
const applicable = labelIds.filter(id => valid.has(id));
|
|
if (applicable.length === 0) continue;
|
|
if (remove) {
|
|
const del = db.prepare('DELETE FROM collection_place_labels WHERE collection_place_id = ? AND label_id = ?');
|
|
for (const pid of pids) for (const lid of applicable) changed += del.run(pid, lid).changes;
|
|
} else {
|
|
const ins = db.prepare('INSERT OR IGNORE INTO collection_place_labels (collection_place_id, label_id) VALUES (?, ?)');
|
|
for (const pid of pids) for (const lid of applicable) changed += ins.run(pid, lid).changes;
|
|
}
|
|
notifyCollectionUsers(cid, socketId, 'collections:updated');
|
|
}
|
|
return { changed };
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Fusion invitations (mirror vacayService, dropping the one-fusion guards)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export function sendInvite(
|
|
collectionId: number, inviterId: number, inviterUsername: string, inviterEmail: string, targetUserId: number,
|
|
role: 'viewer' | 'editor' | 'admin' = 'editor',
|
|
): { error?: string; status?: number } {
|
|
if (!isOwner(inviterId, collectionId)) return { error: 'Not allowed', status: 403 };
|
|
if (targetUserId === inviterId) return { error: 'Cannot invite yourself', status: 400 };
|
|
|
|
const targetUser = db.prepare('SELECT id, username FROM users WHERE id = ?').get(targetUserId);
|
|
if (!targetUser) return { error: 'User not found', status: 404 };
|
|
|
|
const existing = db.prepare('SELECT id, status FROM collection_members WHERE collection_id = ? AND user_id = ?').get(collectionId, targetUserId) as { id: number; status: string } | undefined;
|
|
if (existing) {
|
|
if (existing.status === 'accepted') return { error: 'Already a member', status: 400 };
|
|
if (existing.status === 'pending') return { error: 'Invite already pending', status: 400 };
|
|
}
|
|
|
|
db.prepare("INSERT INTO collection_members (collection_id, user_id, status, role) VALUES (?, ?, 'pending', ?)").run(collectionId, targetUserId, role);
|
|
|
|
broadcastToUser(targetUserId, { type: 'collections:invite', from: { id: inviterId, username: inviterUsername }, collectionId });
|
|
|
|
import('../services/notificationService').then(({ send }) => {
|
|
send({ event: 'collection_invite', actorId: inviterId, scope: 'user', targetId: targetUserId, params: { actor: inviterEmail, collectionId: String(collectionId) } }).catch(() => {});
|
|
}).catch(() => {});
|
|
|
|
return {};
|
|
}
|
|
|
|
export function acceptInvite(userId: number, collectionId: number, socketId: string | undefined): { error?: string; status?: number } {
|
|
const invite = db.prepare("SELECT id FROM collection_members WHERE collection_id = ? AND user_id = ? AND status = 'pending'").get(collectionId, userId) as { id: number } | undefined;
|
|
if (!invite) return { error: 'No pending invite', status: 404 };
|
|
db.prepare("UPDATE collection_members SET status = 'accepted' WHERE id = ?").run(invite.id);
|
|
notifyCollectionUsers(collectionId, socketId, 'collections:accepted');
|
|
return {};
|
|
}
|
|
|
|
export function declineInvite(userId: number, collectionId: number, socketId: string | undefined): void {
|
|
db.prepare("DELETE FROM collection_members WHERE collection_id = ? AND user_id = ? AND status = 'pending'").run(collectionId, userId);
|
|
notifyCollectionUsers(collectionId, socketId, 'collections:declined');
|
|
}
|
|
|
|
export function cancelInvite(collectionId: number, ownerId: number, targetUserId: number): void {
|
|
if (!isOwner(ownerId, collectionId)) httpError(403, 'Not allowed');
|
|
db.prepare("DELETE FROM collection_members WHERE collection_id = ? AND user_id = ? AND status = 'pending'").run(collectionId, targetUserId);
|
|
broadcastToUser(targetUserId, { type: 'collections:cancelled', collectionId });
|
|
}
|
|
|
|
export function leaveCollection(userId: number, collectionId: number, socketId: string | undefined): void {
|
|
if (isOwner(userId, collectionId)) httpError(400, 'Owner cannot leave; delete the list');
|
|
db.prepare("DELETE FROM collection_members WHERE collection_id = ? AND user_id = ? AND status = 'accepted'").run(collectionId, userId);
|
|
notifyCollectionUsers(collectionId, socketId, 'collections:left');
|
|
}
|
|
|
|
/** Owner removes an already-accepted member (a "kick"). */
|
|
export function removeMember(ownerId: number, collectionId: number, targetUserId: number): void {
|
|
if (!isOwner(ownerId, collectionId)) httpError(403, 'Not allowed');
|
|
if (targetUserId === ownerId) httpError(400, 'Owner cannot be removed');
|
|
const res = db.prepare("DELETE FROM collection_members WHERE collection_id = ? AND user_id = ? AND status = 'accepted'").run(collectionId, targetUserId);
|
|
if (res.changes === 0) httpError(404, 'Member not found');
|
|
notifyCollectionUsers(collectionId, undefined, 'collections:left'); // refresh remaining members
|
|
broadcastToUser(targetUserId, { type: 'collections:removed', collectionId }); // bounce the removed user
|
|
}
|
|
|
|
/** Owner changes an accepted member's permission role (viewer/editor/admin). */
|
|
export function setMemberRole(ownerId: number, collectionId: number, targetUserId: number, role: 'viewer' | 'editor' | 'admin'): void {
|
|
if (!isOwner(ownerId, collectionId)) httpError(403, 'Not allowed');
|
|
const res = db.prepare("UPDATE collection_members SET role = ? WHERE collection_id = ? AND user_id = ? AND status = 'accepted'").run(role, collectionId, targetUserId);
|
|
if (res.changes === 0) httpError(404, 'Member not found');
|
|
notifyCollectionUsers(collectionId, undefined, 'collections:updated'); // re-gate the member live
|
|
broadcastToUser(targetUserId, { type: 'collections:updated', collectionId });
|
|
}
|
|
|
|
export function availableUsers(ownerId: number, collectionId: number): { id: number; username: string }[] {
|
|
return db.prepare(`
|
|
SELECT u.id, u.username FROM users u
|
|
WHERE u.id != ?
|
|
AND u.id NOT IN (SELECT user_id FROM collection_members WHERE collection_id = ?)
|
|
AND u.is_guest = 0
|
|
ORDER BY u.username
|
|
`).all(ownerId, collectionId) as { id: number; username: string }[];
|
|
}
|
|
|
|
export function findMembershipForUser(userId: number, collectionId: number): { is_member: boolean; is_owner: boolean; status: string | null } {
|
|
if (isOwner(userId, collectionId)) return { is_member: true, is_owner: true, status: 'accepted' };
|
|
const row = db.prepare('SELECT status FROM collection_members WHERE collection_id = ? AND user_id = ?').get(collectionId, userId) as { status: string } | undefined;
|
|
return { is_member: row?.status === 'accepted', is_owner: false, status: row?.status ?? null };
|
|
}
|