mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-22 06:56:35 +00:00
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:
@@ -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.
|
||||
|
||||
@@ -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,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 });
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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],
|
||||
})
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 };
|
||||
|
||||
@@ -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) {}
|
||||
@@ -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 } }],
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user