chore: move collections backend to proper DI

This commit is contained in:
jubnl
2026-08-01 13:54:19 +02:00
parent 4f8300e2ba
commit cab49ea511
17 changed files with 2091 additions and 1511 deletions
+2 -2
View File
@@ -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).
-398
View File
@@ -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); }
}
);
}
+15 -1
View File
@@ -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
@@ -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<string, never>, 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); }
}
}
@@ -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 {}
File diff suppressed because it is too large Load Diff
@@ -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.
+10 -3
View File
@@ -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.
@@ -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. ---
+2 -1
View File
@@ -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
File diff suppressed because it is too large Load Diff
+6 -3
View File
@@ -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();
@@ -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 },
+2
View File
@@ -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),
);
}
@@ -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<void>) {
const h = await createMcpHarness({ userId, withResources: false });
try { await fn(h); } finally { await h.cleanup(); }
}
function errorText(result: Awaited<ReturnType<McpHarness['client']['callTool']>>): 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<string, unknown> = {}): 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<string[]> {
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);
}
});
});
@@ -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' });
});
});
@@ -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<string>, router: PluginCallRouter = stubRouter) => factory.create(id, granted, router);