mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 05:26:47 +00:00
chore: move collections backend to proper DI
This commit is contained in:
@@ -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).
|
||||
|
||||
@@ -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); }
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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. ---
|
||||
|
||||
@@ -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
@@ -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 },
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
+52
-6
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user