diff --git a/server/src/mcp/resources.ts b/server/src/mcp/resources.ts index 5b2645e63..fa67c95cf 100644 --- a/server/src/mcp/resources.ts +++ b/server/src/mcp/resources.ts @@ -5,11 +5,10 @@ import { listDays, listAccommodations } from '../services/dayService'; import { listPlaces } from '../services/placeService'; import { listBudgetItems, getPerPersonSummary, calculateSettlement } from '../services/budgetService'; import { listReservations } from '../services/reservationService'; -import { listNotes as listCollabNotes, listPolls, listMessages } from '../services/collabService'; import { listBucketList, listVisitedCountries, getStats as getAtlasStats, listManuallyVisitedRegions } from '../services/atlasService'; import { getNotifications } from '../services/inAppNotifications'; import { getActivePlanId, getActivePlan, getPlanData, getEntries as getVacayEntries, getHolidays } from '../services/vacayService'; -import { isAddonEnabled, getCollabFeatures } from '../services/adminService'; +import { isAddonEnabled } from '../services/adminService'; import { ADDON_IDS } from '../addons'; import { canAccessJourney, getJourneyFull, listEntries, listJourneys } from '../services/journeyService'; import { canRead, canReadTrips } from './scopes'; @@ -162,19 +161,9 @@ export function registerResources(server: McpServer, userId: number, scopes: str } ); - // Collab notes for a trip - const collabFeatures = isAddonEnabled(ADDON_IDS.COLLAB) ? getCollabFeatures() : null; - if (collabFeatures?.notes && canRead(scopes, 'collab')) server.registerResource( - 'trip-collab-notes', - new ResourceTemplate('trek://trips/{tripId}/collab-notes', { list: undefined }), - { description: 'Shared collaborative notes for a trip', mimeType: 'application/json' }, - async (uri, { tripId }) => { - const id = parseId(tripId); - if (id === null || !canAccessTrip(id, userId)) return accessDenied(uri.href); - const notes = listCollabNotes(id); - return jsonContent(uri.href, notes); - } - ); + // The trek://trips/{tripId}/collab-notes, …/collab/polls and …/collab/messages + // resources moved to the DI-discovered src/nest/collab/collab.mcp.ts + // (@ResourceTemplate, attached via the nest-mcp registry in registerTools). // The trek://trips/{tripId}/todos resource moved to the DI-discovered // src/nest/todo/todo.mcp.ts (@ResourceTemplate, attached via the nest-mcp @@ -269,36 +258,6 @@ export function registerResources(server: McpServer, userId: number, scopes: str ); } - // Collab polls (addon + sub-feature gated) - if (collabFeatures?.polls && canRead(scopes, 'collab')) { - server.registerResource( - 'trip-collab-polls', - new ResourceTemplate('trek://trips/{tripId}/collab/polls', { list: undefined }), - { description: 'All polls for a trip with vote counts per option', mimeType: 'application/json' }, - async (uri, { tripId }) => { - const id = parseId(tripId); - if (id === null || !canAccessTrip(id, userId)) return accessDenied(uri.href); - const polls = listPolls(id); - return jsonContent(uri.href, polls); - } - ); - } - - // Collab messages (addon + sub-feature gated) - if (collabFeatures?.chat && canRead(scopes, 'collab')) { - server.registerResource( - 'trip-collab-messages', - new ResourceTemplate('trek://trips/{tripId}/collab/messages', { list: undefined }), - { description: 'Most recent 100 chat messages for a trip', mimeType: 'application/json' }, - async (uri, { tripId }) => { - const id = parseId(tripId); - if (id === null || !canAccessTrip(id, userId)) return accessDenied(uri.href); - const messages = listMessages(id); - return jsonContent(uri.href, messages); - } - ); - } - // Vacay resources (addon-gated) if (isAddonEnabled(ADDON_IDS.VACAY) && canRead(scopes, 'vacay')) { server.registerResource( diff --git a/server/src/mcp/tools.ts b/server/src/mcp/tools.ts index bdbe2a72a..017872240 100644 --- a/server/src/mcp/tools.ts +++ b/server/src/mcp/tools.ts @@ -8,7 +8,6 @@ import { registerPlaceTools } from './tools/places'; import { registerCollectionTools } from './tools/collections'; import { registerDayTools } from './tools/days'; import { registerBudgetTools } from './tools/budget'; -import { registerCollabTools } from './tools/collab'; import { registerTripTools } from './tools/trips'; import { registerTransportTools } from './tools/transports'; import { registerTransitTools } from './tools/transit'; @@ -41,7 +40,8 @@ export function registerTools(server: McpServer, userId: number, scopes: string[ registerAtlasTools(server, userId, scopes); - registerCollabTools(server, userId, scopes); + // The collab tools moved to the DI-discovered src/nest/collab/collab.mcp.ts + // (@McpController, attached via the nest-mcp registry below). registerTransportTools(server, userId, scopes); diff --git a/server/src/mcp/tools/collab.ts b/server/src/mcp/tools/collab.ts deleted file mode 100644 index 64a2ed93e..000000000 --- a/server/src/mcp/tools/collab.ts +++ /dev/null @@ -1,285 +0,0 @@ -import { McpServer } from '@modelcontextprotocol/sdk/server/mcp'; -import { z } from 'zod'; -import { canAccessTrip } from '../../db/database'; -import { isDemoUser } from '../../services/authService'; -import { - createNote as createCollabNote, updateNote as updateCollabNote, deleteNote as deleteCollabNote, - listPolls, createPoll, votePoll, closePoll, deletePoll, - listMessages, createMessage, deleteMessage, addOrRemoveReaction, -} from '../../services/collabService'; -import { isAddonEnabled, getCollabFeatures } from '../../services/adminService'; -import { ADDON_IDS } from '../../addons'; -import { - safeBroadcast, TOOL_ANNOTATIONS_WRITE, TOOL_ANNOTATIONS_DELETE, - TOOL_ANNOTATIONS_NON_IDEMPOTENT, TOOL_ANNOTATIONS_READONLY, - demoDenied, noAccess, ok, hasTripPermission, permissionDenied, -} from './_shared'; -import { canRead, canWrite } from '../scopes'; - -export function registerCollabTools(server: McpServer, userId: number, scopes: string[] | null): void { - const R = canRead(scopes, 'collab'); - const W = canWrite(scopes, 'collab'); - - if (!isAddonEnabled(ADDON_IDS.COLLAB)) return; - - const features = getCollabFeatures(); - - // --- COLLAB NOTES --- - - if (features.notes && W) server.registerTool( - 'create_collab_note', - { - description: 'Create a shared collaborative note on a trip (visible to all trip members in the Collab tab).', - inputSchema: { - tripId: z.number().int().positive(), - title: z.string().min(1).max(200), - content: z.string().max(10000).optional(), - category: z.string().max(100).optional().describe('Note category (e.g. "Ideas", "To-do", "General")'), - color: z.string().regex(/^#[0-9a-fA-F]{6}$/).optional().describe('Hex color for the note card'), - pinned: z.boolean().optional().default(false).describe('Pin the note to the top'), - }, - annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT, - }, - async ({ tripId, title, content, category, color, pinned }) => { - if (isDemoUser(userId)) return demoDenied(); - if (!canAccessTrip(tripId, userId)) return noAccess(); - if (!hasTripPermission('collab_edit', tripId, userId)) return permissionDenied(); - const note = createCollabNote(tripId, userId, { title, content, category, color, pinned }); - safeBroadcast(tripId, 'collab:note:created', { note }); - return ok({ note }); - } - ); - - if (features.notes && W) server.registerTool( - 'update_collab_note', - { - description: 'Edit an existing collaborative note on a trip.', - inputSchema: { - tripId: z.number().int().positive(), - noteId: z.number().int().positive(), - title: z.string().min(1).max(200).optional(), - content: z.string().max(10000).optional(), - category: z.string().max(100).optional(), - color: z.string().regex(/^#[0-9a-fA-F]{6}$/).optional().describe('Hex color for the note card'), - pinned: z.boolean().optional().describe('Pin the note to the top'), - }, - annotations: TOOL_ANNOTATIONS_WRITE, - }, - async ({ tripId, noteId, title, content, category, color, pinned }) => { - if (isDemoUser(userId)) return demoDenied(); - if (!canAccessTrip(tripId, userId)) return noAccess(); - if (!hasTripPermission('collab_edit', tripId, userId)) return permissionDenied(); - const note = updateCollabNote(tripId, noteId, { title, content, category, color, pinned }); - if (!note) return { content: [{ type: 'text' as const, text: 'Note not found.' }], isError: true }; - safeBroadcast(tripId, 'collab:note:updated', { note }); - return ok({ note }); - } - ); - - if (features.notes && W) server.registerTool( - 'delete_collab_note', - { - description: 'Delete a collaborative note from a trip.', - inputSchema: { - tripId: z.number().int().positive(), - noteId: z.number().int().positive(), - }, - annotations: TOOL_ANNOTATIONS_DELETE, - }, - async ({ tripId, noteId }) => { - if (isDemoUser(userId)) return demoDenied(); - if (!canAccessTrip(tripId, userId)) return noAccess(); - if (!hasTripPermission('collab_edit', tripId, userId)) return permissionDenied(); - const deleted = deleteCollabNote(tripId, noteId); - if (!deleted) return { content: [{ type: 'text' as const, text: 'Note not found.' }], isError: true }; - safeBroadcast(tripId, 'collab:note:deleted', { noteId }); - return ok({ success: true }); - } - ); - - // --- COLLAB POLLS & CHAT --- - - if (features.polls && R) server.registerTool( - 'list_collab_polls', - { - description: 'List all polls for a trip.', - inputSchema: { - tripId: z.number().int().positive(), - }, - annotations: TOOL_ANNOTATIONS_READONLY, - }, - async ({ tripId }) => { - if (!canAccessTrip(tripId, userId)) return noAccess(); - const polls = listPolls(tripId); - return ok({ polls }); - } - ); - - if (features.polls && W) server.registerTool( - 'create_collab_poll', - { - description: 'Create a new poll in the collab panel.', - inputSchema: { - tripId: z.number().int().positive(), - question: z.string().min(1), - options: z.array(z.string()).min(2).describe('Poll answer options (at least 2)'), - multiple: z.boolean().optional().describe('Allow multiple choice'), - deadline: z.string().optional().describe('ISO date string for poll deadline'), - }, - annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT, - }, - async ({ tripId, question, options, multiple, deadline }) => { - if (isDemoUser(userId)) return demoDenied(); - if (!canAccessTrip(tripId, userId)) return noAccess(); - if (!hasTripPermission('collab_edit', tripId, userId)) return permissionDenied(); - const poll = createPoll(tripId, userId, { question, options, multiple, deadline }); - safeBroadcast(tripId, 'collab:poll:created', { poll }); - return ok({ poll }); - } - ); - - if (features.polls && W) server.registerTool( - 'vote_collab_poll', - { - description: 'Vote on a poll option (or remove vote if already voted for that option).', - inputSchema: { - tripId: z.number().int().positive(), - pollId: z.number().int().positive(), - optionIndex: z.number().int().min(0).describe('Zero-based index of the option to vote for'), - }, - annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT, - }, - async ({ tripId, pollId, optionIndex }) => { - if (!canAccessTrip(tripId, userId)) return noAccess(); - if (!hasTripPermission('collab_edit', tripId, userId)) return permissionDenied(); - const result = votePoll(tripId, pollId, userId, optionIndex); - if (result.error) return { content: [{ type: 'text' as const, text: result.error }], isError: true }; - safeBroadcast(tripId, 'collab:poll:voted', { poll: result.poll }); - return ok({ poll: result.poll }); - } - ); - - if (features.polls && W) server.registerTool( - 'close_collab_poll', - { - description: 'Close a poll so no more votes can be cast.', - inputSchema: { - tripId: z.number().int().positive(), - pollId: z.number().int().positive(), - }, - annotations: TOOL_ANNOTATIONS_WRITE, - }, - async ({ tripId, pollId }) => { - if (isDemoUser(userId)) return demoDenied(); - if (!canAccessTrip(tripId, userId)) return noAccess(); - if (!hasTripPermission('collab_edit', tripId, userId)) return permissionDenied(); - const poll = closePoll(tripId, pollId); - if (!poll) return { content: [{ type: 'text' as const, text: 'Poll not found.' }], isError: true }; - safeBroadcast(tripId, 'collab:poll:closed', { poll }); - return ok({ poll }); - } - ); - - if (features.polls && W) server.registerTool( - 'delete_collab_poll', - { - description: 'Delete a poll and all its votes.', - inputSchema: { - tripId: z.number().int().positive(), - pollId: z.number().int().positive(), - }, - annotations: TOOL_ANNOTATIONS_DELETE, - }, - async ({ tripId, pollId }) => { - if (isDemoUser(userId)) return demoDenied(); - if (!canAccessTrip(tripId, userId)) return noAccess(); - if (!hasTripPermission('collab_edit', tripId, userId)) return permissionDenied(); - const deleted = deletePoll(tripId, pollId); - if (!deleted) return { content: [{ type: 'text' as const, text: 'Poll not found.' }], isError: true }; - safeBroadcast(tripId, 'collab:poll:deleted', { pollId }); - return ok({ success: true }); - } - ); - - if (features.chat && R) server.registerTool( - 'list_collab_messages', - { - description: 'List chat messages for a trip (most recent 100, oldest-first).', - inputSchema: { - tripId: z.number().int().positive(), - before: z.number().int().positive().optional().describe('Load messages with ID less than this (pagination)'), - }, - annotations: TOOL_ANNOTATIONS_READONLY, - }, - async ({ tripId, before }) => { - if (!canAccessTrip(tripId, userId)) return noAccess(); - const messages = listMessages(tripId, before); - return ok({ messages }); - } - ); - - if (features.chat && W) server.registerTool( - 'send_collab_message', - { - description: "Send a chat message to a trip's collab channel.", - inputSchema: { - tripId: z.number().int().positive(), - text: z.string().min(1), - replyTo: z.number().int().positive().optional().describe('Reply to a specific message ID'), - }, - annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT, - }, - async ({ tripId, text, replyTo }) => { - if (isDemoUser(userId)) return demoDenied(); - if (!canAccessTrip(tripId, userId)) return noAccess(); - if (!hasTripPermission('collab_edit', tripId, userId)) return permissionDenied(); - const result = createMessage(tripId, userId, text, replyTo ?? null); - if (result.error) return { content: [{ type: 'text' as const, text: result.error }], isError: true }; - safeBroadcast(tripId, 'collab:message:created', { message: result.message }); - return ok({ message: result.message }); - } - ); - - if (features.chat && W) server.registerTool( - 'delete_collab_message', - { - description: 'Delete a chat message (only the message owner can delete their own messages).', - inputSchema: { - tripId: z.number().int().positive(), - messageId: z.number().int().positive(), - }, - annotations: TOOL_ANNOTATIONS_DELETE, - }, - async ({ tripId, messageId }) => { - if (isDemoUser(userId)) return demoDenied(); - if (!canAccessTrip(tripId, userId)) return noAccess(); - if (!hasTripPermission('collab_edit', tripId, userId)) return permissionDenied(); - const result = deleteMessage(tripId, messageId, userId); - if (result.error) return { content: [{ type: 'text' as const, text: result.error }], isError: true }; - safeBroadcast(tripId, 'collab:message:deleted', { messageId, username: result.username }); - return ok({ success: true }); - } - ); - - if (features.chat && W) server.registerTool( - 'react_collab_message', - { - description: 'Toggle a reaction emoji on a chat message (adds if not present, removes if already reacted).', - inputSchema: { - tripId: z.number().int().positive(), - messageId: z.number().int().positive(), - emoji: z.string().describe('Single emoji character'), - }, - annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT, - }, - async ({ tripId, messageId, emoji }) => { - if (isDemoUser(userId)) return demoDenied(); - if (!canAccessTrip(tripId, userId)) return noAccess(); - if (!hasTripPermission('collab_edit', tripId, userId)) return permissionDenied(); - const result = addOrRemoveReaction(messageId, tripId, userId, emoji); - if (!result.found) return { content: [{ type: 'text' as const, text: 'Message not found.' }], isError: true }; - safeBroadcast(tripId, 'collab:message:reacted', { messageId, reactions: result.reactions }); - return ok({ reactions: result.reactions }); - } - ); -} diff --git a/server/src/mcp/tools/trips.ts b/server/src/mcp/tools/trips.ts index 71e947156..8886fbc97 100644 --- a/server/src/mcp/tools/trips.ts +++ b/server/src/mcp/tools/trips.ts @@ -14,7 +14,7 @@ import { } from '../../nest/share/share.bridge'; import { isAddonEnabled, getCollabFeatures } from '../../services/adminService'; import { ADDON_IDS } from '../../addons'; -import { countMessages, listPolls } from '../../services/collabService'; +import { countMessages, listPolls } from '../../nest/collab/collab.bridge'; import { listItems as listTodoItems, } from '../../nest/todo/todo.bridge'; diff --git a/server/src/nest/README.md b/server/src/nest/README.md index 15703f011..58e5ff202 100644 --- a/server/src/nest/README.md +++ b/server/src/nest/README.md @@ -27,8 +27,8 @@ mount to Nest and leaves the sibling trip routes (days, places, ...) on Express. categories, tags, notifications, atlas. - **Phase 2 (trip sub-domains):** vacay (addon), packing, todo. - **DI-native services (legacy `src/services/*` deleted):** tags, categories, - todo, packing, day-notes, trip-invite, assignments, share, settings, files — - see the migration recipe below. + todo, packing, day-notes, trip-invite, assignments, share, settings, files, + collab — see the migration recipe below. ## Cross-cutting Foundation pieces @@ -124,9 +124,14 @@ by three controllers' module-scope multer configs moved to `files.constants.ts`, and `files.bridge.ts` survives with a single export — the request-time `getAllowedExtensions` app_settings read those configs need outside DI — while every function consumer, including the plugin host and -`TripsService`, injects `FilesService`). Repeat these -steps per -service (next up: collabService). This is a **pure relocation** — byte-identical +`TripsService`, injects `FilesService`); collab followed (a 12-tool + +3-resource `collab.mcp.ts` that piloted **composite `when` gates** — the collab +addon AND the per-sub-feature `getCollabFeatures()` flags (notes/polls/chat) +that the legacy registrar and resources checked at registration time — plus +the plugin-host swap and a 3-export `collab.bridge.ts` for the two remaining +legacy consumers, `tripService`'s trip summary and `mcp/tools/trips.ts`). +Repeat these steps per +service (next up: vacayService). This is a **pure relocation** — byte-identical SQL, statuses, bodies, and error strings. The plugin RPC host is **no longer a bridge consumer**: since Option A of `src/nest/plugins/DI-MIGRATION.md` it injects domain services via `PluginHostDepsFactory`, so a migrated domain adds diff --git a/server/src/nest/collab/collab.bridge.ts b/server/src/nest/collab/collab.bridge.ts new file mode 100644 index 000000000..534718cf3 --- /dev/null +++ b/server/src/nest/collab/collab.bridge.ts @@ -0,0 +1,31 @@ +import { db } from '../../db/database'; +import { DatabaseService } from '../database/database.service'; +import { CollabService } from './collab.service'; + +/** + * Non-Nest entry point for the collab domain — for code running OUTSIDE the + * Nest container (the legacy trip-summary builder in src/services/tripService.ts + * and the legacy get_trip_summary registrar in src/mcp/tools/trips.ts; the + * collab MCP tools and resources moved to the DI-discovered collab.mcp.ts, and + * the plugin RPC host injects CollabService via PluginHostDepsFactory). Exports + * only the legacy services/collabService names still consumed outside the + * container, 1:1, so repointing a consumer is an import-path-only diff. Inside + * the container, inject CollabService instead. Delete this file when + * tripService and the legacy MCP trips registrar migrate. + * + * Module-level construction is safe: `db` is the reinitialize-proof Proxy onto + * the shared better-sqlite3 singleton. + */ +const collab = new CollabService(new DatabaseService(db)); + +export function listNotes(tripId: string | number) { + return collab.listNotes(tripId); +} + +export function listPolls(tripId: string | number) { + return collab.listPolls(tripId); +} + +export function countMessages(tripId: string | number): number { + return collab.countMessages(tripId); +} diff --git a/server/src/nest/collab/collab.mcp.ts b/server/src/nest/collab/collab.mcp.ts new file mode 100644 index 000000000..8b50d33c3 --- /dev/null +++ b/server/src/nest/collab/collab.mcp.ts @@ -0,0 +1,383 @@ +import { + McpController, Tool, ResourceTemplate, type McpContext, + TOOL_ANNOTATIONS_READONLY, TOOL_ANNOTATIONS_WRITE, + TOOL_ANNOTATIONS_DELETE, TOOL_ANNOTATIONS_NON_IDEMPOTENT, + demoDenied, errorResult, ok, +} from '@trek/nest-mcp'; +import { z } from 'zod'; +import { isDemoUser } from '../../services/authService'; +import { isAddonEnabled, getCollabFeatures } from '../../services/adminService'; +import { ADDON_IDS } from '../../addons'; +import { safeBroadcast, noAccess, hasTripPermission, permissionDenied } from '../../mcp/tools/_shared'; +import { CollabService } from './collab.service'; + +/** + * Legacy registrar gates: the whole collab surface rides the collab addon + * (whole-registrar early return), and each tool/resource additionally rides + * its sub-feature flag from getCollabFeatures() — notes, polls or chat. + */ +const collabNotesOn = () => isAddonEnabled(ADDON_IDS.COLLAB) && getCollabFeatures().notes; +const collabPollsOn = () => isAddonEnabled(ADDON_IDS.COLLAB) && getCollabFeatures().polls; +const collabChatOn = () => isAddonEnabled(ADDON_IDS.COLLAB) && getCollabFeatures().chat; + +function parseId(value: string | string[]): number | null { + const n = Number(Array.isArray(value) ? value[0] : value); + return Number.isInteger(n) && n > 0 ? n : null; +} + +function accessDenied(uri: string) { + return { + contents: [{ + uri, + mimeType: 'application/json', + text: JSON.stringify({ error: 'Trip not found or access denied' }), + }], + }; +} + +function jsonContent(uri: string, data: unknown) { + return { + contents: [{ + uri, + mimeType: 'application/json', + text: JSON.stringify(data, null, 2), + }], + }; +} + +/** + * Collab MCP surface — ported 1:1 from the legacy registrars: the twelve tools + * from src/mcp/tools/collab.ts and the trek://trips/{tripId}/collab-notes, + * …/collab/polls and …/collab/messages resources from src/mcp/resources.ts + * (identical names, descriptions, schemas, annotations, error/payload shapes + * and broadcasts). The registration-time gates map to the composite `when` + * thunks (collab addon AND per-sub-feature flag) plus the declarative collab + * read/write access markers (the legacy `if (R)` / `if (W)` checks, resolved + * by trekMcpAccessPolicy). Parity quirks kept on purpose: vote_collab_poll has + * no demo-user gate, and the list tools check only trip access. + */ +@McpController() +export class CollabMcp { + constructor(private readonly collab: CollabService) {} + + // --- COLLAB NOTES --- + + @Tool({ + name: 'create_collab_note', + description: 'Create a shared collaborative note on a trip (visible to all trip members in the Collab tab).', + inputSchema: { + tripId: z.number().int().positive(), + title: z.string().min(1).max(200), + content: z.string().max(10000).optional(), + category: z.string().max(100).optional().describe('Note category (e.g. "Ideas", "To-do", "General")'), + color: z.string().regex(/^#[0-9a-fA-F]{6}$/).optional().describe('Hex color for the note card'), + pinned: z.boolean().optional().default(false).describe('Pin the note to the top'), + }, + annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT, + when: collabNotesOn, + access: { group: 'collab', mode: 'write' }, + }) + async createCollabNote( + { tripId, title, content, category, color, pinned }: { + tripId: number; title: string; content?: string; category?: string; color?: string; pinned?: boolean; + }, + ctx: McpContext, + ) { + if (isDemoUser(ctx.userId)) return demoDenied(); + if (!this.collab.verifyTripAccess(tripId, ctx.userId)) return noAccess(); + if (!hasTripPermission('collab_edit', tripId, ctx.userId)) return permissionDenied(); + const note = this.collab.createNote(tripId, ctx.userId, { title, content, category, color, pinned }); + safeBroadcast(tripId, 'collab:note:created', { note }); + return ok({ note }); + } + + @Tool({ + name: 'update_collab_note', + description: 'Edit an existing collaborative note on a trip.', + inputSchema: { + tripId: z.number().int().positive(), + noteId: z.number().int().positive(), + title: z.string().min(1).max(200).optional(), + content: z.string().max(10000).optional(), + category: z.string().max(100).optional(), + color: z.string().regex(/^#[0-9a-fA-F]{6}$/).optional().describe('Hex color for the note card'), + pinned: z.boolean().optional().describe('Pin the note to the top'), + }, + annotations: TOOL_ANNOTATIONS_WRITE, + when: collabNotesOn, + access: { group: 'collab', mode: 'write' }, + }) + async updateCollabNote( + { tripId, noteId, title, content, category, color, pinned }: { + tripId: number; noteId: number; title?: string; content?: string; category?: string; color?: string; pinned?: boolean; + }, + ctx: McpContext, + ) { + if (isDemoUser(ctx.userId)) return demoDenied(); + if (!this.collab.verifyTripAccess(tripId, ctx.userId)) return noAccess(); + if (!hasTripPermission('collab_edit', tripId, ctx.userId)) return permissionDenied(); + const note = this.collab.updateNote(tripId, noteId, { title, content, category, color, pinned }); + if (!note) return errorResult('Note not found.'); + safeBroadcast(tripId, 'collab:note:updated', { note }); + return ok({ note }); + } + + @Tool({ + name: 'delete_collab_note', + description: 'Delete a collaborative note from a trip.', + inputSchema: { + tripId: z.number().int().positive(), + noteId: z.number().int().positive(), + }, + annotations: TOOL_ANNOTATIONS_DELETE, + when: collabNotesOn, + access: { group: 'collab', mode: 'write' }, + }) + async deleteCollabNote({ tripId, noteId }: { tripId: number; noteId: number }, ctx: McpContext) { + if (isDemoUser(ctx.userId)) return demoDenied(); + if (!this.collab.verifyTripAccess(tripId, ctx.userId)) return noAccess(); + if (!hasTripPermission('collab_edit', tripId, ctx.userId)) return permissionDenied(); + const deleted = this.collab.deleteNote(tripId, noteId); + if (!deleted) return errorResult('Note not found.'); + safeBroadcast(tripId, 'collab:note:deleted', { noteId }); + return ok({ success: true }); + } + + // --- COLLAB POLLS & CHAT --- + + @Tool({ + name: 'list_collab_polls', + description: 'List all polls for a trip.', + inputSchema: { + tripId: z.number().int().positive(), + }, + annotations: TOOL_ANNOTATIONS_READONLY, + when: collabPollsOn, + access: { group: 'collab', mode: 'read' }, + }) + async listCollabPolls({ tripId }: { tripId: number }, ctx: McpContext) { + if (!this.collab.verifyTripAccess(tripId, ctx.userId)) return noAccess(); + const polls = this.collab.listPolls(tripId); + return ok({ polls }); + } + + @Tool({ + name: 'create_collab_poll', + description: 'Create a new poll in the collab panel.', + inputSchema: { + tripId: z.number().int().positive(), + question: z.string().min(1), + options: z.array(z.string()).min(2).describe('Poll answer options (at least 2)'), + multiple: z.boolean().optional().describe('Allow multiple choice'), + deadline: z.string().optional().describe('ISO date string for poll deadline'), + }, + annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT, + when: collabPollsOn, + access: { group: 'collab', mode: 'write' }, + }) + async createCollabPoll( + { tripId, question, options, multiple, deadline }: { + tripId: number; question: string; options: string[]; multiple?: boolean; deadline?: string; + }, + ctx: McpContext, + ) { + if (isDemoUser(ctx.userId)) return demoDenied(); + if (!this.collab.verifyTripAccess(tripId, ctx.userId)) return noAccess(); + if (!hasTripPermission('collab_edit', tripId, ctx.userId)) return permissionDenied(); + const poll = this.collab.createPoll(tripId, ctx.userId, { question, options, multiple, deadline }); + safeBroadcast(tripId, 'collab:poll:created', { poll }); + return ok({ poll }); + } + + @Tool({ + name: 'vote_collab_poll', + description: 'Vote on a poll option (or remove vote if already voted for that option).', + inputSchema: { + tripId: z.number().int().positive(), + pollId: z.number().int().positive(), + optionIndex: z.number().int().min(0).describe('Zero-based index of the option to vote for'), + }, + annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT, + when: collabPollsOn, + access: { group: 'collab', mode: 'write' }, + }) + async voteCollabPoll({ tripId, pollId, optionIndex }: { tripId: number; pollId: number; optionIndex: number }, ctx: McpContext) { + if (!this.collab.verifyTripAccess(tripId, ctx.userId)) return noAccess(); + if (!hasTripPermission('collab_edit', tripId, ctx.userId)) return permissionDenied(); + const result = this.collab.votePoll(tripId, pollId, ctx.userId, optionIndex); + if (result.error) return errorResult(result.error); + safeBroadcast(tripId, 'collab:poll:voted', { poll: result.poll }); + return ok({ poll: result.poll }); + } + + @Tool({ + name: 'close_collab_poll', + description: 'Close a poll so no more votes can be cast.', + inputSchema: { + tripId: z.number().int().positive(), + pollId: z.number().int().positive(), + }, + annotations: TOOL_ANNOTATIONS_WRITE, + when: collabPollsOn, + access: { group: 'collab', mode: 'write' }, + }) + async closeCollabPoll({ tripId, pollId }: { tripId: number; pollId: number }, ctx: McpContext) { + if (isDemoUser(ctx.userId)) return demoDenied(); + if (!this.collab.verifyTripAccess(tripId, ctx.userId)) return noAccess(); + if (!hasTripPermission('collab_edit', tripId, ctx.userId)) return permissionDenied(); + const poll = this.collab.closePoll(tripId, pollId); + if (!poll) return errorResult('Poll not found.'); + safeBroadcast(tripId, 'collab:poll:closed', { poll }); + return ok({ poll }); + } + + @Tool({ + name: 'delete_collab_poll', + description: 'Delete a poll and all its votes.', + inputSchema: { + tripId: z.number().int().positive(), + pollId: z.number().int().positive(), + }, + annotations: TOOL_ANNOTATIONS_DELETE, + when: collabPollsOn, + access: { group: 'collab', mode: 'write' }, + }) + async deleteCollabPoll({ tripId, pollId }: { tripId: number; pollId: number }, ctx: McpContext) { + if (isDemoUser(ctx.userId)) return demoDenied(); + if (!this.collab.verifyTripAccess(tripId, ctx.userId)) return noAccess(); + if (!hasTripPermission('collab_edit', tripId, ctx.userId)) return permissionDenied(); + const deleted = this.collab.deletePoll(tripId, pollId); + if (!deleted) return errorResult('Poll not found.'); + safeBroadcast(tripId, 'collab:poll:deleted', { pollId }); + return ok({ success: true }); + } + + @Tool({ + name: 'list_collab_messages', + description: 'List chat messages for a trip (most recent 100, oldest-first).', + inputSchema: { + tripId: z.number().int().positive(), + before: z.number().int().positive().optional().describe('Load messages with ID less than this (pagination)'), + }, + annotations: TOOL_ANNOTATIONS_READONLY, + when: collabChatOn, + access: { group: 'collab', mode: 'read' }, + }) + async listCollabMessages({ tripId, before }: { tripId: number; before?: number }, ctx: McpContext) { + if (!this.collab.verifyTripAccess(tripId, ctx.userId)) return noAccess(); + const messages = this.collab.listMessages(tripId, before); + return ok({ messages }); + } + + @Tool({ + name: 'send_collab_message', + description: "Send a chat message to a trip's collab channel.", + inputSchema: { + tripId: z.number().int().positive(), + text: z.string().min(1), + replyTo: z.number().int().positive().optional().describe('Reply to a specific message ID'), + }, + annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT, + when: collabChatOn, + access: { group: 'collab', mode: 'write' }, + }) + async sendCollabMessage({ tripId, text, replyTo }: { tripId: number; text: string; replyTo?: number }, ctx: McpContext) { + if (isDemoUser(ctx.userId)) return demoDenied(); + if (!this.collab.verifyTripAccess(tripId, ctx.userId)) return noAccess(); + if (!hasTripPermission('collab_edit', tripId, ctx.userId)) return permissionDenied(); + const result = this.collab.createMessage(tripId, ctx.userId, text, replyTo ?? null); + if (result.error) return errorResult(result.error); + safeBroadcast(tripId, 'collab:message:created', { message: result.message }); + return ok({ message: result.message }); + } + + @Tool({ + name: 'delete_collab_message', + description: 'Delete a chat message (only the message owner can delete their own messages).', + inputSchema: { + tripId: z.number().int().positive(), + messageId: z.number().int().positive(), + }, + annotations: TOOL_ANNOTATIONS_DELETE, + when: collabChatOn, + access: { group: 'collab', mode: 'write' }, + }) + async deleteCollabMessage({ tripId, messageId }: { tripId: number; messageId: number }, ctx: McpContext) { + if (isDemoUser(ctx.userId)) return demoDenied(); + if (!this.collab.verifyTripAccess(tripId, ctx.userId)) return noAccess(); + if (!hasTripPermission('collab_edit', tripId, ctx.userId)) return permissionDenied(); + const result = this.collab.deleteMessage(tripId, messageId, ctx.userId); + if (result.error) return errorResult(result.error); + safeBroadcast(tripId, 'collab:message:deleted', { messageId, username: result.username }); + return ok({ success: true }); + } + + @Tool({ + name: 'react_collab_message', + description: 'Toggle a reaction emoji on a chat message (adds if not present, removes if already reacted).', + inputSchema: { + tripId: z.number().int().positive(), + messageId: z.number().int().positive(), + emoji: z.string().describe('Single emoji character'), + }, + annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT, + when: collabChatOn, + access: { group: 'collab', mode: 'write' }, + }) + async reactCollabMessage({ tripId, messageId, emoji }: { tripId: number; messageId: number; emoji: string }, ctx: McpContext) { + if (isDemoUser(ctx.userId)) return demoDenied(); + if (!this.collab.verifyTripAccess(tripId, ctx.userId)) return noAccess(); + if (!hasTripPermission('collab_edit', tripId, ctx.userId)) return permissionDenied(); + const result = this.collab.reactMessage(messageId, tripId, ctx.userId, emoji); + if (!result.found) return errorResult('Message not found.'); + safeBroadcast(tripId, 'collab:message:reacted', { messageId, reactions: result.reactions }); + return ok({ reactions: result.reactions }); + } + + // --- RESOURCES --- + + @ResourceTemplate({ + name: 'trip-collab-notes', + uriTemplate: 'trek://trips/{tripId}/collab-notes', + description: 'Shared collaborative notes for a trip', + mimeType: 'application/json', + when: collabNotesOn, + access: { group: 'collab', mode: 'read' }, + }) + async tripCollabNotesResource(uri: URL, { tripId }: { tripId: string | string[] }, ctx: McpContext) { + const id = parseId(tripId); + if (id === null || !this.collab.verifyTripAccess(id, ctx.userId)) return accessDenied(uri.href); + const notes = this.collab.listNotes(id); + return jsonContent(uri.href, notes); + } + + @ResourceTemplate({ + name: 'trip-collab-polls', + uriTemplate: 'trek://trips/{tripId}/collab/polls', + description: 'All polls for a trip with vote counts per option', + mimeType: 'application/json', + when: collabPollsOn, + access: { group: 'collab', mode: 'read' }, + }) + async tripCollabPollsResource(uri: URL, { tripId }: { tripId: string | string[] }, ctx: McpContext) { + const id = parseId(tripId); + if (id === null || !this.collab.verifyTripAccess(id, ctx.userId)) return accessDenied(uri.href); + const polls = this.collab.listPolls(id); + return jsonContent(uri.href, polls); + } + + @ResourceTemplate({ + name: 'trip-collab-messages', + uriTemplate: 'trek://trips/{tripId}/collab/messages', + description: 'Most recent 100 chat messages for a trip', + mimeType: 'application/json', + when: collabChatOn, + access: { group: 'collab', mode: 'read' }, + }) + async tripCollabMessagesResource(uri: URL, { tripId }: { tripId: string | string[] }, ctx: McpContext) { + const id = parseId(tripId); + if (id === null || !this.collab.verifyTripAccess(id, ctx.userId)) return accessDenied(uri.href); + const messages = this.collab.listMessages(id); + return jsonContent(uri.href, messages); + } +} diff --git a/server/src/nest/collab/collab.module.ts b/server/src/nest/collab/collab.module.ts index 118dda846..fbbc1ea2b 100644 --- a/server/src/nest/collab/collab.module.ts +++ b/server/src/nest/collab/collab.module.ts @@ -1,9 +1,12 @@ import { Module } from '@nestjs/common'; import { CollabController } from './collab.controller'; import { CollabService } from './collab.service'; +import { CollabMcp } from './collab.mcp'; @Module({ controllers: [CollabController], - providers: [CollabService], + providers: [CollabService, CollabMcp], + // For in-container consumers (PluginHostDepsFactory). + exports: [CollabService], }) export class CollabModule {} diff --git a/server/src/nest/collab/collab.service.ts b/server/src/nest/collab/collab.service.ts index e36a800d2..7f4e5b67c 100644 --- a/server/src/nest/collab/collab.service.ts +++ b/server/src/nest/collab/collab.service.ts @@ -1,27 +1,67 @@ +import path from 'path'; +import fs from 'fs'; import { Injectable } from '@nestjs/common'; import { DatabaseService } from '../database/database.service'; import { broadcast } from '../../websocket'; import { checkPermission } from '../../services/permissions'; -import type { User } from '../../types'; -import * as svc from '../../services/collabService'; +import { verifyTripAccess } from '../../services/tripAccess'; +import { avatarUrl } from '../../services/avatarUrl'; +import { checkSsrf, createPinnedDispatcher } from '../../utils/ssrfGuard'; +import type { CollabNote, CollabPoll, CollabMessage, TripFile, User } from '../../types'; -type Trip = NonNullable>; +type Trip = NonNullable>; + +export interface ReactionRow { + emoji: string; + user_id: number; + username: string; + message_id?: number; +} + +export interface PollVoteRow { + option_index: number; + user_id: number; + username: string; + avatar: string | null; +} + +export interface NoteFileRow { + id: number; + filename: string; + original_name?: string; + file_size?: number; + mime_type?: string; +} + +export interface GroupedReaction { + emoji: string; + users: { user_id: number; username: string }[]; + count: number; +} + +export interface LinkPreviewResult { + title: string | null; + description: string | null; + image: string | null; + site_name?: string | null; + url: string; +} /** - * Thin Nest wrapper around the existing collab service. Trip access, the - * 'collab_edit' / 'file_upload' permissions, the SQL and the WebSocket - * broadcasts reuse the legacy code unchanged. + * Collab domain service — owns the collab SQL (moved 1:1 from the legacy + * services/collabService.ts: identical statements, the `||` falsy-coercion + * defaults, the mixed COALESCE/CASE update, the post-write re-selects and the + * sentinel error strings). Trip access, the 'collab_edit' / 'file_upload' + * permissions and the WebSocket broadcast keep their legacy call paths. + * Non-Nest consumers (legacy tripService, the legacy MCP trips registrar) go + * through collab.bridge.ts instead of importing this class directly. */ @Injectable() export class CollabService { - constructor(private readonly dbs: DatabaseService) {} + constructor(private readonly db: DatabaseService) {} - private get db() { - return this.dbs.connection; - } - - verifyTripAccess(tripId: string, userId: number) { - return svc.verifyTripAccess(tripId, userId); + verifyTripAccess(tripId: string | number, userId: number) { + return verifyTripAccess(tripId, userId); } canEdit(trip: Trip, user: User): boolean { @@ -36,31 +76,410 @@ export class CollabService { broadcast(tripId, event, payload, socketId); } - listNotes(tripId: string) { return svc.listNotes(tripId); } - createNote(tripId: string, userId: number, data: Parameters[2]) { return svc.createNote(tripId, userId, data); } - updateNote(tripId: string, id: string, data: Parameters[2]) { return svc.updateNote(tripId, id, data); } - deleteNote(tripId: string, id: string): boolean { return svc.deleteNote(tripId, id); } - addNoteFile(tripId: string, id: string, file: Parameters[2]) { return svc.addNoteFile(tripId, id, file); } - getFormattedNoteById(id: string) { return svc.getFormattedNoteById(id); } - deleteNoteFile(tripId: string, noteId: string, fileId: string): boolean { return svc.deleteNoteFile(tripId, noteId, fileId); } + /* ------------------------------------------------------------------ */ + /* Reactions */ + /* ------------------------------------------------------------------ */ - listPolls(tripId: string) { return svc.listPolls(tripId); } - createPoll(tripId: string, userId: number, data: Parameters[2]) { return svc.createPoll(tripId, userId, data); } - votePoll(tripId: string, id: string, userId: number, optionIndex: number) { return svc.votePoll(tripId, id, userId, optionIndex); } - closePoll(tripId: string, id: string) { return svc.closePoll(tripId, id); } - deletePoll(tripId: string, id: string): boolean { return svc.deletePoll(tripId, id); } + private loadReactions(messageId: number | string): ReactionRow[] { + return this.db.all(` + SELECT r.emoji, r.user_id, u.username + FROM collab_message_reactions r + JOIN users u ON r.user_id = u.id + WHERE r.message_id = ? + `, messageId); + } - listMessages(tripId: string, before?: string) { return svc.listMessages(tripId, before); } - createMessage(tripId: string, userId: number, text: string, replyTo?: number | null) { return svc.createMessage(tripId, userId, text, replyTo); } - deleteMessage(tripId: string, id: string, userId: number) { return svc.deleteMessage(tripId, id, userId); } - reactMessage(id: string, tripId: string, userId: number, emoji: string) { return svc.addOrRemoveReaction(id, tripId, userId, emoji); } + private groupReactions(reactions: ReactionRow[]): GroupedReaction[] { + const map: Record = {}; + for (const r of reactions) { + if (!map[r.emoji]) map[r.emoji] = []; + map[r.emoji].push({ user_id: r.user_id, username: r.username }); + } + return Object.entries(map).map(([emoji, users]) => ({ emoji, users, count: users.length })); + } - linkPreview(url: string) { return svc.fetchLinkPreview(url); } + reactMessage(messageId: number | string, tripId: number | string, userId: number, emoji: string): { found: boolean; reactions: GroupedReaction[] } { + const msg = this.db.get('SELECT id FROM collab_messages WHERE id = ? AND trip_id = ?', messageId, tripId); + if (!msg) return { found: false, reactions: [] }; - /** Fire-and-forget collab notification (mirrors the route's dynamic import). */ + const existing = this.db.get<{ id: number }>('SELECT id FROM collab_message_reactions WHERE message_id = ? AND user_id = ? AND emoji = ?', messageId, userId, emoji); + if (existing) { + this.db.run('DELETE FROM collab_message_reactions WHERE id = ?', existing.id); + } else { + this.db.run('INSERT INTO collab_message_reactions (message_id, user_id, emoji) VALUES (?, ?, ?)', messageId, userId, emoji); + } + + return { found: true, reactions: this.groupReactions(this.loadReactions(messageId)) }; + } + + /* ------------------------------------------------------------------ */ + /* Notes */ + /* ------------------------------------------------------------------ */ + + private formatNote(note: CollabNote) { + const attachments = this.db.all('SELECT id, filename, original_name, file_size, mime_type FROM trip_files WHERE note_id = ?', note.id); + return { + ...note, + avatar_url: avatarUrl(note), + attachments: attachments.map(a => ({ ...a, url: `/api/trips/${note.trip_id}/files/${a.id}/download` })), + }; + } + + listNotes(tripId: string | number) { + const notes = this.db.all(` + SELECT n.*, u.username, u.avatar + FROM collab_notes n + JOIN users u ON n.user_id = u.id + WHERE n.trip_id = ? + ORDER BY n.pinned DESC, n.updated_at DESC + `, tripId); + + return notes.map(note => this.formatNote(note)); + } + + createNote(tripId: string | number, userId: number, data: { title: string; content?: string | null; category?: string | null; color?: string | null; website?: string | null; pinned?: boolean }) { + const pinned = data.pinned ? 1 : 0; + const result = this.db.run(` + INSERT INTO collab_notes (trip_id, user_id, title, content, category, color, website, pinned) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + `, tripId, userId, data.title, data.content || null, data.category || 'General', data.color || '#6366f1', data.website || null, pinned); + + const note = this.db.get(` + SELECT n.*, u.username, u.avatar FROM collab_notes n JOIN users u ON n.user_id = u.id WHERE n.id = ? + `, result.lastInsertRowid)!; + + return this.formatNote(note); + } + + updateNote(tripId: string | number, noteId: string | number, data: { title?: string; content?: string | null; category?: string | null; color?: string | null; pinned?: number | boolean; website?: string | null }): ReturnType | null { + const existing = this.db.get('SELECT * FROM collab_notes WHERE id = ? AND trip_id = ?', noteId, tripId); + if (!existing) return null; + + this.db.run(` + UPDATE collab_notes SET + title = COALESCE(?, title), + content = CASE WHEN ? THEN ? ELSE content END, + category = COALESCE(?, category), + color = COALESCE(?, color), + pinned = CASE WHEN ? IS NOT NULL THEN ? ELSE pinned END, + website = CASE WHEN ? THEN ? ELSE website END, + updated_at = CURRENT_TIMESTAMP + WHERE id = ? + `, + data.title || null, + data.content !== undefined ? 1 : 0, data.content !== undefined ? data.content : null, + data.category || null, + data.color || null, + data.pinned !== undefined ? 1 : null, data.pinned ? 1 : 0, + data.website !== undefined ? 1 : 0, data.website !== undefined ? data.website : null, + noteId + ); + + const note = this.db.get(` + SELECT n.*, u.username, u.avatar FROM collab_notes n JOIN users u ON n.user_id = u.id WHERE n.id = ? + `, noteId)!; + + return this.formatNote(note); + } + + deleteNote(tripId: string | number, noteId: string | number): boolean { + const existing = this.db.get('SELECT id FROM collab_notes WHERE id = ? AND trip_id = ?', noteId, tripId); + if (!existing) return false; + + // Clean up attached files from disk + const noteFiles = this.db.all('SELECT id, filename FROM trip_files WHERE note_id = ?', noteId); + for (const f of noteFiles) { + const filePath = path.join(__dirname, '../../../uploads', f.filename); + try { fs.unlinkSync(filePath); } catch { /* ignore */ } + } + this.db.run('DELETE FROM trip_files WHERE note_id = ?', noteId); + + this.db.run('DELETE FROM collab_notes WHERE id = ?', noteId); + return true; + } + + /* ------------------------------------------------------------------ */ + /* Note files */ + /* ------------------------------------------------------------------ */ + + addNoteFile(tripId: string | number, noteId: string | number, file: { filename: string; originalname: string; size: number; mimetype: string }): { file: TripFile & { url: string } } | null { + const note = this.db.get('SELECT id FROM collab_notes WHERE id = ? AND trip_id = ?', noteId, tripId); + if (!note) return null; + + const result = this.db.run( + 'INSERT INTO trip_files (trip_id, note_id, filename, original_name, file_size, mime_type) VALUES (?, ?, ?, ?, ?, ?)', + tripId, noteId, `files/${file.filename}`, file.originalname, file.size, file.mimetype + ); + + const saved = this.db.get('SELECT * FROM trip_files WHERE id = ?', result.lastInsertRowid)!; + return { file: { ...saved, url: `/api/trips/${tripId}/files/${saved.id}/download` } }; + } + + getFormattedNoteById(noteId: string | number) { + const note = this.db.get('SELECT n.*, u.username, u.avatar FROM collab_notes n JOIN users u ON n.user_id = u.id WHERE n.id = ?', noteId)!; + return this.formatNote(note); + } + + deleteNoteFile(tripId: string | number, noteId: string | number, fileId: string | number): boolean { + // Scope to the trip — like every sibling collab op — so a caller authorized for THEIR + // trip can't delete a note-file that belongs to someone else's trip (IDOR). trip_files + // carries trip_id, so this ties the deleted object to the URL's :tripId the controller + // access-checked, not just to a note/file id an attacker can enumerate. + const file = this.db.get('SELECT * FROM trip_files WHERE id = ? AND note_id = ? AND trip_id = ?', fileId, noteId, tripId); + if (!file) return false; + + const filePath = path.join(__dirname, '../../../uploads', file.filename); + try { fs.unlinkSync(filePath); } catch { /* ignore */ } + + this.db.run('DELETE FROM trip_files WHERE id = ?', fileId); + return true; + } + + /* ------------------------------------------------------------------ */ + /* Polls */ + /* ------------------------------------------------------------------ */ + + private getPollWithVotes(pollId: number | bigint | string) { + const poll = this.db.get(` + SELECT p.*, u.username, u.avatar + FROM collab_polls p + JOIN users u ON p.user_id = u.id + WHERE p.id = ? + `, pollId); + + if (!poll) return null; + + const options: (string | { label: string })[] = JSON.parse(poll.options); + + const votes = this.db.all(` + SELECT v.option_index, v.user_id, u.username, u.avatar + FROM collab_poll_votes v + JOIN users u ON v.user_id = u.id + WHERE v.poll_id = ? + `, pollId); + + const formattedOptions = options.map((label: string | { label: string }, idx: number) => { + const text = typeof label === 'string' ? label : label.label || label; + return { + // The client renders `opt.text`; keep `label` too for any other consumer. + text, + label: text, + voters: votes + .filter(v => v.option_index === idx) + .map(v => ({ id: v.user_id, user_id: v.user_id, username: v.username, avatar: v.avatar, avatar_url: avatarUrl(v) })), + }; + }); + + return { + ...poll, + avatar_url: avatarUrl(poll), + options: formattedOptions, + is_closed: !!poll.closed, + multiple_choice: !!poll.multiple, + }; + } + + listPolls(tripId: string | number) { + const rows = this.db.all<{ id: number }>(` + SELECT id FROM collab_polls WHERE trip_id = ? ORDER BY created_at DESC + `, tripId); + + return rows.map(row => this.getPollWithVotes(row.id)).filter(Boolean); + } + + createPoll(tripId: string | number, userId: number, data: { question: string; options: unknown[]; multiple?: boolean; multiple_choice?: boolean; deadline?: string }) { + const isMultiple = data.multiple || data.multiple_choice; + + const result = this.db.run(` + INSERT INTO collab_polls (trip_id, user_id, question, options, multiple, deadline) + VALUES (?, ?, ?, ?, ?, ?) + `, tripId, userId, data.question, JSON.stringify(data.options), isMultiple ? 1 : 0, data.deadline || null); + + return this.getPollWithVotes(result.lastInsertRowid); + } + + votePoll(tripId: string | number, pollId: string | number, userId: number, optionIndex: number): { error?: string; poll?: ReturnType } { + const poll = this.db.get('SELECT * FROM collab_polls WHERE id = ? AND trip_id = ?', pollId, tripId); + if (!poll) return { error: 'not_found' }; + if (poll.closed) return { error: 'closed' }; + + const options = JSON.parse(poll.options); + if (optionIndex < 0 || optionIndex >= options.length) { + return { error: 'invalid_index' }; + } + + const existingVote = this.db.get<{ id: number }>( + 'SELECT id FROM collab_poll_votes WHERE poll_id = ? AND user_id = ? AND option_index = ?', + pollId, userId, optionIndex + ); + + if (existingVote) { + this.db.run('DELETE FROM collab_poll_votes WHERE id = ?', existingVote.id); + } else { + if (!poll.multiple) { + this.db.run('DELETE FROM collab_poll_votes WHERE poll_id = ? AND user_id = ?', pollId, userId); + } + this.db.run('INSERT INTO collab_poll_votes (poll_id, user_id, option_index) VALUES (?, ?, ?)', pollId, userId, optionIndex); + } + + return { poll: this.getPollWithVotes(pollId) }; + } + + closePoll(tripId: string | number, pollId: string | number): ReturnType | null { + const poll = this.db.get('SELECT * FROM collab_polls WHERE id = ? AND trip_id = ?', pollId, tripId); + if (!poll) return null; + + this.db.run('UPDATE collab_polls SET closed = 1 WHERE id = ?', pollId); + return this.getPollWithVotes(pollId); + } + + deletePoll(tripId: string | number, pollId: string | number): boolean { + const poll = this.db.get('SELECT id FROM collab_polls WHERE id = ? AND trip_id = ?', pollId, tripId); + if (!poll) return false; + + this.db.run('DELETE FROM collab_polls WHERE id = ?', pollId); + return true; + } + + /* ------------------------------------------------------------------ */ + /* Messages */ + /* ------------------------------------------------------------------ */ + + private formatMessage(msg: CollabMessage, reactions?: GroupedReaction[]) { + return { ...msg, user_avatar: avatarUrl(msg), avatar_url: avatarUrl(msg), reactions: reactions || [] }; + } + + countMessages(tripId: string | number): number { + const row = this.db.get<{ cnt: number }>('SELECT COUNT(*) as cnt FROM collab_messages WHERE trip_id = ?', tripId)!; + return row.cnt; + } + + listMessages(tripId: string | number, before?: string | number) { + const query = ` + SELECT m.*, u.username, u.avatar, + rm.text AS reply_text, ru.username AS reply_username + FROM collab_messages m + JOIN users u ON m.user_id = u.id + LEFT JOIN collab_messages rm ON m.reply_to = rm.id + LEFT JOIN users ru ON rm.user_id = ru.id + WHERE m.trip_id = ?${before ? ' AND m.id < ?' : ''} + ORDER BY m.id DESC + LIMIT 100 + `; + + const messages = before + ? this.db.all(query, tripId, before) + : this.db.all(query, tripId); + + messages.reverse(); + + const msgIds = messages.map(m => m.id); + const reactionsByMsg: Record = {}; + if (msgIds.length > 0) { + const allReactions = this.db.all(` + SELECT r.message_id, r.emoji, r.user_id, u.username + FROM collab_message_reactions r + JOIN users u ON r.user_id = u.id + WHERE r.message_id IN (${msgIds.map(() => '?').join(',')}) + `, ...msgIds); + for (const r of allReactions) { + if (!reactionsByMsg[r.message_id]) reactionsByMsg[r.message_id] = []; + reactionsByMsg[r.message_id].push(r); + } + } + + return messages.map(m => this.formatMessage(m, this.groupReactions(reactionsByMsg[m.id] || []))); + } + + createMessage(tripId: string | number, userId: number, text: string, replyTo?: number | null): { error?: string; message?: ReturnType } { + if (replyTo) { + const replyMsg = this.db.get('SELECT id FROM collab_messages WHERE id = ? AND trip_id = ?', replyTo, tripId); + if (!replyMsg) return { error: 'reply_not_found' }; + } + + const result = this.db.run(` + INSERT INTO collab_messages (trip_id, user_id, text, reply_to) VALUES (?, ?, ?, ?) + `, tripId, userId, text.trim(), replyTo || null); + + const message = this.db.get(` + SELECT m.*, u.username, u.avatar, + rm.text AS reply_text, ru.username AS reply_username + FROM collab_messages m + JOIN users u ON m.user_id = u.id + LEFT JOIN collab_messages rm ON m.reply_to = rm.id + LEFT JOIN users ru ON rm.user_id = ru.id + WHERE m.id = ? + `, result.lastInsertRowid)!; + + return { message: this.formatMessage(message) }; + } + + deleteMessage(tripId: string | number, messageId: string | number, userId: number): { error?: string; username?: string } { + const message = this.db.get('SELECT * FROM collab_messages WHERE id = ? AND trip_id = ?', messageId, tripId); + if (!message) return { error: 'not_found' }; + if (Number(message.user_id) !== Number(userId)) return { error: 'not_owner' }; + + this.db.run('UPDATE collab_messages SET deleted = 1 WHERE id = ?', messageId); + return { username: message.username }; + } + + /* ------------------------------------------------------------------ */ + /* Link preview */ + /* ------------------------------------------------------------------ */ + + async linkPreview(url: string): Promise { + const fallback: LinkPreviewResult = { title: null, description: null, image: null, url }; + + const parsed = new URL(url); + const ssrf = await checkSsrf(url, true); + if (!ssrf.allowed) { + return { ...fallback, error: ssrf.error } as LinkPreviewResult & { error?: string }; + } + + try { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 5000); + + try { + const r = await fetch(url, { + redirect: 'error', + signal: controller.signal, + dispatcher: createPinnedDispatcher(ssrf.resolvedIp!), + headers: { 'User-Agent': 'Mozilla/5.0 (compatible; NOMAD/1.0; +https://github.com/mauriceboe/NOMAD)' }, + } as any); + clearTimeout(timeout); + if (!r.ok) throw new Error('Fetch failed'); + + const html = await r.text(); + const get = (prop: string) => { + const m = html.match(new RegExp(`]*property=["']og:${prop}["'][^>]*content=["']([^"']*)["']`, 'i')) + || html.match(new RegExp(`]*content=["']([^"']*)["'][^>]*property=["']og:${prop}["']`, 'i')); + return m ? m[1] : null; + }; + const titleTag = html.match(/]*>([^<]*)<\/title>/i); + const descMeta = html.match(/]*name=["']description["'][^>]*content=["']([^"']*)["']/i) + || html.match(/]*content=["']([^"']*)["'][^>]*name=["']description["']/i); + + return { + title: get('title') || (titleTag ? titleTag[1].trim() : null), + description: get('description') || (descMeta ? descMeta[1].trim() : null), + image: get('image') || null, + site_name: get('site_name') || null, + url, + }; + } catch { + clearTimeout(timeout); + return fallback; + } + } catch { + return fallback; + } + } + + /** Fire-and-forget collab notification (mirrors the legacy route's dynamic import). */ notifyCollab(tripId: string, actor: User, preview?: string): void { import('../../services/notificationService').then(({ send }) => { - const tripInfo = this.db.prepare('SELECT title FROM trips WHERE id = ?').get(tripId) as { title: string } | undefined; + const tripInfo = this.db.get<{ title: string }>('SELECT title FROM trips WHERE id = ?', tripId); const params: Record = { trip: tripInfo?.title || 'Untitled', actor: actor.email, tripId: String(tripId) }; if (preview !== undefined) params.preview = preview; send({ event: 'collab_message', actorId: actor.id, scope: 'trip', targetId: Number(tripId), params }).catch(() => {}); diff --git a/server/src/nest/plugins/DI-MIGRATION.md b/server/src/nest/plugins/DI-MIGRATION.md index 52a381130..d9152ebc1 100644 --- a/server/src/nest/plugins/DI-MIGRATION.md +++ b/server/src/nest/plugins/DI-MIGRATION.md @@ -95,7 +95,7 @@ moved from hidden (bridge files) to explicit (the container). `DatabaseModule` is `@Global`, so no import needed for it. **Interaction with the service-migration track:** legacy `services/*` functions -(place, day, files, collab, …) stay as plain imports inside the +(place, day, trip, …) stay as plain imports inside the factory *until their own domain migrates*. Each future migration then swaps one import for one injected service (+ module export/import) — **no more bridge files for the plugin host, ever**. The factory's import list shrinks @@ -106,8 +106,10 @@ migrated 2026-07 without touching the factory — it was never imported here either; settings swapped in 2026-07 indirectly — the factory never imported it, but its `resolveLlmConfig` import became the injected `LlmConfigResolver`; fileService swapped in 2026-07 — its ten imported symbols became the injected -`FilesService` plus the load-time constants from `files.constants.ts`; next -factory swap: collabService, the last Wave-3 domain imported here). +`FilesService` plus the load-time constants from `files.constants.ts`; +collabService swapped in 2026-07 — its seven imported symbols became the +injected `CollabService`, completing Wave 3; the remaining factory imports are +all Wave-4+ domains: trips, places, days, journeys, atlas, vacay, collections). ### Test impact (as landed) diff --git a/server/src/nest/plugins/host/plugin-host-deps.factory.ts b/server/src/nest/plugins/host/plugin-host-deps.factory.ts index defcc3930..7ee102550 100644 --- a/server/src/nest/plugins/host/plugin-host-deps.factory.ts +++ b/server/src/nest/plugins/host/plugin-host-deps.factory.ts @@ -5,7 +5,6 @@ import { listBudgetItems } from '../../../services/budgetService'; import { isUpdateConflict } from '../../../services/conflictResult'; import { getWeather } from '../../../services/weatherService'; import { BLOCKED_EXTENSIONS, filesDir } from '../../files/files.constants'; -import { createNote as createCollabNoteSvc, createPoll as createCollabPollSvc, votePoll as voteCollabPollSvc, createMessage as createCollabMessageSvc, listNotes as listCollabNotesSvc, listPolls as listCollabPollsSvc, listMessages as listCollabMessagesSvc } from '../../../services/collabService'; import { getRates as getExchangeRates } from '../../../services/exchangeRateService'; import { joinTripAsMember } from '../../../services/tripMembership'; import { send as sendNotification } from '../../../services/notificationService'; @@ -37,6 +36,7 @@ import { AssignmentsService } from '../../assignments/assignments.service'; import { LlmConfigResolver } from '../../llm-parse/llm-config.resolver'; import { DatabaseService } from '../../database/database.service'; import { FilesService } from '../../files/files.service'; +import { CollabService } from '../../collab/collab.service'; import { notifyBookingChange } from '../../../services/reservationService'; import { PluginRpcHost, ForbiddenResource, BadParams } from './rpc-host'; import { appendAudit } from './plugin-audit'; @@ -137,7 +137,8 @@ export interface PluginCallRouter { * `plugin:{id}:{event}` so a plugin can't forge a core event. * * DI-native domain services (budget, reservations, tags, categories, todo, - * packing, day-notes, assignments, oauth, files, the LLM config resolver) and + * packing, day-notes, assignments, oauth, files, collab, 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 @@ -158,6 +159,7 @@ export class PluginHostDepsFactory { private readonly llmConfig: LlmConfigResolver, private readonly db: DatabaseService, private readonly files: FilesService, + private readonly collab: CollabService, ) {} /** @@ -297,33 +299,33 @@ export class PluginHostDepsFactory { }, // --- Collab reads (collab addon; membership checked by the host). Same hydrated // shapes as the REST GETs, so a collab plugin can finally read what it writes. --- - listCollabNotes: (tripId) => { requireAddon(ADDON_IDS.COLLAB, 'collab'); return listCollabNotesSvc(tripId) as unknown[]; }, - listCollabPolls: (tripId) => { requireAddon(ADDON_IDS.COLLAB, 'collab'); return listCollabPollsSvc(tripId) as unknown[]; }, - listCollabMessages: (tripId, before) => { requireAddon(ADDON_IDS.COLLAB, 'collab'); return listCollabMessagesSvc(tripId, before) as unknown[]; }, + listCollabNotes: (tripId) => { requireAddon(ADDON_IDS.COLLAB, 'collab'); return this.collab.listNotes(tripId) as unknown[]; }, + listCollabPolls: (tripId) => { requireAddon(ADDON_IDS.COLLAB, 'collab'); return this.collab.listPolls(tripId) as unknown[]; }, + listCollabMessages: (tripId, before) => { requireAddon(ADDON_IDS.COLLAB, 'collab'); return this.collab.listMessages(tripId, before) as unknown[]; }, // --- Collab content (collab addon). The services validate + self-report errors. --- canEditCollab: (tripId, userId) => this.canEditTripAs('collab_edit', tripId, userId), createCollabNote: (tripId, input, actingUserId) => { requireAddon(ADDON_IDS.COLLAB, 'collab'); - const note = createCollabNoteSvc(String(tripId), actingUserId, input as never); + const note = this.collab.createNote(String(tripId), actingUserId, input as never); broadcast(tripId, 'collab:note:created', { note }, undefined); return note; }, createCollabPoll: (tripId, input, actingUserId) => { requireAddon(ADDON_IDS.COLLAB, 'collab'); - const poll = createCollabPollSvc(String(tripId), actingUserId, input as never); + const poll = this.collab.createPoll(String(tripId), actingUserId, input as never); broadcast(tripId, 'collab:poll:created', { poll }, undefined); return poll; }, voteCollabPoll: (tripId, pollId, optionIndex, actingUserId) => { requireAddon(ADDON_IDS.COLLAB, 'collab'); - const result = voteCollabPollSvc(String(tripId), String(pollId), actingUserId, optionIndex); + const result = this.collab.votePoll(String(tripId), String(pollId), actingUserId, optionIndex); if (result.error) throw new BadParams(result.error); broadcast(tripId, 'collab:poll:voted', { poll: result.poll }, undefined); return result.poll; }, createCollabMessage: (tripId, text, replyTo, actingUserId) => { requireAddon(ADDON_IDS.COLLAB, 'collab'); - const result = createCollabMessageSvc(String(tripId), actingUserId, text, replyTo ?? null); + const result = this.collab.createMessage(String(tripId), actingUserId, text, replyTo ?? null); if (result.error) throw new BadParams(result.error); broadcast(tripId, 'collab:message:created', { message: result.message }, undefined); return result.message; diff --git a/server/src/nest/plugins/plugins.module.ts b/server/src/nest/plugins/plugins.module.ts index 2d0e6bfb4..3f38b8484 100644 --- a/server/src/nest/plugins/plugins.module.ts +++ b/server/src/nest/plugins/plugins.module.ts @@ -34,6 +34,7 @@ import { DaysModule } from '../days/days.module'; import { AssignmentsModule } from '../assignments/assignments.module'; import { LlmParseModule } from '../llm-parse/llm-parse.module'; import { FilesModule } from '../files/files.module'; +import { CollabModule } from '../collab/collab.module'; /** * Plugin system (#plugins). M0 read side + M2 isolated runtime + M3 frontend: @@ -45,7 +46,7 @@ import { FilesModule } from '../files/files.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], + imports: [TagsModule, CategoriesModule, BudgetModule, ReservationsModule, TodoModule, PackingModule, DaysModule, AssignmentsModule, LlmParseModule, FilesModule, CollabModule], controllers: [PluginsController, PluginsFeedController, PluginsProxyController, PluginFrameController, PlaceDetailsController, TripWarningsController, ViewContributionsController, TripCardContributionsController, PluginPhotosController, PluginCalendarController, MapMarkersController, MapLayersController, PluginRoutesController, DayScheduleController, PdfSectionsController, AtlasLayersController, JournalEntryRowsController, PluginUserSettingsController, PluginOAuthController, PluginActivityController], providers: [PluginsService, PluginRuntimeService, PluginRegistryService, PluginOAuthService, PluginHostDepsFactory], // Exported so the admin addon-toggle handler can cascade-disable plugins whose diff --git a/server/src/services/collabService.ts b/server/src/services/collabService.ts deleted file mode 100644 index 0b3846b47..000000000 --- a/server/src/services/collabService.ts +++ /dev/null @@ -1,451 +0,0 @@ -import path from 'path'; -import fs from 'fs'; -import { db } from '../db/database'; -import { CollabNote, CollabPoll, CollabMessage, TripFile } from '../types'; -import { checkSsrf, createPinnedDispatcher } from '../utils/ssrfGuard'; -import { avatarUrl } from './avatarUrl'; - -/* ------------------------------------------------------------------ */ -/* Internal row types */ -/* ------------------------------------------------------------------ */ - -export interface ReactionRow { - emoji: string; - user_id: number; - username: string; - message_id?: number; -} - -export interface PollVoteRow { - option_index: number; - user_id: number; - username: string; - avatar: string | null; -} - -export interface NoteFileRow { - id: number; - filename: string; - original_name?: string; - file_size?: number; - mime_type?: string; -} - -export interface GroupedReaction { - emoji: string; - users: { user_id: number; username: string }[]; - count: number; -} - -export interface LinkPreviewResult { - title: string | null; - description: string | null; - image: string | null; - site_name?: string | null; - url: string; -} - -/* ------------------------------------------------------------------ */ -/* Helpers */ -/* ------------------------------------------------------------------ */ - -export { avatarUrl }; -export { verifyTripAccess } from './tripAccess'; - -/* ------------------------------------------------------------------ */ -/* Reactions */ -/* ------------------------------------------------------------------ */ - -export function loadReactions(messageId: number | string): ReactionRow[] { - return db.prepare(` - SELECT r.emoji, r.user_id, u.username - FROM collab_message_reactions r - JOIN users u ON r.user_id = u.id - WHERE r.message_id = ? - `).all(messageId) as ReactionRow[]; -} - -export function groupReactions(reactions: ReactionRow[]): GroupedReaction[] { - const map: Record = {}; - for (const r of reactions) { - if (!map[r.emoji]) map[r.emoji] = []; - map[r.emoji].push({ user_id: r.user_id, username: r.username }); - } - return Object.entries(map).map(([emoji, users]) => ({ emoji, users, count: users.length })); -} - -export function addOrRemoveReaction(messageId: number | string, tripId: number | string, userId: number, emoji: string): { found: boolean; reactions: GroupedReaction[] } { - const msg = db.prepare('SELECT id FROM collab_messages WHERE id = ? AND trip_id = ?').get(messageId, tripId); - if (!msg) return { found: false, reactions: [] }; - - const existing = db.prepare('SELECT id FROM collab_message_reactions WHERE message_id = ? AND user_id = ? AND emoji = ?').get(messageId, userId, emoji) as { id: number } | undefined; - if (existing) { - db.prepare('DELETE FROM collab_message_reactions WHERE id = ?').run(existing.id); - } else { - db.prepare('INSERT INTO collab_message_reactions (message_id, user_id, emoji) VALUES (?, ?, ?)').run(messageId, userId, emoji); - } - - return { found: true, reactions: groupReactions(loadReactions(messageId)) }; -} - -/* ------------------------------------------------------------------ */ -/* Notes */ -/* ------------------------------------------------------------------ */ - -export function formatNote(note: CollabNote) { - const attachments = db.prepare('SELECT id, filename, original_name, file_size, mime_type FROM trip_files WHERE note_id = ?').all(note.id) as NoteFileRow[]; - return { - ...note, - avatar_url: avatarUrl(note), - attachments: attachments.map(a => ({ ...a, url: `/api/trips/${note.trip_id}/files/${a.id}/download` })), - }; -} - -export function listNotes(tripId: string | number) { - const notes = db.prepare(` - SELECT n.*, u.username, u.avatar - FROM collab_notes n - JOIN users u ON n.user_id = u.id - WHERE n.trip_id = ? - ORDER BY n.pinned DESC, n.updated_at DESC - `).all(tripId) as CollabNote[]; - - return notes.map(formatNote); -} - -export function createNote(tripId: string | number, userId: number, data: { title: string; content?: string; category?: string; color?: string; website?: string; pinned?: boolean }) { - const pinned = data.pinned ? 1 : 0; - const result = db.prepare(` - INSERT INTO collab_notes (trip_id, user_id, title, content, category, color, website, pinned) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) - `).run(tripId, userId, data.title, data.content || null, data.category || 'General', data.color || '#6366f1', data.website || null, pinned); - - const note = db.prepare(` - SELECT n.*, u.username, u.avatar FROM collab_notes n JOIN users u ON n.user_id = u.id WHERE n.id = ? - `).get(result.lastInsertRowid) as CollabNote; - - return formatNote(note); -} - -export function updateNote(tripId: string | number, noteId: string | number, data: { title?: string; content?: string; category?: string; color?: string; pinned?: number | boolean; website?: string }): ReturnType | null { - const existing = db.prepare('SELECT * FROM collab_notes WHERE id = ? AND trip_id = ?').get(noteId, tripId); - if (!existing) return null; - - db.prepare(` - UPDATE collab_notes SET - title = COALESCE(?, title), - content = CASE WHEN ? THEN ? ELSE content END, - category = COALESCE(?, category), - color = COALESCE(?, color), - pinned = CASE WHEN ? IS NOT NULL THEN ? ELSE pinned END, - website = CASE WHEN ? THEN ? ELSE website END, - updated_at = CURRENT_TIMESTAMP - WHERE id = ? - `).run( - data.title || null, - data.content !== undefined ? 1 : 0, data.content !== undefined ? data.content : null, - data.category || null, - data.color || null, - data.pinned !== undefined ? 1 : null, data.pinned ? 1 : 0, - data.website !== undefined ? 1 : 0, data.website !== undefined ? data.website : null, - noteId - ); - - const note = db.prepare(` - SELECT n.*, u.username, u.avatar FROM collab_notes n JOIN users u ON n.user_id = u.id WHERE n.id = ? - `).get(noteId) as CollabNote; - - return formatNote(note); -} - -export function deleteNote(tripId: string | number, noteId: string | number): boolean { - const existing = db.prepare('SELECT id FROM collab_notes WHERE id = ? AND trip_id = ?').get(noteId, tripId); - if (!existing) return false; - - // Clean up attached files from disk - const noteFiles = db.prepare('SELECT id, filename FROM trip_files WHERE note_id = ?').all(noteId) as NoteFileRow[]; - for (const f of noteFiles) { - const filePath = path.join(__dirname, '../../uploads', f.filename); - try { fs.unlinkSync(filePath); } catch { /* ignore */ } - } - db.prepare('DELETE FROM trip_files WHERE note_id = ?').run(noteId); - - db.prepare('DELETE FROM collab_notes WHERE id = ?').run(noteId); - return true; -} - -/* ------------------------------------------------------------------ */ -/* Note files */ -/* ------------------------------------------------------------------ */ - -export function addNoteFile(tripId: string | number, noteId: string | number, file: { filename: string; originalname: string; size: number; mimetype: string }): { file: TripFile & { url: string } } | null { - const note = db.prepare('SELECT id FROM collab_notes WHERE id = ? AND trip_id = ?').get(noteId, tripId); - if (!note) return null; - - const result = db.prepare( - 'INSERT INTO trip_files (trip_id, note_id, filename, original_name, file_size, mime_type) VALUES (?, ?, ?, ?, ?, ?)' - ).run(tripId, noteId, `files/${file.filename}`, file.originalname, file.size, file.mimetype); - - const saved = db.prepare('SELECT * FROM trip_files WHERE id = ?').get(result.lastInsertRowid) as TripFile; - return { file: { ...saved, url: `/api/trips/${tripId}/files/${saved.id}/download` } }; -} - -export function getFormattedNoteById(noteId: string | number) { - const note = db.prepare('SELECT n.*, u.username, u.avatar FROM collab_notes n JOIN users u ON n.user_id = u.id WHERE n.id = ?').get(noteId) as CollabNote; - return formatNote(note); -} - -export function deleteNoteFile(tripId: string | number, noteId: string | number, fileId: string | number): boolean { - // Scope to the trip — like every sibling collab op — so a caller authorized for THEIR - // trip can't delete a note-file that belongs to someone else's trip (IDOR). trip_files - // carries trip_id, so this ties the deleted object to the URL's :tripId the controller - // access-checked, not just to a note/file id an attacker can enumerate. - const file = db.prepare('SELECT * FROM trip_files WHERE id = ? AND note_id = ? AND trip_id = ?').get(fileId, noteId, tripId) as TripFile | undefined; - if (!file) return false; - - const filePath = path.join(__dirname, '../../uploads', file.filename); - try { fs.unlinkSync(filePath); } catch { /* ignore */ } - - db.prepare('DELETE FROM trip_files WHERE id = ?').run(fileId); - return true; -} - -/* ------------------------------------------------------------------ */ -/* Polls */ -/* ------------------------------------------------------------------ */ - -export function getPollWithVotes(pollId: number | bigint | string) { - const poll = db.prepare(` - SELECT p.*, u.username, u.avatar - FROM collab_polls p - JOIN users u ON p.user_id = u.id - WHERE p.id = ? - `).get(pollId) as CollabPoll | undefined; - - if (!poll) return null; - - const options: (string | { label: string })[] = JSON.parse(poll.options); - - const votes = db.prepare(` - SELECT v.option_index, v.user_id, u.username, u.avatar - FROM collab_poll_votes v - JOIN users u ON v.user_id = u.id - WHERE v.poll_id = ? - `).all(pollId) as PollVoteRow[]; - - const formattedOptions = options.map((label: string | { label: string }, idx: number) => { - const text = typeof label === 'string' ? label : label.label || label; - return { - // The client renders `opt.text`; keep `label` too for any other consumer. - text, - label: text, - voters: votes - .filter(v => v.option_index === idx) - .map(v => ({ id: v.user_id, user_id: v.user_id, username: v.username, avatar: v.avatar, avatar_url: avatarUrl(v) })), - }; - }); - - return { - ...poll, - avatar_url: avatarUrl(poll), - options: formattedOptions, - is_closed: !!poll.closed, - multiple_choice: !!poll.multiple, - }; -} - -export function listPolls(tripId: string | number) { - const rows = db.prepare(` - SELECT id FROM collab_polls WHERE trip_id = ? ORDER BY created_at DESC - `).all(tripId) as { id: number }[]; - - return rows.map(row => getPollWithVotes(row.id)).filter(Boolean); -} - -export function createPoll(tripId: string | number, userId: number, data: { question: string; options: unknown[]; multiple?: boolean; multiple_choice?: boolean; deadline?: string }) { - const isMultiple = data.multiple || data.multiple_choice; - - const result = db.prepare(` - INSERT INTO collab_polls (trip_id, user_id, question, options, multiple, deadline) - VALUES (?, ?, ?, ?, ?, ?) - `).run(tripId, userId, data.question, JSON.stringify(data.options), isMultiple ? 1 : 0, data.deadline || null); - - return getPollWithVotes(result.lastInsertRowid); -} - -export function votePoll(tripId: string | number, pollId: string | number, userId: number, optionIndex: number): { error?: string; poll?: ReturnType } { - const poll = db.prepare('SELECT * FROM collab_polls WHERE id = ? AND trip_id = ?').get(pollId, tripId) as CollabPoll | undefined; - if (!poll) return { error: 'not_found' }; - if (poll.closed) return { error: 'closed' }; - - const options = JSON.parse(poll.options); - if (optionIndex < 0 || optionIndex >= options.length) { - return { error: 'invalid_index' }; - } - - const existingVote = db.prepare( - 'SELECT id FROM collab_poll_votes WHERE poll_id = ? AND user_id = ? AND option_index = ?' - ).get(pollId, userId, optionIndex) as { id: number } | undefined; - - if (existingVote) { - db.prepare('DELETE FROM collab_poll_votes WHERE id = ?').run(existingVote.id); - } else { - if (!poll.multiple) { - db.prepare('DELETE FROM collab_poll_votes WHERE poll_id = ? AND user_id = ?').run(pollId, userId); - } - db.prepare('INSERT INTO collab_poll_votes (poll_id, user_id, option_index) VALUES (?, ?, ?)').run(pollId, userId, optionIndex); - } - - return { poll: getPollWithVotes(pollId) }; -} - -export function closePoll(tripId: string | number, pollId: string | number): ReturnType | null { - const poll = db.prepare('SELECT * FROM collab_polls WHERE id = ? AND trip_id = ?').get(pollId, tripId); - if (!poll) return null; - - db.prepare('UPDATE collab_polls SET closed = 1 WHERE id = ?').run(pollId); - return getPollWithVotes(pollId); -} - -export function deletePoll(tripId: string | number, pollId: string | number): boolean { - const poll = db.prepare('SELECT id FROM collab_polls WHERE id = ? AND trip_id = ?').get(pollId, tripId); - if (!poll) return false; - - db.prepare('DELETE FROM collab_polls WHERE id = ?').run(pollId); - return true; -} - -/* ------------------------------------------------------------------ */ -/* Messages */ -/* ------------------------------------------------------------------ */ - -export function formatMessage(msg: CollabMessage, reactions?: GroupedReaction[]) { - return { ...msg, user_avatar: avatarUrl(msg), avatar_url: avatarUrl(msg), reactions: reactions || [] }; -} - -export function countMessages(tripId: string | number): number { - const row = db.prepare('SELECT COUNT(*) as cnt FROM collab_messages WHERE trip_id = ?').get(tripId) as { cnt: number }; - return row.cnt; -} - -export function listMessages(tripId: string | number, before?: string | number) { - const query = ` - SELECT m.*, u.username, u.avatar, - rm.text AS reply_text, ru.username AS reply_username - FROM collab_messages m - JOIN users u ON m.user_id = u.id - LEFT JOIN collab_messages rm ON m.reply_to = rm.id - LEFT JOIN users ru ON rm.user_id = ru.id - WHERE m.trip_id = ?${before ? ' AND m.id < ?' : ''} - ORDER BY m.id DESC - LIMIT 100 - `; - - const messages = before - ? db.prepare(query).all(tripId, before) as CollabMessage[] - : db.prepare(query).all(tripId) as CollabMessage[]; - - messages.reverse(); - - const msgIds = messages.map(m => m.id); - const reactionsByMsg: Record = {}; - if (msgIds.length > 0) { - const allReactions = db.prepare(` - SELECT r.message_id, r.emoji, r.user_id, u.username - FROM collab_message_reactions r - JOIN users u ON r.user_id = u.id - WHERE r.message_id IN (${msgIds.map(() => '?').join(',')}) - `).all(...msgIds) as (ReactionRow & { message_id: number })[]; - for (const r of allReactions) { - if (!reactionsByMsg[r.message_id]) reactionsByMsg[r.message_id] = []; - reactionsByMsg[r.message_id].push(r); - } - } - - return messages.map(m => formatMessage(m, groupReactions(reactionsByMsg[m.id] || []))); -} - -export function createMessage(tripId: string | number, userId: number, text: string, replyTo?: number | null): { error?: string; message?: ReturnType } { - if (replyTo) { - const replyMsg = db.prepare('SELECT id FROM collab_messages WHERE id = ? AND trip_id = ?').get(replyTo, tripId); - if (!replyMsg) return { error: 'reply_not_found' }; - } - - const result = db.prepare(` - INSERT INTO collab_messages (trip_id, user_id, text, reply_to) VALUES (?, ?, ?, ?) - `).run(tripId, userId, text.trim(), replyTo || null); - - const message = db.prepare(` - SELECT m.*, u.username, u.avatar, - rm.text AS reply_text, ru.username AS reply_username - FROM collab_messages m - JOIN users u ON m.user_id = u.id - LEFT JOIN collab_messages rm ON m.reply_to = rm.id - LEFT JOIN users ru ON rm.user_id = ru.id - WHERE m.id = ? - `).get(result.lastInsertRowid) as CollabMessage; - - return { message: formatMessage(message) }; -} - -export function deleteMessage(tripId: string | number, messageId: string | number, userId: number): { error?: string; username?: string } { - const message = db.prepare('SELECT * FROM collab_messages WHERE id = ? AND trip_id = ?').get(messageId, tripId) as CollabMessage | undefined; - if (!message) return { error: 'not_found' }; - if (Number(message.user_id) !== Number(userId)) return { error: 'not_owner' }; - - db.prepare('UPDATE collab_messages SET deleted = 1 WHERE id = ?').run(messageId); - return { username: message.username }; -} - -/* ------------------------------------------------------------------ */ -/* Link preview */ -/* ------------------------------------------------------------------ */ - -export async function fetchLinkPreview(url: string): Promise { - const fallback: LinkPreviewResult = { title: null, description: null, image: null, url }; - - const parsed = new URL(url); - const ssrf = await checkSsrf(url, true); - if (!ssrf.allowed) { - return { ...fallback, error: ssrf.error } as LinkPreviewResult & { error?: string }; - } - - try { - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), 5000); - - try { - const r = await fetch(url, { - redirect: 'error', - signal: controller.signal, - dispatcher: createPinnedDispatcher(ssrf.resolvedIp!), - headers: { 'User-Agent': 'Mozilla/5.0 (compatible; NOMAD/1.0; +https://github.com/mauriceboe/NOMAD)' }, - } as any); - clearTimeout(timeout); - if (!r.ok) throw new Error('Fetch failed'); - - const html = await r.text(); - const get = (prop: string) => { - const m = html.match(new RegExp(`]*property=["']og:${prop}["'][^>]*content=["']([^"']*)["']`, 'i')) - || html.match(new RegExp(`]*content=["']([^"']*)["'][^>]*property=["']og:${prop}["']`, 'i')); - return m ? m[1] : null; - }; - const titleTag = html.match(/]*>([^<]*)<\/title>/i); - const descMeta = html.match(/]*name=["']description["'][^>]*content=["']([^"']*)["']/i) - || html.match(/]*content=["']([^"']*)["'][^>]*name=["']description["']/i); - - return { - title: get('title') || (titleTag ? titleTag[1].trim() : null), - description: get('description') || (descMeta ? descMeta[1].trim() : null), - image: get('image') || null, - site_name: get('site_name') || null, - url, - }; - } catch { - clearTimeout(timeout); - return fallback; - } - } catch { - return fallback; - } -} diff --git a/server/src/services/tripService.ts b/server/src/services/tripService.ts index 80c52a9dd..ada0e78ac 100644 --- a/server/src/services/tripService.ts +++ b/server/src/services/tripService.ts @@ -10,7 +10,7 @@ import { listDays, listAccommodations, addDays, resyncAccommodationDays, restamp import { listBudgetItems, removeUserFromBudgetItems } from './budgetService'; import { listItems as listPackingItems } from '../nest/packing/packing.bridge'; import { listReservations, loadEndpointsByTrip, resyncReservationDays } from './reservationService'; -import { listNotes as listCollabNotes } from './collabService'; +import { listNotes as listCollabNotes } from '../nest/collab/collab.bridge'; import { shiftOwnerEntriesForTripWindow } from './vacayService'; import { resolveTimeZone } from './timezoneService'; diff --git a/server/tests/e2e/collab.e2e.test.ts b/server/tests/e2e/collab.e2e.test.ts index 1e0516391..79b7f3965 100644 --- a/server/tests/e2e/collab.e2e.test.ts +++ b/server/tests/e2e/collab.e2e.test.ts @@ -1,9 +1,10 @@ /** * Collab module e2e — exercises the migrated /api/trips/:tripId/collab endpoints - * through the real JwtAuthGuard against a temp SQLite db. The collab service, - * permission check, WebSocket broadcast and the chat/note notification are - * mocked; this focuses on auth, trip-access 404, permission 403, the create-201 - * status codes and the vote/react 200 overrides. + * through the real JwtAuthGuard against a temp SQLite db, running CollabService's + * real SQL (DI-injected, no service mock). canAccessTrip, the permission check, + * the WebSocket broadcast and the chat/note notification are mocked; this focuses + * on auth, trip-access 404, permission 403, the create-201 status codes, the + * vote/react 200 overrides and the persisted rows. */ import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest'; import request from 'supertest'; @@ -18,32 +19,49 @@ const { db } = vi.hoisted(() => { const Database = require('better-sqlite3'); const tmp = new Database(':memory:'); tmp.exec('PRAGMA journal_mode = WAL'); + // The note/message/poll formatters join users for username + avatar. tmp.exec(`CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL, - email TEXT NOT NULL UNIQUE, role TEXT NOT NULL DEFAULT 'user', password_version INTEGER NOT NULL DEFAULT 0);`); + email TEXT NOT NULL UNIQUE, role TEXT NOT NULL DEFAULT 'user', password_version INTEGER NOT NULL DEFAULT 0, + avatar TEXT);`); // The note/message notifications read the trip title fire-and-forget; the table // must exist so that query doesn't throw after the test has torn down. tmp.exec('CREATE TABLE trips (id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT);'); tmp.prepare("INSERT INTO trips (id, title) VALUES (5, 'Trip')").run(); + // CollabService's real SQL (DI-injected, no mock) — the collab tables as the + // real schema + migrations shape them (website on notes, deleted on messages, + // note_id on trip_files). + tmp.exec(`CREATE TABLE collab_notes (id INTEGER PRIMARY KEY AUTOINCREMENT, trip_id INTEGER NOT NULL, + user_id INTEGER NOT NULL, category TEXT DEFAULT 'General', title TEXT NOT NULL, content TEXT, + color TEXT DEFAULT '#6366f1', pinned INTEGER DEFAULT 0, website TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP);`); + tmp.exec(`CREATE TABLE collab_polls (id INTEGER PRIMARY KEY AUTOINCREMENT, trip_id INTEGER NOT NULL, + user_id INTEGER NOT NULL, question TEXT NOT NULL, options TEXT NOT NULL, multiple INTEGER DEFAULT 0, + closed INTEGER DEFAULT 0, deadline TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP);`); + tmp.exec(`CREATE TABLE collab_poll_votes (id INTEGER PRIMARY KEY AUTOINCREMENT, poll_id INTEGER NOT NULL, + user_id INTEGER NOT NULL, option_index INTEGER NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE(poll_id, user_id, option_index));`); + tmp.exec(`CREATE TABLE collab_messages (id INTEGER PRIMARY KEY AUTOINCREMENT, trip_id INTEGER NOT NULL, + user_id INTEGER NOT NULL, text TEXT NOT NULL, reply_to INTEGER, deleted INTEGER DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP);`); + tmp.exec(`CREATE TABLE collab_message_reactions (id INTEGER PRIMARY KEY AUTOINCREMENT, + message_id INTEGER NOT NULL, user_id INTEGER NOT NULL, emoji TEXT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP);`); + tmp.exec(`CREATE TABLE trip_files (id INTEGER PRIMARY KEY AUTOINCREMENT, trip_id INTEGER NOT NULL, + note_id INTEGER, filename TEXT NOT NULL, original_name TEXT NOT NULL, file_size INTEGER, + mime_type TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP);`); return { db: tmp }; }); -vi.mock('../../src/db/database', () => ({ db, closeDb: () => {}, reinitialize: () => {} })); +const { canAccessTrip } = vi.hoisted(() => ({ canAccessTrip: vi.fn() })); +vi.mock('../../src/db/database', () => ({ + db, canAccessTrip, isOwner: vi.fn(() => true), getPlaceWithTags: vi.fn(), closeDb: () => {}, reinitialize: () => {}, +})); vi.mock('../../src/websocket', () => ({ broadcast: vi.fn() })); vi.mock('../../src/services/notificationService', () => ({ send: vi.fn().mockResolvedValue(undefined) })); const { checkPermission } = vi.hoisted(() => ({ checkPermission: vi.fn() })); vi.mock('../../src/services/permissions', () => ({ checkPermission })); -const { svc } = vi.hoisted(() => ({ - svc: { - verifyTripAccess: vi.fn(), listNotes: vi.fn(), createNote: vi.fn(), updateNote: vi.fn(), deleteNote: vi.fn(), - addNoteFile: vi.fn(), getFormattedNoteById: vi.fn(), deleteNoteFile: vi.fn(), - listPolls: vi.fn(), createPoll: vi.fn(), votePoll: vi.fn(), closePoll: vi.fn(), deletePoll: vi.fn(), - listMessages: vi.fn(), createMessage: vi.fn(), deleteMessage: vi.fn(), addOrRemoveReaction: vi.fn(), fetchLinkPreview: vi.fn(), - }, -})); -vi.mock('../../src/services/collabService', () => svc); - import { CollabModule } from '../../src/nest/collab/collab.module'; import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter'; @@ -64,18 +82,17 @@ describe('Collab e2e (real auth guard + temp SQLite)', () => { seedUser(db as never, { id: 1 }); app = await build(); server = app.getHttpServer(); - svc.listNotes.mockReturnValue([{ id: 1, title: 'N' }]); - svc.createNote.mockReturnValue({ id: 9, title: 'N' }); - svc.createPoll.mockReturnValue({ id: 7 }); - svc.votePoll.mockReturnValue({ poll: { id: 7 } }); - svc.createMessage.mockReturnValue({ message: { id: 3, text: 'hi' } }); - svc.addOrRemoveReaction.mockReturnValue({ found: true, reactions: [{ emoji: '👍', count: 1 }] }); - svc.fetchLinkPreview.mockResolvedValue({ title: 'T', description: null, image: null, url: 'http://x' }); }); beforeEach(() => { - svc.verifyTripAccess.mockReturnValue({ id: 5, user_id: 1 }); + canAccessTrip.mockReturnValue({ id: 5, user_id: 1 }); checkPermission.mockReturnValue(true); + db.prepare('DELETE FROM collab_message_reactions').run(); + db.prepare('DELETE FROM collab_poll_votes').run(); + db.prepare('DELETE FROM collab_messages').run(); + db.prepare('DELETE FROM collab_polls').run(); + db.prepare('DELETE FROM trip_files').run(); + db.prepare('DELETE FROM collab_notes').run(); }); afterAll(async () => { @@ -87,22 +104,26 @@ describe('Collab e2e (real auth guard + temp SQLite)', () => { }); it('200 list notes for an accessible trip', async () => { + db.prepare("INSERT INTO collab_notes (id, trip_id, user_id, title) VALUES (1, 5, 1, 'N')").run(); const res = await request(server).get('/api/trips/5/collab/notes').set('Cookie', sessionCookie(1)); expect(res.status).toBe(200); - expect(res.body).toEqual({ notes: [{ id: 1, title: 'N' }] }); + expect(res.body.notes).toHaveLength(1); + expect(res.body.notes[0]).toMatchObject({ id: 1, title: 'N', category: 'General', attachments: [] }); }); it('404 when the trip is not accessible', async () => { - svc.verifyTripAccess.mockReturnValue(undefined); + canAccessTrip.mockReturnValue(undefined); const res = await request(server).get('/api/trips/5/collab/notes').set('Cookie', sessionCookie(1)); expect(res.status).toBe(404); expect(res.body).toEqual({ error: 'Trip not found' }); }); - it('201 on note create with permission', async () => { + it('201 on note create with permission, row persisted', async () => { const res = await request(server).post('/api/trips/5/collab/notes').set('Cookie', sessionCookie(1)).send({ title: 'N' }); expect(res.status).toBe(201); - expect(res.body).toEqual({ note: { id: 9, title: 'N' } }); + expect(res.body.note).toMatchObject({ title: 'N', category: 'General', color: '#6366f1', pinned: 0 }); + const row = db.prepare('SELECT * FROM collab_notes WHERE trip_id = 5').get() as { title: string }; + expect(row.title).toBe('N'); }); it('403 on note create without permission', async () => { @@ -112,22 +133,32 @@ describe('Collab e2e (real auth guard + temp SQLite)', () => { expect(res.body).toEqual({ error: 'No permission' }); }); - it('200 on poll vote (not 201)', async () => { + it('200 on poll vote (not 201), vote persisted', async () => { + db.prepare('INSERT INTO collab_polls (id, trip_id, user_id, question, options) VALUES (7, 5, 1, ?, ?)') + .run('Q?', JSON.stringify(['A', 'B'])); const res = await request(server).post('/api/trips/5/collab/polls/7/vote').set('Cookie', sessionCookie(1)).send({ option_index: 0 }); expect(res.status).toBe(200); - expect(res.body).toEqual({ poll: { id: 7 } }); + expect(res.body.poll).toMatchObject({ id: 7, is_closed: false }); + expect(res.body.poll.options[0].voters).toHaveLength(1); + const vote = db.prepare('SELECT * FROM collab_poll_votes WHERE poll_id = 7').get() as { option_index: number }; + expect(vote.option_index).toBe(0); }); - it('201 on message create', async () => { + it('201 on message create, row persisted', async () => { const res = await request(server).post('/api/trips/5/collab/messages').set('Cookie', sessionCookie(1)).send({ text: 'hi' }); expect(res.status).toBe(201); - expect(res.body).toEqual({ message: { id: 3, text: 'hi' } }); + expect(res.body.message).toMatchObject({ text: 'hi', trip_id: 5, user_id: 1 }); + const row = db.prepare('SELECT * FROM collab_messages WHERE trip_id = 5').get() as { text: string }; + expect(row.text).toBe('hi'); }); - it('200 on react (not 201)', async () => { + it('200 on react (not 201), reaction persisted and toggled', async () => { + db.prepare("INSERT INTO collab_messages (id, trip_id, user_id, text) VALUES (3, 5, 1, 'react me')").run(); const res = await request(server).post('/api/trips/5/collab/messages/3/react').set('Cookie', sessionCookie(1)).send({ emoji: '👍' }); expect(res.status).toBe(200); - expect(res.body).toEqual({ reactions: [{ emoji: '👍', count: 1 }] }); + expect(res.body.reactions).toHaveLength(1); + expect(res.body.reactions[0]).toMatchObject({ emoji: '👍', count: 1 }); + expect(db.prepare('SELECT COUNT(*) as c FROM collab_message_reactions WHERE message_id = 3').get()).toEqual({ c: 1 }); }); it('400 on link-preview without a url', async () => { diff --git a/server/tests/helpers/mcp-test-controllers.ts b/server/tests/helpers/mcp-test-controllers.ts index d7edbe99a..503976373 100644 --- a/server/tests/helpers/mcp-test-controllers.ts +++ b/server/tests/helpers/mcp-test-controllers.ts @@ -5,6 +5,8 @@ import { AssignmentsMcp } from '../../src/nest/assignments/assignments.mcp'; import { AssignmentsService } from '../../src/nest/assignments/assignments.service'; 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 { 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'; @@ -32,6 +34,7 @@ export function createMcpTestRegistry(): McpRegistry { new PackingMcp(new PackingService(dbService)), new DayNotesMcp(new DayNotesService(dbService)), new AssignmentsMcp(new AssignmentsService(dbService)), + new CollabMcp(new CollabService(dbService)), ], { accessPolicy: trekMcpAccessPolicy }, ); diff --git a/server/tests/helpers/plugin-host.ts b/server/tests/helpers/plugin-host.ts index 501cace68..36116e5d2 100644 --- a/server/tests/helpers/plugin-host.ts +++ b/server/tests/helpers/plugin-host.ts @@ -14,6 +14,7 @@ import { AssignmentsService } from '../../src/nest/assignments/assignments.servi import { LlmConfigResolver } from '../../src/nest/llm-parse/llm-config.resolver'; import { SettingsService } from '../../src/nest/settings/settings.service'; import { FilesService } from '../../src/nest/files/files.service'; +import { CollabService } from '../../src/nest/collab/collab.service'; /** * Hand-wired counterpart of the PluginsModule DI graph for no-Nest tests @@ -35,6 +36,7 @@ export function createHostDepsFactory(dbs: DatabaseService): PluginHostDepsFacto new LlmConfigResolver(new SettingsService(dbs), dbs), dbs, new FilesService(dbs), + new CollabService(dbs), ); } diff --git a/server/tests/integration/collab.test.ts b/server/tests/integration/collab.test.ts index 4d1c359af..a3ff758aa 100644 --- a/server/tests/integration/collab.test.ts +++ b/server/tests/integration/collab.test.ts @@ -48,22 +48,18 @@ vi.mock('../../src/config', () => ({ })); vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() })); -// Partially mock collabService to make fetchLinkPreview controllable -vi.mock('../../src/services/collabService', async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - fetchLinkPreview: vi.fn().mockResolvedValue({ title: null, description: null, image: null, url: '' }), - }; -}); - import { buildApp } from '../../src/bootstrap'; import { createTables } from '../../src/db/schema'; import { runMigrations } from '../../src/db/migrations'; import { resetTestDb, resetRateLimits } from '../helpers/test-db'; import { createUser, createTrip, addTripMember } from '../helpers/factories'; import { authCookie, generateToken } from '../helpers/auth'; -import * as collabService from '../../src/services/collabService'; +import { CollabService } from '../../src/nest/collab/collab.service'; + +// Spy on the DI-native service's linkPreview so the SSRF-guarded fetch never +// runs; the rest of CollabService exercises its real SQL through the container. +const linkPreviewSpy = vi.spyOn(CollabService.prototype, 'linkPreview') + .mockResolvedValue({ title: null, description: null, image: null, url: '' }); let nestApp: INestApplication; let app: Application; @@ -687,7 +683,7 @@ describe('Link preview', () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); - vi.mocked(collabService.fetchLinkPreview).mockResolvedValueOnce({ + linkPreviewSpy.mockResolvedValueOnce({ title: 'Example Domain', description: 'A test page', image: null, @@ -706,7 +702,7 @@ describe('Link preview', () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); - vi.mocked(collabService.fetchLinkPreview).mockResolvedValueOnce({ + linkPreviewSpy.mockResolvedValueOnce({ title: null, description: null, image: null, @@ -726,7 +722,7 @@ describe('Link preview', () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); - vi.mocked(collabService.fetchLinkPreview).mockRejectedValueOnce(new Error('Unexpected error')); + linkPreviewSpy.mockRejectedValueOnce(new Error('Unexpected error')); const res = await request(app) .get(`/api/trips/${trip.id}/collab/link-preview?url=https://example.com`) diff --git a/server/tests/unit/mcp/resources.test.ts b/server/tests/unit/mcp/resources.test.ts index 90d14cf57..924d29cdf 100644 --- a/server/tests/unit/mcp/resources.test.ts +++ b/server/tests/unit/mcp/resources.test.ts @@ -6,7 +6,8 @@ * see tools-todos.test.ts; trek://trips/{tripId}/packing and .../packing/bags * moved to PackingMcp — see tools-packing.test.ts; * trek://trips/{tripId}/days/{dayId}/notes moved to DayNotesMcp — see - * tools-notes.test.ts). + * tools-notes.test.ts; trek://trips/{tripId}/collab-notes moved to CollabMcp — + * see tools-notes.test.ts). */ import { describe, it, expect, vi, beforeAll, beforeEach, afterAll, afterEach } from 'vitest'; @@ -333,33 +334,9 @@ describe('Resource: trek://trips/{tripId}/members', () => { }); }); -describe('Resource: trek://trips/{tripId}/collab-notes', () => { - it('returns collab notes with username', async () => { - const { user } = createUser(testDb); - const trip = createTrip(testDb, user.id); - createCollabNote(testDb, trip.id, user.id, { title: 'Ideas' }); - - await withHarness(user.id, async (harness) => { - const result = await harness.client.readResource({ uri: `trek://trips/${trip.id}/collab-notes` }); - const notes = parseResourceResult(result) as any[]; - expect(notes).toHaveLength(1); - expect(notes[0].title).toBe('Ideas'); - expect(notes[0].username).toBeTruthy(); - }); - }); - - it('returns access denied for unauthorized trip', async () => { - const { user } = createUser(testDb); - const { user: other } = createUser(testDb); - const trip = createTrip(testDb, other.id); - - await withHarness(user.id, async (harness) => { - const result = await harness.client.readResource({ uri: `trek://trips/${trip.id}/collab-notes` }); - const data = parseResourceResult(result) as any; - expect(data.error).toBeTruthy(); - }); - }); -}); +// trek://trips/{tripId}/collab-notes moved to tools-notes.test.ts: it now +// registers via the nest-mcp registry inside registerTools (CollabMcp +// @ResourceTemplate), which this file's withTools: false harness never attaches. // trek://categories moved to tools-categories.test.ts: it now registers via // the nest-mcp registry inside registerTools (CategoriesMcp @Resource), which diff --git a/server/tests/unit/mcp/tools-collab-polls-chat.test.ts b/server/tests/unit/mcp/tools-collab-polls-chat.test.ts index ed6ba0886..997720ac9 100644 --- a/server/tests/unit/mcp/tools-collab-polls-chat.test.ts +++ b/server/tests/unit/mcp/tools-collab-polls-chat.test.ts @@ -2,7 +2,8 @@ * Unit tests for MCP collab polls and chat tools (collab addon-gated): * list_collab_polls, create_collab_poll, vote_collab_poll, close_collab_poll, * delete_collab_poll, list_collab_messages, send_collab_message, - * delete_collab_message, react_collab_message. + * delete_collab_message, react_collab_message (CollabMcp, DI-discovered — + * attached via the nest-mcp registry inside registerTools). * Resources: trek://trips/{tripId}/collab/polls, trek://trips/{tripId}/collab/messages. */ import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest'; diff --git a/server/tests/unit/mcp/tools-notes.test.ts b/server/tests/unit/mcp/tools-notes.test.ts index fca70ae2b..b7878f42e 100644 --- a/server/tests/unit/mcp/tools-notes.test.ts +++ b/server/tests/unit/mcp/tools-notes.test.ts @@ -2,8 +2,8 @@ * Unit tests for MCP note tools: create_day_note, update_day_note, delete_day_note * (DayNotesMcp, DI-discovered — attached via the nest-mcp registry inside * registerTools, so every harness here keeps withTools on) and - * create_collab_note, update_collab_note, delete_collab_note (still on the - * legacy registrar). + * create_collab_note, update_collab_note, delete_collab_note (CollabMcp, + * DI-discovered via the same registry). */ import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest'; @@ -510,3 +510,35 @@ describe('Tool: delete_collab_note', () => { }); }); }); + +// Moved from resources.test.ts with the collab migration: the resource now +// registers via the nest-mcp registry inside registerTools (CollabMcp +// @ResourceTemplate), which resources.test.ts's withTools: false harness +// never attaches. +describe('Resource: trek://trips/{tripId}/collab-notes', () => { + it('returns collab notes with username', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + createCollabNote(testDb, trip.id, user.id, { title: 'Ideas' }); + + await withHarness(user.id, async (h) => { + const result = await h.client.readResource({ uri: `trek://trips/${trip.id}/collab-notes` }); + const notes = parseResourceResult(result) as any[]; + expect(notes).toHaveLength(1); + expect(notes[0].title).toBe('Ideas'); + expect(notes[0].username).toBeTruthy(); + }); + }); + + it('returns access denied for unauthorized trip', async () => { + const { user } = createUser(testDb); + const { user: other } = createUser(testDb); + const trip = createTrip(testDb, other.id); + + await withHarness(user.id, async (h) => { + const result = await h.client.readResource({ uri: `trek://trips/${trip.id}/collab-notes` }); + const data = parseResourceResult(result) as any; + expect(data.error).toBeTruthy(); + }); + }); +}); diff --git a/server/tests/unit/services/collabService.test.ts b/server/tests/unit/nest/collab.service.test.ts similarity index 67% rename from server/tests/unit/services/collabService.test.ts rename to server/tests/unit/nest/collab.service.test.ts index f20a7b7da..5a09f7842 100644 --- a/server/tests/unit/services/collabService.test.ts +++ b/server/tests/unit/nest/collab.service.test.ts @@ -1,7 +1,10 @@ /** - * Unit tests for collabService — COLLAB-SVC-001 to COLLAB-SVC-030. - * Covers votePoll edge cases, listMessages pagination, deleteMessage ownership, - * updateNote partial fields, fetchLinkPreview, avatarUrl, createMessage reply validation. + * Unit tests for the DI-native CollabService — COLLAB-SVC-001 to COLLAB-SVC-033 + * (001–030 moved 1:1 from the legacy tests/unit/services/collabService.test.ts; + * 031–033 pin the collab.bridge delegation). Covers votePoll edge cases, + * listMessages pagination, deleteMessage ownership, updateNote partial fields, + * linkPreview, avatarUrl, createMessage reply validation. Uses a real in-memory + * SQLite DB so SQL logic is exercised faithfully. */ import { describe, it, expect, vi, beforeAll, beforeEach, afterAll, afterEach } from 'vitest'; @@ -36,8 +39,9 @@ vi.mock('../../../src/config', () => ({ ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2', updateJwtSecret: () => {}, })); +vi.mock('../../../src/websocket', () => ({ broadcast: vi.fn() })); -// Stub checkSsrf so fetchLinkPreview tests can control SSRF behaviour +// Stub checkSsrf so linkPreview tests can control SSRF behaviour const { mockCheckSsrf, mockCreatePinnedDispatcher } = vi.hoisted(() => ({ mockCheckSsrf: vi.fn(async () => ({ allowed: true, resolvedIp: '93.184.216.34' })), mockCreatePinnedDispatcher: vi.fn(() => ({})), @@ -51,18 +55,12 @@ import { createTables } from '../../../src/db/schema'; import { runMigrations } from '../../../src/db/migrations'; import { resetTestDb } from '../../helpers/test-db'; import { createUser, createTrip } from '../../helpers/factories'; -import { - avatarUrl, - votePoll, - listMessages, - createMessage, - deleteMessage, - updateNote, - createNote, - createPoll, - closePoll, - fetchLinkPreview, -} from '../../../src/services/collabService'; +import { avatarUrl } from '../../../src/services/avatarUrl'; +import { DatabaseService } from '../../../src/nest/database/database.service'; +import { CollabService } from '../../../src/nest/collab/collab.service'; +import { listNotes as bridgeListNotes, listPolls as bridgeListPolls, countMessages as bridgeCountMessages } from '../../../src/nest/collab/collab.bridge'; + +const svc = new CollabService(new DatabaseService(testDb)); beforeAll(() => { createTables(testDb); @@ -114,40 +112,40 @@ describe('avatarUrl', () => { describe('votePoll', () => { it('COLLAB-SVC-004: returns error "closed" when poll is closed', () => { const { user1, trip } = setup(); - const poll = createPoll(trip.id, user1.id, { question: 'Q?', options: ['A', 'B'] }); - closePoll(trip.id, poll!.id); + const poll = svc.createPoll(trip.id, user1.id, { question: 'Q?', options: ['A', 'B'] }); + svc.closePoll(trip.id, poll!.id); - const result = votePoll(trip.id, poll!.id, user1.id, 0); + const result = svc.votePoll(trip.id, poll!.id, user1.id, 0); expect(result.error).toBe('closed'); }); it('COLLAB-SVC-005: returns error "invalid_index" for negative index', () => { const { user1, trip } = setup(); - const poll = createPoll(trip.id, user1.id, { question: 'Q?', options: ['A', 'B'] }); + const poll = svc.createPoll(trip.id, user1.id, { question: 'Q?', options: ['A', 'B'] }); - const result = votePoll(trip.id, poll!.id, user1.id, -1); + const result = svc.votePoll(trip.id, poll!.id, user1.id, -1); expect(result.error).toBe('invalid_index'); }); it('COLLAB-SVC-006: returns error "invalid_index" for out-of-range index', () => { const { user1, trip } = setup(); - const poll = createPoll(trip.id, user1.id, { question: 'Q?', options: ['A', 'B'] }); + const poll = svc.createPoll(trip.id, user1.id, { question: 'Q?', options: ['A', 'B'] }); - const result = votePoll(trip.id, poll!.id, user1.id, 5); + const result = svc.votePoll(trip.id, poll!.id, user1.id, 5); expect(result.error).toBe('invalid_index'); }); it('COLLAB-SVC-007: returns error "not_found" for nonexistent poll', () => { const { user1, trip } = setup(); - const result = votePoll(trip.id, 9999, user1.id, 0); + const result = svc.votePoll(trip.id, 9999, user1.id, 0); expect(result.error).toBe('not_found'); }); it('COLLAB-SVC-008: successfully votes and returns poll with voters', () => { const { user1, trip } = setup(); - const poll = createPoll(trip.id, user1.id, { question: 'Q?', options: ['Yes', 'No'] }); + const poll = svc.createPoll(trip.id, user1.id, { question: 'Q?', options: ['Yes', 'No'] }); - const result = votePoll(trip.id, poll!.id, user1.id, 0); + const result = svc.votePoll(trip.id, poll!.id, user1.id, 0); expect(result.error).toBeUndefined(); expect(result.poll).toBeDefined(); expect(result.poll!.options[0].voters).toHaveLength(1); @@ -155,10 +153,10 @@ describe('votePoll', () => { it('COLLAB-SVC-009: toggles vote off when voted again on same option', () => { const { user1, trip } = setup(); - const poll = createPoll(trip.id, user1.id, { question: 'Q?', options: ['Yes', 'No'] }); + const poll = svc.createPoll(trip.id, user1.id, { question: 'Q?', options: ['Yes', 'No'] }); - votePoll(trip.id, poll!.id, user1.id, 0); - const result = votePoll(trip.id, poll!.id, user1.id, 0); + svc.votePoll(trip.id, poll!.id, user1.id, 0); + const result = svc.votePoll(trip.id, poll!.id, user1.id, 0); expect(result.poll!.options[0].voters).toHaveLength(0); }); }); @@ -168,21 +166,21 @@ describe('votePoll', () => { describe('listMessages', () => { it('COLLAB-SVC-010: returns all messages when no before cursor', () => { const { user1, trip } = setup(); - createMessage(trip.id, user1.id, 'Hello'); - createMessage(trip.id, user1.id, 'World'); + svc.createMessage(trip.id, user1.id, 'Hello'); + svc.createMessage(trip.id, user1.id, 'World'); - const msgs = listMessages(trip.id); + const msgs = svc.listMessages(trip.id); expect(msgs).toHaveLength(2); }); it('COLLAB-SVC-011: paginates using before cursor (returns messages with id < before)', () => { const { user1, trip } = setup(); - const r1 = createMessage(trip.id, user1.id, 'First'); - const r2 = createMessage(trip.id, user1.id, 'Second'); - const r3 = createMessage(trip.id, user1.id, 'Third'); + svc.createMessage(trip.id, user1.id, 'First'); + svc.createMessage(trip.id, user1.id, 'Second'); + const r3 = svc.createMessage(trip.id, user1.id, 'Third'); const id3 = r3.message!.id; - const msgs = listMessages(trip.id, id3); + const msgs = svc.listMessages(trip.id, id3); expect(msgs.length).toBe(2); const texts = msgs.map(m => m.text); expect(texts).toContain('First'); @@ -192,22 +190,22 @@ describe('listMessages', () => { it('COLLAB-SVC-012: returns messages in ascending order (reversed after DESC query)', () => { const { user1, trip } = setup(); - createMessage(trip.id, user1.id, 'A'); - createMessage(trip.id, user1.id, 'B'); - createMessage(trip.id, user1.id, 'C'); + svc.createMessage(trip.id, user1.id, 'A'); + svc.createMessage(trip.id, user1.id, 'B'); + svc.createMessage(trip.id, user1.id, 'C'); - const msgs = listMessages(trip.id); + const msgs = svc.listMessages(trip.id); expect(msgs[0].text).toBe('A'); expect(msgs[2].text).toBe('C'); }); it('COLLAB-SVC-013: includes reactions grouped by emoji', () => { const { user1, trip } = setup(); - const r = createMessage(trip.id, user1.id, 'React me'); + const r = svc.createMessage(trip.id, user1.id, 'React me'); const msgId = r.message!.id; testDb.prepare('INSERT INTO collab_message_reactions (message_id, user_id, emoji) VALUES (?, ?, ?)').run(msgId, user1.id, '👍'); - const msgs = listMessages(trip.id); + const msgs = svc.listMessages(trip.id); expect(msgs[0].reactions).toBeDefined(); expect(msgs[0].reactions).toHaveLength(1); expect(msgs[0].reactions[0].emoji).toBe('👍'); @@ -219,14 +217,14 @@ describe('listMessages', () => { describe('createMessage', () => { it('COLLAB-SVC-014: returns error when replyTo message does not exist', () => { const { user1, trip } = setup(); - const result = createMessage(trip.id, user1.id, 'Reply to nothing', 9999); + const result = svc.createMessage(trip.id, user1.id, 'Reply to nothing', 9999); expect(result.error).toBe('reply_not_found'); }); it('COLLAB-SVC-015: creates message with valid replyTo', () => { const { user1, trip } = setup(); - const r1 = createMessage(trip.id, user1.id, 'Original'); - const r2 = createMessage(trip.id, user1.id, 'Reply', r1.message!.id); + const r1 = svc.createMessage(trip.id, user1.id, 'Original'); + const r2 = svc.createMessage(trip.id, user1.id, 'Reply', r1.message!.id); expect(r2.error).toBeUndefined(); expect(r2.message!.reply_to).toBe(r1.message!.id); }); @@ -237,23 +235,23 @@ describe('createMessage', () => { describe('deleteMessage', () => { it('COLLAB-SVC-016: returns error "not_owner" when user does not own message', () => { const { user1, user2, trip } = setup(); - const r = createMessage(trip.id, user1.id, 'My message'); + const r = svc.createMessage(trip.id, user1.id, 'My message'); - const result = deleteMessage(trip.id, r.message!.id, user2.id); + const result = svc.deleteMessage(trip.id, r.message!.id, user2.id); expect(result.error).toBe('not_owner'); }); it('COLLAB-SVC-017: returns error "not_found" for nonexistent message', () => { const { user1, trip } = setup(); - const result = deleteMessage(trip.id, 9999, user1.id); + const result = svc.deleteMessage(trip.id, 9999, user1.id); expect(result.error).toBe('not_found'); }); it('COLLAB-SVC-018: marks message as deleted when owner deletes it', () => { const { user1, trip } = setup(); - const r = createMessage(trip.id, user1.id, 'Delete me'); + const r = svc.createMessage(trip.id, user1.id, 'Delete me'); - const result = deleteMessage(trip.id, r.message!.id, user1.id); + const result = svc.deleteMessage(trip.id, r.message!.id, user1.id); expect(result.error).toBeUndefined(); const row = testDb.prepare('SELECT deleted FROM collab_messages WHERE id = ?').get(r.message!.id) as any; @@ -266,9 +264,9 @@ describe('deleteMessage', () => { describe('updateNote', () => { it('COLLAB-SVC-019: updates only title when other fields are undefined', () => { const { user1, trip } = setup(); - const note = createNote(trip.id, user1.id, { title: 'Original', content: 'Some content', website: 'https://example.com' }); + const note = svc.createNote(trip.id, user1.id, { title: 'Original', content: 'Some content', website: 'https://example.com' }); - updateNote(trip.id, note.id, { title: 'Updated' }); + svc.updateNote(trip.id, note.id, { title: 'Updated' }); const updated = testDb.prepare('SELECT * FROM collab_notes WHERE id = ?').get(note.id) as any; expect(updated.title).toBe('Updated'); @@ -278,9 +276,9 @@ describe('updateNote', () => { it('COLLAB-SVC-020: clears content when content is explicitly set to empty string', () => { const { user1, trip } = setup(); - const note = createNote(trip.id, user1.id, { title: 'T', content: 'Old content' }); + const note = svc.createNote(trip.id, user1.id, { title: 'T', content: 'Old content' }); - updateNote(trip.id, note.id, { content: '' }); + svc.updateNote(trip.id, note.id, { content: '' }); const updated = testDb.prepare('SELECT * FROM collab_notes WHERE id = ?').get(note.id) as any; expect(updated.content).toBe(''); @@ -288,9 +286,9 @@ describe('updateNote', () => { it('COLLAB-SVC-021: updates website when website is defined', () => { const { user1, trip } = setup(); - const note = createNote(trip.id, user1.id, { title: 'T' }); + const note = svc.createNote(trip.id, user1.id, { title: 'T' }); - updateNote(trip.id, note.id, { website: 'https://new.example.com' }); + svc.updateNote(trip.id, note.id, { website: 'https://new.example.com' }); const updated = testDb.prepare('SELECT * FROM collab_notes WHERE id = ?').get(note.id) as any; expect(updated.website).toBe('https://new.example.com'); @@ -298,9 +296,9 @@ describe('updateNote', () => { it('COLLAB-SVC-022: clears website when website is explicitly set to empty string', () => { const { user1, trip } = setup(); - const note = createNote(trip.id, user1.id, { title: 'T', website: 'https://old.com' }); + const note = svc.createNote(trip.id, user1.id, { title: 'T', website: 'https://old.com' }); - updateNote(trip.id, note.id, { website: '' }); + svc.updateNote(trip.id, note.id, { website: '' }); const updated = testDb.prepare('SELECT * FROM collab_notes WHERE id = ?').get(note.id) as any; expect(updated.website).toBe(''); @@ -308,24 +306,24 @@ describe('updateNote', () => { it('COLLAB-SVC-023: returns null when note does not exist', () => { const { trip } = setup(); - const result = updateNote(trip.id, 9999, { title: 'Ghost' }); + const result = svc.updateNote(trip.id, 9999, { title: 'Ghost' }); expect(result).toBeNull(); }); it('COLLAB-SVC-024: updates pinned flag', () => { const { user1, trip } = setup(); - const note = createNote(trip.id, user1.id, { title: 'T', pinned: false }); + const note = svc.createNote(trip.id, user1.id, { title: 'T', pinned: false }); - updateNote(trip.id, note.id, { pinned: true }); + svc.updateNote(trip.id, note.id, { pinned: true }); const updated = testDb.prepare('SELECT * FROM collab_notes WHERE id = ?').get(note.id) as any; expect(updated.pinned).toBe(1); }); }); -// ── fetchLinkPreview ────────────────────────────────────────────────────────── +// ── linkPreview ─────────────────────────────────────────────────────────────── -describe('fetchLinkPreview', () => { +describe('linkPreview', () => { afterEach(() => { vi.unstubAllGlobals(); }); @@ -345,7 +343,7 @@ describe('fetchLinkPreview', () => { `, })); - const result = await fetchLinkPreview('https://example.com/page'); + const result = await svc.linkPreview('https://example.com/page'); expect(result.title).toBe('Test Title'); expect(result.description).toBe('Test Description'); expect(result.image).toBe('https://example.com/image.jpg'); @@ -358,7 +356,7 @@ describe('fetchLinkPreview', () => { text: async () => `Page Title`, })); - const result = await fetchLinkPreview('https://example.com/'); + const result = await svc.linkPreview('https://example.com/'); expect(result.title).toBe('Page Title'); }); @@ -368,7 +366,7 @@ describe('fetchLinkPreview', () => { text: async () => '', })); - const result = await fetchLinkPreview('https://example.com/bad'); + const result = await svc.linkPreview('https://example.com/bad'); expect(result.title).toBeNull(); expect(result.description).toBeNull(); expect(result.url).toBe('https://example.com/bad'); @@ -377,14 +375,14 @@ describe('fetchLinkPreview', () => { it('COLLAB-SVC-028: returns fallback when SSRF check blocks the URL', async () => { mockCheckSsrf.mockResolvedValue({ allowed: false, error: 'SSRF blocked' }); - const result = await fetchLinkPreview('https://169.254.169.254/'); + const result = await svc.linkPreview('https://169.254.169.254/'); expect(result.title).toBeNull(); }); it('COLLAB-SVC-029: returns fallback when fetch throws (network error)', async () => { vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Network error'))); - const result = await fetchLinkPreview('https://example.com/net-error'); + const result = await svc.linkPreview('https://example.com/net-error'); expect(result.title).toBeNull(); expect(result.url).toBe('https://example.com/net-error'); }); @@ -399,7 +397,38 @@ describe('fetchLinkPreview', () => { `, })); - const result = await fetchLinkPreview('https://example.com/meta'); + const result = await svc.linkPreview('https://example.com/meta'); expect(result.description).toBe('Meta description here'); }); }); + +// ── collab.bridge delegation (out-of-container consumers) ───────────────────── + +describe('collab.bridge', () => { + it('COLLAB-SVC-031: listNotes delegates to CollabService over the shared db', () => { + const { user1, trip } = setup(); + svc.createNote(trip.id, user1.id, { title: 'Bridged note' }); + + const notes = bridgeListNotes(trip.id); + expect(notes).toHaveLength(1); + expect(notes[0].title).toBe('Bridged note'); + }); + + it('COLLAB-SVC-032: listPolls delegates to CollabService over the shared db', () => { + const { user1, trip } = setup(); + svc.createPoll(trip.id, user1.id, { question: 'Bridged?', options: ['A', 'B'] }); + + const polls = bridgeListPolls(trip.id); + expect(polls).toHaveLength(1); + expect(polls[0]!.question).toBe('Bridged?'); + }); + + it('COLLAB-SVC-033: countMessages delegates to CollabService over the shared db', () => { + const { user1, trip } = setup(); + svc.createMessage(trip.id, user1.id, 'one'); + svc.createMessage(trip.id, user1.id, 'two'); + + expect(bridgeCountMessages(trip.id)).toBe(2); + }); +}); + diff --git a/server/tests/unit/plugins/plugin-host-deps.factory.test.ts b/server/tests/unit/plugins/plugin-host-deps.factory.test.ts index d7a082878..64d261517 100644 --- a/server/tests/unit/plugins/plugin-host-deps.factory.test.ts +++ b/server/tests/unit/plugins/plugin-host-deps.factory.test.ts @@ -198,7 +198,9 @@ const filesStub = { findForeignLinkTarget: vi.fn((_tid: number, opts: { reservation_id?: number | null; place_id?: number | null }) => (Number(opts.reservation_id) === 999 ? 'reservation_id' : Number(opts.place_id) === 999 ? 'place_id' : null)), } as unknown as FilesService; -vi.mock('../../../src/services/collabService', () => ({ +// Collab: injected stub since the collabService DI migration (same behaviors +// as the old path mock). +const collabStub = { listNotes: vi.fn((tid: number) => [{ id: 1, trip_id: Number(tid), title: 'Note' }]), listPolls: vi.fn((tid: number) => [{ id: 2, trip_id: Number(tid), question: 'Q?' }]), listMessages: vi.fn((tid: number, before?: number) => [{ id: 3, trip_id: Number(tid), text: 'hi', _before: before ?? null }]), @@ -208,7 +210,7 @@ vi.mock('../../../src/services/collabService', () => ({ (optionIndex > 5 ? { error: 'Invalid option' } : { poll: { id: Number(pollId), votes: 1 } })), createMessage: vi.fn((tid: number, uid: number, text: string) => (text === 'toolong' ? { error: 'Message too long' } : { message: { id: 142, trip_id: Number(tid), user_id: uid, text } })), -})); +} as unknown as CollabService; vi.mock('../../../src/services/tripMembership', () => ({ joinTripAsMember: vi.fn((tripId: number, userId: number) => ({ joined: userId !== 5, tripId })), // owner add = no-op })); @@ -309,12 +311,13 @@ import type { AssignmentsService } from '../../../src/nest/assignments/assignmen import type { PluginOAuthService } from '../../../src/nest/plugins/plugin-oauth.service'; import type { LlmConfigResolver } from '../../../src/nest/llm-parse/llm-config.resolver'; import type { FilesService } from '../../../src/nest/files/files.service'; +import type { CollabService } from '../../../src/nest/collab/collab.service'; import { DatabaseService } from '../../../src/nest/database/database.service'; // The factory under test, wired exactly like PluginsModule does — but with the // DI-native domain services replaced by the stubs above. The shim keeps the // ~45 historical call sites unchanged and supplies a default no-op router. -const factory = new PluginHostDepsFactory(budgetStub, reservationsStub, tagsStub, categoriesStub, todoStub, packingStub, oauthStub, dayNotesStub, assignmentsStub, llmConfigStub, new DatabaseService(mockDb), filesStub); +const factory = new PluginHostDepsFactory(budgetStub, reservationsStub, tagsStub, categoriesStub, todoStub, packingStub, oauthStub, dayNotesStub, assignmentsStub, llmConfigStub, new DatabaseService(mockDb), filesStub, collabStub); const stubRouter: PluginCallRouter = { callPlugin: async () => undefined, emitPluginEvent: () => {} }; const createRealRpcHost = (id: string, granted: ReadonlySet, router: PluginCallRouter = stubRouter) => factory.create(id, granted, router);