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 { const out: Record = {}; 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 { const out: Record = {}; 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 { const out: Record = {}; 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 | 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[]; 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; 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(); 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 { const ids = new Set([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 | 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 | 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(); 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([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(); 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 }; }