From cab49ea51124867eceb6fbfb6da904f4434586b5 Mon Sep 17 00:00:00 2001 From: jubnl Date: Sat, 1 Aug 2026 13:54:19 +0200 Subject: [PATCH] chore: move collections backend to proper DI --- server/src/mcp/tools.ts | 4 +- server/src/mcp/tools/collections.ts | 398 ------ server/src/nest/README.md | 16 +- .../src/nest/collections/collections.mcp.ts | 404 +++++++ .../nest/collections/collections.module.ts | 10 +- .../nest/collections/collections.service.ts | 1074 ++++++++++++++++- .../nest/permissions/permissions.bridge.ts | 2 +- server/src/nest/plugins/DI-MIGRATION.md | 13 +- .../plugins/host/plugin-host-deps.factory.ts | 19 +- server/src/nest/plugins/plugins.module.ts | 3 +- server/src/services/collectionsService.ts | 1024 ---------------- server/tests/e2e/collections.e2e.test.ts | 9 +- server/tests/helpers/mcp-test-controllers.ts | 3 + server/tests/helpers/plugin-host.ts | 2 + .../tests/unit/mcp/tools-collections.test.ts | 531 ++++++++ .../collections.service.test.ts} | 58 +- .../plugins/plugin-host-deps.factory.test.ts | 32 +- 17 files changed, 2091 insertions(+), 1511 deletions(-) delete mode 100644 server/src/mcp/tools/collections.ts create mode 100644 server/src/nest/collections/collections.mcp.ts delete mode 100644 server/src/services/collectionsService.ts create mode 100644 server/tests/unit/mcp/tools-collections.test.ts rename server/tests/unit/{services/collectionsService.test.ts => nest/collections.service.test.ts} (91%) diff --git a/server/src/mcp/tools.ts b/server/src/mcp/tools.ts index 4e52abbd8..d7b91d81a 100644 --- a/server/src/mcp/tools.ts +++ b/server/src/mcp/tools.ts @@ -3,7 +3,6 @@ import { registerJourneyTools } from './tools/journey'; import { registerMapsWeatherTools } from './tools/mapsWeather'; import { registerNotificationTools } from './tools/notifications'; import { registerAtlasTools } from './tools/atlas'; -import { registerCollectionTools } from './tools/collections'; import { registerTransportTools } from './tools/transports'; import { registerMcpPrompts } from './tools/prompts'; import { getMcpRegistry } from './registry-handoff'; @@ -17,7 +16,8 @@ export function registerTools(server: McpServer, userId: number, scopes: string[ // The place tools moved to the DI-discovered src/nest/places/places.mcp.ts // (@McpController, attached via the nest-mcp registry below). - registerCollectionTools(server, userId, scopes); + // The collection tools moved to the DI-discovered src/nest/collections/ + // collections.mcp.ts (@McpController, attached via the nest-mcp registry below). // The budget tools moved to the DI-discovered src/nest/budget/budget.mcp.ts // (@McpController, attached via the nest-mcp registry below). diff --git a/server/src/mcp/tools/collections.ts b/server/src/mcp/tools/collections.ts deleted file mode 100644 index f837c5c36..000000000 --- a/server/src/mcp/tools/collections.ts +++ /dev/null @@ -1,398 +0,0 @@ -import { McpServer } from '@modelcontextprotocol/sdk/server/mcp'; -import { z } from 'zod'; -import { db } from '../../db/database'; -import { isDemoUser } from '../../services/authService'; -import * as collections from '../../services/collectionsService'; -import { - TOOL_ANNOTATIONS_READONLY, TOOL_ANNOTATIONS_WRITE, TOOL_ANNOTATIONS_DELETE, - TOOL_ANNOTATIONS_NON_IDEMPOTENT, demoDenied, ok, -} from './_shared'; -import { canRead, canWrite } from '../scopes'; -import { - collectionCreateRequestSchema, collectionUpdateRequestSchema, - collectionSavePlaceRequestSchema, collectionPlaceUpdateRequestSchema, - collectionCopyToTripRequestSchema, collectionLabelCreateRequestSchema, - collectionLabelUpdateRequestSchema, collectionLabelAssignRequestSchema, - collectionInviteRequestSchema, COLLECTION_STATUSES, COLLECTION_ROLES, -} from '@trek/shared'; - -/** Convert a thrown service error (httpError carries `.message`) into MCP error text. */ -function errText(err: unknown): string { - if (err instanceof Error) return err.message; - if (typeof err === 'string') return err; - return 'Operation failed'; -} -function fail(err: unknown) { - return { content: [{ type: 'text' as const, text: errText(err) }], isError: true }; -} - -/** - * Collections MCP surface (#1435) — the full set of actions a member can take on - * a saved-places list: browse, CRUD lists + places, status, collaborative star - * ratings, per-list labels, sharing, and copy-into-a-trip. Every service call - * enforces the caller's membership/role, so the tools stay thin wrappers. Place - * outputs carry the `ratings`/`rating_avg`/`rating_count` aggregate. - */ -export function registerCollectionTools(server: McpServer, userId: number, scopes: string[] | null): void { - const R = canRead(scopes, 'collections'); - const W = canWrite(scopes, 'collections'); - - const denyDemo = () => (isDemoUser(userId) ? demoDenied() : null); - - // ── Read ────────────────────────────────────────────────────────────── - - if (R) server.registerTool( - 'list_collections', - { - description: 'List all saved-place collections (lists) the user owns or has accepted a share for, plus any pending incoming invites. Use get_collection for a list\'s places.', - inputSchema: {}, - annotations: TOOL_ANNOTATIONS_READONLY, - }, - async () => { - try { return ok(collections.listCollections(userId)); } catch (err) { return fail(err); } - } - ); - - if (R) server.registerTool( - 'get_collection', - { - description: 'Get one collection with its members, labels, and all saved places. Each place includes rating_avg / rating_count and the per-member ratings (#1435) so you can plan around highly-rated spots.', - inputSchema: { collectionId: z.number().int().positive() }, - annotations: TOOL_ANNOTATIONS_READONLY, - }, - async ({ collectionId }) => { - try { return ok(collections.getCollection(userId, collectionId)); } catch (err) { return fail(err); } - } - ); - - if (R) server.registerTool( - 'available_collection_users', - { - description: 'List users who can still be invited to a collection (excludes current members and guests). Use the returned ids with invite_to_collection.', - inputSchema: { collectionId: z.number().int().positive() }, - annotations: TOOL_ANNOTATIONS_READONLY, - }, - async ({ collectionId }) => { - try { - // Owner-only, mirroring the REST gate — availableUsers() itself does no - // access check, so without this any token could enumerate the user list. - collections.assertAccess(userId, collectionId); - if (!collections.isOwner(userId, collectionId)) { - return { content: [{ type: 'text' as const, text: 'Only the collection owner can view invitable users.' }], isError: true }; - } - return ok({ users: collections.availableUsers(userId, collectionId) }); - } catch (err) { return fail(err); } - } - ); - - // ── Collections CRUD ───────────────────────────────────────────────── - - if (W) server.registerTool( - 'create_collection', - { - description: 'Create a new saved-place collection (list) owned by the user.', - inputSchema: collectionCreateRequestSchema.shape, - annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT, - }, - async (body) => { - const demo = denyDemo(); if (demo) return demo; - try { return ok({ collection: collections.createCollection(userId, body) }); } catch (err) { return fail(err); } - } - ); - - if (W) server.registerTool( - 'update_collection', - { - description: 'Update a collection\'s name, description, colour, icon, cover, links, or sort order. Owner/admin only.', - inputSchema: { collectionId: z.number().int().positive(), ...collectionUpdateRequestSchema.shape }, - annotations: TOOL_ANNOTATIONS_WRITE, - }, - async ({ collectionId, ...body }) => { - const demo = denyDemo(); if (demo) return demo; - try { return ok({ collection: collections.updateCollection(userId, collectionId, body) }); } catch (err) { return fail(err); } - } - ); - - if (W) server.registerTool( - 'delete_collection', - { - description: 'Permanently delete a collection and all its saved places. Owner only. This cannot be undone.', - inputSchema: { collectionId: z.number().int().positive() }, - annotations: TOOL_ANNOTATIONS_DELETE, - }, - async ({ collectionId }) => { - const demo = denyDemo(); if (demo) return demo; - try { collections.deleteCollection(userId, collectionId); return ok({ success: true }); } catch (err) { return fail(err); } - } - ); - - if (W) server.registerTool( - 'reorder_collections', - { - description: 'Reorder the user\'s collections. Pass every collection id in the desired order.', - inputSchema: { orderedIds: z.array(z.number().int().positive()).min(1) }, - annotations: TOOL_ANNOTATIONS_WRITE, - }, - async ({ orderedIds }) => { - const demo = denyDemo(); if (demo) return demo; - try { collections.reorderCollections(userId, orderedIds); return ok({ success: true }); } catch (err) { return fail(err); } - } - ); - - // ── Places ──────────────────────────────────────────────────────────── - - if (W) server.registerTool( - 'save_place_to_collection', - { - description: 'Save a place into a collection from a raw payload (name required; set google_place_id/osm_id from search_place for rich details). Returns a duplicate marker instead of saving when a similar place already exists, unless force is true.', - inputSchema: collectionSavePlaceRequestSchema.shape, - annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT, - }, - async (body) => { - const demo = denyDemo(); if (demo) return demo; - try { return ok(collections.savePlace(userId, body)); } catch (err) { return fail(err); } - } - ); - - if (W) server.registerTool( - 'save_trip_places_to_collection', - { - description: 'Copy one or more existing trip places into a collection (the server reads each place). Their star ratings (#1435) travel along for members shared on both. Duplicates are skipped unless force is true.', - inputSchema: { - collectionId: z.number().int().positive(), - tripId: z.number().int().positive(), - placeIds: z.array(z.number().int().positive()).min(1).max(1000), - force: z.boolean().optional(), - }, - annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT, - }, - async ({ collectionId, tripId, placeIds, force }) => { - const demo = denyDemo(); if (demo) return demo; - try { return ok(collections.saveFromTripPlaces(userId, collectionId, tripId, placeIds, force)); } catch (err) { return fail(err); } - } - ); - - if (W) server.registerTool( - 'update_collection_place', - { - description: 'Update a saved place\'s name, description, notes, status, category, links, tags, labels, image, or move it to another collection (set collection_id).', - inputSchema: { placeId: z.number().int().positive(), ...collectionPlaceUpdateRequestSchema.shape }, - annotations: TOOL_ANNOTATIONS_WRITE, - }, - async ({ placeId, ...body }) => { - const demo = denyDemo(); if (demo) return demo; - try { return ok({ place: collections.updatePlace(userId, placeId, body) }); } catch (err) { return fail(err); } - } - ); - - if (W) server.registerTool( - 'set_collection_place_status', - { - description: 'Set a saved place\'s status: idea, want, or visited.', - inputSchema: { placeId: z.number().int().positive(), status: z.enum(COLLECTION_STATUSES) }, - annotations: TOOL_ANNOTATIONS_WRITE, - }, - async ({ placeId, status }) => { - const demo = denyDemo(); if (demo) return demo; - try { return ok({ place: collections.setStatus(userId, placeId, status) }); } catch (err) { return fail(err); } - } - ); - - if (W) server.registerTool( - 'rate_collection_place', - { - description: "Set or clear the current user's 1-5 star rating on a saved collection place (#1435). Every member rates independently; the place shows the average. Pass null (or omit rating) to remove the user's vote. Ratings a member casts here follow the place into any trip it is later copied to. Use the ratings to capture the user's preferences.", - inputSchema: { - placeId: z.number().int().positive(), - rating: z.number().int().min(1).max(5).nullable().optional().describe('1-5 stars; null/omitted clears the vote'), - }, - annotations: TOOL_ANNOTATIONS_WRITE, - }, - async ({ placeId, rating }) => { - const demo = denyDemo(); if (demo) return demo; - try { return ok({ place: collections.setRating(userId, placeId, rating ?? null) }); } catch (err) { return fail(err); } - } - ); - - if (W) server.registerTool( - 'delete_collection_place', - { - description: 'Remove a saved place from its collection. Requires delete permission on the list.', - inputSchema: { placeId: z.number().int().positive() }, - annotations: TOOL_ANNOTATIONS_DELETE, - }, - async ({ placeId }) => { - const demo = denyDemo(); if (demo) return demo; - try { collections.deletePlace(userId, placeId); return ok({ success: true }); } catch (err) { return fail(err); } - } - ); - - if (W) server.registerTool( - 'copy_collection_places_to_trip', - { - description: 'Copy one or more saved collection places into a trip (dedup precheck on the server). Ratings (#1435) travel into the trip; trip members keep voting there. Requires edit access to the target trip.', - inputSchema: collectionCopyToTripRequestSchema.shape, - annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT, - }, - async (body) => { - const demo = denyDemo(); if (demo) return demo; - try { return ok(collections.copyToTrip(userId, body)); } catch (err) { return fail(err); } - } - ); - - // ── Labels ──────────────────────────────────────────────────────────── - - if (W) server.registerTool( - 'create_collection_label', - { - description: 'Create a custom per-collection label (name + optional hex colour) for grouping/filtering places.', - inputSchema: collectionLabelCreateRequestSchema.shape, - annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT, - }, - async ({ collection_id, name, color }) => { - const demo = denyDemo(); if (demo) return demo; - try { return ok({ label: collections.createLabel(userId, collection_id, name, color) }); } catch (err) { return fail(err); } - } - ); - - if (W) server.registerTool( - 'update_collection_label', - { - description: 'Rename or recolour a collection label, or change its sort order.', - inputSchema: { labelId: z.number().int().positive(), ...collectionLabelUpdateRequestSchema.shape }, - annotations: TOOL_ANNOTATIONS_WRITE, - }, - async ({ labelId, ...body }) => { - const demo = denyDemo(); if (demo) return demo; - try { return ok({ label: collections.updateLabel(userId, labelId, body) }); } catch (err) { return fail(err); } - } - ); - - if (W) server.registerTool( - 'delete_collection_label', - { - description: 'Delete a collection label; its assignments on places are cleared.', - inputSchema: { labelId: z.number().int().positive() }, - annotations: TOOL_ANNOTATIONS_DELETE, - }, - async ({ labelId }) => { - const demo = denyDemo(); if (demo) return demo; - try { collections.deleteLabel(userId, labelId); return ok({ success: true }); } catch (err) { return fail(err); } - } - ); - - if (W) server.registerTool( - 'assign_collection_labels', - { - description: 'Add (or with remove=true, take away) one or more labels across a set of saved places. Only labels belonging to each place\'s own list are applied.', - inputSchema: { ...collectionLabelAssignRequestSchema.shape, remove: z.boolean().optional() }, - annotations: TOOL_ANNOTATIONS_WRITE, - }, - async ({ label_ids, place_ids, remove }) => { - const demo = denyDemo(); if (demo) return demo; - try { return ok(collections.assignLabels(userId, label_ids, place_ids, remove ?? false)); } catch (err) { return fail(err); } - } - ); - - // ── Sharing ─────────────────────────────────────────────────────────── - - if (W) server.registerTool( - 'invite_to_collection', - { - description: 'Invite a user (by id, from available_collection_users) to collaborate on a collection, with a role of viewer, editor, or admin (default editor). Owner only.', - inputSchema: collectionInviteRequestSchema.shape, - annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT, - }, - async ({ collection_id, user_id, role }) => { - const demo = denyDemo(); if (demo) return demo; - const me = db.prepare('SELECT username, email FROM users WHERE id = ?').get(userId) as { username: string; email: string } | undefined; - const res = collections.sendInvite(collection_id, userId, me?.username ?? '', me?.email ?? '', user_id, role); - if (res.error) return { content: [{ type: 'text' as const, text: res.error }], isError: true }; - return ok({ success: true }); - } - ); - - if (W) server.registerTool( - 'set_collection_member_role', - { - description: 'Change an accepted member\'s permission role (viewer, editor, or admin). Owner only.', - inputSchema: { - collectionId: z.number().int().positive(), - userId: z.number().int().positive(), - role: z.enum(COLLECTION_ROLES), - }, - annotations: TOOL_ANNOTATIONS_WRITE, - }, - async ({ collectionId, userId: targetUserId, role }) => { - const demo = denyDemo(); if (demo) return demo; - try { collections.setMemberRole(userId, collectionId, targetUserId, role); return ok({ success: true }); } catch (err) { return fail(err); } - } - ); - - if (W) server.registerTool( - 'remove_collection_member', - { - description: 'Remove an accepted member from a shared collection (a kick). Owner only.', - inputSchema: { collectionId: z.number().int().positive(), userId: z.number().int().positive() }, - annotations: TOOL_ANNOTATIONS_DELETE, - }, - async ({ collectionId, userId: targetUserId }) => { - const demo = denyDemo(); if (demo) return demo; - try { collections.removeMember(userId, collectionId, targetUserId); return ok({ success: true }); } catch (err) { return fail(err); } - } - ); - - if (W) server.registerTool( - 'cancel_collection_invite', - { - description: 'Cancel a pending invite you sent to a user for a collection. Owner only.', - inputSchema: { collectionId: z.number().int().positive(), userId: z.number().int().positive() }, - annotations: TOOL_ANNOTATIONS_WRITE, - }, - async ({ collectionId, userId: targetUserId }) => { - const demo = denyDemo(); if (demo) return demo; - try { collections.cancelInvite(collectionId, userId, targetUserId); return ok({ success: true }); } catch (err) { return fail(err); } - } - ); - - if (W) server.registerTool( - 'accept_collection_invite', - { - description: 'Accept a pending invite to join a shared collection.', - inputSchema: { collectionId: z.number().int().positive() }, - annotations: TOOL_ANNOTATIONS_WRITE, - }, - async ({ collectionId }) => { - const demo = denyDemo(); if (demo) return demo; - const res = collections.acceptInvite(userId, collectionId, undefined); - if (res.error) return { content: [{ type: 'text' as const, text: res.error }], isError: true }; - return ok({ success: true }); - } - ); - - if (W) server.registerTool( - 'decline_collection_invite', - { - description: 'Decline a pending invite to a shared collection.', - inputSchema: { collectionId: z.number().int().positive() }, - annotations: TOOL_ANNOTATIONS_WRITE, - }, - async ({ collectionId }) => { - const demo = denyDemo(); if (demo) return demo; - try { collections.declineInvite(userId, collectionId, undefined); return ok({ success: true }); } catch (err) { return fail(err); } - } - ); - - if (W) server.registerTool( - 'leave_collection', - { - description: 'Leave a shared collection you are a member of. The owner cannot leave (delete the list instead).', - inputSchema: { collectionId: z.number().int().positive() }, - annotations: TOOL_ANNOTATIONS_WRITE, - }, - async ({ collectionId }) => { - const demo = denyDemo(); if (demo) return demo; - try { collections.leaveCollection(userId, collectionId, undefined); return ok({ success: true }); } catch (err) { return fail(err); } - } - ); -} diff --git a/server/src/nest/README.md b/server/src/nest/README.md index 73ba8f6c7..ba9f0ea53 100644 --- a/server/src/nest/README.md +++ b/server/src/nest/README.md @@ -29,7 +29,8 @@ mount to Nest and leaves the sibling trip routes (days, places, ...) on Express. - **DI-native services (legacy `src/services/*` deleted):** tags, categories, todo, packing, day-notes, trip-invite, assignments, share, settings, files, collab, vacay, reservations, day, permissions, audit, budget, trip, maps, - transit, place, transit-itinerary — see the migration recipe below. + transit, place, transit-itinerary, collections — see the migration recipe + below. ## Cross-cutting Foundation pieces @@ -292,6 +293,19 @@ dependency-honest order; the legacy module had no direct suite, so a new 21-case `TRANSIT-ITIN-*` characterization suite now pins all 12 superRefine error strings, the `??` time fallbacks, the coordinate tolerances and the reservation endpoint/metadata builder). +collectionsService followed (the biggest single fold yet — 1024 lines / 28 +exports into `CollectionsService` over DatabaseService + PermissionsService + +RealtimeService, with the `deleteOldCollectionCover` path re-anchored one +directory deeper and the `sendInvite` lazy notificationService `import()` +kept, collab precedent; the 25-tool legacy registrar `mcp/tools/collections.ts` +moved to `collections.mcp.ts` — deliberately with NO `when:` addon gate, since +the legacy registrar registered unconditionally while REST/plugin-host gate on +the addon, a parity quirk now pinned by a new 23-case `tools-collections` +characterization suite (the legacy registrar had no tool-level tests at all); +the plugin host swapped its 7 collections imports for the injected service — +its 22nd constructor dep — and NO bridge was needed anywhere; the dead +`buildDedupSet` module helper was dropped in the move, the only line that +didn't relocate verbatim). Repeat these steps per service (next up: **the notifications fan-in** — per the dependency-honest order in diff --git a/server/src/nest/collections/collections.mcp.ts b/server/src/nest/collections/collections.mcp.ts new file mode 100644 index 000000000..8b9ee1abc --- /dev/null +++ b/server/src/nest/collections/collections.mcp.ts @@ -0,0 +1,404 @@ +import { + McpController, Tool, type McpContext, + TOOL_ANNOTATIONS_READONLY, TOOL_ANNOTATIONS_WRITE, + TOOL_ANNOTATIONS_DELETE, TOOL_ANNOTATIONS_NON_IDEMPOTENT, + demoDenied, ok, +} from '@trek/nest-mcp'; +import { z } from 'zod'; +import { isDemoUser } from '../../services/authService'; +import { DatabaseService } from '../database/database.service'; +import { CollectionsService } from './collections.service'; +import { + collectionCreateRequestSchema, collectionUpdateRequestSchema, + collectionSavePlaceRequestSchema, collectionPlaceUpdateRequestSchema, + collectionCopyToTripRequestSchema, collectionLabelCreateRequestSchema, + collectionLabelUpdateRequestSchema, collectionLabelAssignRequestSchema, + collectionInviteRequestSchema, COLLECTION_STATUSES, COLLECTION_ROLES, +} from '@trek/shared'; +import type { + CollectionCreateRequest, CollectionUpdateRequest, CollectionSavePlaceRequest, + CollectionPlaceUpdateRequest, CollectionCopyToTripRequest, CollectionInviteRequest, + CollectionLabelCreateRequest, CollectionLabelUpdateRequest, CollectionLabelAssignRequest, + CollectionStatus, CollectionRole, +} from '@trek/shared'; + +/** Convert a thrown service error (httpError carries `.message`) into MCP error text. */ +function errText(err: unknown): string { + if (err instanceof Error) return err.message; + if (typeof err === 'string') return err; + return 'Operation failed'; +} +function fail(err: unknown) { + return { content: [{ type: 'text' as const, text: errText(err) }], isError: true }; +} + +/** + * Collections MCP surface (#1435) — ported 1:1 from the legacy registrar + * src/mcp/tools/collections.ts: the full set of actions a member can take on + * a saved-places list (browse, CRUD lists + places, status, collaborative star + * ratings, per-list labels, sharing, and copy-into-a-trip) with identical + * names, descriptions, schemas, annotations and error/payload shapes. Every + * service call enforces the caller's membership/role, so the tools stay thin + * wrappers. The legacy `if (R)` / `if (W)` scope checks map to the declarative + * collections read/write access markers (resolved by trekMcpAccessPolicy); + * like the legacy registrar there is deliberately NO addon `when:` gate — the + * REST surface is addon-gated but the MCP tools never were (parity). + */ +@McpController() +export class CollectionsMcp { + constructor( + private readonly collections: CollectionsService, + private readonly db: DatabaseService, + ) {} + + private denyDemo(userId: number) { + return isDemoUser(userId) ? demoDenied() : null; + } + + // ── Read ────────────────────────────────────────────────────────────── + + @Tool({ + name: 'list_collections', + description: 'List all saved-place collections (lists) the user owns or has accepted a share for, plus any pending incoming invites. Use get_collection for a list\'s places.', + inputSchema: {}, + annotations: TOOL_ANNOTATIONS_READONLY, + access: { group: 'collections', mode: 'read' }, + }) + async listCollections(_args: Record, ctx: McpContext) { + try { return ok(this.collections.listCollections(ctx.userId)); } catch (err) { return fail(err); } + } + + @Tool({ + name: 'get_collection', + description: 'Get one collection with its members, labels, and all saved places. Each place includes rating_avg / rating_count and the per-member ratings (#1435) so you can plan around highly-rated spots.', + inputSchema: { collectionId: z.number().int().positive() }, + annotations: TOOL_ANNOTATIONS_READONLY, + access: { group: 'collections', mode: 'read' }, + }) + async getCollection({ collectionId }: { collectionId: number }, ctx: McpContext) { + try { return ok(this.collections.getCollection(ctx.userId, collectionId)); } catch (err) { return fail(err); } + } + + @Tool({ + name: 'available_collection_users', + description: 'List users who can still be invited to a collection (excludes current members and guests). Use the returned ids with invite_to_collection.', + inputSchema: { collectionId: z.number().int().positive() }, + annotations: TOOL_ANNOTATIONS_READONLY, + access: { group: 'collections', mode: 'read' }, + }) + async availableCollectionUsers({ collectionId }: { collectionId: number }, ctx: McpContext) { + try { + // Owner-only, mirroring the REST gate — availableUsers() itself does no + // access check, so without this any token could enumerate the user list. + this.collections.assertAccess(ctx.userId, collectionId); + if (!this.collections.isOwner(ctx.userId, collectionId)) { + return { content: [{ type: 'text' as const, text: 'Only the collection owner can view invitable users.' }], isError: true }; + } + return ok({ users: this.collections.availableUsers(ctx.userId, collectionId) }); + } catch (err) { return fail(err); } + } + + // ── Collections CRUD ───────────────────────────────────────────────── + + @Tool({ + name: 'create_collection', + description: 'Create a new saved-place collection (list) owned by the user.', + inputSchema: collectionCreateRequestSchema.shape, + annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT, + access: { group: 'collections', mode: 'write' }, + }) + async createCollection(body: CollectionCreateRequest, ctx: McpContext) { + const demo = this.denyDemo(ctx.userId); if (demo) return demo; + try { return ok({ collection: this.collections.createCollection(ctx.userId, body) }); } catch (err) { return fail(err); } + } + + @Tool({ + name: 'update_collection', + description: 'Update a collection\'s name, description, colour, icon, cover, links, or sort order. Owner/admin only.', + inputSchema: { collectionId: z.number().int().positive(), ...collectionUpdateRequestSchema.shape }, + annotations: TOOL_ANNOTATIONS_WRITE, + access: { group: 'collections', mode: 'write' }, + }) + async updateCollection({ collectionId, ...body }: { collectionId: number } & CollectionUpdateRequest, ctx: McpContext) { + const demo = this.denyDemo(ctx.userId); if (demo) return demo; + try { return ok({ collection: this.collections.updateCollection(ctx.userId, collectionId, body) }); } catch (err) { return fail(err); } + } + + @Tool({ + name: 'delete_collection', + description: 'Permanently delete a collection and all its saved places. Owner only. This cannot be undone.', + inputSchema: { collectionId: z.number().int().positive() }, + annotations: TOOL_ANNOTATIONS_DELETE, + access: { group: 'collections', mode: 'write' }, + }) + async deleteCollection({ collectionId }: { collectionId: number }, ctx: McpContext) { + const demo = this.denyDemo(ctx.userId); if (demo) return demo; + try { this.collections.deleteCollection(ctx.userId, collectionId); return ok({ success: true }); } catch (err) { return fail(err); } + } + + @Tool({ + name: 'reorder_collections', + description: 'Reorder the user\'s collections. Pass every collection id in the desired order.', + inputSchema: { orderedIds: z.array(z.number().int().positive()).min(1) }, + annotations: TOOL_ANNOTATIONS_WRITE, + access: { group: 'collections', mode: 'write' }, + }) + async reorderCollections({ orderedIds }: { orderedIds: number[] }, ctx: McpContext) { + const demo = this.denyDemo(ctx.userId); if (demo) return demo; + try { this.collections.reorderCollections(ctx.userId, orderedIds); return ok({ success: true }); } catch (err) { return fail(err); } + } + + // ── Places ──────────────────────────────────────────────────────────── + + @Tool({ + name: 'save_place_to_collection', + description: 'Save a place into a collection from a raw payload (name required; set google_place_id/osm_id from search_place for rich details). Returns a duplicate marker instead of saving when a similar place already exists, unless force is true.', + inputSchema: collectionSavePlaceRequestSchema.shape, + annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT, + access: { group: 'collections', mode: 'write' }, + }) + async savePlaceToCollection(body: CollectionSavePlaceRequest, ctx: McpContext) { + const demo = this.denyDemo(ctx.userId); if (demo) return demo; + try { return ok(this.collections.savePlace(ctx.userId, body)); } catch (err) { return fail(err); } + } + + @Tool({ + name: 'save_trip_places_to_collection', + description: 'Copy one or more existing trip places into a collection (the server reads each place). Their star ratings (#1435) travel along for members shared on both. Duplicates are skipped unless force is true.', + inputSchema: { + collectionId: z.number().int().positive(), + tripId: z.number().int().positive(), + placeIds: z.array(z.number().int().positive()).min(1).max(1000), + force: z.boolean().optional(), + }, + annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT, + access: { group: 'collections', mode: 'write' }, + }) + async saveTripPlacesToCollection( + { collectionId, tripId, placeIds, force }: { collectionId: number; tripId: number; placeIds: number[]; force?: boolean }, + ctx: McpContext, + ) { + const demo = this.denyDemo(ctx.userId); if (demo) return demo; + try { return ok(this.collections.saveFromTripPlaces(ctx.userId, collectionId, tripId, placeIds, force)); } catch (err) { return fail(err); } + } + + @Tool({ + name: 'update_collection_place', + description: 'Update a saved place\'s name, description, notes, status, category, links, tags, labels, image, or move it to another collection (set collection_id).', + inputSchema: { placeId: z.number().int().positive(), ...collectionPlaceUpdateRequestSchema.shape }, + annotations: TOOL_ANNOTATIONS_WRITE, + access: { group: 'collections', mode: 'write' }, + }) + async updateCollectionPlace({ placeId, ...body }: { placeId: number } & CollectionPlaceUpdateRequest, ctx: McpContext) { + const demo = this.denyDemo(ctx.userId); if (demo) return demo; + try { return ok({ place: this.collections.updatePlace(ctx.userId, placeId, body) }); } catch (err) { return fail(err); } + } + + @Tool({ + name: 'set_collection_place_status', + description: 'Set a saved place\'s status: idea, want, or visited.', + inputSchema: { placeId: z.number().int().positive(), status: z.enum(COLLECTION_STATUSES) }, + annotations: TOOL_ANNOTATIONS_WRITE, + access: { group: 'collections', mode: 'write' }, + }) + async setCollectionPlaceStatus({ placeId, status }: { placeId: number; status: CollectionStatus }, ctx: McpContext) { + const demo = this.denyDemo(ctx.userId); if (demo) return demo; + try { return ok({ place: this.collections.setStatus(ctx.userId, placeId, status) }); } catch (err) { return fail(err); } + } + + @Tool({ + name: 'rate_collection_place', + description: "Set or clear the current user's 1-5 star rating on a saved collection place (#1435). Every member rates independently; the place shows the average. Pass null (or omit rating) to remove the user's vote. Ratings a member casts here follow the place into any trip it is later copied to. Use the ratings to capture the user's preferences.", + inputSchema: { + placeId: z.number().int().positive(), + rating: z.number().int().min(1).max(5).nullable().optional().describe('1-5 stars; null/omitted clears the vote'), + }, + annotations: TOOL_ANNOTATIONS_WRITE, + access: { group: 'collections', mode: 'write' }, + }) + async rateCollectionPlace({ placeId, rating }: { placeId: number; rating?: number | null }, ctx: McpContext) { + const demo = this.denyDemo(ctx.userId); if (demo) return demo; + try { return ok({ place: this.collections.setRating(ctx.userId, placeId, rating ?? null) }); } catch (err) { return fail(err); } + } + + @Tool({ + name: 'delete_collection_place', + description: 'Remove a saved place from its collection. Requires delete permission on the list.', + inputSchema: { placeId: z.number().int().positive() }, + annotations: TOOL_ANNOTATIONS_DELETE, + access: { group: 'collections', mode: 'write' }, + }) + async deleteCollectionPlace({ placeId }: { placeId: number }, ctx: McpContext) { + const demo = this.denyDemo(ctx.userId); if (demo) return demo; + try { this.collections.deletePlace(ctx.userId, placeId); return ok({ success: true }); } catch (err) { return fail(err); } + } + + @Tool({ + name: 'copy_collection_places_to_trip', + description: 'Copy one or more saved collection places into a trip (dedup precheck on the server). Ratings (#1435) travel into the trip; trip members keep voting there. Requires edit access to the target trip.', + inputSchema: collectionCopyToTripRequestSchema.shape, + annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT, + access: { group: 'collections', mode: 'write' }, + }) + async copyCollectionPlacesToTrip(body: CollectionCopyToTripRequest, ctx: McpContext) { + const demo = this.denyDemo(ctx.userId); if (demo) return demo; + try { return ok(this.collections.copyToTrip(ctx.userId, body)); } catch (err) { return fail(err); } + } + + // ── Labels ──────────────────────────────────────────────────────────── + + @Tool({ + name: 'create_collection_label', + description: 'Create a custom per-collection label (name + optional hex colour) for grouping/filtering places.', + inputSchema: collectionLabelCreateRequestSchema.shape, + annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT, + access: { group: 'collections', mode: 'write' }, + }) + async createCollectionLabel({ collection_id, name, color }: CollectionLabelCreateRequest, ctx: McpContext) { + const demo = this.denyDemo(ctx.userId); if (demo) return demo; + try { return ok({ label: this.collections.createLabel(ctx.userId, collection_id, name, color) }); } catch (err) { return fail(err); } + } + + @Tool({ + name: 'update_collection_label', + description: 'Rename or recolour a collection label, or change its sort order.', + inputSchema: { labelId: z.number().int().positive(), ...collectionLabelUpdateRequestSchema.shape }, + annotations: TOOL_ANNOTATIONS_WRITE, + access: { group: 'collections', mode: 'write' }, + }) + async updateCollectionLabel({ labelId, ...body }: { labelId: number } & CollectionLabelUpdateRequest, ctx: McpContext) { + const demo = this.denyDemo(ctx.userId); if (demo) return demo; + try { return ok({ label: this.collections.updateLabel(ctx.userId, labelId, body) }); } catch (err) { return fail(err); } + } + + @Tool({ + name: 'delete_collection_label', + description: 'Delete a collection label; its assignments on places are cleared.', + inputSchema: { labelId: z.number().int().positive() }, + annotations: TOOL_ANNOTATIONS_DELETE, + access: { group: 'collections', mode: 'write' }, + }) + async deleteCollectionLabel({ labelId }: { labelId: number }, ctx: McpContext) { + const demo = this.denyDemo(ctx.userId); if (demo) return demo; + try { this.collections.deleteLabel(ctx.userId, labelId); return ok({ success: true }); } catch (err) { return fail(err); } + } + + @Tool({ + name: 'assign_collection_labels', + description: 'Add (or with remove=true, take away) one or more labels across a set of saved places. Only labels belonging to each place\'s own list are applied.', + inputSchema: { ...collectionLabelAssignRequestSchema.shape, remove: z.boolean().optional() }, + annotations: TOOL_ANNOTATIONS_WRITE, + access: { group: 'collections', mode: 'write' }, + }) + async assignCollectionLabels( + { label_ids, place_ids, remove }: CollectionLabelAssignRequest & { remove?: boolean }, + ctx: McpContext, + ) { + const demo = this.denyDemo(ctx.userId); if (demo) return demo; + try { return ok(this.collections.assignLabels(ctx.userId, label_ids, place_ids, remove ?? false)); } catch (err) { return fail(err); } + } + + // ── Sharing ─────────────────────────────────────────────────────────── + + @Tool({ + name: 'invite_to_collection', + description: 'Invite a user (by id, from available_collection_users) to collaborate on a collection, with a role of viewer, editor, or admin (default editor). Owner only.', + inputSchema: collectionInviteRequestSchema.shape, + annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT, + access: { group: 'collections', mode: 'write' }, + }) + async inviteToCollection({ collection_id, user_id, role }: CollectionInviteRequest, ctx: McpContext) { + const demo = this.denyDemo(ctx.userId); if (demo) return demo; + const me = this.db.get<{ username: string; email: string }>('SELECT username, email FROM users WHERE id = ?', ctx.userId); + const res = this.collections.sendInvite(collection_id, ctx.userId, me?.username ?? '', me?.email ?? '', user_id, role); + if (res.error) return { content: [{ type: 'text' as const, text: res.error }], isError: true }; + return ok({ success: true }); + } + + @Tool({ + name: 'set_collection_member_role', + description: 'Change an accepted member\'s permission role (viewer, editor, or admin). Owner only.', + inputSchema: { + collectionId: z.number().int().positive(), + userId: z.number().int().positive(), + role: z.enum(COLLECTION_ROLES), + }, + annotations: TOOL_ANNOTATIONS_WRITE, + access: { group: 'collections', mode: 'write' }, + }) + async setCollectionMemberRole( + { collectionId, userId: targetUserId, role }: { collectionId: number; userId: number; role: CollectionRole }, + ctx: McpContext, + ) { + const demo = this.denyDemo(ctx.userId); if (demo) return demo; + try { this.collections.setMemberRole(ctx.userId, collectionId, targetUserId, role); return ok({ success: true }); } catch (err) { return fail(err); } + } + + @Tool({ + name: 'remove_collection_member', + description: 'Remove an accepted member from a shared collection (a kick). Owner only.', + inputSchema: { collectionId: z.number().int().positive(), userId: z.number().int().positive() }, + annotations: TOOL_ANNOTATIONS_DELETE, + access: { group: 'collections', mode: 'write' }, + }) + async removeCollectionMember( + { collectionId, userId: targetUserId }: { collectionId: number; userId: number }, + ctx: McpContext, + ) { + const demo = this.denyDemo(ctx.userId); if (demo) return demo; + try { this.collections.removeMember(ctx.userId, collectionId, targetUserId); return ok({ success: true }); } catch (err) { return fail(err); } + } + + @Tool({ + name: 'cancel_collection_invite', + description: 'Cancel a pending invite you sent to a user for a collection. Owner only.', + inputSchema: { collectionId: z.number().int().positive(), userId: z.number().int().positive() }, + annotations: TOOL_ANNOTATIONS_WRITE, + access: { group: 'collections', mode: 'write' }, + }) + async cancelCollectionInvite( + { collectionId, userId: targetUserId }: { collectionId: number; userId: number }, + ctx: McpContext, + ) { + const demo = this.denyDemo(ctx.userId); if (demo) return demo; + try { this.collections.cancelInvite(collectionId, ctx.userId, targetUserId); return ok({ success: true }); } catch (err) { return fail(err); } + } + + @Tool({ + name: 'accept_collection_invite', + description: 'Accept a pending invite to join a shared collection.', + inputSchema: { collectionId: z.number().int().positive() }, + annotations: TOOL_ANNOTATIONS_WRITE, + access: { group: 'collections', mode: 'write' }, + }) + async acceptCollectionInvite({ collectionId }: { collectionId: number }, ctx: McpContext) { + const demo = this.denyDemo(ctx.userId); if (demo) return demo; + const res = this.collections.acceptInvite(ctx.userId, collectionId, undefined); + if (res.error) return { content: [{ type: 'text' as const, text: res.error }], isError: true }; + return ok({ success: true }); + } + + @Tool({ + name: 'decline_collection_invite', + description: 'Decline a pending invite to a shared collection.', + inputSchema: { collectionId: z.number().int().positive() }, + annotations: TOOL_ANNOTATIONS_WRITE, + access: { group: 'collections', mode: 'write' }, + }) + async declineCollectionInvite({ collectionId }: { collectionId: number }, ctx: McpContext) { + const demo = this.denyDemo(ctx.userId); if (demo) return demo; + try { this.collections.declineInvite(ctx.userId, collectionId, undefined); return ok({ success: true }); } catch (err) { return fail(err); } + } + + @Tool({ + name: 'leave_collection', + description: 'Leave a shared collection you are a member of. The owner cannot leave (delete the list instead).', + inputSchema: { collectionId: z.number().int().positive() }, + annotations: TOOL_ANNOTATIONS_WRITE, + access: { group: 'collections', mode: 'write' }, + }) + async leaveCollection({ collectionId }: { collectionId: number }, ctx: McpContext) { + const demo = this.denyDemo(ctx.userId); if (demo) return demo; + try { this.collections.leaveCollection(ctx.userId, collectionId, undefined); return ok({ success: true }); } catch (err) { return fail(err); } + } +} diff --git a/server/src/nest/collections/collections.module.ts b/server/src/nest/collections/collections.module.ts index 2e6dbf4af..2cfe2e9e2 100644 --- a/server/src/nest/collections/collections.module.ts +++ b/server/src/nest/collections/collections.module.ts @@ -1,12 +1,18 @@ import { Module } from '@nestjs/common'; import { CollectionsController } from './collections.controller'; import { CollectionsService } from './collections.service'; +import { CollectionsMcp } from './collections.mcp'; import { CollectionsAddonGuard } from './collections-addon.guard'; import { AddonsModule } from '../addons/addons.module'; +import { PermissionsModule } from '../permissions/permissions.module'; +/** Collections domain (saved-places library). Registered in AppModule. + * Exports CollectionsService for in-container consumers (PluginsModule's + * RPC host deps factory). */ @Module({ - imports: [AddonsModule], + imports: [AddonsModule, PermissionsModule], controllers: [CollectionsController], - providers: [CollectionsService, CollectionsAddonGuard], + providers: [CollectionsService, CollectionsMcp, CollectionsAddonGuard], + exports: [CollectionsService], }) export class CollectionsModule {} diff --git a/server/src/nest/collections/collections.service.ts b/server/src/nest/collections/collections.service.ts index d0a93f0f5..9e511ae0a 100644 --- a/server/src/nest/collections/collections.service.ts +++ b/server/src/nest/collections/collections.service.ts @@ -1,65 +1,1047 @@ +import fs from 'fs'; +import path from 'path'; import { Injectable } from '@nestjs/common'; -import * as svc from '../../services/collectionsService'; +import { DatabaseService } from '../database/database.service'; +import { PermissionsService } from '../permissions/permissions.service'; +import { RealtimeService } from '../realtime/realtime.service'; +import { reclaimPlaceImage } from '../../services/placeImage'; import type { + Collection, + CollectionDetailResponse, + CollectionListResponse, + CollectionMember, + CollectionMembership, + CollectionPlace, + CollectionLink, CollectionCreateRequest, CollectionUpdateRequest, CollectionSavePlaceRequest, - CollectionPlaceUpdateRequest, + CollectionSaveResult, CollectionCopyToTripRequest, + CollectionPlaceUpdateRequest, 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; +} + /** - * Thin Nest wrapper around services/collectionsService. All access control lives - * in the legacy service (assertAccess / isOwner throw, fusion returns {error}); - * this just re-exposes the functions one-to-one. + * 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; +} + +export type EffectiveRole = 'owner' | 'admin' | 'editor' | 'viewer' | null; + +interface PlaceRow extends CollectionPlace { + category_name?: string | null; + category_color?: string | null; + category_icon?: string | null; +} + +// --------------------------------------------------------------------------- +// 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 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; +} + +const MAX_LABELS_PER_COLLECTION = 50; + +/** + * Collections domain service — owns the collections SQL (moved 1:1 from the + * legacy services/collectionsService.ts: identical statements, the `??` + * defaults, the post-write re-selects, the exact error strings and the + * per-user broadcast fan-outs). The `place_edit` trip permission (copyToTrip) + * goes through PermissionsService; per-user WebSocket delivery goes through + * RealtimeService; the notification send in sendInvite keeps its call-time + * lazy import of the legacy notificationService (collab precedent). */ @Injectable() export class CollectionsService { - listCollections(userId: number) { return svc.listCollections(userId); } - getCollection(userId: number, id: number) { return svc.getCollection(userId, id); } - createCollection(userId: number, body: CollectionCreateRequest) { return svc.createCollection(userId, body); } - updateCollection(userId: number, id: number, body: CollectionUpdateRequest, socketId?: string) { return svc.updateCollection(userId, id, body, socketId); } - setCollectionCover(userId: number, id: number, coverUrl: string | null, socketId?: string) { return svc.setCollectionCover(userId, id, coverUrl, socketId); } - deleteCollection(userId: number, id: number) { return svc.deleteCollection(userId, id); } - reorderCollections(userId: number, orderedIds: number[]) { return svc.reorderCollections(userId, orderedIds); } + constructor( + private readonly db: DatabaseService, + private readonly permissions: PermissionsService, + private readonly realtime: RealtimeService, + ) {} - savePlace(userId: number, body: CollectionSavePlaceRequest, socketId?: string) { return svc.savePlace(userId, body, socketId); } - saveFromTripPlace(userId: number, collectionId: number, tripId: number, placeId: number, force?: boolean) { - return svc.saveFromTripPlace(userId, collectionId, tripId, placeId, force); - } - saveFromTripPlaces(userId: number, collectionId: number, tripId: number, placeIds: number[], force?: boolean) { - return svc.saveFromTripPlaces(userId, collectionId, tripId, placeIds, force); - } - updatePlace(userId: number, placeId: number, body: CollectionPlaceUpdateRequest, socketId?: string) { return svc.updatePlace(userId, placeId, body, socketId); } - setStatus(userId: number, placeId: number, status: CollectionStatus, socketId?: string) { return svc.setStatus(userId, placeId, status, socketId); } - setRating(userId: number, placeId: number, rating: number | null, socketId?: string) { return svc.setRating(userId, placeId, rating, socketId); } - setPlaceImage(userId: number, placeId: number, imageUrl: string | null, socketId?: string) { return svc.setPlaceImage(userId, placeId, imageUrl, socketId); } - deletePlace(userId: number, placeId: number, socketId?: string) { return svc.deletePlace(userId, placeId, socketId); } - deletePlacesMany(userId: number, ids: number[], socketId?: string) { return svc.deletePlacesMany(userId, ids, socketId); } + // ------------------------------------------------------------------------- + // Visibility — a user may see/edit a collection if they own it OR are an + // accepted member. Every read/write goes through assertAccess. + // ------------------------------------------------------------------------- - copyToTrip(userId: number, body: CollectionCopyToTripRequest) { return svc.copyToTrip(userId, body); } - findMembership(userId: number, query: { google_place_id?: string; google_ftid?: string; name?: string; lat?: number; lng?: number }) { - return svc.findMembership(userId, query); + accessibleCollectionIds(userId: number): number[] { + const rows = this.db.all<{ id: number }>(` + SELECT id FROM collections WHERE owner_id = ? + UNION + SELECT collection_id FROM collection_members WHERE user_id = ? AND status = 'accepted' + `, userId, userId); + return rows.map(r => r.id); } - createLabel(userId: number, collectionId: number, name: string, color: string | undefined, socketId?: string) { return svc.createLabel(userId, collectionId, name, color, socketId); } - updateLabel(userId: number, labelId: number, body: { name?: string; color?: string; sort_order?: number }, socketId?: string) { return svc.updateLabel(userId, labelId, body, socketId); } - deleteLabel(userId: number, labelId: number, socketId?: string) { return svc.deleteLabel(userId, labelId, socketId); } - assignLabels(userId: number, labelIds: number[], placeIds: number[], remove: boolean, socketId?: string) { return svc.assignLabels(userId, labelIds, placeIds, remove, socketId); } - - // Access helpers used by the controller's owner guards. - assertAccess(userId: number, collectionId: number) { return svc.assertAccess(userId, collectionId); } - isOwner(userId: number, collectionId: number) { return svc.isOwner(userId, collectionId); } - - sendInvite(collectionId: number, inviterId: number, inviterUsername: string, inviterEmail: string, targetUserId: number, role?: 'viewer' | 'editor' | 'admin') { - return svc.sendInvite(collectionId, inviterId, inviterUsername, inviterEmail, targetUserId, role); + private isVisible(userId: number, collectionId: number): boolean { + const row = this.db.get(` + 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 + `, collectionId, userId, collectionId, userId); + return !!row; + } + + assertAccess(userId: number, collectionId: number): void { + if (!this.isVisible(userId, collectionId)) httpError(404, 'Collection not found'); + } + + isOwner(userId: number, collectionId: number): boolean { + const row = this.db.get('SELECT 1 FROM collections WHERE id = ? AND owner_id = ?', collectionId, userId); + return !!row; + } + + /** The viewer's effective permission on a list: owner (full), or their accepted + * member role, or null when they have no access. */ + roleOf(userId: number, collectionId: number): EffectiveRole { + if (this.isOwner(userId, collectionId)) return 'owner'; + const row = this.db.get<{ role: string }>( + "SELECT role FROM collection_members WHERE collection_id = ? AND user_id = ? AND status = 'accepted'", + collectionId, userId, + ); + 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. */ + assertCanEdit(userId: number, collectionId: number): void { + const r = this.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. */ + assertCanDelete(userId: number, collectionId: number): void { + const r = this.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'); + } + + private ownerOf(collectionId: number): number { + const row = this.db.get<{ owner_id: number }>('SELECT owner_id FROM collections WHERE id = ?', collectionId); + if (!row) httpError(404, 'Collection not found'); + return row.owner_id; + } + + // ------------------------------------------------------------------------- + // Hydration helpers + // ------------------------------------------------------------------------- + + private loadTagsByCollectionPlaceIds(placeIds: number[]): Record { + const out: Record = {}; + if (placeIds.length === 0) return out; + const placeholders = placeIds.map(() => '?').join(','); + const rows = this.db.all<{ pid: number; id: number; name: string; color: string }>(` + 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}) + `, ...placeIds); + 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. */ + private loadLabelsByCollection(collectionId: number): CollectionLabel[] { + return this.db.all( + 'SELECT id, collection_id, name, color, sort_order FROM collection_labels WHERE collection_id = ? ORDER BY sort_order, id', + collectionId, + ); + } + + /** Assigned label ids per place, batched (mirrors loadTagsByCollectionPlaceIds). */ + private loadLabelIdsByPlaceIds(placeIds: number[]): Record { + const out: Record = {}; + if (placeIds.length === 0) return out; + const placeholders = placeIds.map(() => '?').join(','); + const rows = this.db.all<{ pid: number; label_id: number }>( + `SELECT collection_place_id AS pid, label_id FROM collection_place_labels WHERE collection_place_id IN (${placeholders})`, + ...placeIds, + ); + 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). */ + private loadRatingsByCollectionPlaceIds(placeIds: number[]): Record { + const out: Record = {}; + if (placeIds.length === 0) return out; + const rows = this.db.all<{ pid: number; user_id: number; username: string; avatar: string | null; rating: number }>(` + 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 + `, ...placeIds); + for (const { pid, ...rest } of rows) { + if (!out[pid]) out[pid] = []; + out[pid].push(rest); + } + return out; + } + + private hydratePlaces(rows: PlaceRow[]): CollectionPlace[] { + const ids = rows.map(r => r.id); + const tagsByPlace = this.loadTagsByCollectionPlaceIds(ids); + const labelsByPlace = this.loadLabelIdsByPlaceIds(ids); + const ratingsByPlace = this.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; + }); + } + + private getPlaceById(placeId: number): CollectionPlace { + const row = this.db.get(` + 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 = ? + `, placeId); + if (!row) httpError(404, 'Place not found'); + return this.hydratePlaces([row])[0]; + } + + private collectionIdOfPlace(placeId: number): number { + const row = this.db.get<{ collection_id: number }>('SELECT collection_id FROM collection_places WHERE id = ?', placeId); + if (!row) httpError(404, 'Place not found'); + return row.collection_id; + } + + private buildMembers(collectionId: number): CollectionMember[] { + const owner = this.db.get>(` + 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 = ? + `, collectionId); + const members = this.db.all>(` + 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 + `, collectionId); + 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; + } + + private getCollectionRow(id: number): Collection { + const col = this.db.get('SELECT * FROM collections WHERE id = ?', id); + if (!col) httpError(404, 'Collection not found'); + const placeCount = this.db.get<{ n: number }>('SELECT COUNT(*) AS n FROM collection_places WHERE collection_id = ?', id)!.n; + return { ...col, links: parseLinks(col.links), place_count: placeCount, members: this.buildMembers(id) }; + } + + // ------------------------------------------------------------------------- + // Lists CRUD + // ------------------------------------------------------------------------- + + listCollections(userId: number): CollectionListResponse { + const ids = this.accessibleCollectionIds(userId); + const collections: Collection[] = ids + .map(id => { + const col = this.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 = this.db.all<{ collection_id: number; name: string; from_id: number; from_username: string }>(` + 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' + `, userId) + .map(r => ({ collection_id: r.collection_id, name: r.name, from: { id: r.from_id, username: r.from_username } })); + + return { collections, incomingInvites }; + } + + getCollection(userId: number, id: number): CollectionDetailResponse { + this.assertAccess(userId, id); + const collection = this.getCollectionRow(id); + const rows = this.db.all(` + 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 + `, id); + return { + collection: { ...collection, is_owner: collection.owner_id === userId, labels: this.loadLabelsByCollection(id) }, + places: this.hydratePlaces(rows), + }; + } + + createCollection(userId: number, body: CollectionCreateRequest): Collection { + const max = this.db.get<{ m: number }>('SELECT COALESCE(MAX(sort_order), -1) AS m FROM collections WHERE owner_id = ?', userId)!.m; + const result = this.db.run(` + INSERT INTO collections (owner_id, name, description, color, icon, cover_image, links, sort_order) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + `, + userId, + body.name, + body.description ?? null, + body.color ?? '#6366f1', + body.icon ?? 'Bookmark', + body.cover_image ?? null, + serializeLinks(body.links), + max + 1, + ); + const col = this.getCollectionRow(Number(result.lastInsertRowid)); + return { ...col, is_owner: true }; + } + + updateCollection(userId: number, id: number, body: CollectionUpdateRequest, socketId?: string): Collection { + this.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); + this.db.run(`UPDATE collections SET ${updates.join(', ')} WHERE id = ?`, ...params); + } + this.notifyCollectionUsers(id, socketId, 'collections:updated'); + const col = this.getCollectionRow(id); + return { ...col, is_owner: col.owner_id === userId }; + } + + /** Set (or clear) a list's cover image, reclaiming the previous file. */ + setCollectionCover(userId: number, id: number, coverUrl: string | null, socketId?: string): Collection { + this.assertCanEdit(userId, id); + const prev = this.db.get<{ cover_image: string | null }>('SELECT cover_image FROM collections WHERE id = ?', id)?.cover_image ?? null; + this.db.run('UPDATE collections SET cover_image = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?', coverUrl, id); + if (prev && prev !== coverUrl) deleteOldCollectionCover(prev); + this.notifyCollectionUsers(id, socketId, 'collections:updated'); + const col = this.getCollectionRow(id); + return { ...col, is_owner: col.owner_id === userId }; + } + + deleteCollection(userId: number, id: number): void { + this.assertAccess(userId, id); + if (!this.isOwner(userId, id)) httpError(403, 'Only the owner can delete this list'); + + // Snapshot recipients BEFORE the cascade wipes collection_members. + const accepted = this.db.all<{ user_id: number }>("SELECT user_id FROM collection_members WHERE collection_id = ? AND status = 'accepted'", id).map(r => r.user_id); + const pending = this.db.all<{ user_id: number }>("SELECT user_id FROM collection_members WHERE collection_id = ? AND status = 'pending'", id).map(r => r.user_id); + + this.db.run('DELETE FROM collections WHERE id = ?', id); // CASCADE drops members + places + tags + + [...new Set([...accepted, ...pending])] + .filter(uid => uid !== userId) + .forEach(uid => this.realtime.broadcastToUser(uid, { type: 'collections:deleted', collectionId: id })); + } + + reorderCollections(userId: number, orderedIds: number[]): void { + const visible = new Set(this.accessibleCollectionIds(userId)); + const stmt = this.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) + // ------------------------------------------------------------------------- + + private 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 = this.db.get<{ id: number; name: string }>(` + SELECT id, name FROM collection_places + WHERE collection_id = ? AND lower(trim(name)) = ? + ORDER BY id ASC LIMIT 1 + `, collectionId, normalizedName); + if (dup) return dup; + } + if (candidate.lat != null && candidate.lng != null) { + return this.db.get<{ id: number; name: string }>(` + 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 + `, collectionId, candidate.lat, COORD_DEDUP_TOLERANCE, candidate.lng, COORD_DEDUP_TOLERANCE) || null; + } + return null; + } + + // ------------------------------------------------------------------------- + // Saved places CRUD + // ------------------------------------------------------------------------- + + private attachTags(collectionPlaceId: number, tagIds: number[] | undefined): void { + if (!tagIds || tagIds.length === 0) return; + const stmt = this.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. */ + private collectionMemberIds(collectionId: number): Set { + const ids = new Set([this.ownerOf(collectionId)]); + const rows = this.db.all<{ user_id: number }>("SELECT user_id FROM collection_members WHERE collection_id = ? AND status = 'accepted'", collectionId); + 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. + */ + private copyTripRatings(sourcePlaceId: number, collectionPlaceId: number, collectionId: number): void { + const eligible = this.collectionMemberIds(collectionId); + const rows = this.db.all<{ user_id: number; rating: number }>('SELECT user_id, rating FROM place_ratings WHERE place_id = ?', sourcePlaceId); + const ins = this.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); + } + } + + savePlace(userId: number, body: CollectionSavePlaceRequest, socketId?: string): CollectionSaveResult { + this.assertCanEdit(userId, body.collection_id); + + if (!body.force) { + const dup = this.findDuplicateCollectionPlace(body.collection_id, { name: body.name, lat: body.lat, lng: body.lng }); + if (dup) return { duplicate: true, duplicateOf: dup }; + } + + const ownerId = this.ownerOf(body.collection_id); + const result = this.db.run(` + 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, + 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); + this.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 && + this.db.canAccessTrip(body.source_trip_id, userId) && + this.db.get('SELECT 1 FROM places WHERE id = ? AND trip_id = ?', body.source_place_id, body.source_trip_id) + ) { + this.copyTripRatings(body.source_place_id, placeId, body.collection_id); + } + this.notifyCollectionUsers(body.collection_id, socketId, 'collections:updated'); + return { place: this.getPlaceById(placeId) }; + } + + saveFromTripPlace( + userId: number, collectionId: number, tripId: number, placeId: number, force?: boolean, + ): CollectionSaveResult { + this.assertCanEdit(userId, collectionId); + if (!this.db.canAccessTrip(tripId, userId)) httpError(404, 'Trip not found'); + + const place = this.db.get>('SELECT * FROM places WHERE id = ? AND trip_id = ?', placeId, tripId); + if (!place) httpError(404, 'Place not found'); + + return this.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'. */ + saveFromTripPlaces( + userId: number, collectionId: number, tripId: number, placeIds: number[], force?: boolean, + ): { copied: number; skipped: { id: number; name: string }[] } { + this.assertCanEdit(userId, collectionId); + if (!this.db.canAccessTrip(tripId, userId)) httpError(404, 'Trip not found'); + + const ownerId = this.ownerOf(collectionId); + const insert = this.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 = this.db.get>('SELECT * FROM places WHERE id = ? AND trip_id = ?', placeId, tripId); + 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 && this.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, + ); + this.copyTripRatings(placeId, Number(res.lastInsertRowid), collectionId); + copied++; + } + if (copied > 0) this.notifyCollectionUsers(collectionId, undefined, 'collections:updated'); + return { copied, skipped }; + } + + updatePlace(userId: number, placeId: number, body: CollectionPlaceUpdateRequest, socketId?: string): CollectionPlace { + const currentCollection = this.collectionIdOfPlace(placeId); + this.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 + ? this.db.get<{ image_url: string | null }>('SELECT image_url FROM collection_places WHERE id = ?', placeId)?.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.lat !== undefined) { updates.push('lat = ?'); params.push(body.lat ?? null); } + if (body.lng !== undefined) { updates.push('lng = ?'); params.push(body.lng ?? 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) { + this.assertCanEdit(userId, body.collection_id); + updates.push('collection_id = ?'); params.push(body.collection_id); + updates.push('owner_id = ?'); params.push(this.ownerOf(body.collection_id)); + movedTo = body.collection_id; + } + + if (updates.length > 0) { + updates.push("updated_at = CURRENT_TIMESTAMP"); + params.push(placeId); + this.db.run(`UPDATE collection_places SET ${updates.join(', ')} WHERE id = ?`, ...params); + } + + if (body.tag_ids !== undefined) { + this.db.run('DELETE FROM collection_place_tags WHERE collection_place_id = ?', placeId); + this.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) this.db.run('DELETE FROM collection_place_labels WHERE collection_place_id = ?', placeId); + if (body.label_ids !== undefined) this.setPlaceLabels(placeId, movedTo ?? currentCollection, body.label_ids); + + if (body.image_url !== undefined && prevImage !== (body.image_url ?? null)) { + reclaimPlaceImage(prevImage); + } + + this.notifyCollectionUsers(currentCollection, socketId, 'collections:updated'); + if (movedTo) this.notifyCollectionUsers(movedTo, socketId, 'collections:updated'); + return this.getPlaceById(placeId); + } + + setStatus(userId: number, placeId: number, status: CollectionStatus, socketId?: string): CollectionPlace { + const collectionId = this.collectionIdOfPlace(placeId); + this.assertCanEdit(userId, collectionId); + this.db.run("UPDATE collection_places SET status = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?", status, placeId); + this.notifyCollectionUsers(collectionId, socketId, 'collections:updated'); + return this.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. + */ + setRating(userId: number, placeId: number, rating: number | null, socketId?: string): CollectionPlace { + const collectionId = this.collectionIdOfPlace(placeId); + this.assertAccess(userId, collectionId); + if (rating === null) { + this.db.run('DELETE FROM collection_place_ratings WHERE collection_place_id = ? AND user_id = ?', placeId, userId); + } else { + this.db.run(` + 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 + `, placeId, userId, rating); + } + this.notifyCollectionUsers(collectionId, socketId, 'collections:updated'); + return this.getPlaceById(placeId); + } + + deletePlace(userId: number, placeId: number, socketId?: string): void { + const collectionId = this.collectionIdOfPlace(placeId); + this.assertCanDelete(userId, collectionId); + const image = this.db.get<{ image_url: string | null }>('SELECT image_url FROM collection_places WHERE id = ?', placeId)?.image_url ?? null; + this.db.run('DELETE FROM collection_places WHERE id = ?', placeId); // CASCADE drops tags. NO photo-cache reclaim. + reclaimPlaceImage(image); + this.notifyCollectionUsers(collectionId, socketId, 'collections:updated'); + } + + 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 = this.collectionIdOfPlace(id); + this.assertCanDelete(userId, collectionId); + images.push(this.db.get<{ image_url: string | null }>('SELECT image_url FROM collection_places WHERE id = ?', id)?.image_url ?? null); + this.db.run('DELETE FROM collection_places WHERE id = ?', id); + deleted.push(id); + touched.add(collectionId); + } + for (const image of images) reclaimPlaceImage(image); + touched.forEach(cid => this.notifyCollectionUsers(cid, socketId, 'collections:updated')); + return deleted; + } + + /** Set (or clear) a saved place's custom thumbnail, reclaiming the previous upload. */ + setPlaceImage(userId: number, placeId: number, imageUrl: string | null, socketId?: string): CollectionPlace { + const collectionId = this.collectionIdOfPlace(placeId); + this.assertCanEdit(userId, collectionId); + const prev = this.db.get<{ image_url: string | null }>('SELECT image_url FROM collection_places WHERE id = ?', placeId)?.image_url ?? null; + this.db.run('UPDATE collection_places SET image_url = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?', imageUrl, placeId); + if (prev !== imageUrl) reclaimPlaceImage(prev); + this.notifyCollectionUsers(collectionId, socketId, 'collections:updated'); + return this.getPlaceById(placeId); + } + + // ------------------------------------------------------------------------- + // Copy to trip + // ------------------------------------------------------------------------- + + copyToTrip(userId: number, body: CollectionCopyToTripRequest): { copied: number; skipped: { id: number; name: string }[] } { + const trip = this.db.canAccessTrip(body.trip_id, userId); + if (!trip) httpError(404, 'Trip not found'); + const role = this.db.get<{ role: string }>('SELECT role FROM users WHERE id = ?', userId)?.role ?? 'user'; + if (!this.permissions.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 this.db.all<{ user_id: number }>('SELECT user_id FROM trip_members WHERE trip_id = ?', body.trip_id)) { + 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 = this.db.get<(typeof sources)[number]>(` + 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 = ? + `, pid); + if (!row) httpError(404, 'Place not found'); + this.assertAccess(userId, row.collection_id); + sources.push(row); + } + + // Trip dedup set (mirrors placeService.buildDedupSet). + const existing = this.db.all<{ name: string | null; lat: number | null; lng: number | null }>('SELECT name, lat, lng FROM places WHERE trip_id = ?', body.trip_id); + 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 = this.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 = this.db.prepare('INSERT OR IGNORE INTO place_tags (place_id, tag_id) VALUES (?, ?)'); + const insertRating = this.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 = this.db.all<{ tag_id: number }>('SELECT tag_id FROM collection_place_tags WHERE collection_place_id = ?', s.id); + 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 = this.db.all<{ user_id: number; rating: number }>('SELECT user_id, rating FROM collection_place_ratings WHERE collection_place_id = ?', s.id); + 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) + // ------------------------------------------------------------------------- + + findMembership( + userId: number, + query: { google_place_id?: string; google_ftid?: string; name?: string; lat?: number; lng?: number }, + ): CollectionMembership { + const ids = this.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 = this.db.all<{ place_id: number; collection_id: number; name: string }>(` + 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 ')}) + `, ...params); + + return { saved: rows.length > 0, lists: rows.map(r => ({ collection_id: r.collection_id, name: r.name, place_id: r.place_id })) }; + } + + // ------------------------------------------------------------------------- + // WebSocket notify + // ------------------------------------------------------------------------- + + notifyCollectionUsers( + collectionId: number, + excludeSid: string | undefined, + event: 'collections:updated' | 'collections:accepted' | 'collections:declined' | 'collections:left' = 'collections:updated', + ): void { + const owner = this.db.get<{ owner_id: number }>('SELECT owner_id FROM collections WHERE id = ?', collectionId); + if (!owner) return; + const userIds = [owner.owner_id]; + const members = this.db.all<{ user_id: number }>("SELECT user_id FROM collection_members WHERE collection_id = ? AND status = 'accepted'", collectionId); + members.forEach(m => userIds.push(m.user_id)); + userIds.forEach(id => this.realtime.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. + // ------------------------------------------------------------------------- + + private collectionIdOfLabel(labelId: number): number { + const row = this.db.get<{ collection_id: number }>('SELECT collection_id FROM collection_labels WHERE id = ?', labelId); + if (!row) httpError(404, 'Label not found'); + return row.collection_id; + } + + private getLabelById(labelId: number): CollectionLabel { + return this.db.get('SELECT id, collection_id, name, color, sort_order FROM collection_labels WHERE id = ?', labelId) as CollectionLabel; + } + + /** Replace a place's label assignments, keeping only labels of `collectionId`. */ + private setPlaceLabels(placeId: number, collectionId: number, labelIds: number[]): void { + this.db.run('DELETE FROM collection_place_labels WHERE collection_place_id = ?', placeId); + if (labelIds.length === 0) return; + const valid = new Set(this.loadLabelsByCollection(collectionId).map(l => l.id)); + const stmt = this.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); + } + + createLabel(userId: number, collectionId: number, name: string, color?: string, socketId?: string): CollectionLabel { + this.assertCanEdit(userId, collectionId); + const trimmed = name.trim(); + if (!trimmed) httpError(400, 'Label name is required'); + const count = this.db.get<{ n: number }>('SELECT COUNT(*) AS n FROM collection_labels WHERE collection_id = ?', collectionId)!.n; + if (count >= MAX_LABELS_PER_COLLECTION) httpError(400, `A list can have at most ${MAX_LABELS_PER_COLLECTION} labels`); + if (this.db.get('SELECT 1 FROM collection_labels WHERE collection_id = ? AND lower(name) = lower(?)', collectionId, trimmed)) { + httpError(409, 'A label with this name already exists'); + } + const nextSort = this.db.get<{ m: number }>('SELECT COALESCE(MAX(sort_order), -1) AS m FROM collection_labels WHERE collection_id = ?', collectionId)!.m + 1; + const res = this.db.run('INSERT INTO collection_labels (collection_id, name, color, sort_order) VALUES (?, ?, ?, ?)', + collectionId, trimmed, color ?? '#6366f1', nextSort); + this.notifyCollectionUsers(collectionId, socketId, 'collections:updated'); + return this.getLabelById(Number(res.lastInsertRowid)); + } + + updateLabel(userId: number, labelId: number, body: { name?: string; color?: string; sort_order?: number }, socketId?: string): CollectionLabel { + const collectionId = this.collectionIdOfLabel(labelId); + this.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 (this.db.get('SELECT 1 FROM collection_labels WHERE collection_id = ? AND lower(name) = lower(?) AND id != ?', 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); + this.db.run(`UPDATE collection_labels SET ${updates.join(', ')} WHERE id = ?`, ...params); + } + this.notifyCollectionUsers(collectionId, socketId, 'collections:updated'); + return this.getLabelById(labelId); + } + + deleteLabel(userId: number, labelId: number, socketId?: string): void { + const collectionId = this.collectionIdOfLabel(labelId); + this.assertCanEdit(userId, collectionId); + this.db.run('DELETE FROM collection_labels WHERE id = ?', labelId); // CASCADE clears place assignments + this.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. */ + assignLabels(userId: number, labelIds: number[], placeIds: number[], remove: boolean, socketId?: string): { changed: number } { + const byCollection = new Map(); + for (const pid of placeIds) { + const cid = this.collectionIdOfPlace(pid); + if (!byCollection.has(cid)) byCollection.set(cid, []); + byCollection.get(cid)!.push(pid); + } + let changed = 0; + for (const [cid, pids] of byCollection) { + this.assertCanEdit(userId, cid); + const valid = new Set(this.loadLabelsByCollection(cid).map(l => l.id)); + const applicable = labelIds.filter(id => valid.has(id)); + if (applicable.length === 0) continue; + if (remove) { + const del = this.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 = this.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; + } + this.notifyCollectionUsers(cid, socketId, 'collections:updated'); + } + return { changed }; + } + + // ------------------------------------------------------------------------- + // Fusion invitations (mirror vacayService, dropping the one-fusion guards) + // ------------------------------------------------------------------------- + + sendInvite( + collectionId: number, inviterId: number, inviterUsername: string, inviterEmail: string, targetUserId: number, + role: 'viewer' | 'editor' | 'admin' = 'editor', + ): { error?: string; status?: number } { + if (!this.isOwner(inviterId, collectionId)) return { error: 'Not allowed', status: 403 }; + if (targetUserId === inviterId) return { error: 'Cannot invite yourself', status: 400 }; + + const targetUser = this.db.get('SELECT id, username FROM users WHERE id = ?', targetUserId); + if (!targetUser) return { error: 'User not found', status: 404 }; + + const existing = this.db.get<{ id: number; status: string }>('SELECT id, status FROM collection_members WHERE collection_id = ? AND user_id = ?', collectionId, targetUserId); + if (existing) { + if (existing.status === 'accepted') return { error: 'Already a member', status: 400 }; + if (existing.status === 'pending') return { error: 'Invite already pending', status: 400 }; + } + + this.db.run("INSERT INTO collection_members (collection_id, user_id, status, role) VALUES (?, ?, 'pending', ?)", collectionId, targetUserId, role); + + this.realtime.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 {}; + } + + acceptInvite(userId: number, collectionId: number, socketId: string | undefined): { error?: string; status?: number } { + const invite = this.db.get<{ id: number }>("SELECT id FROM collection_members WHERE collection_id = ? AND user_id = ? AND status = 'pending'", collectionId, userId); + if (!invite) return { error: 'No pending invite', status: 404 }; + this.db.run("UPDATE collection_members SET status = 'accepted' WHERE id = ?", invite.id); + this.notifyCollectionUsers(collectionId, socketId, 'collections:accepted'); + return {}; + } + + declineInvite(userId: number, collectionId: number, socketId: string | undefined): void { + this.db.run("DELETE FROM collection_members WHERE collection_id = ? AND user_id = ? AND status = 'pending'", collectionId, userId); + this.notifyCollectionUsers(collectionId, socketId, 'collections:declined'); + } + + cancelInvite(collectionId: number, ownerId: number, targetUserId: number): void { + if (!this.isOwner(ownerId, collectionId)) httpError(403, 'Not allowed'); + this.db.run("DELETE FROM collection_members WHERE collection_id = ? AND user_id = ? AND status = 'pending'", collectionId, targetUserId); + this.realtime.broadcastToUser(targetUserId, { type: 'collections:cancelled', collectionId }); + } + + leaveCollection(userId: number, collectionId: number, socketId: string | undefined): void { + if (this.isOwner(userId, collectionId)) httpError(400, 'Owner cannot leave; delete the list'); + this.db.run("DELETE FROM collection_members WHERE collection_id = ? AND user_id = ? AND status = 'accepted'", collectionId, userId); + this.notifyCollectionUsers(collectionId, socketId, 'collections:left'); + } + + /** Owner removes an already-accepted member (a "kick"). */ + removeMember(ownerId: number, collectionId: number, targetUserId: number): void { + if (!this.isOwner(ownerId, collectionId)) httpError(403, 'Not allowed'); + if (targetUserId === ownerId) httpError(400, 'Owner cannot be removed'); + const res = this.db.run("DELETE FROM collection_members WHERE collection_id = ? AND user_id = ? AND status = 'accepted'", collectionId, targetUserId); + if (res.changes === 0) httpError(404, 'Member not found'); + this.notifyCollectionUsers(collectionId, undefined, 'collections:left'); // refresh remaining members + this.realtime.broadcastToUser(targetUserId, { type: 'collections:removed', collectionId }); // bounce the removed user + } + + /** Owner changes an accepted member's permission role (viewer/editor/admin). */ + setMemberRole(ownerId: number, collectionId: number, targetUserId: number, role: 'viewer' | 'editor' | 'admin'): void { + if (!this.isOwner(ownerId, collectionId)) httpError(403, 'Not allowed'); + const res = this.db.run("UPDATE collection_members SET role = ? WHERE collection_id = ? AND user_id = ? AND status = 'accepted'", role, collectionId, targetUserId); + if (res.changes === 0) httpError(404, 'Member not found'); + this.notifyCollectionUsers(collectionId, undefined, 'collections:updated'); // re-gate the member live + this.realtime.broadcastToUser(targetUserId, { type: 'collections:updated', collectionId }); + } + + availableUsers(ownerId: number, collectionId: number): { id: number; username: string }[] { + return this.db.all<{ id: number; username: string }>(` + 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 + `, ownerId, collectionId); + } + + findMembershipForUser(userId: number, collectionId: number): { is_member: boolean; is_owner: boolean; status: string | null } { + if (this.isOwner(userId, collectionId)) return { is_member: true, is_owner: true, status: 'accepted' }; + const row = this.db.get<{ status: string }>('SELECT status FROM collection_members WHERE collection_id = ? AND user_id = ?', collectionId, userId); + return { is_member: row?.status === 'accepted', is_owner: false, status: row?.status ?? null }; } - acceptInvite(userId: number, collectionId: number, socketId?: string) { return svc.acceptInvite(userId, collectionId, socketId); } - declineInvite(userId: number, collectionId: number, socketId?: string) { return svc.declineInvite(userId, collectionId, socketId); } - cancelInvite(collectionId: number, ownerId: number, targetUserId: number) { return svc.cancelInvite(collectionId, ownerId, targetUserId); } - leaveCollection(userId: number, collectionId: number, socketId?: string) { return svc.leaveCollection(userId, collectionId, socketId); } - removeMember(ownerId: number, collectionId: number, targetUserId: number) { return svc.removeMember(ownerId, collectionId, targetUserId); } - setMemberRole(ownerId: number, collectionId: number, targetUserId: number, role: 'viewer' | 'editor' | 'admin') { return svc.setMemberRole(ownerId, collectionId, targetUserId, role); } - availableUsers(ownerId: number, collectionId: number) { return svc.availableUsers(ownerId, collectionId); } } diff --git a/server/src/nest/permissions/permissions.bridge.ts b/server/src/nest/permissions/permissions.bridge.ts index a07887939..9064fac6c 100644 --- a/server/src/nest/permissions/permissions.bridge.ts +++ b/server/src/nest/permissions/permissions.bridge.ts @@ -6,7 +6,7 @@ import type { PermissionLevel } from './permissions.service'; /** * Non-Nest entry point for the permissions domain — for code running OUTSIDE * the Nest container (the legacy MCP _shared.ts hasTripPermission helper and - * the legacy adminService/authService/backupService/collectionsService). + * the legacy adminService/authService/backupService). * Exports the legacy services/permissions names 1:1 so repointing a consumer * is an import-path-only diff. Inside the container, inject PermissionsService * instead. Delete this file when the last legacy consumer migrates. diff --git a/server/src/nest/plugins/DI-MIGRATION.md b/server/src/nest/plugins/DI-MIGRATION.md index 781029890..5bbc75c0c 100644 --- a/server/src/nest/plugins/DI-MIGRATION.md +++ b/server/src/nest/plugins/DI-MIGRATION.md @@ -126,8 +126,12 @@ became the injected `TripsService`, with the error classes now imported from `nest/trips/trips.service`; placeService swapped in 2026-07 with the place fold — its four imported symbols (`createPlace`, `updatePlace`, `deletePlace`, `getPlace`) became the injected `PlacesService`, the factory's 21st constructor -dep; the remaining factory -imports are all Wave-5 domains: journeys, atlas, collections). +dep; collectionsService swapped in 2026-08 with the collections fold — its +seven imported symbols (`listCollections`, `getCollection`, `createCollection`, +`updateCollection`, `savePlace`, `copyToTrip`, `deletePlace`) became the +injected `CollectionsService`, the factory's 22nd constructor dep, with +`mapCollectionError` unchanged on top; the remaining factory +imports are all Wave-5 domains: journeys, atlas). ### Test impact (as landed) @@ -141,7 +145,10 @@ imports are all Wave-5 domains: journeys, atlas, collections). `nest/trips/trips.service` error classes); the ~22 legacy-service path mocks shrank by one with the 2026-07 budget migration (the budgetService satisfy-the-import mock died with the legacy file — the - `budgetStub` gained `listBudgetItems` for the two swapped closures); a + `budgetStub` gained `listBudgetItems` for the two swapped closures); the + collectionsService path mock became `collectionsStub` with the 2026-08 + collections fold (same sentinel behaviors — the status-tagged throws the + factory's `mapCollectionError` maps); a file-local shim keeps the historical `createRealRpcHost(id, granted)` call sites and supplies a default no-op router. diff --git a/server/src/nest/plugins/host/plugin-host-deps.factory.ts b/server/src/nest/plugins/host/plugin-host-deps.factory.ts index 080736c7e..df2a93b92 100644 --- a/server/src/nest/plugins/host/plugin-host-deps.factory.ts +++ b/server/src/nest/plugins/host/plugin-host-deps.factory.ts @@ -21,7 +21,7 @@ import { isDemoEmail } from '../../../services/demo'; import { ADDON_IDS } from '../../../addons'; import { listJourneys, listEntries as listJournalEntriesSvc, createEntry as createJournalEntrySvc, updateEntry as updateJournalEntrySvc, deleteEntry as deleteJournalEntrySvc, createJourney as createJourneySvc, deleteJourney as deleteJourneySvc, onPlaceCreated, onPlaceUpdated, onPlaceDeleted } from '../../../services/journeyService'; import { listVisitedCountries, listManuallyVisitedRegions, listBucketList, markCountryVisited, unmarkCountryVisited, markRegionVisited, unmarkRegionVisited, createBucketItem as createBucketItemSvc, deleteBucketItem as deleteBucketItemSvc } from '../../../services/atlasService'; -import { listCollections, getCollection, createCollection, updateCollection, savePlace as saveCollectionPlaceSvc, copyToTrip as copyCollectionToTripSvc, deletePlace as deleteCollectionPlaceSvc } from '../../../services/collectionsService'; +import { CollectionsService } from '../../collections/collections.service'; import { BudgetService } from '../../budget/budget.service'; import { ExchangeRatesService } from '../../budget/exchange-rates.service'; import { ReservationsService } from '../../reservations/reservations.service'; @@ -132,7 +132,7 @@ export interface PluginCallRouter { * * DI-native domain services (budget, exchange-rates, reservations, tags, * categories, todo, packing, day-notes, assignments, oauth, files, collab, - * vacay, trips, the LLM config resolver) and + * vacay, trips, places, collections, the LLM config resolver) and * the DatabaseService (all inline SQL + the trip-access helper) are * constructor-injected; legacy `services/*` domains stay plain function * imports until their own migration lands (DI-MIGRATION.md), at which point @@ -162,6 +162,7 @@ export class PluginHostDepsFactory { private readonly realtime: RealtimeService, private readonly trips: TripsService, private readonly places: PlacesService, + private readonly collections: CollectionsService, ) {} /** @@ -663,29 +664,29 @@ export class PluginHostDepsFactory { }, atlasBucketForUser: (userId) => { requireAddon(ADDON_IDS.ATLAS, 'atlas'); return listBucketList(userId) as unknown[]; }, vacayForUser: (userId) => { requireAddon(ADDON_IDS.VACAY, 'vacay'); return this.vacay.getPlanData(userId); }, - listCollectionsForUser: (userId) => { requireAddon(ADDON_IDS.COLLECTIONS, 'collections'); return listCollections(userId); }, - getCollectionForUser: (userId, id) => { requireAddon(ADDON_IDS.COLLECTIONS, 'collections'); return getCollection(userId, id); }, + listCollectionsForUser: (userId) => { requireAddon(ADDON_IDS.COLLECTIONS, 'collections'); return this.collections.listCollections(userId); }, + getCollectionForUser: (userId, id) => { requireAddon(ADDON_IDS.COLLECTIONS, 'collections'); return this.collections.getCollection(userId, id); }, // --- Collections write. The service self-gates per-collection role (assertAccess/ // assertCanEdit throw status-tagged errors) — map those to the RPC error codes. --- createCollectionForUser: (userId, input) => { requireAddon(ADDON_IDS.COLLECTIONS, 'collections'); - return mapCollectionError(() => createCollection(userId, input as never)); + return mapCollectionError(() => this.collections.createCollection(userId, input as never)); }, updateCollectionForUser: (userId, id, input) => { requireAddon(ADDON_IDS.COLLECTIONS, 'collections'); - return mapCollectionError(() => updateCollection(userId, id, input as never, undefined)); + return mapCollectionError(() => this.collections.updateCollection(userId, id, input as never, undefined)); }, saveCollectionPlace: (userId, input) => { requireAddon(ADDON_IDS.COLLECTIONS, 'collections'); - return mapCollectionError(() => saveCollectionPlaceSvc(userId, input as never, undefined)); + return mapCollectionError(() => this.collections.savePlace(userId, input as never, undefined)); }, copyCollectionToTrip: (userId, input) => { requireAddon(ADDON_IDS.COLLECTIONS, 'collections'); - return mapCollectionError(() => copyCollectionToTripSvc(userId, input as never)); + return mapCollectionError(() => this.collections.copyToTrip(userId, input as never)); }, deleteCollectionPlace: (userId, placeId) => { requireAddon(ADDON_IDS.COLLECTIONS, 'collections'); - mapCollectionError(() => deleteCollectionPlaceSvc(userId, placeId, undefined)); + mapCollectionError(() => this.collections.deletePlace(userId, placeId, undefined)); return { deleted: true }; }, // --- Atlas write: plain uid-scoped rows, no broadcasts in the service. --- diff --git a/server/src/nest/plugins/plugins.module.ts b/server/src/nest/plugins/plugins.module.ts index 13a390494..565ad9327 100644 --- a/server/src/nest/plugins/plugins.module.ts +++ b/server/src/nest/plugins/plugins.module.ts @@ -41,6 +41,7 @@ import { PlacesModule } from '../places/places.module'; import { PermissionsModule } from '../permissions/permissions.module'; import { AuditModule } from '../audit/audit.module'; import { AddonsModule } from '../addons/addons.module'; +import { CollectionsModule } from '../collections/collections.module'; /** * Plugin system (#plugins). M0 read side + M2 isolated runtime + M3 frontend: @@ -52,7 +53,7 @@ import { AddonsModule } from '../addons/addons.module'; @Module({ // The DI-native domain services the plugin host wiring injects // (PluginHostDepsFactory); DatabaseModule is @Global, so not listed. - imports: [TagsModule, CategoriesModule, BudgetModule, ReservationsModule, TodoModule, PackingModule, DaysModule, AssignmentsModule, LlmParseModule, FilesModule, CollabModule, VacayModule, TripsModule, PermissionsModule, AuditModule, AddonsModule, PlacesModule], + imports: [TagsModule, CategoriesModule, BudgetModule, ReservationsModule, TodoModule, PackingModule, DaysModule, AssignmentsModule, LlmParseModule, FilesModule, CollabModule, VacayModule, TripsModule, PermissionsModule, AuditModule, AddonsModule, PlacesModule, CollectionsModule], controllers: [PluginsController, PluginsFeedController, PluginsProxyController, PluginFrameController, PlaceDetailsController, TripWarningsController, ViewContributionsController, TripCardContributionsController, PluginPhotosController, PluginCalendarController, MapMarkersController, MapLayersController, PluginRoutesController, DayScheduleController, PdfSectionsController, AtlasLayersController, JournalEntryRowsController, PluginUserSettingsController, PluginOAuthController, PluginActivityController], providers: [PluginsService, PluginRuntimeService, PluginRegistryService, PluginOAuthService, PluginHostDepsFactory], // Exported so the admin addon-toggle handler can cascade-disable plugins whose diff --git a/server/src/services/collectionsService.ts b/server/src/services/collectionsService.ts deleted file mode 100644 index 326e7841d..000000000 --- a/server/src/services/collectionsService.ts +++ /dev/null @@ -1,1024 +0,0 @@ -import fs from 'fs'; -import path from 'path'; -import { db, canAccessTrip } from '../db/database'; -import { broadcastToUser } from '../websocket'; -import { checkPermission } from '../nest/permissions/permissions.bridge'; -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.lat !== undefined) { updates.push('lat = ?'); params.push(body.lat ?? null); } - if (body.lng !== undefined) { updates.push('lng = ?'); params.push(body.lng ?? 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 }; -} diff --git a/server/tests/e2e/collections.e2e.test.ts b/server/tests/e2e/collections.e2e.test.ts index 969df3016..00357d7ce 100644 --- a/server/tests/e2e/collections.e2e.test.ts +++ b/server/tests/e2e/collections.e2e.test.ts @@ -1,7 +1,8 @@ /** * Collections e2e — drives /api/addons/collections through the REAL JwtAuthGuard - * AND the real collectionsService against a temp SQLite db (full schema). Only the - * addon flag, websocket and notification send are mocked. Covers: the addon gate + * AND the real DI-native CollectionsService (DatabaseModule + RealtimeModule + + * CollectionsModule) against a temp SQLite db (full schema). Only the addon + * flag, websocket and notification send are mocked. Covers: the addon gate * (404 before auth), auth, CRUD happy paths, invite/accept/decline, copy-to-trip, * cross-user 404s and the non-owner 403 on /:id/available-users (no enumeration). */ @@ -39,6 +40,8 @@ import { createTables } from '../../src/db/schema'; import { runMigrations } from '../../src/db/migrations'; import { createUser, createTrip, createCategory } from '../helpers/factories'; import { CollectionsModule } from '../../src/nest/collections/collections.module'; +import { DatabaseModule } from '../../src/nest/database/database.module'; +import { RealtimeModule } from '../../src/nest/realtime/realtime.module'; import { AddonsService } from '../../src/nest/addons/addons.service'; import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter'; import { ZodValidationPipe } from '../../src/nest/common/zod-validation.pipe'; @@ -51,7 +54,7 @@ describe('Collections e2e (real auth guard + real service + temp SQLite)', () => let tripId: number; async function build() { - const moduleRef = await Test.createTestingModule({ imports: [CollectionsModule] }) + const moduleRef = await Test.createTestingModule({ imports: [DatabaseModule, RealtimeModule, CollectionsModule] }) .overrideProvider(AddonsService) .useValue({ isAddonEnabled }) .compile(); diff --git a/server/tests/helpers/mcp-test-controllers.ts b/server/tests/helpers/mcp-test-controllers.ts index 7f5bff36a..c1a42f308 100644 --- a/server/tests/helpers/mcp-test-controllers.ts +++ b/server/tests/helpers/mcp-test-controllers.ts @@ -10,6 +10,8 @@ import { CategoriesMcp } from '../../src/nest/categories/categories.mcp'; import { CategoriesService } from '../../src/nest/categories/categories.service'; import { CollabMcp } from '../../src/nest/collab/collab.mcp'; import { CollabService } from '../../src/nest/collab/collab.service'; +import { CollectionsMcp } from '../../src/nest/collections/collections.mcp'; +import { CollectionsService } from '../../src/nest/collections/collections.service'; import { DatabaseService } from '../../src/nest/database/database.service'; import { DayNotesMcp } from '../../src/nest/days/day-notes.mcp'; import { DayNotesService } from '../../src/nest/days/day-notes.service'; @@ -91,6 +93,7 @@ export function createMcpTestRegistry(): McpRegistry { new ShareMcp(new ShareService(dbService, new SettingsService(dbService), permissionsService)), new MapsMcp(mapsService), new PlacesMcp(placesService, mapsService, dbService), + new CollectionsMcp(new CollectionsService(dbService, permissionsService, realtimeService), dbService), new TransitMcp(new TransitService(), daysService, reservationsService, dbService), ], { accessPolicy: trekMcpAccessPolicy, validateAccess: trekMcpValidateAccess }, diff --git a/server/tests/helpers/plugin-host.ts b/server/tests/helpers/plugin-host.ts index b61d0aece..c4edf0ddb 100644 --- a/server/tests/helpers/plugin-host.ts +++ b/server/tests/helpers/plugin-host.ts @@ -20,6 +20,7 @@ import { CollabService } from '../../src/nest/collab/collab.service'; import { VacayService } from '../../src/nest/vacay/vacay.service'; import { TripsService } from '../../src/nest/trips/trips.service'; import { PlacesService } from '../../src/nest/places/places.service'; +import { CollectionsService } from '../../src/nest/collections/collections.service'; import { MapsService } from '../../src/nest/maps/maps.service'; import { PermissionsService } from '../../src/nest/permissions/permissions.service'; import { AuditService } from '../../src/nest/audit/audit.service'; @@ -68,6 +69,7 @@ export function createHostDepsFactory(dbs: DatabaseService): PluginHostDepsFacto realtime, new TripsService(dbs, todos, packing, files, reservations, days, permissions, budget, collab, vacay, realtime, places), places, + new CollectionsService(dbs, permissions, realtime), ); } diff --git a/server/tests/unit/mcp/tools-collections.test.ts b/server/tests/unit/mcp/tools-collections.test.ts new file mode 100644 index 000000000..4ace10860 --- /dev/null +++ b/server/tests/unit/mcp/tools-collections.test.ts @@ -0,0 +1,531 @@ +/** + * Unit tests for the collections MCP surface (CollectionsMcp, DI-discovered): + * the 25 tools ported 1:1 from the legacy src/mcp/tools/collections.ts + * registrar. New with the DI fold — the legacy registrar had no tool-level + * suite — so this is a characterization of the ported behavior: payload + * shapes, error texts (the service's thrown httpError messages surfaced via + * fail()), the owner-only available-users gate, demo denial on writes, the + * collections read/write scope gating, and the deliberate ABSENCE of an + * addon gate (the REST surface is addon-gated; the MCP tools never were). + */ +import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest'; + +const { testDb, dbMock, broadcastToUser } = vi.hoisted(() => { + const Database = require('better-sqlite3'); + const db = new Database(':memory:'); + db.exec('PRAGMA journal_mode = WAL'); + db.exec('PRAGMA foreign_keys = ON'); + db.exec('PRAGMA busy_timeout = 5000'); + const broadcastToUser = vi.fn(); + const mock = { + db, + closeDb: () => {}, + reinitialize: () => {}, + getPlaceWithTags: () => null, + canAccessTrip: (tripId: number | string, userId: number) => + db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId), + isOwner: (tripId: number | string, userId: number) => + !!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId), + }; + return { testDb: db, dbMock: mock, broadcastToUser }; +}); + +vi.mock('../../../src/db/database', () => dbMock); +vi.mock('../../../src/config', () => ({ + JWT_SECRET: 'test-jwt-secret-for-trek-testing-only', + ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2', + updateJwtSecret: () => {}, +})); +vi.mock('../../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser })); +const { notifSend } = vi.hoisted(() => ({ notifSend: vi.fn().mockResolvedValue(undefined) })); +vi.mock('../../../src/services/notificationService', () => ({ send: notifSend })); + +import { createTables } from '../../../src/db/schema'; +import { runMigrations } from '../../../src/db/migrations'; +import { createUser, createTrip, createPlace, createCategory } from '../../helpers/factories'; +import { createMcpHarness, parseToolResult, type McpHarness } from '../../helpers/mcp-harness'; +import { ADDON_IDS } from '../../../src/addons'; + +function clearCollections() { + testDb.exec(` + DELETE FROM collection_place_labels; + DELETE FROM collection_labels; + DELETE FROM collection_place_tags; + DELETE FROM collection_place_ratings; + DELETE FROM collection_places; + DELETE FROM collection_members; + DELETE FROM collections; + DELETE FROM place_tags; + DELETE FROM places; + DELETE FROM trip_members; + DELETE FROM trips; + DELETE FROM users; + `); +} + +beforeAll(async () => { + createTables(testDb); + runMigrations(testDb); + // Warm the notificationService module: sendInvite does a fire-and-forget + // dynamic import of it, and a cold load can otherwise race the mock registry + // under slow (coverage) runs — same precaution as tests/integration/vacay.test.ts. + await import('../../../src/services/notificationService'); +}); + +beforeEach(() => { + clearCollections(); + broadcastToUser.mockClear(); + notifSend.mockClear(); + delete process.env.DEMO_MODE; +}); + +afterAll(() => { + testDb.close(); +}); + +async function withHarness(userId: number, fn: (h: McpHarness) => Promise) { + const h = await createMcpHarness({ userId, withResources: false }); + try { await fn(h); } finally { await h.cleanup(); } +} + +function errorText(result: Awaited>): string { + const text = (result.content as { type: string; text?: string }[]).find((c) => c.type === 'text'); + return text?.text ?? ''; +} + +/** Seed a collection directly (owner_id row only — no service round trip). */ +function seedCollection(ownerId: number, name = 'List'): number { + return Number(testDb.prepare('INSERT INTO collections (owner_id, name, sort_order) VALUES (?, ?, 0)').run(ownerId, name).lastInsertRowid); +} + +function addMember(colId: number, userId: number, role: 'viewer' | 'editor' | 'admin' = 'editor', status: 'accepted' | 'pending' = 'accepted') { + testDb.prepare('INSERT INTO collection_members (collection_id, user_id, status, role) VALUES (?, ?, ?, ?)').run(colId, userId, status, role); +} + +function seedPlace(colId: number, ownerId: number, name: string, extra: Record = {}): number { + return Number(testDb.prepare( + 'INSERT INTO collection_places (collection_id, owner_id, saved_by, name, lat, lng, status) VALUES (?, ?, ?, ?, ?, ?, ?)', + ).run(colId, ownerId, ownerId, name, extra.lat ?? null, extra.lng ?? null, extra.status ?? 'idea').lastInsertRowid); +} + +// --------------------------------------------------------------------------- +// Read tools +// --------------------------------------------------------------------------- + +describe('Tool: list_collections', () => { + it('lists owned collections plus incoming invites', async () => { + const { user } = createUser(testDb); + const { user: inviter } = createUser(testDb); + seedCollection(user.id, 'Mine'); + const invitedTo = seedCollection(inviter.id, 'Theirs'); + addMember(invitedTo, user.id, 'editor', 'pending'); + await withHarness(user.id, async (h) => { + const data = parseToolResult(await h.client.callTool({ name: 'list_collections', arguments: {} })) as { + collections: { name: string }[]; incomingInvites: { collection_id: number }[]; + }; + expect(data.collections.map((c) => c.name)).toEqual(['Mine']); + expect(data.incomingInvites.map((i) => i.collection_id)).toEqual([invitedTo]); + }); + }); +}); + +describe('Tool: get_collection', () => { + it('returns the detail with places, labels and members', async () => { + const { user } = createUser(testDb); + const col = seedCollection(user.id, 'Detail'); + seedPlace(col, user.id, 'Louvre'); + await withHarness(user.id, async (h) => { + const data = parseToolResult(await h.client.callTool({ name: 'get_collection', arguments: { collectionId: col } })) as { + collection: { id: number; labels: unknown[] }; places: { name: string }[]; + }; + expect(data.collection.id).toBe(col); + expect(data.places.map((p) => p.name)).toEqual(['Louvre']); + }); + }); + + it('surfaces the 404 error text for an inaccessible collection', async () => { + const { user } = createUser(testDb); + const { user: owner } = createUser(testDb); + const col = seedCollection(owner.id, 'Hidden'); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ name: 'get_collection', arguments: { collectionId: col } }); + expect(result.isError).toBe(true); + expect(errorText(result)).toBe('Collection not found'); + }); + }); +}); + +describe('Tool: available_collection_users', () => { + it('is owner-only: owner 200, accepted member gets the bespoke error, stranger a 404 text', async () => { + const { user: owner } = createUser(testDb); + const { user: member } = createUser(testDb); + const { user: stranger } = createUser(testDb); + const { user: invitable } = createUser(testDb); + const col = seedCollection(owner.id, 'Members'); + addMember(col, member.id); + + await withHarness(owner.id, async (h) => { + const data = parseToolResult(await h.client.callTool({ name: 'available_collection_users', arguments: { collectionId: col } })) as { users: { id: number }[] }; + const ids = data.users.map((u) => u.id); + expect(ids).toContain(invitable.id); + expect(ids).not.toContain(owner.id); + expect(ids).not.toContain(member.id); + }); + await withHarness(member.id, async (h) => { + const result = await h.client.callTool({ name: 'available_collection_users', arguments: { collectionId: col } }); + expect(result.isError).toBe(true); + expect(errorText(result)).toBe('Only the collection owner can view invitable users.'); + }); + await withHarness(stranger.id, async (h) => { + const result = await h.client.callTool({ name: 'available_collection_users', arguments: { collectionId: col } }); + expect(result.isError).toBe(true); + expect(errorText(result)).toBe('Collection not found'); + }); + }); +}); + +// --------------------------------------------------------------------------- +// Collections CRUD +// --------------------------------------------------------------------------- + +describe('Tool: create_collection / update_collection / delete_collection / reorder_collections', () => { + it('create → update → reorder → delete round trip', async () => { + const { user } = createUser(testDb); + await withHarness(user.id, async (h) => { + const created = parseToolResult(await h.client.callTool({ name: 'create_collection', arguments: { name: 'Italy' } })) as { collection: { id: number; name: string; is_owner: boolean } }; + expect(created.collection.name).toBe('Italy'); + expect(created.collection.is_owner).toBe(true); + + const updated = parseToolResult(await h.client.callTool({ name: 'update_collection', arguments: { collectionId: created.collection.id, name: 'Italia' } })) as { collection: { name: string } }; + expect(updated.collection.name).toBe('Italia'); + + const second = parseToolResult(await h.client.callTool({ name: 'create_collection', arguments: { name: 'France' } })) as { collection: { id: number } }; + const reordered = parseToolResult(await h.client.callTool({ name: 'reorder_collections', arguments: { orderedIds: [second.collection.id, created.collection.id] } })); + expect(reordered).toEqual({ success: true }); + expect(testDb.prepare('SELECT sort_order FROM collections WHERE id = ?').get(second.collection.id)).toEqual({ sort_order: 0 }); + + const deleted = parseToolResult(await h.client.callTool({ name: 'delete_collection', arguments: { collectionId: created.collection.id } })); + expect(deleted).toEqual({ success: true }); + expect(testDb.prepare('SELECT COUNT(*) n FROM collections WHERE id = ?').get(created.collection.id)).toEqual({ n: 0 }); + }); + }); + + it('delete_collection is owner-only (bespoke 403 text) and update surfaces the viewer 403', async () => { + const { user: owner } = createUser(testDb); + const { user: viewer } = createUser(testDb); + const col = seedCollection(owner.id, 'Guarded'); + addMember(col, viewer.id, 'viewer'); + await withHarness(viewer.id, async (h) => { + const del = await h.client.callTool({ name: 'delete_collection', arguments: { collectionId: col } }); + expect(del.isError).toBe(true); + expect(errorText(del)).toBe('Only the owner can delete this list'); + + const upd = await h.client.callTool({ name: 'update_collection', arguments: { collectionId: col, name: 'Nope' } }); + expect(upd.isError).toBe(true); + expect(errorText(upd)).toBe('You have read-only access to this list'); + }); + }); + + it('blocks demo user on writes', async () => { + process.env.DEMO_MODE = 'true'; + const { user } = createUser(testDb, { email: 'demo@nomad.app' }); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ name: 'create_collection', arguments: { name: 'X' } }); + expect(result.isError).toBe(true); + }); + }); +}); + +// --------------------------------------------------------------------------- +// Places +// --------------------------------------------------------------------------- + +describe('Tool: save_place_to_collection', () => { + it('saves a place and returns the duplicate marker on a re-save', async () => { + const { user } = createUser(testDb); + const col = seedCollection(user.id, 'Dedup'); + await withHarness(user.id, async (h) => { + const saved = parseToolResult(await h.client.callTool({ name: 'save_place_to_collection', arguments: { collection_id: col, name: 'Eiffel Tower' } })) as { place?: { id: number } }; + expect(saved.place).toBeDefined(); + + const dup = parseToolResult(await h.client.callTool({ name: 'save_place_to_collection', arguments: { collection_id: col, name: 'eiffel tower' } })) as { duplicate?: boolean; duplicateOf?: { name: string } }; + expect(dup.duplicate).toBe(true); + expect(dup.duplicateOf?.name).toBe('Eiffel Tower'); + }); + }); +}); + +describe('Tool: save_trip_places_to_collection', () => { + it('copies trip places, skipping duplicates', async () => { + const { user } = createUser(testDb); + createCategory(testDb); + const trip = createTrip(testDb, user.id); + const p1 = createPlace(testDb, trip.id, { name: 'Colosseum' }); + const p2 = createPlace(testDb, trip.id, { name: 'Pantheon' }); + const col = seedCollection(user.id, 'From trip'); + seedPlace(col, user.id, 'Pantheon'); // pre-existing → duplicate + await withHarness(user.id, async (h) => { + const data = parseToolResult(await h.client.callTool({ + name: 'save_trip_places_to_collection', + arguments: { collectionId: col, tripId: trip.id, placeIds: [p1.id, p2.id] }, + })) as { copied: number; skipped: { name: string }[] }; + expect(data.copied).toBe(1); + expect(data.skipped.map((s) => s.name)).toEqual(['Pantheon']); + }); + }); +}); + +describe('Tool: update_collection_place / set_collection_place_status / rate_collection_place', () => { + it('updates fields, sets status, and stores/clears a rating', async () => { + const { user } = createUser(testDb); + const col = seedCollection(user.id, 'P'); + const pid = seedPlace(col, user.id, 'Gate'); + await withHarness(user.id, async (h) => { + const upd = parseToolResult(await h.client.callTool({ name: 'update_collection_place', arguments: { placeId: pid, name: 'Brandenburg Gate' } })) as { place: { name: string } }; + expect(upd.place.name).toBe('Brandenburg Gate'); + + const status = parseToolResult(await h.client.callTool({ name: 'set_collection_place_status', arguments: { placeId: pid, status: 'want' } })) as { place: { status: string } }; + expect(status.place.status).toBe('want'); + + const rated = parseToolResult(await h.client.callTool({ name: 'rate_collection_place', arguments: { placeId: pid, rating: 5 } })) as { place: { rating_avg: number | null; rating_count: number } }; + expect(rated.place.rating_avg).toBe(5); + expect(rated.place.rating_count).toBe(1); + + // omitted rating clears the vote (legacy `rating ?? null`) + const cleared = parseToolResult(await h.client.callTool({ name: 'rate_collection_place', arguments: { placeId: pid } })) as { place: { rating_avg: number | null; rating_count: number } }; + expect(cleared.place.rating_avg).toBeNull(); + expect(cleared.place.rating_count).toBe(0); + }); + }); +}); + +describe('Tool: delete_collection_place', () => { + it('deletes a place; an unknown id surfaces the 404 text', async () => { + const { user } = createUser(testDb); + const col = seedCollection(user.id, 'D'); + const pid = seedPlace(col, user.id, 'Gone'); + await withHarness(user.id, async (h) => { + expect(parseToolResult(await h.client.callTool({ name: 'delete_collection_place', arguments: { placeId: pid } }))).toEqual({ success: true }); + const missing = await h.client.callTool({ name: 'delete_collection_place', arguments: { placeId: pid } }); + expect(missing.isError).toBe(true); + expect(errorText(missing)).toBe('Place not found'); + }); + }); +}); + +describe('Tool: copy_collection_places_to_trip', () => { + it('copies saved places into a trip the user can edit', async () => { + const { user } = createUser(testDb); + createCategory(testDb); + const trip = createTrip(testDb, user.id); + const col = seedCollection(user.id, 'Plan'); + const pid = seedPlace(col, user.id, 'Trevi Fountain'); + await withHarness(user.id, async (h) => { + const data = parseToolResult(await h.client.callTool({ name: 'copy_collection_places_to_trip', arguments: { trip_id: trip.id, place_ids: [pid] } })) as { copied: number }; + expect(data.copied).toBe(1); + expect(testDb.prepare("SELECT COUNT(*) n FROM places WHERE trip_id = ? AND name = 'Trevi Fountain'").get(trip.id)).toEqual({ n: 1 }); + }); + }); + + it('surfaces the 404 text for a trip the user cannot access', async () => { + const { user } = createUser(testDb); + const { user: other } = createUser(testDb); + createCategory(testDb); + const trip = createTrip(testDb, other.id); + const col = seedCollection(user.id, 'C'); + const pid = seedPlace(col, user.id, 'X'); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ name: 'copy_collection_places_to_trip', arguments: { trip_id: trip.id, place_ids: [pid] } }); + expect(result.isError).toBe(true); + expect(errorText(result)).toBe('Trip not found'); + }); + }); +}); + +// --------------------------------------------------------------------------- +// Labels +// --------------------------------------------------------------------------- + +describe('Label tools', () => { + it('create → assign → update → unassign → delete a label', async () => { + const { user } = createUser(testDb); + const col = seedCollection(user.id, 'DE'); + const pid = seedPlace(col, user.id, 'Gate'); + await withHarness(user.id, async (h) => { + const created = parseToolResult(await h.client.callTool({ name: 'create_collection_label', arguments: { collection_id: col, name: 'Berlin', color: '#ff0000' } })) as { label: { id: number; name: string } }; + expect(created.label.name).toBe('Berlin'); + + const assigned = parseToolResult(await h.client.callTool({ name: 'assign_collection_labels', arguments: { label_ids: [created.label.id], place_ids: [pid] } })) as { changed: number }; + expect(assigned.changed).toBe(1); + + const renamed = parseToolResult(await h.client.callTool({ name: 'update_collection_label', arguments: { labelId: created.label.id, name: 'Museums' } })) as { label: { name: string } }; + expect(renamed.label.name).toBe('Museums'); + + const removed = parseToolResult(await h.client.callTool({ name: 'assign_collection_labels', arguments: { label_ids: [created.label.id], place_ids: [pid], remove: true } })) as { changed: number }; + expect(removed.changed).toBe(1); + + expect(parseToolResult(await h.client.callTool({ name: 'delete_collection_label', arguments: { labelId: created.label.id } }))).toEqual({ success: true }); + expect(testDb.prepare('SELECT COUNT(*) n FROM collection_labels WHERE collection_id = ?').get(col)).toEqual({ n: 0 }); + }); + }); + + it('duplicate label name surfaces the 409 text', async () => { + const { user } = createUser(testDb); + const col = seedCollection(user.id, 'DE'); + await withHarness(user.id, async (h) => { + await h.client.callTool({ name: 'create_collection_label', arguments: { collection_id: col, name: 'Berlin' } }); + const dup = await h.client.callTool({ name: 'create_collection_label', arguments: { collection_id: col, name: 'berlin' } }); + expect(dup.isError).toBe(true); + expect(errorText(dup)).toBe('A label with this name already exists'); + }); + }); +}); + +// --------------------------------------------------------------------------- +// Sharing +// --------------------------------------------------------------------------- + +describe('Sharing tools', () => { + it('invite → accept → set role → remove member round trip', async () => { + const { user: owner } = createUser(testDb); + const { user: target } = createUser(testDb); + const col = seedCollection(owner.id, 'Shared'); + + await withHarness(owner.id, async (h) => { + const invited = parseToolResult(await h.client.callTool({ name: 'invite_to_collection', arguments: { collection_id: col, user_id: target.id } })); + expect(invited).toEqual({ success: true }); + expect(broadcastToUser).toHaveBeenCalledWith(target.id, expect.objectContaining({ type: 'collections:invite' })); + }); + await withHarness(target.id, async (h) => { + expect(parseToolResult(await h.client.callTool({ name: 'accept_collection_invite', arguments: { collectionId: col } }))).toEqual({ success: true }); + }); + await withHarness(owner.id, async (h) => { + expect(parseToolResult(await h.client.callTool({ name: 'set_collection_member_role', arguments: { collectionId: col, userId: target.id, role: 'admin' } }))).toEqual({ success: true }); + expect(testDb.prepare('SELECT role FROM collection_members WHERE collection_id = ? AND user_id = ?').get(col, target.id)).toEqual({ role: 'admin' }); + expect(parseToolResult(await h.client.callTool({ name: 'remove_collection_member', arguments: { collectionId: col, userId: target.id } }))).toEqual({ success: true }); + expect(testDb.prepare('SELECT COUNT(*) n FROM collection_members WHERE collection_id = ?').get(col)).toEqual({ n: 0 }); + }); + }); + + it('invite errors come back as isError text (self-invite / not owner)', async () => { + const { user: owner } = createUser(testDb); + const { user: other } = createUser(testDb); + const col = seedCollection(owner.id, 'OwnerOnly'); + await withHarness(owner.id, async (h) => { + const self = await h.client.callTool({ name: 'invite_to_collection', arguments: { collection_id: col, user_id: owner.id } }); + expect(self.isError).toBe(true); + expect(errorText(self)).toBe('Cannot invite yourself'); + }); + await withHarness(other.id, async (h) => { + const notOwner = await h.client.callTool({ name: 'invite_to_collection', arguments: { collection_id: col, user_id: owner.id } }); + expect(notOwner.isError).toBe(true); + expect(errorText(notOwner)).toBe('Not allowed'); + }); + }); + + it('accept without a pending invite → error text; decline and cancel clear the pending row', async () => { + const { user: owner } = createUser(testDb); + const { user: target } = createUser(testDb); + const col = seedCollection(owner.id, 'Pending'); + + await withHarness(target.id, async (h) => { + const noInvite = await h.client.callTool({ name: 'accept_collection_invite', arguments: { collectionId: col } }); + expect(noInvite.isError).toBe(true); + expect(errorText(noInvite)).toBe('No pending invite'); + }); + + addMember(col, target.id, 'editor', 'pending'); + await withHarness(target.id, async (h) => { + expect(parseToolResult(await h.client.callTool({ name: 'decline_collection_invite', arguments: { collectionId: col } }))).toEqual({ success: true }); + }); + expect(testDb.prepare('SELECT COUNT(*) n FROM collection_members WHERE collection_id = ?').get(col)).toEqual({ n: 0 }); + + addMember(col, target.id, 'editor', 'pending'); + await withHarness(owner.id, async (h) => { + expect(parseToolResult(await h.client.callTool({ name: 'cancel_collection_invite', arguments: { collectionId: col, userId: target.id } }))).toEqual({ success: true }); + }); + expect(testDb.prepare('SELECT COUNT(*) n FROM collection_members WHERE collection_id = ?').get(col)).toEqual({ n: 0 }); + }); + + it('leave_collection: member leaves; the owner gets the bespoke 400 text', async () => { + const { user: owner } = createUser(testDb); + const { user: member } = createUser(testDb); + const col = seedCollection(owner.id, 'Leavable'); + addMember(col, member.id); + await withHarness(member.id, async (h) => { + expect(parseToolResult(await h.client.callTool({ name: 'leave_collection', arguments: { collectionId: col } }))).toEqual({ success: true }); + }); + await withHarness(owner.id, async (h) => { + const blocked = await h.client.callTool({ name: 'leave_collection', arguments: { collectionId: col } }); + expect(blocked.isError).toBe(true); + expect(errorText(blocked)).toBe('Owner cannot leave; delete the list'); + }); + }); +}); + +// --------------------------------------------------------------------------- +// Scope gating (collections read/write, registration-time) +// --------------------------------------------------------------------------- + +const READ_TOOLS = ['list_collections', 'get_collection', 'available_collection_users']; +const WRITE_TOOLS = [ + 'create_collection', 'update_collection', 'delete_collection', 'reorder_collections', + 'save_place_to_collection', 'save_trip_places_to_collection', 'update_collection_place', + 'set_collection_place_status', 'rate_collection_place', 'delete_collection_place', + 'copy_collection_places_to_trip', 'create_collection_label', 'update_collection_label', + 'delete_collection_label', 'assign_collection_labels', 'invite_to_collection', + 'set_collection_member_role', 'remove_collection_member', 'cancel_collection_invite', + 'accept_collection_invite', 'decline_collection_invite', 'leave_collection', +]; + +describe('Collection tools — scope gating', () => { + async function listToolNames(userId: number, scopes: string[] | null): Promise { + const h = await createMcpHarness({ userId, withResources: false, scopes }); + try { + return (await h.client.listTools()).tools.map((t) => t.name); + } finally { + await h.cleanup(); + } + } + + it('registers all 25 tools with null scopes (full access)', async () => { + const { user } = createUser(testDb); + const names = await listToolNames(user.id, null); + for (const tool of [...READ_TOOLS, ...WRITE_TOOLS]) expect(names).toContain(tool); + }); + + it('registers only the read tools with collections:read', async () => { + const { user } = createUser(testDb); + const names = await listToolNames(user.id, ['collections:read']); + for (const tool of READ_TOOLS) expect(names).toContain(tool); + for (const tool of WRITE_TOOLS) expect(names).not.toContain(tool); + }); + + it('registers no collection tools for an unrelated scope', async () => { + const { user } = createUser(testDb); + const names = await listToolNames(user.id, ['budget:read']); + for (const tool of [...READ_TOOLS, ...WRITE_TOOLS]) expect(names).not.toContain(tool); + }); +}); + +// --------------------------------------------------------------------------- +// Addon NON-gating — parity quirk carried over from the legacy registrar: +// unlike the REST CollectionsAddonGuard and the plugin host's requireAddon, +// the MCP tools register even with the collections addon disabled. +// --------------------------------------------------------------------------- + +describe('Collection tools — no addon gate (legacy parity)', () => { + it('still registers the tools when the collections addon is disabled', async () => { + const { user } = createUser(testDb); + testDb.prepare('UPDATE addons SET enabled = 0 WHERE id = ?').run(ADDON_IDS.COLLECTIONS); + try { + await withHarness(user.id, async (h) => { + const names = (await h.client.listTools()).tools.map((t) => t.name); + expect(names).toContain('list_collections'); + expect(names).toContain('create_collection'); + }); + } finally { + testDb.prepare('UPDATE addons SET enabled = 1 WHERE id = ?').run(ADDON_IDS.COLLECTIONS); + } + }); +}); diff --git a/server/tests/unit/services/collectionsService.test.ts b/server/tests/unit/nest/collections.service.test.ts similarity index 91% rename from server/tests/unit/services/collectionsService.test.ts rename to server/tests/unit/nest/collections.service.test.ts index cf95e8a6d..63fbc2a9d 100644 --- a/server/tests/unit/services/collectionsService.test.ts +++ b/server/tests/unit/nest/collections.service.test.ts @@ -1,8 +1,12 @@ /** - * Unit tests for collectionsService (COLLECTIONS-SVC-001 … 040). - * Real in-memory SQLite (full schema + migrations) so the SQL — owner/member - * visibility, the collection-scoped dedup, the fusion state machine and the - * widened photo-cache reference check — is exercised faithfully. + * Unit tests for the DI-native CollectionsService (COLLECTIONS-SVC-001 … 081; + * moved 1:1 from the legacy tests/unit/services/collectionsService.test.ts, + * case IDs preserved — the 080/081 membership-lookup cases are new with the + * fold). Real in-memory SQLite (full schema + migrations) so the SQL — + * owner/member visibility, the collection-scoped dedup, the fusion state + * machine and the widened photo-cache reference check — is exercised + * faithfully. Keeps its own clearCollections() reset (the shared + * resetTestDb RESET_TABLES list has no collection tables). */ import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest'; @@ -44,10 +48,15 @@ import path from 'path'; import { createTables } from '../../../src/db/schema'; import { runMigrations } from '../../../src/db/migrations'; import { createUser, createTrip, createPlace, createCategory, createTag, addTripMember } from '../../helpers/factories'; -import * as svc from '../../../src/services/collectionsService'; +import { DatabaseService } from '../../../src/nest/database/database.service'; +import { PermissionsService } from '../../../src/nest/permissions/permissions.service'; +import { RealtimeService } from '../../../src/nest/realtime/realtime.service'; +import { CollectionsService } from '../../../src/nest/collections/collections.service'; import { removeIfUnreferenced } from '../../../src/services/placePhotoCache'; import { PLACE_IMAGES_DIR } from '../../../src/services/placeImage'; +const svc = new CollectionsService(new DatabaseService(testDb), new PermissionsService(new DatabaseService(testDb)), new RealtimeService()); + function clearCollections() { testDb.exec(` DELETE FROM collection_place_labels; @@ -65,9 +74,13 @@ function clearCollections() { `); } -beforeAll(() => { +beforeAll(async () => { createTables(testDb); runMigrations(testDb); + // Warm the notificationService module: sendInvite does a fire-and-forget + // dynamic import of it, and a cold load can otherwise race the mock registry + // under slow (coverage) runs — same precaution as tests/integration/vacay.test.ts. + await import('../../../src/services/notificationService'); }); beforeEach(() => { @@ -749,3 +762,36 @@ describe('collaborative ratings (#1435)', () => { expect(ids).not.toContain(notInTrip.id); }); }); + +// ── Membership lookups ─────────────────────────────────────────────────────── + +describe('membership lookups', () => { + it('COLLECTIONS-SVC-080: findMembership matches by google id and coords, never by bare name', () => { + const u = createUser(testDb).user; + const col = svc.createCollection(u.id, { name: 'Lookup' }); + svc.savePlace(u.id, { collection_id: col.id, name: 'Starbucks', lat: 48.8584, lng: 2.2945, google_place_id: 'gp-1' }); + + expect(svc.findMembership(u.id, { google_place_id: 'gp-1' }).saved).toBe(true); + expect(svc.findMembership(u.id, { lat: 48.8584, lng: 2.2945 }).saved).toBe(true); + // A bare name is deliberately NOT a condition on its own — no false positives. + expect(svc.findMembership(u.id, { name: 'Starbucks' })).toEqual({ saved: false, lists: [] }); + // No lists at all short-circuits. + const other = createUser(testDb).user; + expect(svc.findMembership(other.id, { google_place_id: 'gp-1' })).toEqual({ saved: false, lists: [] }); + }); + + it('COLLECTIONS-SVC-081: findMembershipForUser reports owner / accepted / pending / none', () => { + const owner = createUser(testDb).user; + const member = createUser(testDb).user; + const outsider = createUser(testDb).user; + const col = svc.createCollection(owner.id, { name: 'M' }); + + expect(svc.findMembershipForUser(owner.id, col.id)).toEqual({ is_member: true, is_owner: true, status: 'accepted' }); + expect(svc.findMembershipForUser(outsider.id, col.id)).toEqual({ is_member: false, is_owner: false, status: null }); + + svc.sendInvite(col.id, owner.id, owner.username, owner.email, member.id); + expect(svc.findMembershipForUser(member.id, col.id)).toEqual({ is_member: false, is_owner: false, status: 'pending' }); + svc.acceptInvite(member.id, col.id, undefined); + expect(svc.findMembershipForUser(member.id, col.id)).toEqual({ is_member: true, is_owner: false, status: 'accepted' }); + }); +}); diff --git a/server/tests/unit/plugins/plugin-host-deps.factory.test.ts b/server/tests/unit/plugins/plugin-host-deps.factory.test.ts index 99c8f2920..f65c6098a 100644 --- a/server/tests/unit/plugins/plugin-host-deps.factory.test.ts +++ b/server/tests/unit/plugins/plugin-host-deps.factory.test.ts @@ -5,7 +5,7 @@ * host, and trip broadcasts are force-namespaced to plugin:{id}:{event}. * DI-native domains (budget/exchange-rates/reservations/tags/categories/todo/ * packing/day-notes/days/assignments/oauth/llm-config/files/collab/vacay/ - * permissions/trips/places) + * permissions/trips/places/collections) * are constructor-injected stubs; legacy services/* domains stay path-mocked * until their own DI migration lands. */ @@ -267,19 +267,20 @@ vi.mock('../../../src/services/atlasService', () => ({ createBucketItem: vi.fn((uid: number, data: { name: string }) => ({ id: 110, user_id: uid, name: data.name })), deleteBucketItem: vi.fn((_uid: number, itemId: number) => Number(itemId) !== 404), })); -vi.mock('../../../src/services/collectionsService', () => { - const httpError = (status: number, message: string) => { const e = new Error(message) as Error & { status: number }; e.status = status; throw e; }; - return { - listCollections: vi.fn((uid: number) => ({ collections: [{ id: 1, owner: uid }] })), - getCollection: vi.fn((uid: number, id: number) => ({ id, owner: uid, places: [] })), - createCollection: vi.fn((uid: number, body: unknown) => ({ id: 100, owner_id: uid, ...(body as object) })), - // id 99 = viewer-only (403); id 404 = invisible (404) — the service throws status-tagged errors - updateCollection: vi.fn((_uid: number, id: number, body: unknown) => { if (id === 99) httpError(403, 'read-only'); if (id === 404) httpError(404, 'Collection not found'); return { id, ...(body as object) }; }), - savePlace: vi.fn((uid: number, body: unknown) => ({ id: 101, saved_by: uid, ...(body as object) })), - copyToTrip: vi.fn(() => ({ copied: 2, skipped: [] })), - deletePlace: vi.fn((_uid: number, placeId: number) => { if (placeId === 404) httpError(404, 'Collection not found'); }), - }; -}); +// Collections is a constructor-injected stub since the collections fold (same +// behaviors as the old services/collectionsService path mock — the service +// throws status-tagged errors the factory maps onto the RPC error classes). +const collectionsHttpError = (status: number, message: string) => { const e = new Error(message) as Error & { status: number }; e.status = status; throw e; }; +const collectionsStub = { + listCollections: vi.fn((uid: number) => ({ collections: [{ id: 1, owner: uid }] })), + getCollection: vi.fn((uid: number, id: number) => ({ id, owner: uid, places: [] })), + createCollection: vi.fn((uid: number, body: unknown) => ({ id: 100, owner_id: uid, ...(body as object) })), + // id 99 = viewer-only (403); id 404 = invisible (404) — the service throws status-tagged errors + updateCollection: vi.fn((_uid: number, id: number, body: unknown) => { if (id === 99) collectionsHttpError(403, 'read-only'); if (id === 404) collectionsHttpError(404, 'Collection not found'); return { id, ...(body as object) }; }), + savePlace: vi.fn((uid: number, body: unknown) => ({ id: 101, saved_by: uid, ...(body as object) })), + copyToTrip: vi.fn(() => ({ copied: 2, skipped: [] })), + deletePlace: vi.fn((_uid: number, placeId: number) => { if (placeId === 404) collectionsHttpError(404, 'Collection not found'); }), +} as unknown as CollectionsService; // Day notes are a constructor-injected stub (same behaviors as the old path mock). const dayNotesStub = { list: vi.fn((dayId: number, tripId: number) => [{ id: 1, day_id: dayId, trip_id: tripId }]), @@ -319,6 +320,7 @@ import type { CollabService } from '../../../src/nest/collab/collab.service'; import type { VacayService } from '../../../src/nest/vacay/vacay.service'; import type { PermissionsService } from '../../../src/nest/permissions/permissions.service'; import type { PlacesService } from '../../../src/nest/places/places.service'; +import type { CollectionsService } from '../../../src/nest/collections/collections.service'; import { DatabaseService } from '../../../src/nest/database/database.service'; import { RealtimeService } from '../../../src/nest/realtime/realtime.service'; import { NotFoundError, ValidationError } from '../../../src/nest/trips/trips.service'; @@ -341,7 +343,7 @@ const tripsStub = { list: () => [{ id: 1 }], removeMember: vi.fn(), } as unknown as import('../../../src/nest/trips/trips.service').TripsService; -const factory = new PluginHostDepsFactory(budgetStub, reservationsStub, tagsStub, categoriesStub, todoStub, packingStub, oauthStub, dayNotesStub, assignmentsStub, llmConfigStub, new DatabaseService(mockDb), filesStub, collabStub, vacayStub, daysStub, permissionsStub, exchangeRatesStub, addonsStub, new RealtimeService(), tripsStub, placesStub); +const factory = new PluginHostDepsFactory(budgetStub, reservationsStub, tagsStub, categoriesStub, todoStub, packingStub, oauthStub, dayNotesStub, assignmentsStub, llmConfigStub, new DatabaseService(mockDb), filesStub, collabStub, vacayStub, daysStub, permissionsStub, exchangeRatesStub, addonsStub, new RealtimeService(), tripsStub, placesStub, collectionsStub); const stubRouter: PluginCallRouter = { callPlugin: async () => undefined, emitPluginEvent: () => {} }; const createRealRpcHost = (id: string, granted: ReadonlySet, router: PluginCallRouter = stubRouter) => factory.create(id, granted, router);