mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 05:26:47 +00:00
ec8509f2de
Adds the Collections addon backend: a server-wide-per-user library of saved places, independent of any trip, with multiple named lists, an idea/want/visited status, and Vacay-style fusion invitations to share a list with other users. - Data: collection / collection_members / collection_places / collection_place_tags tables (+ migration and baseline schema). Saved places carry the owner plus a nullable saved_by so a member deleting their account can't drop shared content. - Service: list + place CRUD with owner-or-accepted-member visibility, dedup, status, save-from-trip and copy-to-trip (reusing the trip copy column list), and the full fusion-invitation state machine mirrored from vacay (send / accept / decline / cancel / leave) with a websocket broadcast and an invite notification. Deleting a list snapshots its members and notifies them. - NestJS module + addon guard (404 before auth), registered in the app module. - Widens the place photo cache reference check to count collection places so the nightly sweep no longer evicts photos a saved place still uses. - collection_invite notification wired across all 22 locales.
630 lines
29 KiB
TypeScript
630 lines
29 KiB
TypeScript
import { db, canAccessTrip } from '../db/database';
|
|
import { broadcastToUser } from '../websocket';
|
|
import { checkPermission } from './permissions';
|
|
import type {
|
|
Collection,
|
|
CollectionDetailResponse,
|
|
CollectionListResponse,
|
|
CollectionMember,
|
|
CollectionMembership,
|
|
CollectionPlace,
|
|
CollectionCreateRequest,
|
|
CollectionUpdateRequest,
|
|
CollectionSavePlaceRequest,
|
|
CollectionSaveResult,
|
|
CollectionCopyToTripRequest,
|
|
CollectionStatus,
|
|
} from '@trek/shared';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
function hydratePlaces(rows: PlaceRow[]): CollectionPlace[] {
|
|
const ids = rows.map(r => r.id);
|
|
const tagsByPlace = loadTagsByCollectionPlaceIds(ids);
|
|
return rows.map(r => {
|
|
const { category_name, category_color, category_icon, ...rest } = r;
|
|
return {
|
|
...rest,
|
|
category: r.category_id
|
|
? { id: r.category_id, name: category_name ?? '', color: category_color ?? null, icon: category_icon ?? null }
|
|
: undefined,
|
|
tags: tagsByPlace[r.id] || [],
|
|
} 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
|
|
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', 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 | 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, 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 }, 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, sort_order)
|
|
VALUES (?, ?, ?, ?, ?, ?)
|
|
`).run(
|
|
userId,
|
|
body.name,
|
|
body.description ?? null,
|
|
body.color ?? '#6366f1',
|
|
body.icon ?? 'Bookmark',
|
|
max + 1,
|
|
);
|
|
const col = getCollectionRow(Number(result.lastInsertRowid));
|
|
return { ...col, is_owner: true };
|
|
}
|
|
|
|
export function updateCollection(userId: number, id: number, body: CollectionUpdateRequest): Collection {
|
|
assertAccess(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.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, undefined, '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);
|
|
}
|
|
|
|
export function savePlace(userId: number, body: CollectionSavePlaceRequest): CollectionSaveResult {
|
|
assertAccess(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
|
|
) 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,
|
|
);
|
|
|
|
const placeId = Number(result.lastInsertRowid);
|
|
attachTags(placeId, body.tag_ids);
|
|
notifyCollectionUsers(body.collection_id, undefined, 'collections:updated');
|
|
return { place: getPlaceById(placeId) };
|
|
}
|
|
|
|
export function saveFromTripPlace(
|
|
userId: number, collectionId: number, tripId: number, placeId: number, force?: boolean,
|
|
): CollectionSaveResult {
|
|
assertAccess(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,
|
|
});
|
|
}
|
|
|
|
export function updatePlace(userId: number, placeId: number, body: import('@trek/shared').CollectionPlaceUpdateRequest): CollectionPlace {
|
|
const currentCollection = collectionIdOfPlace(placeId);
|
|
assertAccess(userId, currentCollection);
|
|
|
|
const updates: string[] = [];
|
|
const params: (string | number | null)[] = [];
|
|
if (body.name !== undefined) { updates.push('name = ?'); params.push(body.name); }
|
|
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); }
|
|
|
|
let movedTo: number | null = null;
|
|
if (body.collection_id !== undefined && body.collection_id !== currentCollection) {
|
|
assertAccess(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);
|
|
}
|
|
|
|
notifyCollectionUsers(currentCollection, undefined, 'collections:updated');
|
|
if (movedTo) notifyCollectionUsers(movedTo, undefined, 'collections:updated');
|
|
return getPlaceById(placeId);
|
|
}
|
|
|
|
export function setStatus(userId: number, placeId: number, status: CollectionStatus): CollectionPlace {
|
|
const collectionId = collectionIdOfPlace(placeId);
|
|
assertAccess(userId, collectionId);
|
|
db.prepare("UPDATE collection_places SET status = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?").run(status, placeId);
|
|
notifyCollectionUsers(collectionId, undefined, 'collections:updated');
|
|
return getPlaceById(placeId);
|
|
}
|
|
|
|
export function deletePlace(userId: number, placeId: number): void {
|
|
const collectionId = collectionIdOfPlace(placeId);
|
|
assertAccess(userId, collectionId);
|
|
db.prepare('DELETE FROM collection_places WHERE id = ?').run(placeId); // CASCADE drops tags. NO photo-cache reclaim.
|
|
notifyCollectionUsers(collectionId, undefined, 'collections:updated');
|
|
}
|
|
|
|
export function deletePlacesMany(userId: number, ids: number[]): number[] {
|
|
const deleted: number[] = [];
|
|
const touched = new Set<number>();
|
|
for (const id of ids) {
|
|
const collectionId = collectionIdOfPlace(id);
|
|
assertAccess(userId, collectionId);
|
|
db.prepare('DELETE FROM collection_places WHERE id = ?').run(id);
|
|
deleted.push(id);
|
|
touched.add(collectionId);
|
|
}
|
|
touched.forEach(cid => notifyCollectionUsers(cid, undefined, 'collections:updated'));
|
|
return deleted;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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');
|
|
}
|
|
|
|
// 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 (?, ?)');
|
|
|
|
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);
|
|
|
|
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); }
|
|
if (query.name && query.name.trim()) { conditions.push('lower(trim(cp.name)) = ?'); params.push(query.name.trim().toLowerCase()); }
|
|
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));
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Fusion invitations (mirror vacayService, dropping the one-fusion guards)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export function sendInvite(
|
|
collectionId: number, inviterId: number, inviterUsername: string, inviterEmail: string, targetUserId: number,
|
|
): { 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) VALUES (?, ?, 'pending')").run(collectionId, targetUserId);
|
|
|
|
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');
|
|
}
|
|
|
|
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 };
|
|
}
|