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