mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
chore: move collab backend to proper DI
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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(() => {});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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`)
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+98
-69
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user