chore: move collab backend to proper DI

This commit is contained in:
jubnl
2026-07-27 00:27:11 +02:00
parent 470e4dc92c
commit c08c595b7e
23 changed files with 1129 additions and 986 deletions
+4 -45
View File
@@ -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(
+2 -2
View File
@@ -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);
-285
View File
@@ -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 });
}
);
}
+1 -1
View File
@@ -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';
+10 -5
View File
@@ -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
+31
View File
@@ -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);
}
+383
View File
@@ -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);
}
}
+4 -1
View File
@@ -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 {}
+451 -32
View File
@@ -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<ReturnType<typeof svc.verifyTripAccess>>;
type Trip = NonNullable<ReturnType<typeof verifyTripAccess>>;
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<typeof svc.createNote>[2]) { return svc.createNote(tripId, userId, data); }
updateNote(tripId: string, id: string, data: Parameters<typeof svc.updateNote>[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<typeof svc.addNoteFile>[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<typeof svc.createPoll>[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<ReactionRow>(`
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<string, { user_id: number; username: string }[]> = {};
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<NoteFileRow>('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<CollabNote>(`
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<CollabNote>(`
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<CollabService['formatNote']> | 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<CollabNote>(`
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<NoteFileRow>('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<TripFile>('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<CollabNote>('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<TripFile>('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<CollabPoll>(`
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<PollVoteRow>(`
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<CollabService['getPollWithVotes']> } {
const poll = this.db.get<CollabPoll>('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<CollabService['getPollWithVotes']> | 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<CollabMessage>(query, tripId, before)
: this.db.all<CollabMessage>(query, tripId);
messages.reverse();
const msgIds = messages.map(m => m.id);
const reactionsByMsg: Record<number, ReactionRow[]> = {};
if (msgIds.length > 0) {
const allReactions = this.db.all<ReactionRow & { message_id: number }>(`
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<CollabService['formatMessage']> } {
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<CollabMessage>(`
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<CollabMessage>('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<LinkPreviewResult> {
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(`<meta[^>]*property=["']og:${prop}["'][^>]*content=["']([^"']*)["']`, 'i'))
|| html.match(new RegExp(`<meta[^>]*content=["']([^"']*)["'][^>]*property=["']og:${prop}["']`, 'i'));
return m ? m[1] : null;
};
const titleTag = html.match(/<title[^>]*>([^<]*)<\/title>/i);
const descMeta = html.match(/<meta[^>]*name=["']description["'][^>]*content=["']([^"']*)["']/i)
|| html.match(/<meta[^>]*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<string, string> = { 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(() => {});
+5 -3
View File
@@ -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)
@@ -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;
+2 -1
View File
@@ -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
-451
View File
@@ -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<string, { user_id: number; username: string }[]> = {};
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<typeof formatNote> | 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<typeof getPollWithVotes> } {
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<typeof getPollWithVotes> | 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<number, ReactionRow[]> = {};
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<typeof formatMessage> } {
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<LinkPreviewResult> {
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(`<meta[^>]*property=["']og:${prop}["'][^>]*content=["']([^"']*)["']`, 'i'))
|| html.match(new RegExp(`<meta[^>]*content=["']([^"']*)["'][^>]*property=["']og:${prop}["']`, 'i'));
return m ? m[1] : null;
};
const titleTag = html.match(/<title[^>]*>([^<]*)<\/title>/i);
const descMeta = html.match(/<meta[^>]*name=["']description["'][^>]*content=["']([^"']*)["']/i)
|| html.match(/<meta[^>]*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;
}
}
+1 -1
View File
@@ -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';
+65 -34
View File
@@ -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 () => {
@@ -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 },
);
+2
View File
@@ -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),
);
}
+9 -13
View File
@@ -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<typeof import('../../src/services/collabService')>();
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`)
+5 -28
View File
@@ -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
@@ -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';
+34 -2
View File
@@ -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();
});
});
});
@@ -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 () => `<html><head><title>Page Title</title></head></html>`,
}));
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);
});
});
@@ -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<string>, router: PluginCallRouter = stubRouter) => factory.create(id, granted, router);