chore: move trip backend to proper DI

Fold the legacy tripService SQL into TripsService; port the trips MCP registrar/resources/trip-summary prompt to trips.mcp.ts and the share-link tools to share.mcp.ts (predicate access for the trips:delete/trips:share/canReadTrips gates, deprecation notice via the attach ctx); adopt trips.dto.ts and clear the seven allow-list entries; delete todo/share bridges with their last consumers. Includes the trailing quirk fixes: transactional deleteTrip/deleteGuest, owner display_name in listMembers.
This commit is contained in:
jubnl
2026-07-29 01:53:40 +02:00
parent a3c2cfa4b2
commit 530c7cb005
34 changed files with 3576 additions and 1804 deletions
+7
View File
@@ -12,6 +12,13 @@ declare module '@trek/nest-mcp' {
userId: number;
scopes: string[] | null;
isStaticToken: boolean;
/**
* Fire-once static-token deprecation notice closure (built per session in
* src/mcp/index.ts and threaded through registerTools → registry.attach).
* Optional so direct createTestRegistry ctxs without it keep working —
* consumers (list_trips / get_trip_summary) treat absence as "no notice".
*/
getDeprecationNotice?: () => string | null;
}
// The augmentation pattern requires an empty single-extends interface —
// same false positive (and same disable) as McpContext in @trek/nest-mcp.
+6 -39
View File
@@ -1,13 +1,12 @@
import { McpServer, ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp';
import { canAccessTrip } from '../db/database';
import { listTrips, getTrip, getTripOwner, listMembers } from '../services/tripService';
import { listPlaces } from '../services/placeService';
import { listBucketList, listVisitedCountries, getStats as getAtlasStats, listManuallyVisitedRegions } from '../services/atlasService';
import { getNotifications } from '../services/inAppNotifications';
import { isAddonEnabled } from '../nest/addons/addons.bridge';
import { ADDON_IDS } from '../addons';
import { canAccessJourney, getJourneyFull, listEntries, listJourneys } from '../services/journeyService';
import { canRead, canReadTrips } from './scopes';
import { canRead } from './scopes';
function parseId(value: string | string[]): number | null {
const n = Number(Array.isArray(value) ? value[0] : value);
@@ -45,29 +44,9 @@ function jsonContent(uri: string, data: unknown) {
}
export function registerResources(server: McpServer, userId: number, scopes: string[] | null): void {
// List all accessible trips
if (canReadTrips(scopes)) server.registerResource(
'trips',
'trek://trips',
{ description: 'All trips the user owns or is a member of', mimeType: 'application/json' },
async (uri) => {
const trips = listTrips(userId, 0);
return jsonContent(uri.href, trips);
}
);
// Single trip detail
if (canReadTrips(scopes)) server.registerResource(
'trip',
new ResourceTemplate('trek://trips/{tripId}', { list: undefined }),
{ description: 'A single trip with metadata and member count', mimeType: 'application/json' },
async (uri, { tripId }) => {
const id = parseId(tripId);
if (id === null || !canAccessTrip(id, userId)) return accessDenied(uri.href);
const trip = getTrip(id, userId);
return jsonContent(uri.href, trip);
}
);
// The trips and trip (trek://trips, trek://trips/{tripId}) resources moved
// to the DI-discovered src/nest/trips/trips.mcp.ts (@Resource /
// @ResourceTemplate, attached via the nest-mcp registry in registerTools).
// The trip-days resource moved to the DI-discovered
// src/nest/days/days.mcp.ts (@ResourceTemplate).
@@ -101,20 +80,8 @@ export function registerResources(server: McpServer, userId: number, scopes: str
// The trip-accommodations resource moved to the DI-discovered
// src/nest/days/days.mcp.ts (@ResourceTemplate).
// Trip members (owner + collaborators)
if (canReadTrips(scopes)) server.registerResource(
'trip-members',
new ResourceTemplate('trek://trips/{tripId}/members', { list: undefined }),
{ description: 'Owner and collaborators of a trip', mimeType: 'application/json' },
async (uri, { tripId }) => {
const id = parseId(tripId);
if (id === null || !canAccessTrip(id, userId)) return accessDenied(uri.href);
const ownerRow = getTripOwner(id);
if (!ownerRow) return accessDenied(uri.href);
const { owner, members } = listMembers(id, ownerRow.user_id);
return jsonContent(uri.href, { owner, members });
}
);
// The trip-members resource moved to the DI-discovered
// src/nest/trips/trips.mcp.ts (@ResourceTemplate).
// The trek://trips/{tripId}/collab-notes, …/collab/polls and …/collab/messages
// resources moved to the DI-discovered src/nest/collab/collab.mcp.ts
+5 -3
View File
@@ -5,14 +5,16 @@ import { registerNotificationTools } from './tools/notifications';
import { registerAtlasTools } from './tools/atlas';
import { registerPlaceTools } from './tools/places';
import { registerCollectionTools } from './tools/collections';
import { registerTripTools } from './tools/trips';
import { registerTransportTools } from './tools/transports';
import { registerTransitTools } from './tools/transit';
import { registerMcpPrompts } from './tools/prompts';
import { getMcpRegistry } from './registry-handoff';
export function registerTools(server: McpServer, userId: number, scopes: string[] | null, isStaticToken = false, getDeprecationNotice: () => string | null = () => null): void {
registerTripTools(server, userId, scopes, getDeprecationNotice);
// The trip tools moved to the DI-discovered src/nest/trips/trips.mcp.ts and
// the share-link tools to src/nest/share/share.mcp.ts (@McpController,
// attached via the nest-mcp registry below — getDeprecationNotice rides the
// attach ctx).
registerPlaceTools(server, userId, scopes);
@@ -61,5 +63,5 @@ export function registerTools(server: McpServer, userId: number, scopes: string[
// callers without a Nest app, e.g. unit tests) ⇒ skip; the test harness
// attaches its own via createTestRegistry + setMcpRegistry.
const registry = getMcpRegistry();
if (registry) registry.attach(server, { userId, scopes, isStaticToken });
if (registry) registry.attach(server, { userId, scopes, isStaticToken, getDeprecationNotice });
}
+4 -35
View File
@@ -1,7 +1,7 @@
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp';
import { z } from 'zod';
import { canAccessTrip } from '../../db/database';
import { getTripSummary } from '../../services/tripService';
import { getTripSummary } from '../../nest/trips/trips.bridge';
import { listItems as listPackingItems } from '../../nest/packing/packing.bridge';
import { isAddonEnabled } from '../../nest/addons/addons.bridge';
import { ADDON_IDS } from '../../addons';
@@ -29,40 +29,9 @@ export function registerMcpPrompts(server: McpServer, _userId: number, isStaticT
}
const userId = _userId;
server.registerPrompt(
'trip-summary',
{
title: 'Trip Summary',
description: 'Load a full summary of a trip for context before planning or modifications',
argsSchema: {
tripId: z.number().int().positive().describe('Trip ID to summarize'),
},
},
async ({ tripId }) => {
if (!canAccessTrip(tripId, userId)) {
return { messages: [{ role: 'user', content: { type: 'text', text: 'Trip not found or access denied.' } }] };
}
const summary = getTripSummary(tripId, userId);
if (!summary) {
return { messages: [{ role: 'user', content: { type: 'text', text: 'Trip not found.' } }] };
}
const { trip, days, members, budget, packing, reservations, collab_notes } = summary;
const memberList = [members?.owner, ...(members?.collaborators || [])].filter(Boolean);
const text = `Trip: ${trip?.title || 'Untitled'}${trip?.description ? `\n${trip.description}` : ''}
Dates: ${trip?.start_date || '?'} to ${trip?.end_date || '?'}
Members: ${memberList.length} (${memberList.map((m: any) => m.name || m.email).join(', ') || 'none'})
Days: ${days?.length || 0}
Packing: ${packing?.checked || 0}/${packing?.total || 0} items packed
Budget: ${budget?.total || 0} ${trip?.currency || 'EUR'} total
Reservations: ${reservations?.length || 0}
Collab Notes: ${collab_notes?.length || 0}
${days?.map((d: any, i: number) => `Day ${i + 1} (${d.date}): ${d.assignments?.length || 0} places${d.title ? ` - ${d.title}` : ''}`).join('\n') || 'No days yet'}`;
return {
description: `Summary of trip "${trip?.title || tripId}"`,
messages: [{ role: 'user', content: { type: 'text', text } }],
};
}
);
// The trip-summary prompt moved to the DI-discovered
// src/nest/trips/trips.mcp.ts (@Prompt, attached via the nest-mcp registry
// in registerTools).
if (isAddonEnabled(ADDON_IDS.PACKING)) server.registerPrompt(
'packing-list',
-396
View File
@@ -1,396 +0,0 @@
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp';
import { z } from 'zod';
import { canAccessTrip } from '../../db/database';
import { isDemoUser } from '../../services/authService';
import {
listTrips, createTrip, updateTrip, deleteTrip, getTripSummary,
isOwner, verifyTripAccess,
listMembers as listTripMembers, getTripOwner, addMember as addTripMember,
removeMember as removeTripMember,
copyTripById, exportICS, NotFoundError, ValidationError,
} from '../../services/tripService';
import {
createOrUpdateShareLink, getShareLink, deleteShareLink,
} from '../../nest/share/share.bridge';
import { isAddonEnabled, getCollabFeatures } from '../../nest/addons/addons.bridge';
import { ADDON_IDS } from '../../addons';
import { countMessages, listPolls } from '../../nest/collab/collab.bridge';
import {
listItems as listTodoItems,
} from '../../nest/todo/todo.bridge';
import { rebaseTripCurrency } from '../../nest/budget/budget.bridge';
import {
safeBroadcast, MAX_MCP_TRIP_DAYS,
TOOL_ANNOTATIONS_READONLY, TOOL_ANNOTATIONS_WRITE,
TOOL_ANNOTATIONS_DELETE, TOOL_ANNOTATIONS_NON_IDEMPOTENT,
demoDenied, noAccess, ok, hasTripPermission, permissionDenied,
} from './_shared';
import { canRead, canReadTrips, canWrite, canDeleteTrips, canShareTrips } from '../scopes';
export function registerTripTools(server: McpServer, userId: number, scopes: string[] | null, getDeprecationNotice: () => string | null = () => null): void {
const R = canReadTrips(scopes);
const W = canWrite(scopes, 'trips');
const D = canDeleteTrips(scopes);
const S = canShareTrips(scopes);
// --- TRIPS ---
if (W) server.registerTool(
'create_trip',
{
description: 'Create a new trip. Returns the created trip with its generated days.',
inputSchema: {
title: z.string().min(1).max(200).describe('Trip title'),
description: z.string().max(2000).optional().describe('Trip description'),
start_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe('Start date (YYYY-MM-DD)'),
end_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe('End date (YYYY-MM-DD)'),
currency: z.string().length(3).optional().describe('Currency code (e.g. EUR, USD)'),
},
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
},
async ({ title, description, start_date, end_date, currency }) => {
if (isDemoUser(userId)) return demoDenied();
if (start_date) {
const d = new Date(start_date + 'T00:00:00Z');
if (isNaN(d.getTime()) || d.toISOString().slice(0, 10) !== start_date)
return { content: [{ type: 'text' as const, text: 'start_date is not a valid calendar date.' }], isError: true };
}
if (end_date) {
const d = new Date(end_date + 'T00:00:00Z');
if (isNaN(d.getTime()) || d.toISOString().slice(0, 10) !== end_date)
return { content: [{ type: 'text' as const, text: 'end_date is not a valid calendar date.' }], isError: true };
}
if (start_date && end_date && new Date(end_date) < new Date(start_date)) {
return { content: [{ type: 'text' as const, text: 'End date must be after start date.' }], isError: true };
}
const { trip } = createTrip(userId, { title, description, start_date, end_date, currency }, MAX_MCP_TRIP_DAYS);
return ok({ trip });
}
);
if (W) server.registerTool(
'update_trip',
{
description: 'Update an existing trip\'s details.',
inputSchema: {
tripId: z.number().int().positive(),
title: z.string().min(1).max(200).optional(),
description: z.string().max(2000).optional(),
start_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
end_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
currency: z.string().length(3).optional(),
is_archived: z.boolean().optional().describe('Archive (true) or unarchive (false) the trip'),
cover_image: z.string().optional().describe('Cover image path, e.g. /uploads/covers/abc.jpg'),
date_shift_mode: z.enum(['keep_bookings', 'shift_all']).optional().describe(
'When changing dates: keep_bookings (default) keeps dated reservations/accommodations on their dates while day plans move; shift_all moves the whole itinerary, bookings included'),
},
annotations: TOOL_ANNOTATIONS_WRITE,
},
async ({ tripId, title, description, start_date, end_date, currency, is_archived, cover_image, date_shift_mode }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
if (!hasTripPermission('trip_edit', tripId, userId)) return permissionDenied();
if (start_date) {
const d = new Date(start_date + 'T00:00:00Z');
if (isNaN(d.getTime()) || d.toISOString().slice(0, 10) !== start_date)
return { content: [{ type: 'text' as const, text: 'start_date is not a valid calendar date.' }], isError: true };
}
if (end_date) {
const d = new Date(end_date + 'T00:00:00Z');
if (isNaN(d.getTime()) || d.toISOString().slice(0, 10) !== end_date)
return { content: [{ type: 'text' as const, text: 'end_date is not a valid calendar date.' }], isError: true };
}
// Re-anchor the budget before the trip row moves off the old currency (#1543).
await rebaseTripCurrency(tripId, currency);
const { updatedTrip } = updateTrip(tripId, userId, { title, description, start_date, end_date, currency, is_archived, cover_image, date_shift_mode }, 'user');
safeBroadcast(tripId, 'trip:updated', { trip: updatedTrip });
return ok({ trip: updatedTrip });
}
);
if (D) server.registerTool(
'delete_trip',
{
description: 'Delete a trip. Only the trip owner can delete it.',
inputSchema: {
tripId: z.number().int().positive(),
},
annotations: TOOL_ANNOTATIONS_DELETE,
},
async ({ tripId }) => {
if (isDemoUser(userId)) return demoDenied();
if (!isOwner(tripId, userId)) return noAccess();
deleteTrip(tripId, userId, 'user');
return ok({ success: true, tripId });
}
);
// list_trips and get_trip_summary are always registered regardless of OAuth scopes —
// they are navigation tools that any MCP client needs to discover trip IDs.
server.registerTool(
'list_trips',
{
description: 'List all trips the current user owns or is a member of. Use this for trip discovery before calling get_trip_summary.',
inputSchema: {
include_archived: z.boolean().optional().describe('Include archived trips (default false)'),
},
annotations: TOOL_ANNOTATIONS_READONLY,
},
async ({ include_archived }) => {
const notice = getDeprecationNotice();
const trips = listTrips(userId, include_archived ? null : 0);
if (notice) return {
isError: true as const,
content: [
{ type: 'text' as const, text: notice },
{ type: 'text' as const, text: JSON.stringify({ trips }, null, 2) },
],
};
return ok({ trips });
}
);
// --- TRIP SUMMARY ---
server.registerTool(
'get_trip_summary',
{
description: 'Get a full denormalized summary of a trip in a single call: metadata, members, days with assignments and notes, accommodations, budget line items (when enabled), packing list (when enabled), reservations, collab notes and poll/message counts (when enabled), and to-do items (when enabled). Use this as a context loader before planning or modifying a trip.',
inputSchema: {
tripId: z.number().int().positive(),
},
annotations: TOOL_ANNOTATIONS_READONLY,
},
async ({ tripId }) => {
if (!canAccessTrip(tripId, userId)) return noAccess();
const summary = getTripSummary(tripId, userId);
if (!summary) return noAccess();
// Addon availability gates
const packingEnabled = isAddonEnabled(ADDON_IDS.PACKING);
const budgetEnabled = isAddonEnabled(ADDON_IDS.BUDGET);
const collabEnabled = isAddonEnabled(ADDON_IDS.COLLAB);
const collabFeatures = collabEnabled ? getCollabFeatures() : null;
// Scope gates — sections not covered by the client's OAuth scopes are omitted.
// Core trip data (metadata, days, members, accommodations) is always included
// because this tool is always registered and needed for navigation.
const canReadBudget = budgetEnabled && canRead(scopes, 'budget');
const canReadPacking = packingEnabled && canRead(scopes, 'packing');
const canReadCollab = collabEnabled && canRead(scopes, 'collab');
const canReadTodos = packingEnabled && canRead(scopes, 'todos');
const canReadRes = canRead(scopes, 'reservations');
const todos = canReadTodos ? listTodoItems(tripId) : [];
let pollCount = 0;
let messageCount = 0;
if (canReadCollab) {
if (collabFeatures?.polls) pollCount = listPolls(tripId).length;
if (collabFeatures?.chat) messageCount = countMessages(tripId);
}
const notice = getDeprecationNotice();
// The core bucket (trip metadata, members WITH email, days with place
// coordinates, accommodations) carries confidential PII and itinerary data,
// so it is gated on trips:read just like the sub-sections below. Without a
// read scope the tool still resolves trip id + title so it stays usable for
// navigation (list_trips already covers discovery). trek_ PATs (null scopes)
// and any trips:read holder keep the full payload — no behaviour change.
const summaryData = {
trip: R ? summary.trip : { id: summary.trip.id, title: summary.trip.title },
members: R ? summary.members : undefined,
days: R ? summary.days : undefined,
// Accommodations are "accommodation details" under reservations:read too
// (see SCOPE_INFO) and pair with reservations in the share payload, so a
// reservations-scoped token keeps them — gate on either read scope.
accommodations: (R || canReadRes) ? summary.accommodations : undefined,
reservations: canReadRes ? summary.reservations : undefined,
packing: canReadPacking ? summary.packing : undefined,
budget: canReadBudget ? summary.budget : undefined,
collab_notes: canReadCollab && collabFeatures?.notes ? summary.collab_notes : [],
todos,
pollCount,
messageCount,
};
if (notice) return {
isError: true as const,
content: [
{ type: 'text' as const, text: notice },
{ type: 'text' as const, text: JSON.stringify(summaryData, null, 2) },
],
};
return ok(summaryData);
}
);
// --- TRIP MEMBERS, COPY, ICS, SHARE ---
if (R) server.registerTool(
'list_trip_members',
{
description: 'List all members of a trip (owner + collaborators).',
inputSchema: {
tripId: z.number().int().positive(),
},
annotations: TOOL_ANNOTATIONS_READONLY,
},
async ({ tripId }) => {
if (!canAccessTrip(tripId, userId)) return noAccess();
const ownerRow = getTripOwner(tripId);
if (!ownerRow) return noAccess();
const { owner, members } = listTripMembers(tripId, ownerRow.user_id);
return ok({ owner, members });
}
);
if (W) server.registerTool(
'add_trip_member',
{
description: 'Add a user to a trip by their username or email address. Only the trip owner can do this.',
inputSchema: {
tripId: z.number().int().positive(),
identifier: z.string().min(1).describe('Username or email of the user to add'),
},
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
},
async ({ tripId, identifier }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
const ownerRow = getTripOwner(tripId);
if (!ownerRow || ownerRow.user_id !== userId)
return { content: [{ type: 'text' as const, text: 'Only the trip owner can add members.' }], isError: true };
try {
const result = addTripMember(tripId, identifier, ownerRow.user_id, userId);
safeBroadcast(tripId, 'member:added', { member: result.member });
return ok({ member: result.member });
} catch (err) {
const msg = err instanceof ValidationError || err instanceof NotFoundError ? err.message : 'Failed to add member.';
return { content: [{ type: 'text' as const, text: msg }], isError: true };
}
}
);
if (W) server.registerTool(
'remove_trip_member',
{
description: 'Remove a member from a trip. Only the trip owner can do this.',
inputSchema: {
tripId: z.number().int().positive(),
memberId: z.number().int().positive().describe('User ID of the member to remove'),
},
annotations: TOOL_ANNOTATIONS_DELETE,
},
async ({ tripId, memberId }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
const ownerRow = getTripOwner(tripId);
if (!ownerRow || ownerRow.user_id !== userId)
return { content: [{ type: 'text' as const, text: 'Only the trip owner can remove members.' }], isError: true };
removeTripMember(tripId, memberId);
safeBroadcast(tripId, 'member:removed', { userId: memberId });
return ok({ success: true });
}
);
if (W) server.registerTool(
'copy_trip',
{
description: 'Duplicate a trip (all days, places, itinerary, packing, budget, reservations, day notes). Packing items are reset to unchecked. Returns the new trip.',
inputSchema: {
tripId: z.number().int().positive().describe('Source trip ID to duplicate'),
title: z.string().min(1).max(200).optional().describe('Title for the new trip (defaults to source title)'),
},
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
},
async ({ tripId, title }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
try {
const newTripId = copyTripById(tripId, userId, title);
const newTrip = canAccessTrip(newTripId, userId);
return ok({ trip: { id: newTripId, ...newTrip } });
} catch {
return { content: [{ type: 'text' as const, text: 'Failed to copy trip.' }], isError: true };
}
}
);
if (R) server.registerTool(
'export_trip_ics',
{
description: 'Export a trip\'s itinerary and reservations as iCalendar (.ics) format text. Useful for importing into calendar apps.',
inputSchema: {
tripId: z.number().int().positive(),
},
annotations: TOOL_ANNOTATIONS_READONLY,
},
async ({ tripId }) => {
if (!canAccessTrip(tripId, userId)) return noAccess();
try {
const { ics, filename } = exportICS(tripId);
return ok({ ics, filename });
} catch {
return { content: [{ type: 'text' as const, text: 'Trip not found.' }], isError: true };
}
}
);
if (S) server.registerTool(
'get_share_link',
{
description: 'Get the current public share link for a trip, including its permission flags. Returns null if no share link exists.',
inputSchema: {
tripId: z.number().int().positive(),
},
annotations: TOOL_ANNOTATIONS_READONLY,
},
async ({ tripId }) => {
// Read parity with the REST route GET /api/trips/:tripId/share-link, which
// only requires trip membership (share_manage gates create/delete, not read).
if (!canAccessTrip(tripId, userId)) return noAccess();
const link = getShareLink(String(tripId));
return ok({ link });
}
);
if (S) server.registerTool(
'create_share_link',
{
description: 'Create or update the public share link for a trip. Set permission flags to control what is visible to guests.',
inputSchema: {
tripId: z.number().int().positive(),
share_map: z.boolean().optional().default(true).describe('Share the map and places'),
share_bookings: z.boolean().optional().default(true).describe('Share reservations'),
share_packing: z.boolean().optional().default(false).describe('Share packing list'),
share_budget: z.boolean().optional().default(false).describe('Share budget'),
share_collab: z.boolean().optional().default(false).describe('Share collab messages'),
},
annotations: TOOL_ANNOTATIONS_WRITE,
},
async ({ tripId, share_map, share_bookings, share_packing, share_budget, share_collab }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
if (!hasTripPermission('share_manage', tripId, userId)) return permissionDenied();
// The zod .default()s above fill omitted flags, and ShareService applies
// the same defaults again for undefined — no re-defaulting needed here.
const { token, created } = createOrUpdateShareLink(String(tripId), userId, {
share_map, share_bookings, share_packing, share_budget, share_collab,
});
return ok({ token, created });
}
);
if (S) server.registerTool(
'delete_share_link',
{
description: 'Revoke the public share link for a trip. Guests will no longer be able to access the shared view.',
inputSchema: {
tripId: z.number().int().positive(),
},
annotations: TOOL_ANNOTATIONS_DELETE,
},
async ({ tripId }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
if (!hasTripPermission('share_manage', tripId, userId)) return permissionDenied();
deleteShareLink(String(tripId));
return ok({ success: true });
}
);
}
+1 -1
View File
@@ -9,7 +9,7 @@ import { isDemoUser } from '../../services/authService';
import { isAddonEnabled } from '../addons/addons.bridge';
import { ADDON_IDS } from '../../addons';
import { safeBroadcast, noAccess, hasTripPermission, permissionDenied } from '../../mcp/tools/_shared';
import { getTripOwner, listMembers } from '../../services/tripService';
import { getTripOwner, listMembers } from '../trips/trips.bridge';
import { DatabaseService } from '../database/database.service';
import { BudgetService } from './budget.service';
import { ExchangeRatesService } from './exchange-rates.service';
@@ -109,13 +109,6 @@ export const BODY_CONTRACT_ALLOW_LIST: string[] = [
'SynologyMemoriesController.test',
'TagsController.create',
'TagsController.update',
'TripsController.addMember',
'TripsController.copy',
'TripsController.create',
'TripsController.createGuest',
'TripsController.renameGuest',
'TripsController.transferOwnership',
'TripsController.update',
'UnifiedMemoriesController.addPhotos',
'UnifiedMemoriesController.createAlbumLink',
'UnifiedMemoriesController.setSharing',
+2
View File
@@ -1,8 +1,10 @@
import { Module } from '@nestjs/common';
import { TripsModule } from '../trips/trips.module';
import { FeedsService } from './feeds.service';
import { FeedsPublicController, TripFeedTokenController, UserFeedTokenController } from './feeds.controller';
@Module({
imports: [TripsModule],
controllers: [FeedsPublicController, TripFeedTokenController, UserFeedTokenController],
providers: [FeedsService],
})
+7 -4
View File
@@ -1,7 +1,7 @@
import { Injectable } from '@nestjs/common';
import { randomUUID } from 'crypto';
import { DatabaseService } from '../database/database.service';
import { exportICS } from '../../services/tripService';
import { TripsService } from '../trips/trips.service';
const ninetyDaysAgo = () => {
const d = new Date();
@@ -15,7 +15,10 @@ function feedUrl(token: string, scope: 'trip' | 'user', base: string): string {
@Injectable()
export class FeedsService {
constructor(private readonly dbs: DatabaseService) {}
constructor(
private readonly dbs: DatabaseService,
private readonly trips: TripsService,
) {}
private get db() {
return this.dbs.connection;
@@ -92,7 +95,7 @@ export class FeedsService {
| undefined;
if (!row) return null;
try {
const { ics, filename } = exportICS(row.id);
const { ics, filename } = this.trips.exportICS(row.id);
// Inject calendar-subscription refresh hints into the VCALENDAR header so
// clients re-fetch hourly. The one-time download path (exportICS) is left
// untouched; this is feed-only.
@@ -142,7 +145,7 @@ export class FeedsService {
let events = '';
for (const { id } of trips) {
try {
const { ics } = exportICS(id);
const { ics } = this.trips.exportICS(id);
for (const vtz of extractVTimezones(ics)) {
const tzid = vtz.match(/\r\nTZID:(.+)\r\n/)?.[1];
if (tzid && !zones.has(tzid)) zones.set(tzid, vtz);
+8 -7
View File
@@ -7,13 +7,14 @@ import { PermissionsService } from '../permissions/permissions.service';
/**
* Non-Nest entry point for the packing domain — for code running OUTSIDE the
* Nest container (the legacy packing-list prompt registrar in
* src/mcp/tools/prompts.ts and the trip-summary packing counts in
* services/tripService.ts; the packing MCP tools and resources moved to the
* DI-discovered packing.mcp.ts, and the plugin RPC host injects PackingService
* via PluginHostDepsFactory). Exports only the legacy services/packingService
* names still consumed outside the container, 1:1, so repointing a consumer is
* an import-path-only diff. Inside the container, inject PackingService
* instead. Delete this file when those legacy consumers migrate.
* src/mcp/tools/prompts.ts; the packing MCP tools and resources moved to the
* DI-discovered packing.mcp.ts, the plugin RPC host injects PackingService
* via PluginHostDepsFactory, and the trip domain folded into the DI-native
* TripsService, which injects PackingService). Exports only the legacy
* services/packingService names still consumed outside the container, 1:1, so
* repointing a consumer is an import-path-only diff. Inside the container,
* inject PackingService instead. Delete this file when the prompts registrar
* migrates.
*
* Module-level construction is safe: `db` is the reinitialize-proof Proxy onto
* the shared better-sqlite3 singleton.
-33
View File
@@ -1,33 +0,0 @@
import { db } from '../../db/database';
import { DatabaseService } from '../database/database.service';
import { ShareService } from './share.service';
import { PermissionsService } from '../permissions/permissions.service';
import type { SharePermissions } from './share.service';
import { SettingsService } from '../settings/settings.service';
/**
* Non-Nest entry point for the share domain — for code running OUTSIDE the
* Nest container (the legacy share-link tools in src/mcp/tools/trips.ts; those
* stay in the trips registrar because their `trips:share` scope gate has no
* declarative `access: { group, mode }` equivalent yet). Exports only the
* legacy services/shareService names still consumed outside the container,
* 1:1, so repointing a consumer is an import-path-only diff. Inside the
* container, inject ShareService instead.
* Delete this file when the legacy MCP trips registrar migrates.
*
* Module-level construction is safe: `db` is the reinitialize-proof Proxy onto
* the shared better-sqlite3 singleton.
*/
const share = new ShareService(new DatabaseService(db), new SettingsService(new DatabaseService(db)), new PermissionsService(new DatabaseService(db)));
export function createOrUpdateShareLink(tripId: string, createdBy: number, permissions: SharePermissions) {
return share.createOrUpdate(tripId, createdBy, permissions);
}
export function getShareLink(tripId: string) {
return share.get(tripId);
}
export function deleteShareLink(tripId: string) {
return share.remove(tripId);
}
+88
View File
@@ -0,0 +1,88 @@
import {
McpController, Tool, type McpContext,
TOOL_ANNOTATIONS_READONLY, TOOL_ANNOTATIONS_WRITE, TOOL_ANNOTATIONS_DELETE,
demoDenied, ok,
} from '@trek/nest-mcp';
import { z } from 'zod';
import { isDemoUser } from '../../services/authService';
import { noAccess, hasTripPermission, permissionDenied } from '../../mcp/tools/_shared';
import { canShareTrips } from '../../mcp/scopes';
import { ShareService } from './share.service';
/**
* Share-link MCP surface — ported 1:1 from the three share tools that lived in
* the legacy src/mcp/tools/trips.ts registrar (identical names, descriptions,
* schemas, annotations and error/payload shapes). They stayed behind when the
* share domain migrated because the `trips:share` scope gate has no
* declarative `access: { group, mode }` equivalent — all three use the
* predicate escape hatch instead, preserving the legacy `if (S)` gate exactly.
*/
@McpController()
export class ShareMcp {
constructor(private readonly share: ShareService) {}
@Tool({
name: 'get_share_link',
description: 'Get the current public share link for a trip, including its permission flags. Returns null if no share link exists.',
inputSchema: {
tripId: z.number().int().positive(),
},
annotations: TOOL_ANNOTATIONS_READONLY,
access: (ctx) => canShareTrips(ctx.scopes),
})
async getShareLink({ tripId }: { tripId: number }, ctx: McpContext) {
// Read parity with the REST route GET /api/trips/:tripId/share-link, which
// only requires trip membership (share_manage gates create/delete, not read).
if (!this.share.verifyTripAccess(String(tripId), ctx.userId)) return noAccess();
const link = this.share.get(String(tripId));
return ok({ link });
}
@Tool({
name: 'create_share_link',
description: 'Create or update the public share link for a trip. Set permission flags to control what is visible to guests.',
inputSchema: {
tripId: z.number().int().positive(),
share_map: z.boolean().optional().default(true).describe('Share the map and places'),
share_bookings: z.boolean().optional().default(true).describe('Share reservations'),
share_packing: z.boolean().optional().default(false).describe('Share packing list'),
share_budget: z.boolean().optional().default(false).describe('Share budget'),
share_collab: z.boolean().optional().default(false).describe('Share collab messages'),
},
annotations: TOOL_ANNOTATIONS_WRITE,
access: (ctx) => canShareTrips(ctx.scopes),
})
async createShareLink(
{ tripId, share_map, share_bookings, share_packing, share_budget, share_collab }: {
tripId: number; share_map?: boolean; share_bookings?: boolean; share_packing?: boolean; share_budget?: boolean; share_collab?: boolean;
},
ctx: McpContext,
) {
if (isDemoUser(ctx.userId)) return demoDenied();
if (!this.share.verifyTripAccess(String(tripId), ctx.userId)) return noAccess();
if (!hasTripPermission('share_manage', tripId, ctx.userId)) return permissionDenied();
// The zod .default()s above fill omitted flags, and ShareService applies
// the same defaults again for undefined — no re-defaulting needed here.
const { token, created } = this.share.createOrUpdate(String(tripId), ctx.userId, {
share_map, share_bookings, share_packing, share_budget, share_collab,
});
return ok({ token, created });
}
@Tool({
name: 'delete_share_link',
description: 'Revoke the public share link for a trip. Guests will no longer be able to access the shared view.',
inputSchema: {
tripId: z.number().int().positive(),
},
annotations: TOOL_ANNOTATIONS_DELETE,
access: (ctx) => canShareTrips(ctx.scopes),
})
async deleteShareLink({ tripId }: { tripId: number }, ctx: McpContext) {
if (isDemoUser(ctx.userId)) return demoDenied();
if (!this.share.verifyTripAccess(String(tripId), ctx.userId)) return noAccess();
if (!hasTripPermission('share_manage', tripId, ctx.userId)) return permissionDenied();
this.share.remove(String(tripId));
return ok({ success: true });
}
}
+2 -1
View File
@@ -1,12 +1,13 @@
import { Module } from '@nestjs/common';
import { TripShareController, SharedController } from './share.controller';
import { ShareService } from './share.service';
import { ShareMcp } from './share.mcp';
import { SettingsModule } from '../settings/settings.module';
import { PermissionsModule } from '../permissions/permissions.module';
@Module({
imports: [SettingsModule, PermissionsModule],
controllers: [TripShareController, SharedController],
providers: [ShareService],
providers: [ShareService, ShareMcp],
})
export class ShareModule {}
-41
View File
@@ -1,41 +0,0 @@
import { db } from '../../db/database';
import { DatabaseService } from '../database/database.service';
import { RealtimeService } from '../realtime/realtime.service';
import { TodoService } from './todo.service';
import { PermissionsService } from '../permissions/permissions.service';
/**
* Non-Nest entry point for the todo domain — for code running OUTSIDE the
* Nest container (the legacy get_trip_summary registrar in
* src/mcp/tools/trips.ts; the todo MCP tools and resource moved to the
* DI-discovered todo.mcp.ts, and the plugin RPC host injects TodoService via
* PluginHostDepsFactory). Exports only the legacy services/todoService names
* still consumed outside the container, 1:1, so repointing a consumer is an
* import-path-only diff. Inside the container, inject TodoService instead.
* Delete this file when the legacy MCP trips registrar migrates.
*
* Module-level construction is safe: `db` is the reinitialize-proof Proxy onto
* the shared better-sqlite3 singleton.
*/
const todos = new TodoService(new DatabaseService(db), new PermissionsService(new DatabaseService(db)), new RealtimeService());
export function listItems(tripId: string | number) {
return todos.listItems(tripId);
}
export function createItem(tripId: string | number, data: Parameters<TodoService['createItem']>[1]) {
return todos.createItem(tripId, data);
}
export function updateItem(
tripId: string | number,
id: string | number,
data: Parameters<TodoService['updateItem']>[2],
bodyKeys: string[]
) {
return todos.updateItem(tripId, id, data, bodyKeys);
}
export function deleteItem(tripId: string | number, id: string | number): boolean {
return todos.deleteItem(tripId, id);
}
+57
View File
@@ -0,0 +1,57 @@
import { db } from '../../db/database';
import { DatabaseService } from '../database/database.service';
import { RealtimeService } from '../realtime/realtime.service';
import { PermissionsService } from '../permissions/permissions.service';
import { TripsService } from './trips.service';
import { TodoService } from '../todo/todo.service';
import { PackingService } from '../packing/packing.service';
import { FilesService } from '../files/files.service';
import { ReservationsService } from '../reservations/reservations.service';
import { DaysService } from '../days/days.service';
import { BudgetService } from '../budget/budget.service';
import { ExchangeRatesService } from '../budget/exchange-rates.service';
import { CollabService } from '../collab/collab.service';
import { VacayService } from '../vacay/vacay.service';
/**
* Non-Nest entry point for the trip domain — for the two consumers that cannot
* inject TripsService: the legacy packing-list/budget-overview prompt
* registrar in src/mcp/tools/prompts.ts (outside the container) and
* budget.mcp.ts's owner/member lookups (injecting TripsService there would
* need a forwardRef'd TripsModule↔BudgetModule cycle, so it stays on the
* bridge seam migration-graph.md planned). Exports only the legacy
* services/tripService names they consume, 1:1, so repointing was an
* import-path-only diff. Everything else injects TripsService. Delete this
* file when the prompts registrar migrates and the budget MCP surface can
* inject TripsService without a module cycle.
*
* Module-level construction is safe: `db` is the reinitialize-proof Proxy onto
* the shared better-sqlite3 singleton.
*/
const dbs = () => new DatabaseService(db);
const budget = new BudgetService(dbs(), new PermissionsService(dbs()), new ExchangeRatesService(), new RealtimeService());
const trips = new TripsService(
dbs(),
new TodoService(dbs(), new PermissionsService(dbs()), new RealtimeService()),
new PackingService(dbs(), new PermissionsService(dbs()), new RealtimeService()),
new FilesService(dbs(), new PermissionsService(dbs()), new RealtimeService()),
new ReservationsService(dbs(), new PermissionsService(dbs()), budget, new RealtimeService()),
new DaysService(dbs(), new PermissionsService(dbs()), new RealtimeService()),
new PermissionsService(dbs()),
budget,
new CollabService(dbs(), new PermissionsService(dbs()), new RealtimeService()),
new VacayService(dbs(), new RealtimeService()),
new RealtimeService(),
);
export function getTripOwner(tripId: string | number) {
return trips.getOwner(tripId);
}
export function listMembers(tripId: string | number, tripOwnerId: number) {
return trips.listMembers(tripId, tripOwnerId);
}
export function getTripSummary(tripId: number, viewerUserId?: number) {
return trips.getTripSummary(tripId, viewerUserId);
}
+20 -25
View File
@@ -31,7 +31,8 @@ import { getClientIp } from '../audit/client-ip';
import { logInfo } from '../audit/audit-log.logger';
import { AuditService } from '../audit/audit.service';
import { isDemoEmail } from '../../services/demo';
import { NotFoundError, ValidationError } from '../../services/tripService';
import { NotFoundError, ValidationError } from './trips.service';
import { TripCreateDto, TripUpdateDto, TripCopyDto, TripAddMemberDto, TripTransferOwnershipDto, TripCreateGuestDto, TripRenameGuestDto } from './trips.dto';
import { saveUnsplashCover, isUnsplashCoverUrl } from '../../services/unsplashService';
const MAX_COVER_SIZE = 20 * 1024 * 1024;
@@ -92,16 +93,14 @@ export class TripsController {
@Post()
@HttpCode(201)
create(@CurrentUser() user: User, @Body() body: Record<string, unknown>, @Req() req: Request) {
create(@CurrentUser() user: User, @Body() body: TripCreateDto, @Req() req: Request) {
if (!this.trips.can('trip_create', user.role, null, user.id, false)) {
throw new HttpException({ error: 'No permission to create trips' }, 403);
}
const { title, description, currency, reminder_days, day_count } = body as Record<string, never>;
if (!title) {
throw new HttpException({ error: 'Title is required' }, 400);
}
let start_date: string | null = (body.start_date as string) || null;
let end_date: string | null = (body.end_date as string) || null;
// Presence/shape validation happens in the ZodValidationPipe (tripCreateRequestSchema).
const { title, description, currency, reminder_days, day_count } = body;
let start_date: string | null = body.start_date || null;
let end_date: string | null = body.end_date || null;
if (start_date && !end_date) end_date = toDateStr(addDays(new Date(start_date), 6));
else if (!start_date && end_date) start_date = toDateStr(addDays(new Date(end_date), -6));
if (start_date && end_date && new Date(end_date) < new Date(start_date)) {
@@ -124,7 +123,7 @@ export class TripsController {
}
@Put(':id')
async update(@CurrentUser() user: User, @Param('id') id: string, @Body() body: Record<string, unknown>, @Req() req: Request, @Headers('x-socket-id') socketId?: string) {
async update(@CurrentUser() user: User, @Param('id') id: string, @Body() body: TripUpdateDto, @Req() req: Request, @Headers('x-socket-id') socketId?: string) {
const access = this.trips.canAccessTrip(id, user.id);
if (!access) {
throw new HttpException({ error: 'Trip not found' }, 404);
@@ -205,13 +204,14 @@ export class TripsController {
@Post(':id/copy')
@HttpCode(201)
copy(@CurrentUser() user: User, @Param('id') id: string, @Body('title') title: string | undefined, @Req() req: Request) {
copy(@CurrentUser() user: User, @Param('id') id: string, @Body() body: TripCopyDto, @Req() req: Request) {
if (!this.trips.can('trip_create', user.role, null, user.id, false)) {
throw new HttpException({ error: 'No permission to create trips' }, 403);
}
if (!this.trips.canAccessTrip(id, user.id)) {
throw new HttpException({ error: 'Trip not found' }, 404);
}
const { title } = body;
try {
const newTripId = this.trips.copy(id, user.id, title);
this.audit.writeAudit({ userId: user.id, action: 'trip.copy', ip: getClientIp(req), details: { sourceTripId: Number(id), newTripId, title } });
@@ -249,7 +249,8 @@ export class TripsController {
@Post(':id/members')
@HttpCode(201)
addMember(@CurrentUser() user: User, @Param('id') id: string, @Body('identifier') identifier: string) {
addMember(@CurrentUser() user: User, @Param('id') id: string, @Body() body: TripAddMemberDto) {
const { identifier } = body;
const access = this.trips.canAccessTrip(id, user.id);
if (!access) {
throw new HttpException({ error: 'Trip not found' }, 404);
@@ -286,10 +287,11 @@ export class TripsController {
transferOwnership(
@CurrentUser() user: User,
@Param('id') id: string,
@Body('newOwnerId') newOwnerId: unknown,
@Body() body: TripTransferOwnershipDto,
@Req() req: Request,
@Headers('x-socket-id') socketId?: string,
) {
const { newOwnerId } = body;
const access = this.trips.canAccessTrip(id, user.id);
if (!access) {
throw new HttpException({ error: 'Trip not found' }, 404);
@@ -299,9 +301,6 @@ export class TripsController {
if (access.user_id !== user.id) {
throw new HttpException({ error: 'Only the owner can transfer ownership' }, 403);
}
if (typeof newOwnerId !== 'number') {
throw new HttpException({ error: 'newOwnerId is required' }, 400);
}
try {
const result = this.trips.transferOwnership(id, newOwnerId, user.id);
this.audit.writeAudit({ userId: user.id, action: 'trip.transfer_ownership', ip: getClientIp(req), details: { tripId: Number(id), trip: result.tripTitle, from: result.fromEmail, to: result.toEmail } });
@@ -330,14 +329,13 @@ export class TripsController {
@Post(':id/guests')
@HttpCode(201)
createGuest(@CurrentUser() user: User, @Param('id') id: string, @Body('name') name: unknown) {
createGuest(@CurrentUser() user: User, @Param('id') id: string, @Body() body: TripCreateGuestDto) {
this.requireOwner(id, user);
if (typeof name !== 'string' || !name.trim()) {
throw new HttpException({ error: 'Guest name is required' }, 400);
}
// Whitespace-only names still 400 with the legacy body — the service throws
// ValidationError('Guest name is required') after trimming.
try {
// No notifyInvite: a guest has no inbox.
return this.trips.createGuest(id, name, user.id);
return this.trips.createGuest(id, body.name, user.id);
} catch (e: unknown) {
if (e instanceof ValidationError) throw new HttpException({ error: e.message }, 400);
throw e;
@@ -345,13 +343,10 @@ export class TripsController {
}
@Put(':id/guests/:userId')
renameGuest(@CurrentUser() user: User, @Param('id') id: string, @Param('userId') userId: string, @Body('name') name: unknown) {
renameGuest(@CurrentUser() user: User, @Param('id') id: string, @Param('userId') userId: string, @Body() body: TripRenameGuestDto) {
this.requireOwner(id, user);
if (typeof name !== 'string' || !name.trim()) {
throw new HttpException({ error: 'Guest name is required' }, 400);
}
try {
if (!this.trips.renameGuest(id, parseInt(userId), name)) {
if (!this.trips.renameGuest(id, parseInt(userId), body.name)) {
throw new HttpException({ error: 'Guest not found' }, 404);
}
return { success: true };
+24
View File
@@ -0,0 +1,24 @@
import { createZodDto } from 'nestjs-zod';
import {
tripCreateRequestSchema,
tripUpdateRequestSchema,
tripCopyRequestSchema,
tripAddMemberRequestSchema,
tripTransferOwnershipRequestSchema,
tripCreateGuestRequestSchema,
tripRenameGuestRequestSchema,
} from '@trek/shared';
/**
* Server-side createZodDto wrappers over the @trek/shared trip contracts. The
* global ZodValidationPipe (APP_PIPE in app.module.ts) validates any @Body()
* parameter typed with one of these classes by metatype — the Zod schemas in
* shared/ remain the single source of truth for the wire contract.
*/
export class TripCreateDto extends createZodDto(tripCreateRequestSchema) {}
export class TripUpdateDto extends createZodDto(tripUpdateRequestSchema) {}
export class TripCopyDto extends createZodDto(tripCopyRequestSchema) {}
export class TripAddMemberDto extends createZodDto(tripAddMemberRequestSchema) {}
export class TripTransferOwnershipDto extends createZodDto(tripTransferOwnershipRequestSchema) {}
export class TripCreateGuestDto extends createZodDto(tripCreateGuestRequestSchema) {}
export class TripRenameGuestDto extends createZodDto(tripRenameGuestRequestSchema) {}
+443
View File
@@ -0,0 +1,443 @@
import {
McpController, Tool, Resource, ResourceTemplate, Prompt, type McpContext,
TOOL_ANNOTATIONS_READONLY, TOOL_ANNOTATIONS_WRITE,
TOOL_ANNOTATIONS_DELETE, TOOL_ANNOTATIONS_NON_IDEMPOTENT,
demoDenied, ok,
} from '@trek/nest-mcp';
import { z } from 'zod';
import { isDemoUser } from '../../services/authService';
import { isAddonEnabled, getCollabFeatures } from '../addons/addons.bridge';
import { ADDON_IDS } from '../../addons';
import { safeBroadcast, MAX_MCP_TRIP_DAYS, noAccess, hasTripPermission, permissionDenied } from '../../mcp/tools/_shared';
import { canRead, canReadTrips, canDeleteTrips } from '../../mcp/scopes';
import { TripsService, NotFoundError, ValidationError } from './trips.service';
import { TodoService } from '../todo/todo.service';
import { CollabService } from '../collab/collab.service';
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),
}],
};
}
/**
* Trip MCP surface — ported 1:1 from the legacy registrars: ten tools from
* src/mcp/tools/trips.ts (the three share-link tools moved to
* src/nest/share/share.mcp.ts), the trek://trips, trek://trips/{tripId} and
* trek://trips/{tripId}/members resources from src/mcp/resources.ts, and the
* trip-summary prompt from src/mcp/tools/prompts.ts (identical names,
* descriptions, schemas, annotations, error/payload shapes and broadcasts).
*
* Access markers deviate from the declarative default in two places, on
* purpose (parity with the legacy registration-time gates):
* - the legacy `R` was canReadTrips(), which also accepts trips:delete /
* trips:share tokens — a declarative { group: 'trips', mode: 'read' }
* would silently deny those, so reads use the predicate escape hatch;
* - delete_trip's trips:delete gate has no declarative mode, so it is a
* predicate too. list_trips / get_trip_summary stay unconditionally
* registered (navigation tools — the legacy comment applies unchanged).
*/
@McpController()
export class TripsMcp {
constructor(
private readonly trips: TripsService,
private readonly todos: TodoService,
private readonly collab: CollabService,
) {}
// --- TRIPS ---
@Tool({
name: 'create_trip',
description: 'Create a new trip. Returns the created trip with its generated days.',
inputSchema: {
title: z.string().min(1).max(200).describe('Trip title'),
description: z.string().max(2000).optional().describe('Trip description'),
start_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe('Start date (YYYY-MM-DD)'),
end_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe('End date (YYYY-MM-DD)'),
currency: z.string().length(3).optional().describe('Currency code (e.g. EUR, USD)'),
},
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
access: { group: 'trips', mode: 'write' },
})
async createTrip(
{ title, description, start_date, end_date, currency }: {
title: string; description?: string; start_date?: string; end_date?: string; currency?: string;
},
ctx: McpContext,
) {
if (isDemoUser(ctx.userId)) return demoDenied();
if (start_date) {
const d = new Date(start_date + 'T00:00:00Z');
if (isNaN(d.getTime()) || d.toISOString().slice(0, 10) !== start_date)
return { content: [{ type: 'text' as const, text: 'start_date is not a valid calendar date.' }], isError: true };
}
if (end_date) {
const d = new Date(end_date + 'T00:00:00Z');
if (isNaN(d.getTime()) || d.toISOString().slice(0, 10) !== end_date)
return { content: [{ type: 'text' as const, text: 'end_date is not a valid calendar date.' }], isError: true };
}
if (start_date && end_date && new Date(end_date) < new Date(start_date)) {
return { content: [{ type: 'text' as const, text: 'End date must be after start date.' }], isError: true };
}
const { trip } = this.trips.create(ctx.userId, { title, description, start_date, end_date, currency }, MAX_MCP_TRIP_DAYS);
return ok({ trip });
}
@Tool({
name: 'update_trip',
description: 'Update an existing trip\'s details.',
inputSchema: {
tripId: z.number().int().positive(),
title: z.string().min(1).max(200).optional(),
description: z.string().max(2000).optional(),
start_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
end_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
currency: z.string().length(3).optional(),
is_archived: z.boolean().optional().describe('Archive (true) or unarchive (false) the trip'),
cover_image: z.string().optional().describe('Cover image path, e.g. /uploads/covers/abc.jpg'),
date_shift_mode: z.enum(['keep_bookings', 'shift_all']).optional().describe(
'When changing dates: keep_bookings (default) keeps dated reservations/accommodations on their dates while day plans move; shift_all moves the whole itinerary, bookings included'),
},
annotations: TOOL_ANNOTATIONS_WRITE,
access: { group: 'trips', mode: 'write' },
})
async updateTrip(
{ tripId, title, description, start_date, end_date, currency, is_archived, cover_image, date_shift_mode }: {
tripId: number; title?: string; description?: string; start_date?: string; end_date?: string;
currency?: string; is_archived?: boolean; cover_image?: string; date_shift_mode?: 'keep_bookings' | 'shift_all';
},
ctx: McpContext,
) {
if (isDemoUser(ctx.userId)) return demoDenied();
if (!this.trips.canAccessTrip(tripId, ctx.userId)) return noAccess();
if (!hasTripPermission('trip_edit', tripId, ctx.userId)) return permissionDenied();
if (start_date) {
const d = new Date(start_date + 'T00:00:00Z');
if (isNaN(d.getTime()) || d.toISOString().slice(0, 10) !== start_date)
return { content: [{ type: 'text' as const, text: 'start_date is not a valid calendar date.' }], isError: true };
}
if (end_date) {
const d = new Date(end_date + 'T00:00:00Z');
if (isNaN(d.getTime()) || d.toISOString().slice(0, 10) !== end_date)
return { content: [{ type: 'text' as const, text: 'end_date is not a valid calendar date.' }], isError: true };
}
// update() re-anchors the budget before the trip row moves off the old
// currency (#1543) and then runs the legacy updateTrip core.
const { updatedTrip } = await this.trips.update(tripId, ctx.userId, { title, description, start_date, end_date, currency, is_archived, cover_image, date_shift_mode }, 'user');
safeBroadcast(tripId, 'trip:updated', { trip: updatedTrip });
return ok({ trip: updatedTrip });
}
@Tool({
name: 'delete_trip',
description: 'Delete a trip. Only the trip owner can delete it.',
inputSchema: {
tripId: z.number().int().positive(),
},
annotations: TOOL_ANNOTATIONS_DELETE,
access: (ctx) => canDeleteTrips(ctx.scopes),
})
async deleteTrip({ tripId }: { tripId: number }, ctx: McpContext) {
if (isDemoUser(ctx.userId)) return demoDenied();
if (!this.trips.isOwner(tripId, ctx.userId)) return noAccess();
this.trips.remove(tripId, ctx.userId, 'user');
return ok({ success: true, tripId });
}
// list_trips and get_trip_summary are always registered regardless of OAuth scopes —
// they are navigation tools that any MCP client needs to discover trip IDs.
@Tool({
name: 'list_trips',
description: 'List all trips the current user owns or is a member of. Use this for trip discovery before calling get_trip_summary.',
inputSchema: {
include_archived: z.boolean().optional().describe('Include archived trips (default false)'),
},
annotations: TOOL_ANNOTATIONS_READONLY,
})
async listTrips({ include_archived }: { include_archived?: boolean }, ctx: McpContext) {
const notice = ctx.getDeprecationNotice ? ctx.getDeprecationNotice() : null;
const trips = this.trips.list(ctx.userId, include_archived ? null : 0);
if (notice) return {
isError: true as const,
content: [
{ type: 'text' as const, text: notice },
{ type: 'text' as const, text: JSON.stringify({ trips }, null, 2) },
],
};
return ok({ trips });
}
// --- TRIP SUMMARY ---
@Tool({
name: 'get_trip_summary',
description: 'Get a full denormalized summary of a trip in a single call: metadata, members, days with assignments and notes, accommodations, budget line items (when enabled), packing list (when enabled), reservations, collab notes and poll/message counts (when enabled), and to-do items (when enabled). Use this as a context loader before planning or modifying a trip.',
inputSchema: {
tripId: z.number().int().positive(),
},
annotations: TOOL_ANNOTATIONS_READONLY,
})
async getTripSummary({ tripId }: { tripId: number }, ctx: McpContext) {
if (!this.trips.canAccessTrip(tripId, ctx.userId)) return noAccess();
const summary = this.trips.getTripSummary(tripId, ctx.userId);
if (!summary) return noAccess();
const R = canReadTrips(ctx.scopes);
// Addon availability gates
const packingEnabled = isAddonEnabled(ADDON_IDS.PACKING);
const budgetEnabled = isAddonEnabled(ADDON_IDS.BUDGET);
const collabEnabled = isAddonEnabled(ADDON_IDS.COLLAB);
const collabFeatures = collabEnabled ? getCollabFeatures() : null;
// Scope gates — sections not covered by the client's OAuth scopes are omitted.
// Core trip data (metadata, days, members, accommodations) is always included
// because this tool is always registered and needed for navigation.
const canReadBudget = budgetEnabled && canRead(ctx.scopes, 'budget');
const canReadPacking = packingEnabled && canRead(ctx.scopes, 'packing');
const canReadCollab = collabEnabled && canRead(ctx.scopes, 'collab');
const canReadTodos = packingEnabled && canRead(ctx.scopes, 'todos');
const canReadRes = canRead(ctx.scopes, 'reservations');
const todos = canReadTodos ? this.todos.listItems(tripId) : [];
let pollCount = 0;
let messageCount = 0;
if (canReadCollab) {
if (collabFeatures?.polls) pollCount = this.collab.listPolls(tripId).length;
if (collabFeatures?.chat) messageCount = this.collab.countMessages(tripId);
}
const notice = ctx.getDeprecationNotice ? ctx.getDeprecationNotice() : null;
// The core bucket (trip metadata, members WITH email, days with place
// coordinates, accommodations) carries confidential PII and itinerary data,
// so it is gated on trips:read just like the sub-sections below. Without a
// read scope the tool still resolves trip id + title so it stays usable for
// navigation (list_trips already covers discovery). trek_ PATs (null scopes)
// and any trips:read holder keep the full payload — no behaviour change.
const summaryData = {
trip: R ? summary.trip : { id: summary.trip.id, title: summary.trip.title },
members: R ? summary.members : undefined,
days: R ? summary.days : undefined,
// Accommodations are "accommodation details" under reservations:read too
// (see SCOPE_INFO) and pair with reservations in the share payload, so a
// reservations-scoped token keeps them — gate on either read scope.
accommodations: (R || canReadRes) ? summary.accommodations : undefined,
reservations: canReadRes ? summary.reservations : undefined,
packing: canReadPacking ? summary.packing : undefined,
budget: canReadBudget ? summary.budget : undefined,
collab_notes: canReadCollab && collabFeatures?.notes ? summary.collab_notes : [],
todos,
pollCount,
messageCount,
};
if (notice) return {
isError: true as const,
content: [
{ type: 'text' as const, text: notice },
{ type: 'text' as const, text: JSON.stringify(summaryData, null, 2) },
],
};
return ok(summaryData);
}
// --- TRIP MEMBERS, COPY, ICS ---
@Tool({
name: 'list_trip_members',
description: 'List all members of a trip (owner + collaborators).',
inputSchema: {
tripId: z.number().int().positive(),
},
annotations: TOOL_ANNOTATIONS_READONLY,
access: (ctx) => canReadTrips(ctx.scopes),
})
async listTripMembers({ tripId }: { tripId: number }, ctx: McpContext) {
if (!this.trips.canAccessTrip(tripId, ctx.userId)) return noAccess();
const ownerRow = this.trips.getOwner(tripId);
if (!ownerRow) return noAccess();
const { owner, members } = this.trips.listMembers(tripId, ownerRow.user_id);
return ok({ owner, members });
}
@Tool({
name: 'add_trip_member',
description: 'Add a user to a trip by their username or email address. Only the trip owner can do this.',
inputSchema: {
tripId: z.number().int().positive(),
identifier: z.string().min(1).describe('Username or email of the user to add'),
},
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
access: { group: 'trips', mode: 'write' },
})
async addTripMember({ tripId, identifier }: { tripId: number; identifier: string }, ctx: McpContext) {
if (isDemoUser(ctx.userId)) return demoDenied();
if (!this.trips.canAccessTrip(tripId, ctx.userId)) return noAccess();
const ownerRow = this.trips.getOwner(tripId);
if (!ownerRow || ownerRow.user_id !== ctx.userId)
return { content: [{ type: 'text' as const, text: 'Only the trip owner can add members.' }], isError: true };
try {
const result = this.trips.addMember(tripId, identifier, ownerRow.user_id, ctx.userId);
safeBroadcast(tripId, 'member:added', { member: result.member });
return ok({ member: result.member });
} catch (err) {
const msg = err instanceof ValidationError || err instanceof NotFoundError ? err.message : 'Failed to add member.';
return { content: [{ type: 'text' as const, text: msg }], isError: true };
}
}
@Tool({
name: 'remove_trip_member',
description: 'Remove a member from a trip. Only the trip owner can do this.',
inputSchema: {
tripId: z.number().int().positive(),
memberId: z.number().int().positive().describe('User ID of the member to remove'),
},
annotations: TOOL_ANNOTATIONS_DELETE,
access: { group: 'trips', mode: 'write' },
})
async removeTripMember({ tripId, memberId }: { tripId: number; memberId: number }, ctx: McpContext) {
if (isDemoUser(ctx.userId)) return demoDenied();
if (!this.trips.canAccessTrip(tripId, ctx.userId)) return noAccess();
const ownerRow = this.trips.getOwner(tripId);
if (!ownerRow || ownerRow.user_id !== ctx.userId)
return { content: [{ type: 'text' as const, text: 'Only the trip owner can remove members.' }], isError: true };
this.trips.removeMember(tripId, memberId);
safeBroadcast(tripId, 'member:removed', { userId: memberId });
return ok({ success: true });
}
@Tool({
name: 'copy_trip',
description: 'Duplicate a trip (all days, places, itinerary, packing, budget, reservations, day notes). Packing items are reset to unchecked. Returns the new trip.',
inputSchema: {
tripId: z.number().int().positive().describe('Source trip ID to duplicate'),
title: z.string().min(1).max(200).optional().describe('Title for the new trip (defaults to source title)'),
},
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
access: { group: 'trips', mode: 'write' },
})
async copyTrip({ tripId, title }: { tripId: number; title?: string }, ctx: McpContext) {
if (isDemoUser(ctx.userId)) return demoDenied();
if (!this.trips.canAccessTrip(tripId, ctx.userId)) return noAccess();
try {
const newTripId = this.trips.copy(tripId, ctx.userId, title);
const newTrip = this.trips.canAccessTrip(newTripId, ctx.userId);
return ok({ trip: { id: newTripId, ...newTrip } });
} catch {
return { content: [{ type: 'text' as const, text: 'Failed to copy trip.' }], isError: true };
}
}
@Tool({
name: 'export_trip_ics',
description: 'Export a trip\'s itinerary and reservations as iCalendar (.ics) format text. Useful for importing into calendar apps.',
inputSchema: {
tripId: z.number().int().positive(),
},
annotations: TOOL_ANNOTATIONS_READONLY,
access: (ctx) => canReadTrips(ctx.scopes),
})
async exportTripIcs({ tripId }: { tripId: number }, ctx: McpContext) {
if (!this.trips.canAccessTrip(tripId, ctx.userId)) return noAccess();
try {
const { ics, filename } = this.trips.exportICS(tripId);
return ok({ ics, filename });
} catch {
return { content: [{ type: 'text' as const, text: 'Trip not found.' }], isError: true };
}
}
// --- RESOURCES ---
@Resource({
name: 'trips',
uri: 'trek://trips',
description: 'All trips the user owns or is a member of',
mimeType: 'application/json',
access: (ctx) => canReadTrips(ctx.scopes),
})
async tripsResource(uri: URL, ctx: McpContext) {
const trips = this.trips.list(ctx.userId, 0);
return jsonContent(uri.href, trips);
}
@ResourceTemplate({
name: 'trip',
uriTemplate: 'trek://trips/{tripId}',
description: 'A single trip with metadata and member count',
mimeType: 'application/json',
access: (ctx) => canReadTrips(ctx.scopes),
})
async tripResource(uri: URL, { tripId }: { tripId: string | string[] }, ctx: McpContext) {
const id = parseId(tripId);
if (id === null || !this.trips.canAccessTrip(id, ctx.userId)) return accessDenied(uri.href);
const trip = this.trips.get(id, ctx.userId);
return jsonContent(uri.href, trip);
}
@ResourceTemplate({
name: 'trip-members',
uriTemplate: 'trek://trips/{tripId}/members',
description: 'Owner and collaborators of a trip',
mimeType: 'application/json',
access: (ctx) => canReadTrips(ctx.scopes),
})
async tripMembersResource(uri: URL, { tripId }: { tripId: string | string[] }, ctx: McpContext) {
const id = parseId(tripId);
if (id === null || !this.trips.canAccessTrip(id, ctx.userId)) return accessDenied(uri.href);
const ownerRow = this.trips.getOwner(id);
if (!ownerRow) return accessDenied(uri.href);
const { owner, members } = this.trips.listMembers(id, ownerRow.user_id);
return jsonContent(uri.href, { owner, members });
}
// --- PROMPTS ---
@Prompt({
name: 'trip-summary',
title: 'Trip Summary',
description: 'Load a full summary of a trip for context before planning or modifications',
argsSchema: {
tripId: z.number().int().positive().describe('Trip ID to summarize'),
},
})
async tripSummaryPrompt({ tripId }: { tripId: number }, ctx: McpContext) {
if (!this.trips.canAccessTrip(tripId, ctx.userId)) {
return { messages: [{ role: 'user' as const, content: { type: 'text' as const, text: 'Trip not found or access denied.' } }] };
}
const summary = this.trips.getTripSummary(tripId, ctx.userId);
if (!summary) {
return { messages: [{ role: 'user' as const, content: { type: 'text' as const, text: 'Trip not found.' } }] };
}
const { trip, days, members, budget, packing, reservations, collab_notes } = summary as any;
const memberList = [members?.owner, ...(members?.collaborators || [])].filter(Boolean);
const text = `Trip: ${trip?.title || 'Untitled'}${trip?.description ? `\n${trip.description}` : ''}
Dates: ${trip?.start_date || '?'} to ${trip?.end_date || '?'}
Members: ${memberList.length} (${memberList.map((m: any) => m.name || m.email).join(', ') || 'none'})
Days: ${days?.length || 0}
Packing: ${packing?.checked || 0}/${packing?.total || 0} items packed
Budget: ${budget?.total || 0} ${trip?.currency || 'EUR'} total
Reservations: ${reservations?.length || 0}
Collab Notes: ${collab_notes?.length || 0}
${days?.map((d: any, i: number) => `Day ${i + 1} (${d.date}): ${d.assignments?.length || 0} places${d.title ? ` - ${d.title}` : ''}`).join('\n') || 'No days yet'}`;
return {
description: `Summary of trip "${trip?.title || tripId}"`,
messages: [{ role: 'user' as const, content: { type: 'text' as const, text } }],
};
}
}
+7 -2
View File
@@ -1,20 +1,25 @@
import { Module } from '@nestjs/common';
import { BudgetModule } from '../budget/budget.module';
import { CollabModule } from '../collab/collab.module';
import { DaysModule } from '../days/days.module';
import { FilesModule } from '../files/files.module';
import { PackingModule } from '../packing/packing.module';
import { ReservationsModule } from '../reservations/reservations.module';
import { TodoModule } from '../todo/todo.module';
import { VacayModule } from '../vacay/vacay.module';
import { PermissionsModule } from '../permissions/permissions.module';
import { AuditModule } from '../audit/audit.module';
import { TripsController } from './trips.controller';
import { TripsService } from './trips.service';
import { TripsMcp } from './trips.mcp';
/** Trips aggregate root (C1 — Phase 3). Uses exact strangler prefixes so it does
* not capture the nested sub-domain mounts (collab, files, ...). */
@Module({
imports: [TodoModule, PackingModule, FilesModule, ReservationsModule, DaysModule, PermissionsModule, AuditModule, BudgetModule],
imports: [TodoModule, PackingModule, FilesModule, ReservationsModule, DaysModule, PermissionsModule, AuditModule, BudgetModule, CollabModule, VacayModule],
controllers: [TripsController],
providers: [TripsService],
providers: [TripsService, TripsMcp],
// Exported for FeedsModule (ICS feeds) and PluginsModule (RPC host injection).
exports: [TripsService],
})
export class TripsModule {}
File diff suppressed because it is too large Load Diff