diff --git a/server/src/mcp/nest-mcp-policy.ts b/server/src/mcp/nest-mcp-policy.ts index 469f25270..ccc6814d4 100644 --- a/server/src/mcp/nest-mcp-policy.ts +++ b/server/src/mcp/nest-mcp-policy.ts @@ -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. diff --git a/server/src/mcp/resources.ts b/server/src/mcp/resources.ts index a57627d29..58125aaf7 100644 --- a/server/src/mcp/resources.ts +++ b/server/src/mcp/resources.ts @@ -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 diff --git a/server/src/mcp/tools.ts b/server/src/mcp/tools.ts index 1f1beaf4c..e8810b59d 100644 --- a/server/src/mcp/tools.ts +++ b/server/src/mcp/tools.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 }); } diff --git a/server/src/mcp/tools/prompts.ts b/server/src/mcp/tools/prompts.ts index 4f090a585..d5ad53cd1 100644 --- a/server/src/mcp/tools/prompts.ts +++ b/server/src/mcp/tools/prompts.ts @@ -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', diff --git a/server/src/mcp/tools/trips.ts b/server/src/mcp/tools/trips.ts deleted file mode 100644 index ba391f64f..000000000 --- a/server/src/mcp/tools/trips.ts +++ /dev/null @@ -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 }); - } - ); -} diff --git a/server/src/nest/budget/budget.mcp.ts b/server/src/nest/budget/budget.mcp.ts index 8e64bcedd..f929b65c6 100644 --- a/server/src/nest/budget/budget.mcp.ts +++ b/server/src/nest/budget/budget.mcp.ts @@ -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'; diff --git a/server/src/nest/common/body-contract-allow-list.ts b/server/src/nest/common/body-contract-allow-list.ts index 096d83d94..49914817d 100644 --- a/server/src/nest/common/body-contract-allow-list.ts +++ b/server/src/nest/common/body-contract-allow-list.ts @@ -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', diff --git a/server/src/nest/feeds/feeds.module.ts b/server/src/nest/feeds/feeds.module.ts index 3934f7fe8..1aab7b275 100644 --- a/server/src/nest/feeds/feeds.module.ts +++ b/server/src/nest/feeds/feeds.module.ts @@ -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], }) diff --git a/server/src/nest/feeds/feeds.service.ts b/server/src/nest/feeds/feeds.service.ts index b1a41e7a4..473b1cb87 100644 --- a/server/src/nest/feeds/feeds.service.ts +++ b/server/src/nest/feeds/feeds.service.ts @@ -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); diff --git a/server/src/nest/packing/packing.bridge.ts b/server/src/nest/packing/packing.bridge.ts index 72c3a4720..b309550ba 100644 --- a/server/src/nest/packing/packing.bridge.ts +++ b/server/src/nest/packing/packing.bridge.ts @@ -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. diff --git a/server/src/nest/share/share.bridge.ts b/server/src/nest/share/share.bridge.ts deleted file mode 100644 index 538bb94aa..000000000 --- a/server/src/nest/share/share.bridge.ts +++ /dev/null @@ -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); -} diff --git a/server/src/nest/share/share.mcp.ts b/server/src/nest/share/share.mcp.ts new file mode 100644 index 000000000..0536231e7 --- /dev/null +++ b/server/src/nest/share/share.mcp.ts @@ -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 }); + } +} diff --git a/server/src/nest/share/share.module.ts b/server/src/nest/share/share.module.ts index 38d6cedb7..0dfc5423a 100644 --- a/server/src/nest/share/share.module.ts +++ b/server/src/nest/share/share.module.ts @@ -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 {} diff --git a/server/src/nest/todo/todo.bridge.ts b/server/src/nest/todo/todo.bridge.ts deleted file mode 100644 index 6410a1ed4..000000000 --- a/server/src/nest/todo/todo.bridge.ts +++ /dev/null @@ -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[1]) { - return todos.createItem(tripId, data); -} - -export function updateItem( - tripId: string | number, - id: string | number, - data: Parameters[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); -} diff --git a/server/src/nest/trips/trips.bridge.ts b/server/src/nest/trips/trips.bridge.ts new file mode 100644 index 000000000..2bd0990d9 --- /dev/null +++ b/server/src/nest/trips/trips.bridge.ts @@ -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); +} diff --git a/server/src/nest/trips/trips.controller.ts b/server/src/nest/trips/trips.controller.ts index 7ad5160f8..3033e0d74 100644 --- a/server/src/nest/trips/trips.controller.ts +++ b/server/src/nest/trips/trips.controller.ts @@ -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, @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; - 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, @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 }; diff --git a/server/src/nest/trips/trips.dto.ts b/server/src/nest/trips/trips.dto.ts new file mode 100644 index 000000000..0a465dc00 --- /dev/null +++ b/server/src/nest/trips/trips.dto.ts @@ -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) {} diff --git a/server/src/nest/trips/trips.mcp.ts b/server/src/nest/trips/trips.mcp.ts new file mode 100644 index 000000000..c3d299988 --- /dev/null +++ b/server/src/nest/trips/trips.mcp.ts @@ -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 } }], + }; + } +} diff --git a/server/src/nest/trips/trips.module.ts b/server/src/nest/trips/trips.module.ts index 629f8f859..044b49dde 100644 --- a/server/src/nest/trips/trips.module.ts +++ b/server/src/nest/trips/trips.module.ts @@ -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 {} diff --git a/server/src/nest/trips/trips.service.ts b/server/src/nest/trips/trips.service.ts index 2a48db62c..0338a1c3f 100644 --- a/server/src/nest/trips/trips.service.ts +++ b/server/src/nest/trips/trips.service.ts @@ -1,25 +1,216 @@ import { Injectable } from '@nestjs/common'; +import path from 'path'; +import fs from 'fs'; +import { randomUUID } from 'crypto'; import { DatabaseService } from '../database/database.service'; import type { TrekWsPayload, TrekWsTripEventName } from '@trek/shared'; import { RealtimeService } from '../realtime/realtime.service'; import { PermissionsService } from '../permissions/permissions.service'; -import type { User } from '../../types'; -import * as tripSvc from '../../services/tripService'; +import type { Trip, User } from '../../types'; +import { avatarUrl } from '../../services/avatarUrl'; +import { erasePluginUserData } from '../../services/userCleanupService'; +import { emitUserDeleted } from '../../plugin-user-lifecycle'; +import { resolveTimeZone } from '../../services/timezoneService'; import { listPlaces } from '../../services/placeService'; -import { DaysService } from '../days/days.service'; +import { DaysService, addDays } from '../days/days.service'; import { PackingService } from '../packing/packing.service'; import { TodoService } from '../todo/todo.service'; import { BudgetService } from '../budget/budget.service'; import { ReservationsService } from '../reservations/reservations.service'; import { FilesService } from '../files/files.service'; +import { CollabService } from '../collab/collab.service'; +import { VacayService } from '../vacay/vacay.service'; import { searchUnsplashPhotos, getUnsplashKey } from '../../services/unsplashService'; +export const MS_PER_DAY = 86400000; +export const MAX_TRIP_DAYS = 365; + +export const TRIP_SELECT = ` + SELECT t.*, + (SELECT COUNT(*) FROM days d WHERE d.trip_id = t.id) as day_count, + (SELECT COUNT(*) FROM places p WHERE p.trip_id = t.id) as place_count, + CASE WHEN t.user_id = :userId THEN 1 ELSE 0 END as is_owner, + u.username as owner_username, + (SELECT COUNT(*) FROM trip_members tm WHERE tm.trip_id = t.id) as shared_count + FROM trips t + JOIN users u ON u.id = t.user_id +`; + +interface CreateTripData { + title: string; + description?: string | null; + start_date?: string | null; + end_date?: string | null; + currency?: string; + reminder_days?: number; + day_count?: number; +} + +// Nullable where the wire contract (tripUpdateRequestSchema) is nullable — the +// legacy route accepted arbitrary JSON, so null always reached these fields. +interface UpdateTripData { + title?: string; + description?: string | null; + start_date?: string | null; + end_date?: string | null; + currency?: string; + is_archived?: boolean | number; + cover_image?: string | null; + reminder_days?: number; + day_count?: number; + date_shift_mode?: 'keep_bookings' | 'shift_all'; +} + +export interface UpdateTripResult { + updatedTrip: any; + changes: Record; + isAdminEdit: boolean; + ownerEmail?: string; + newTitle: string; + newReminder: number; + oldReminder: number; +} + +export interface DeleteTripInfo { + tripId: number; + title: string; + ownerId: number; + isAdminDelete: boolean; + ownerEmail?: string; +} + +export interface AddMemberResult { + member: { id: number; username: string; email: string; avatar?: string | null; role: string; avatar_url: string | null }; + targetUserId: number; + tripTitle: string; +} + +export interface TransferOwnershipResult { + tripTitle: string; + fromEmail: string; + toEmail: string; +} + +// ── Guest members (#1362) ─────────────────────────────────────────────────── +// +// A guest is a credential-less users row (is_guest=1) joined into trip_members, so +// it is assignable everywhere a real member is (budget splits, packing, to-dos, day +// participants) yet can never authenticate (the auth/global-list guards exclude +// is_guest=1). The display name lives in users.username so every existing JOIN that +// renders a member name shows the guest correctly; a synthetic, non-deliverable +// email keeps the UNIQUE/NOT NULL constraints satisfied. + +export interface GuestMember { + id: number; + username: string; + email: string; + role: 'member'; + is_guest: true; + avatar_url: null; +} + +// ── ICS folding ───────────────────────────────────────────────────────────── + +// RFC 5545 §3.1: content lines longer than 75 octets must be folded with a CRLF +// followed by a single leading space. We fold on UTF-8 *octet* boundaries and +// never split a multi-byte codepoint, so non-ASCII titles/notes (accents, CJK, +// emoji) stay intact. Applied to the whole calendar, so both the one-time +// download and the subscribable feed emit spec-compliant output. +function foldICS(ics: string): string { + const foldLine = (line: string): string => { + const bytes = Buffer.from(line, 'utf8'); + if (bytes.length <= 75) return line; + const parts: Buffer[] = []; + let start = 0; + let limit = 75; // first physical line may use 75 octets + while (start < bytes.length) { + let end = Math.min(start + limit, bytes.length); + // Back off so we never cut a multi-byte UTF-8 sequence (0x80–0xBF = continuation byte). + while (end < bytes.length && (bytes[end] & 0xc0) === 0x80) end--; + parts.push(bytes.subarray(start, end)); + start = end; + limit = 74; // continuation lines spend one octet on the leading space + } + return parts.map((b, i) => (i === 0 ? '' : ' ') + b.toString('utf8')).join('\r\n'); + }; + return ics.split('\r\n').map(foldLine).join('\r\n'); +} + +// ── ICS time-zone helpers ──────────────────────────────────────────────────── +// Timed events must carry an explicit IANA zone; a bare "YYYYMMDDTHHMMSS" is an +// RFC 5545 "floating" time that clients render in the *subscriber's* zone (#1453). + +// A stored/plugin-provided timezone (e.g. a transport endpoint's `timezone`) is a +// free string that need not be a real IANA zone. Intl.DateTimeFormat throws a +// RangeError on an unknown zone, which — via buildVTimezone → tzOffsetString — +// would crash the whole ICS export (and drop the trip from the all-trips feed). +// Validate once so an invalid zone degrades to a floating local time instead. +// Module-scoped on purpose (like the permissions/FX caches): the bridge instance +// and the DI singleton share one cache. +const _tzValidCache = new Map(); +function isValidTimeZone(zone: string): boolean { + const cached = _tzValidCache.get(zone); + if (cached !== undefined) return cached; + let ok = false; + try { + new Intl.DateTimeFormat('en-US', { timeZone: zone }); + ok = true; + } catch { + // Unknown/invalid zone → ok stays false. + } + // Bound the cache — the key is a free-form (plugin/importer-written) zone string, + // so cap distinct entries rather than growing for the process lifetime. + if (_tzValidCache.size >= 1000) _tzValidCache.clear(); + _tzValidCache.set(zone, ok); + return ok; +} + +// UTC offset ("+0200") the zone uses on the given YYYYMMDD date. Only feeds the +// fallback VTIMEZONE offset; iOS/Google resolve the named zone from their own +// IANA database, so a single representative offset is sufficient. +function tzOffsetString(zone: string, yyyymmdd: string): string { + const iso = `${yyyymmdd.slice(0, 4)}-${yyyymmdd.slice(4, 6)}-${yyyymmdd.slice(6, 8)}T12:00:00Z`; + const probe = new Date(iso); + if (Number.isNaN(probe.getTime())) return '+0000'; + const parts = new Intl.DateTimeFormat('en-US', { + timeZone: zone, + timeZoneName: 'longOffset', + }).formatToParts(probe); + const raw = parts.find((p) => p.type === 'timeZoneName')?.value ?? 'GMT'; + const m = raw.match(/GMT([+-])(\d{2}):?(\d{2})?/); + if (!m) return '+0000'; // "GMT" (UTC) has no offset digits + return `${m[1]}${m[2]}${m[3] ?? '00'}`; +} + +// Minimal but RFC-valid VTIMEZONE. Smart clients override it with their own tz +// rules; dumb clients fall back to this fixed offset. +function buildVTimezone(zone: string, yyyymmdd: string): string { + const off = tzOffsetString(zone, yyyymmdd); + return ( + 'BEGIN:VTIMEZONE\r\n' + + `TZID:${zone}\r\n` + + 'BEGIN:STANDARD\r\n' + + 'DTSTART:19700101T000000\r\n' + + `TZOFFSETFROM:${off}\r\n` + + `TZOFFSETTO:${off}\r\n` + + `TZNAME:${zone}\r\n` + + 'END:STANDARD\r\n' + + 'END:VTIMEZONE\r\n' + ); +} + /** - * Thin Nest wrapper around the existing trip service + the per-domain list - * services used to build the offline bundle. Auth (canAccessTrip), permissions, - * the SQL and the ICS export reuse the legacy code unchanged. Per-field - * permission checks and audit logging stay in the controller (1:1 with the - * legacy route). + * Trip aggregate root, DI-native. The SQL moved 1:1 from the legacy + * services/tripService.ts: identical statements, the `||` falsy-coercion + * defaults, the post-write TRIP_SELECT re-selects and the mixed + * named/positional parameter styles are all preserved byte-for-byte. + * Post-migration quirk fixes on top of the 1:1 move: the multi-statement + * deletes (remove, deleteGuest's re-split + user delete) run in + * db.transaction(), and listMembers' owner row COALESCEs display_name like + * the member rows. Auth (canAccessTrip), per-field permission checks and + * audit logging stay in the controller (1:1 with the legacy route); + * trip:updated / trip:deleted broadcasts stay in the controller too — this + * service emits none. */ @Injectable() export class TripsService { @@ -32,6 +223,8 @@ export class TripsService { private readonly days: DaysService, private readonly permissions: PermissionsService, private readonly budget: BudgetService, + private readonly collab: CollabService, + private readonly vacay: VacayService, private readonly realtime: RealtimeService, ) {} @@ -39,10 +232,14 @@ export class TripsService { return this.dbs.connection; } - canAccessTrip(tripId: string, userId: number) { + canAccessTrip(tripId: string | number, userId: number) { return this.dbs.canAccessTrip(tripId, userId) as { user_id: number } | null | undefined; } + isOwner(tripId: string | number, userId: number): boolean { + return this.dbs.isOwner(tripId, userId); + } + can(action: string, role: string, ownerId: number | null, userId: number, isMember: boolean): boolean { return this.permissions.checkPermission(action, role, ownerId, userId, isMember); } @@ -51,91 +248,960 @@ export class TripsService { this.realtime.broadcast(tripId, event, payload, socketId); } - list(userId: number, archived: number) { - return tripSvc.listTrips(userId, archived); + // ── Day generation ──────────────────────────────────────────────────────── + + generateDays(tripId: number | bigint | string, startDate: string | null, endDate: string | null, maxDays?: number, dayCount?: number) { + const existing = this.db.prepare('SELECT id, day_number, date FROM days WHERE trip_id = ?').all(tripId) as { id: number; day_number: number; date: string | null }[]; + const setDayNumber = this.db.prepare('UPDATE days SET day_number = ? WHERE id = ?'); + + // Helper: two-phase renumber to avoid UNIQUE(trip_id, day_number) collisions + function renumber(days: { id: number }[]) { + days.forEach((d, i) => setDayNumber.run(-(i + 1), d.id)); + days.forEach((d, i) => setDayNumber.run(i + 1, d.id)); + } + + if (!startDate || !endDate) { + // Nullify all dated days instead of deleting them — preserves assignments/notes/accommodations + const withDates = existing.filter(d => d.date); + if (withDates.length > 0) { + const nullify = this.db.prepare('UPDATE days SET date = NULL WHERE id = ?'); + for (const d of withDates) nullify.run(d.id); + } + // Now all days are dateless — adjust count toward dayCount target + const allDays = this.db.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY day_number').all(tripId) as { id: number }[]; + const targetCount = Math.min(Math.max(dayCount ?? (allDays.length || 7), 1), MAX_TRIP_DAYS); + const needed = targetCount - allDays.length; + if (needed > 0) { + const insert = this.db.prepare('INSERT INTO days (trip_id, day_number, date) VALUES (?, ?, NULL)'); + for (let i = 0; i < needed; i++) insert.run(tripId, allDays.length + i + 1); + } else if (needed < 0) { + // Only trim trailing empty days to avoid destroying content + const candidates = this.db.prepare( + `SELECT d.id FROM days d + WHERE d.trip_id = ? + AND NOT EXISTS (SELECT 1 FROM day_assignments da WHERE da.day_id = d.id) + AND NOT EXISTS (SELECT 1 FROM day_notes dn WHERE dn.day_id = d.id) + AND NOT EXISTS (SELECT 1 FROM day_accommodations dac WHERE dac.start_day_id = d.id OR dac.end_day_id = d.id) + ORDER BY d.day_number DESC + LIMIT ?` + ).all(tripId, -needed) as { id: number }[]; + const del = this.db.prepare('DELETE FROM days WHERE id = ?'); + for (const d of candidates) del.run(d.id); + } + const remaining = this.db.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY day_number').all(tripId) as { id: number }[]; + renumber(remaining); + return; + } + + const [sy, sm, sd] = startDate.split('-').map(Number); + const [ey, em, ed] = endDate.split('-').map(Number); + const startMs = Date.UTC(sy, sm - 1, sd); + const endMs = Date.UTC(ey, em - 1, ed); + const numDays = Math.min(Math.floor((endMs - startMs) / MS_PER_DAY) + 1, maxDays ?? MAX_TRIP_DAYS); + + const targetDates: string[] = []; + for (let i = 0; i < numDays; i++) { + const d = new Date(startMs + i * MS_PER_DAY); + const yyyy = d.getUTCFullYear(); + const mm = String(d.getUTCMonth() + 1).padStart(2, '0'); + const dd = String(d.getUTCDate()).padStart(2, '0'); + targetDates.push(`${yyyy}-${mm}-${dd}`); + } + + // Split into dated (sorted by day_number = position) and dateless (spare pool) + const dated = existing.filter(d => d.date).sort((a, b) => a.day_number - b.day_number); + const dateless = existing.filter(d => !d.date).sort((a, b) => a.day_number - b.day_number); + + // Phase 1: stamp all existing days with negative day_numbers to free up slots + const allExisting = [...dated, ...dateless]; + allExisting.forEach((d, i) => setDayNumber.run(-(i + 1), d.id)); + + const assignDay = this.db.prepare('UPDATE days SET date = ?, day_number = ? WHERE id = ?'); + const insert = this.db.prepare('INSERT INTO days (trip_id, day_number, date) VALUES (?, ?, ?)'); + + let datelessIdx = 0; + + for (let i = 0; i < targetDates.length; i++) { + const date = targetDates[i]; + if (i < dated.length) { + // Positional remap: existing dated day i gets new date — keeps all children + assignDay.run(date, i + 1, dated[i].id); + } else if (datelessIdx < dateless.length) { + // Reuse a dateless day — keeps its assignments, notes, etc. + assignDay.run(date, i + 1, dateless[datelessIdx].id); + datelessIdx++; + } else { + insert.run(tripId, i + 1, date); + } + } + + // Overflow dated days (trip shrunk): delete them (issue #909). + // Cascade removes their assignments, notes, and accommodations. + const del = this.db.prepare('DELETE FROM days WHERE id = ?'); + for (let i = targetDates.length; i < dated.length; i++) { + del.run(dated[i].id); + } + + // Any remaining unused dateless days: drop the empty placeholders so day_count + // reflects the dated range, but keep ones that still hold content (assignments, + // notes, accommodations) — mirrors the dateless-path trimming above (#1083). + // Base must be max(targetDates.length, dated.length) to avoid colliding with + // positives already assigned by the main loop or the overflow loop above. + const isEmptyDay = this.db.prepare( + `SELECT NOT EXISTS (SELECT 1 FROM day_assignments da WHERE da.day_id = @id) + AND NOT EXISTS (SELECT 1 FROM day_notes dn WHERE dn.day_id = @id) + AND NOT EXISTS (SELECT 1 FROM day_accommodations dac WHERE dac.start_day_id = @id OR dac.end_day_id = @id) AS empty` + ); + const maxAssigned = Math.max(targetDates.length, dated.length); + let keptDateless = 0; + for (let i = datelessIdx; i < dateless.length; i++) { + const empty = (isEmptyDay.get({ id: dateless[i].id }) as { empty: number }).empty; + if (empty) { + del.run(dateless[i].id); + } else { + setDayNumber.run(maxAssigned + keptDateless + 1, dateless[i].id); + keptDateless++; + } + } + + // Final renumber to compact and eliminate any gaps/negatives + const remaining = this.db.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY day_number').all(tripId) as { id: number }[]; + renumber(remaining); } - create(userId: number, data: Parameters[1]) { - return tripSvc.createTrip(userId, data); + // ── Trip CRUD ───────────────────────────────────────────────────────────── + + list(userId: number, archived: number | null) { + if (archived === null) { + return this.db.prepare(` + ${TRIP_SELECT} + LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = :userId + WHERE (t.user_id = :userId OR m.user_id IS NOT NULL) + ORDER BY t.created_at DESC + `).all({ userId }); + } + return this.db.prepare(` + ${TRIP_SELECT} + LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = :userId + WHERE (t.user_id = :userId OR m.user_id IS NOT NULL) AND t.is_archived = :archived + ORDER BY t.created_at DESC + `).all({ userId, archived }); } - get(tripId: string, userId: number) { - return tripSvc.getTrip(tripId, userId); + create(userId: number, data: CreateTripData, maxDays?: number) { + const rd = data.reminder_days !== undefined + ? (Number(data.reminder_days) >= 0 && Number(data.reminder_days) <= 30 ? Number(data.reminder_days) : 3) + : 3; + + const result = this.db.prepare(` + INSERT INTO trips (user_id, title, description, start_date, end_date, currency, reminder_days) + VALUES (?, ?, ?, ?, ?, ?, ?) + `).run(userId, data.title, data.description || null, data.start_date || null, data.end_date || null, data.currency || 'EUR', rd); + + const tripId = result.lastInsertRowid; + this.generateDays(tripId, data.start_date || null, data.end_date || null, maxDays, data.day_count); + + const trip = this.db.prepare(`${TRIP_SELECT} WHERE t.id = :tripId`).get({ userId, tripId }); + return { trip, tripId: Number(tripId), reminderDays: rd }; } - getRaw(tripId: string) { - return tripSvc.getTripRaw(tripId); + get(tripId: string | number, userId: number) { + return this.db.prepare(` + ${TRIP_SELECT} + LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = :userId + WHERE t.id = :tripId AND (t.user_id = :userId OR m.user_id IS NOT NULL) + `).get({ userId, tripId }) as Trip | undefined; + } + + getRaw(tripId: string | number): Trip | undefined { + return this.db.prepare('SELECT * FROM trips WHERE id = ?').get(tripId) as Trip | undefined; } searchCoverImages(query: string, userId: number) { return searchUnsplashPhotos(query, 9, getUnsplashKey(userId)); } - getOwner(tripId: string) { - return tripSvc.getTripOwner(tripId); + getOwner(tripId: string | number): { user_id: number } | undefined { + return this.db.prepare('SELECT user_id FROM trips WHERE id = ?').get(tripId) as { user_id: number } | undefined; } - async update(tripId: string, userId: number, body: Parameters[2], role: string) { + /** + * The folded legacy updateTrip core — no currency rebase. The REST path goes + * through update() below; the plugin RPC host calls this directly (parity: + * the legacy host path never rebased). + */ + updateTrip(tripId: string | number, userId: number, data: UpdateTripData, userRole: string): UpdateTripResult { + const trip = this.db.prepare('SELECT * FROM trips WHERE id = ?').get(tripId) as Trip & { reminder_days?: number } | undefined; + if (!trip) throw new NotFoundError('Trip not found'); + + const { title, description, start_date, end_date, currency, is_archived, cover_image, reminder_days } = data; + + if (start_date && end_date && new Date(end_date) < new Date(start_date)) + throw new ValidationError('End date must be after start date'); + + const newTitle = title || trip.title; + const newDesc = description !== undefined ? description : trip.description; + const newStart = start_date !== undefined ? start_date : trip.start_date; + const newEnd = end_date !== undefined ? end_date : trip.end_date; + const newCurrency = currency || trip.currency; + const newArchived = is_archived !== undefined ? (is_archived ? 1 : 0) : trip.is_archived; + const newCover = cover_image !== undefined ? cover_image : trip.cover_image; + const oldReminder = (trip as any).reminder_days ?? 3; + const newReminder = reminder_days !== undefined + ? (Number(reminder_days) >= 0 && Number(reminder_days) <= 30 ? Number(reminder_days) : oldReminder) + : oldReminder; + + this.db.prepare(` + UPDATE trips SET title=?, description=?, start_date=?, end_date=?, + currency=?, is_archived=?, cover_image=?, reminder_days=?, updated_at=CURRENT_TIMESTAMP + WHERE id=? + `).run(newTitle, newDesc, newStart || null, newEnd || null, newCurrency, newArchived, newCover, newReminder, tripId); + + if (trip.start_date && trip.end_date && newStart && newStart !== trip.start_date) + this.vacay.shiftOwnerEntriesForTripWindow(trip.user_id, trip.start_date, trip.end_date, newStart); + + const dayCount = data.day_count ? Math.min(Math.max(Number(data.day_count) || 7, 1), MAX_TRIP_DAYS) : undefined; + if (newStart !== trip.start_date || newEnd !== trip.end_date || dayCount) { + this.db.transaction(() => { + // Accommodations have no absolute date columns, so their pre-change dates must be + // snapshotted before generateDays re-dates the day rows in place. + const prevDateByDayId = new Map( + (this.db.prepare('SELECT id, date FROM days WHERE trip_id = ?').all(tripId) as { id: number; date: string | null }[]) + .map(d => [d.id, d.date]), + ); + this.generateDays(tripId, newStart || null, newEnd || null, undefined, dayCount); + if (data.date_shift_mode === 'shift_all') { + // Explicit "shift everything": bookings stay glued to their (re-dated) day rows, + // so re-stamp reservation_time to follow — same rules as reorderDays/insertDay. + const newDateByDayId = new Map( + (this.db.prepare('SELECT id, date FROM days WHERE trip_id = ?').all(tripId) as { id: number; date: string | null }[]) + .map(d => [d.id, d.date]), + ); + this.days.restampReservationDates(tripId, prevDateByDayId, newDateByDayId); + } else { + // Default: generateDays re-dates day rows positionally; re-anchor dated bookings to + // the day matching their absolute reservation_time, and accommodations (+ their + // linked hotel reservations) to the days now holding their pre-change dates (#1288). + this.reservations.resyncReservationDays(tripId); + this.days.resyncAccommodationDays(tripId, prevDateByDayId); + } + })(); + } + + const changes: Record = {}; + if (title && title !== trip.title) changes.title = title; + if (newStart !== trip.start_date) changes.start_date = newStart; + if (newEnd !== trip.end_date) changes.end_date = newEnd; + if (newReminder !== oldReminder) changes.reminder_days = newReminder === 0 ? 'none' : `${newReminder} days`; + if (is_archived !== undefined && newArchived !== trip.is_archived) changes.archived = !!newArchived; + + const isAdminEdit = userRole === 'admin' && trip.user_id !== userId; + let ownerEmail: string | undefined; + if (Object.keys(changes).length > 0 && isAdminEdit) { + ownerEmail = (this.db.prepare('SELECT email FROM users WHERE id = ?').get(trip.user_id) as { email: string } | undefined)?.email; + } + + const updatedTrip = this.db.prepare(`${TRIP_SELECT} WHERE t.id = :tripId`).get({ userId, tripId }); + + return { updatedTrip, changes, isAdminEdit, ownerEmail, newTitle, newReminder, oldReminder }; + } + + async update(tripId: string | number, userId: number, body: UpdateTripData, role: string) { // Re-anchor the budget while the outgoing currency is still on the trip row, // otherwise the frozen FX rates and the currency-less expenses that inherit the // trip's base are left pointing at a currency that no longer exists (#1543). await this.budget.rebaseTripCurrency(tripId, body.currency); - return tripSvc.updateTrip(tripId, userId, body, role); + return this.updateTrip(tripId, userId, body, role); } - remove(tripId: string, userId: number, role: string) { - return tripSvc.deleteTrip(tripId, userId, role); + // ── Delete ───────────────────────────────────────────────────────────────── + + remove(tripId: string | number, userId: number, userRole: string): DeleteTripInfo { + const trip = this.db.prepare('SELECT title, user_id FROM trips WHERE id = ?').get(tripId) as { title: string; user_id: number } | undefined; + if (!trip) throw new NotFoundError('Trip not found'); + + const isAdminDelete = userRole === 'admin' && trip.user_id !== userId; + let ownerEmail: string | undefined; + if (isAdminDelete) { + ownerEmail = (this.db.prepare('SELECT email FROM users WHERE id = ?').get(trip.user_id) as { email: string } | undefined)?.email; + } + + // Quirk fix on top of the 1:1 move: the three-statement delete runs in a + // transaction, so a failure mid-flow can't leave journey entries detached + // from a trip that still exists. + this.db.transaction(() => { + // Clean up journey entries synced from this trip before deleting + // Delete skeleton entries (unfilled synced places) + this.db.prepare(` + DELETE FROM journey_entries + WHERE source_trip_id = ? AND type = 'skeleton' + `).run(tripId); + // Detach filled entries (keep user's written content, just remove trip link) + this.db.prepare(` + UPDATE journey_entries SET source_trip_id = NULL, source_place_id = NULL + WHERE source_trip_id = ? + `).run(tripId); + + this.db.prepare('DELETE FROM trips WHERE id = ?').run(tripId); + })(); + + return { tripId: Number(tripId), title: trip.title, ownerId: trip.user_id, isAdminDelete, ownerEmail }; } + // ── Cover image ─────────────────────────────────────────────────────────── + deleteOldCover(coverImage: string | null | undefined): void { - tripSvc.deleteOldCover(coverImage as never); + if (!coverImage) return; + // cover_image is client-supplied, so treat it as untrusted: covers live in + // uploads/covers as a flat filename — use basename() and confine the unlink + // to that directory. (dist/nest/trips is one level deeper than the legacy + // dist/services home, hence the extra '..' — same resolved directory.) + const coversDir = path.resolve(__dirname, '../../../uploads/covers'); + const resolvedPath = path.resolve(path.join(coversDir, path.basename(coverImage))); + if (resolvedPath.startsWith(coversDir + path.sep) && fs.existsSync(resolvedPath)) { + fs.unlinkSync(resolvedPath); + } } - updateCoverImage(tripId: string, url: string): void { - tripSvc.updateCoverImage(tripId, url); + updateCoverImage(tripId: string | number, coverUrl: string): void { + this.db.prepare('UPDATE trips SET cover_image=?, updated_at=CURRENT_TIMESTAMP WHERE id=?').run(coverUrl, tripId); } - copy(tripId: string, userId: number, title?: string) { - return tripSvc.copyTripById(tripId, userId, title); + // ── Copy / duplicate ───────────────────────────────────────────────────── + + /** + * Duplicates a trip (all days, places, assignments, accommodations, reservations, + * budget, packing bags/items, day notes) into a new trip owned by `newOwnerId`. + * Packing items are reset to unchecked. Budget paid status is cleared. + * Returns the new trip's ID. + */ + copy(sourceTripId: string | number, newOwnerId: number, title?: string): number { + const src = this.db.prepare('SELECT * FROM trips WHERE id = ?').get(sourceTripId) as any; + if (!src) throw new NotFoundError('Trip not found'); + + const newTitle = title || src.title; + + const fn = this.db.transaction(() => { + const tripResult = this.db.prepare(` + INSERT INTO trips (user_id, title, description, start_date, end_date, currency, cover_image, is_archived, reminder_days) + VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?) + `).run(newOwnerId, newTitle, src.description, src.start_date, src.end_date, src.currency, src.cover_image, src.reminder_days ?? 3); + const newTripId = tripResult.lastInsertRowid; + + const oldDays = this.db.prepare('SELECT * FROM days WHERE trip_id = ? ORDER BY day_number').all(sourceTripId) as any[]; + const dayMap = new Map(); + const insertDay = this.db.prepare('INSERT INTO days (trip_id, day_number, date, notes, title) VALUES (?, ?, ?, ?, ?)'); + for (const d of oldDays) { + const r = insertDay.run(newTripId, d.day_number, d.date, d.notes, d.title); + dayMap.set(d.id, r.lastInsertRowid); + } + + const oldPlaces = this.db.prepare('SELECT * FROM places WHERE trip_id = ?').all(sourceTripId) as any[]; + const placeMap = new Map(); + const insertPlace = this.db.prepare(` + INSERT INTO places (trip_id, name, description, lat, lng, address, category_id, price, currency, + reservation_status, reservation_notes, reservation_datetime, place_time, end_time, + duration_minutes, notes, image_url, google_place_id, google_ftid, website, phone, transport_mode, osm_id, + route_geometry, route_color) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `); + for (const p of oldPlaces) { + const r = insertPlace.run(newTripId, p.name, p.description, p.lat, p.lng, p.address, p.category_id, + p.price, p.currency, p.reservation_status, p.reservation_notes, p.reservation_datetime, + p.place_time, p.end_time, p.duration_minutes, p.notes, p.image_url, p.google_place_id, + p.google_ftid, p.website, p.phone, p.transport_mode, p.osm_id, p.route_geometry, p.route_color); + placeMap.set(p.id, r.lastInsertRowid); + } + + const oldTags = this.db.prepare(` + SELECT pt.* FROM place_tags pt JOIN places p ON p.id = pt.place_id WHERE p.trip_id = ? + `).all(sourceTripId) as any[]; + const insertTag = this.db.prepare('INSERT OR IGNORE INTO place_tags (place_id, tag_id) VALUES (?, ?)'); + for (const t of oldTags) { + const newPlaceId = placeMap.get(t.place_id); + if (newPlaceId) insertTag.run(newPlaceId, t.tag_id); + } + + const oldAssignments = this.db.prepare(` + SELECT da.* FROM day_assignments da JOIN days d ON d.id = da.day_id WHERE d.trip_id = ? + `).all(sourceTripId) as any[]; + const assignmentMap = new Map(); + const insertAssignment = this.db.prepare(` + INSERT INTO day_assignments (day_id, place_id, order_index, notes, reservation_status, reservation_notes, reservation_datetime, assignment_time, assignment_end_time) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + `); + for (const a of oldAssignments) { + const newDayId = dayMap.get(a.day_id); + const newPlaceId = placeMap.get(a.place_id); + if (newDayId && newPlaceId) { + const r = insertAssignment.run(newDayId, newPlaceId, a.order_index, a.notes, + a.reservation_status, a.reservation_notes, a.reservation_datetime, + a.assignment_time, a.assignment_end_time); + assignmentMap.set(a.id, r.lastInsertRowid); + } + } + + const oldAccom = this.db.prepare('SELECT * FROM day_accommodations WHERE trip_id = ?').all(sourceTripId) as any[]; + const accomMap = new Map(); + const insertAccom = this.db.prepare(` + INSERT INTO day_accommodations (trip_id, place_id, start_day_id, end_day_id, check_in, check_out, confirmation, notes) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + `); + for (const a of oldAccom) { + const newPlaceId = placeMap.get(a.place_id); + const newStartDay = dayMap.get(a.start_day_id); + const newEndDay = dayMap.get(a.end_day_id); + if (newPlaceId && newStartDay && newEndDay) { + const r = insertAccom.run(newTripId, newPlaceId, newStartDay, newEndDay, a.check_in, a.check_out, a.confirmation, a.notes); + accomMap.set(a.id, r.lastInsertRowid); + } + } + + const oldReservations = this.db.prepare('SELECT * FROM reservations WHERE trip_id = ?').all(sourceTripId) as any[]; + const insertReservation = this.db.prepare(` + INSERT INTO reservations (trip_id, day_id, end_day_id, place_id, assignment_id, accommodation_id, title, reservation_time, reservation_end_time, + location, confirmation_number, notes, status, type, metadata, day_plan_position, needs_review) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `); + for (const r of oldReservations) { + insertReservation.run(newTripId, + r.day_id ? (dayMap.get(r.day_id) ?? null) : null, + // end_day_id is a day reference too (multi-day transport) — remap it like + // day_id, otherwise the duplicated trip loses the reservation's end-day link. + r.end_day_id ? (dayMap.get(r.end_day_id) ?? null) : null, + r.place_id ? (placeMap.get(r.place_id) ?? null) : null, + r.assignment_id ? (assignmentMap.get(r.assignment_id) ?? null) : null, + r.accommodation_id ? (accomMap.get(r.accommodation_id) ?? null) : null, + r.title, r.reservation_time, r.reservation_end_time, + r.location, r.confirmation_number, r.notes, r.status, r.type, + r.metadata, r.day_plan_position, r.needs_review ?? 0); + } + + const oldBudget = this.db.prepare('SELECT * FROM budget_items WHERE trip_id = ?').all(sourceTripId) as any[]; + const insertBudget = this.db.prepare(` + INSERT INTO budget_items (trip_id, category, name, total_price, persons, days, note, sort_order) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + `); + for (const b of oldBudget) { + insertBudget.run(newTripId, b.category, b.name, b.total_price, b.persons, b.days, b.note, b.sort_order); + } + + const oldBags = this.db.prepare('SELECT * FROM packing_bags WHERE trip_id = ?').all(sourceTripId) as any[]; + const bagMap = new Map(); + const insertBag = this.db.prepare(` + INSERT INTO packing_bags (trip_id, name, color, weight_limit_grams, sort_order) + VALUES (?, ?, ?, ?, ?) + `); + for (const bag of oldBags) { + const r = insertBag.run(newTripId, bag.name, bag.color, bag.weight_limit_grams, bag.sort_order); + bagMap.set(bag.id, r.lastInsertRowid); + } + + const oldPacking = this.db.prepare('SELECT * FROM packing_items WHERE trip_id = ?').all(sourceTripId) as any[]; + const insertPacking = this.db.prepare(` + INSERT INTO packing_items (trip_id, name, checked, category, sort_order, weight_grams, bag_id, updated_at) + VALUES (?, ?, 0, ?, ?, ?, ?, CURRENT_TIMESTAMP) + `); + for (const p of oldPacking) { + insertPacking.run(newTripId, p.name, p.category, p.sort_order, p.weight_grams, + p.bag_id ? (bagMap.get(p.bag_id) ?? null) : null); + } + + const oldNotes = this.db.prepare('SELECT * FROM day_notes WHERE trip_id = ?').all(sourceTripId) as any[]; + const insertNote = this.db.prepare(` + INSERT INTO day_notes (day_id, trip_id, text, time, icon, sort_order) + VALUES (?, ?, ?, ?, ?, ?) + `); + for (const n of oldNotes) { + const newDayId = dayMap.get(n.day_id); + if (newDayId) insertNote.run(newDayId, newTripId, n.text, n.time, n.icon, n.sort_order); + } + + const oldTodos = this.db.prepare('SELECT * FROM todo_items WHERE trip_id = ?').all(sourceTripId) as any[]; + const insertTodo = this.db.prepare(` + INSERT INTO todo_items (trip_id, name, checked, category, sort_order, due_date, description, assigned_user_id, priority) + VALUES (?, ?, 0, ?, ?, ?, ?, NULL, ?) + `); + for (const t of oldTodos) { + insertTodo.run(newTripId, t.name, t.category, t.sort_order, t.due_date, t.description, t.priority); + } + + const oldCategoryOrder = this.db.prepare('SELECT category, sort_order FROM budget_category_order WHERE trip_id = ?').all(sourceTripId) as any[]; + const insertCategoryOrder = this.db.prepare(` + INSERT INTO budget_category_order (trip_id, category, sort_order) + VALUES (?, ?, ?) + `); + for (const o of oldCategoryOrder) { + insertCategoryOrder.run(newTripId, o.category, o.sort_order); + } + + return Number(newTripId); + }); + + return fn(); } /** Re-read a freshly copied trip in list shape (mirrors the route's TRIP_SELECT query). */ getCopiedTrip(newTripId: number, userId: number) { - return this.db.prepare(`${tripSvc.TRIP_SELECT} WHERE t.id = :tripId`).get({ userId, tripId: newTripId }); + return this.db.prepare(`${TRIP_SELECT} WHERE t.id = :tripId`).get({ userId, tripId: newTripId }); } - listMembers(tripId: string, ownerId: number) { - return tripSvc.listMembers(tripId, ownerId); + // ── Members ─────────────────────────────────────────────────────────────── + + listMembers(tripId: string | number, tripOwnerId: number) { + // u.is_guest rides along (#1362) so guests stay assignable everywhere a member is, + // while the UI can badge them and suppress owner-only actions. The owner is never a guest. + const members = this.db.prepare(` + SELECT u.id, COALESCE(u.display_name, u.username) AS username, u.email, u.avatar, u.is_guest, + CASE WHEN u.id = ? THEN 'owner' ELSE 'member' END as role, + m.added_at, + COALESCE(ib.display_name, ib.username) as invited_by_username + FROM trip_members m + JOIN users u ON u.id = m.user_id + LEFT JOIN users ib ON ib.id = m.invited_by + WHERE m.trip_id = ? + ORDER BY m.added_at ASC + `).all(tripOwnerId, tripId) as { id: number; username: string; email: string; avatar: string | null; is_guest: number; role: string; added_at: string; invited_by_username: string | null }[]; + + // Quirk fix on top of the 1:1 move: the owner row prefers display_name like + // every member row does (the legacy query read the raw username only). + const owner = this.db.prepare('SELECT id, COALESCE(display_name, username) AS username, email, avatar FROM users WHERE id = ?').get(tripOwnerId) as Pick; + + return { + owner: { ...owner, role: 'owner', is_guest: false, avatar_url: avatarUrl(owner) }, + members: members.map(m => ({ ...m, is_guest: !!m.is_guest, avatar_url: avatarUrl(m) })), + }; } - addMember(tripId: string, identifier: string, ownerId: number, userId: number) { - return tripSvc.addMember(tripId, identifier, ownerId, userId); + addMember(tripId: string | number, identifier: string, tripOwnerId: number, invitedByUserId: number): AddMemberResult { + if (!identifier) throw new ValidationError('Email or username required'); + + // Guests (#1362) are not invitable accounts — exclude them so a trip-scoped guest + // can never be resolved (and re-attached to another trip) through the invite box. + const target = this.db.prepare( + 'SELECT id, username, email, avatar FROM users WHERE (email = ? OR username = ?) AND COALESCE(is_guest, 0) = 0' + ).get(identifier.trim(), identifier.trim()) as Pick | undefined; + + if (!target) throw new NotFoundError('User not found'); + + if (target.id === tripOwnerId) + throw new ValidationError('Trip owner is already a member'); + + const existing = this.db.prepare('SELECT id FROM trip_members WHERE trip_id = ? AND user_id = ?').get(tripId, target.id); + if (existing) throw new ValidationError('User already has access'); + + this.db.prepare('INSERT INTO trip_members (trip_id, user_id, invited_by) VALUES (?, ?, ?)').run(tripId, target.id, invitedByUserId); + + const tripInfo = this.db.prepare('SELECT title FROM trips WHERE id = ?').get(tripId) as { title: string } | undefined; + + return { + member: { ...target, role: 'member', avatar_url: avatarUrl(target) }, + targetUserId: target.id, + tripTitle: tripInfo?.title || 'Untitled', + }; } - removeMember(tripId: string, targetId: number): void { - tripSvc.removeMember(tripId, targetId); + removeMember(tripId: string | number, targetUserId: number): void { + this.db.prepare('DELETE FROM trip_members WHERE trip_id = ? AND user_id = ?').run(tripId, targetUserId); } - transferOwnership(tripId: string, newOwnerId: number, currentOwnerId: number) { - return tripSvc.transferOwnership(tripId, newOwnerId, currentOwnerId); + /** + * Hand a trip over to one of its existing members (#973). The new owner must + * already be a member; afterwards they hold `trips.user_id` and the former owner + * becomes a regular member, so nobody loses access. Runs in a transaction so the + * owner pointer and the membership rows never diverge. + */ + transferOwnership( + tripId: string | number, + newOwnerId: number, + currentOwnerId: number, + ): TransferOwnershipResult { + const trip = this.db.prepare('SELECT id, title, user_id FROM trips WHERE id = ?').get(tripId) as { id: number; title: string; user_id: number } | undefined; + if (!trip) throw new NotFoundError('Trip not found'); + if (trip.user_id !== currentOwnerId) throw new ValidationError('Only the owner can transfer ownership'); + if (newOwnerId === currentOwnerId) throw new ValidationError('You already own this trip'); + + const newOwner = this.db.prepare('SELECT id, email, is_guest FROM users WHERE id = ?').get(newOwnerId) as { id: number; email: string; is_guest?: number } | undefined; + if (!newOwner) throw new NotFoundError('User not found'); + // A guest (#1362) can never log in, so it must never become the owner of a trip. + if (newOwner.is_guest) throw new ValidationError('Cannot transfer ownership to a guest'); + + const isMember = this.db.prepare('SELECT id FROM trip_members WHERE trip_id = ? AND user_id = ?').get(tripId, newOwnerId); + if (!isMember) throw new ValidationError('New owner must be a trip member'); + + const fromEmail = (this.db.prepare('SELECT email FROM users WHERE id = ?').get(currentOwnerId) as { email: string } | undefined)?.email || ''; + + const run = this.db.transaction(() => { + this.db.prepare('UPDATE trips SET user_id = ? WHERE id = ?').run(newOwnerId, tripId); + // The new owner is no longer a plain member… + this.db.prepare('DELETE FROM trip_members WHERE trip_id = ? AND user_id = ?').run(tripId, newOwnerId); + // …and the former owner keeps access as a member. + this.db.prepare('INSERT OR IGNORE INTO trip_members (trip_id, user_id, invited_by) VALUES (?, ?, ?)').run(tripId, currentOwnerId, newOwnerId); + }); + run(); + + return { tripTitle: trip.title, fromEmail, toEmail: newOwner.email }; } - createGuest(tripId: string, name: string, invitedBy: number) { - return tripSvc.createGuest(tripId, name, invitedBy); + // ── Guest members (#1362) ─────────────────────────────────────────────────── + + /** username is UNIQUE across all users — keep the typed name but disambiguate guests + * that happen to share it (e.g. two "Anna"s) with a numeric suffix. */ + createGuest(tripId: string | number, name: string, invitedByUserId: number): { member: GuestMember } { + const display = (name || '').trim(); + if (!display) throw new ValidationError('Guest name is required'); + if (display.length > 50) throw new ValidationError('Guest name must be 50 characters or fewer'); + + // The human name lives in display_name (not unique — two trips can each have a + // "Jake", #1446); username is a uuid handle only for the UNIQUE constraint and is + // never shown (member views COALESCE display_name over it). + const email = `guest-${randomUUID()}@guests.invalid`; + const username = `guest-${randomUUID()}`; + + const create = this.db.transaction(() => { + const res = this.db.prepare( + "INSERT INTO users (username, email, password_hash, role, is_guest, display_name) VALUES (?, ?, '', 'user', 1, ?)" + ).run(username, email, display); + const guestId = Number(res.lastInsertRowid); + this.db.prepare('INSERT INTO trip_members (trip_id, user_id, invited_by) VALUES (?, ?, ?)').run(tripId, guestId, invitedByUserId); + return guestId; + }); + const guestId = create(); + + return { member: { id: guestId, username: display, email, role: 'member', is_guest: true, avatar_url: null } }; } - renameGuest(tripId: string, guestUserId: number, name: string): boolean { - return tripSvc.renameGuest(tripId, guestUserId, name); + /** Confirms a user id is a guest of THIS trip, so guest mutations stay trip-scoped. */ + private guestOfTrip(tripId: string | number, guestUserId: number): boolean { + return !!this.db.prepare( + 'SELECT u.id FROM users u JOIN trip_members m ON m.user_id = u.id WHERE u.id = ? AND m.trip_id = ? AND u.is_guest = 1' + ).get(guestUserId, tripId); } - deleteGuest(tripId: string, guestUserId: number): boolean { - return tripSvc.deleteGuest(tripId, guestUserId); + renameGuest(tripId: string | number, guestUserId: number, name: string): boolean { + const display = (name || '').trim(); + if (!display) throw new ValidationError('Guest name is required'); + if (display.length > 50) throw new ValidationError('Guest name must be 50 characters or fewer'); + if (!this.guestOfTrip(tripId, guestUserId)) return false; + + // Rename only the display name — no global-uniqueness dedup, so a rename to a name + // another trip's guest already uses no longer produces "Name 2" (#1446). + this.db.prepare('UPDATE users SET display_name = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND is_guest = 1').run(display, guestUserId); + return true; } - exportICS(tripId: string) { - return tripSvc.exportICS(tripId); + deleteGuest(tripId: string | number, guestUserId: number): boolean { + if (!this.guestOfTrip(tripId, guestUserId)) return false; + // A guest is still a user id a plugin may hold data for, so erase that too — the + // host-side per-user tables + a durable own-db erasure per granted plugin — exactly + // like a full account deletion (otherwise a deleted guest's plugin data lingers). + erasePluginUserData(guestUserId); + // Quirk fix on top of the 1:1 move: the budget re-split and the user delete + // run in one transaction, so a failure mid-flow can't leave the expense + // divisors re-derived for a guest that still exists (or vice versa). The + // plugin-side erasure/notification keep their order around it. + this.db.transaction(() => { + // Re-split the expenses they were part of before the cascade takes their member + // rows away — the divisor is denormalized and cannot follow a foreign key (#1553). + this.budget.removeUserFromBudgetItems(guestUserId); + // Deleting the guest's users row cascades its membership and every assignment join + // (trip_members, budget/packing/assignment links) via the ON DELETE foreign keys. + this.db.prepare('DELETE FROM users WHERE id = ? AND is_guest = 1').run(guestUserId); + })(); + emitUserDeleted(guestUserId); // deliver the erasure to any active plugin now + return true; } + // ── ICS export ──────────────────────────────────────────────────────────── + + exportICS(tripId: string | number): { ics: string; filename: string } { + const trip = this.db.prepare('SELECT * FROM trips WHERE id = ?').get(tripId) as any; + if (!trip) throw new NotFoundError('Trip not found'); + + const reservations = this.db + .prepare( + `SELECT r.*, pl.lat AS place_lat, pl.lng AS place_lng + FROM reservations r + LEFT JOIN places pl ON r.place_id = pl.id + WHERE r.trip_id = ?`, + ) + .all(tripId) as any[]; + + const esc = (s: string) => s + .replace(/\\/g, '\\\\') + .replace(/;/g, '\\;') + .replace(/,/g, '\\,') + .replace(/\r?\n/g, '\\n') + .replace(/\r/g, ''); + const fmtDate = (d: string) => d.replace(/-/g, ''); + const now = new Date().toISOString().replace(/[-:]/g, '').split('.')[0] + 'Z'; + const uid = (id: number, type: string) => `trek-${type}-${id}@trek`; + + // Format datetime: handles full ISO "2026-03-30T09:00" and time-only "10:00" + // iCal requires exactly YYYYMMDDTHHMMSS format + const fmtDateTime = (d: string, refDate?: string) => { + if (d.includes('T')) { + const raw = d.replace(/[-:]/g, '').split('.')[0]; + // Pad to 15 chars (YYYYMMDDTHHMMSS) — add missing seconds + return raw.length === 13 ? raw + '00' : raw; + } + // Time-only: combine with reference date + if (refDate && d.match(/^\d{2}:\d{2}/)) { + const datePart = refDate.split('T')[0]; + return `${datePart}T${d.replace(/:/g, '')}00`.replace(/-/g, ''); + } + return d.replace(/[-:]/g, ''); + }; + + // Zones referenced by timed events → representative YYYYMMDD (for the fallback + // VTIMEZONE offset). Populated by dtLine; emitted once as VTIMEZONE blocks. + const usedZones = new Map(); + + // Emit a DTSTART/DTEND line, attaching TZID when the event's zone is known so + // subscribers see the time in TREK's zone. Falls back to a floating local time + // (unchanged behavior) when no zone resolves or the value is not a date-time. + const dtLine = ( + prop: 'DTSTART' | 'DTEND', + wallClock: string, + zone: string | null, + refDate?: string, + ): string => { + const val = fmtDateTime(wallClock, refDate); + if (zone && isValidTimeZone(zone) && /^\d{8}T\d{6}$/.test(val)) { + if (!usedZones.has(zone)) usedZones.set(zone, val.slice(0, 8)); + return `${prop};TZID=${zone}:${val}\r\n`; + } + return `${prop}:${val}\r\n`; + }; + + let ics = 'BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//TREK//Travel Planner//EN\r\nCALSCALE:GREGORIAN\r\nMETHOD:PUBLISH\r\n'; + ics += `X-WR-CALNAME:${esc(trip.title || 'TREK Trip')}\r\n`; + + // Trip as all-day event. DTEND is exclusive, so it must be the day *after* the last + // day. addDays() stays in UTC — building a local-time Date here dropped the trip's + // last day on any server east of Greenwich (#1453). + if (trip.start_date && trip.end_date) { + const endStr = fmtDate(addDays(trip.end_date, 1)); + ics += `BEGIN:VEVENT\r\nUID:${uid(trip.id, 'trip')}\r\nDTSTAMP:${now}\r\nDTSTART;VALUE=DATE:${fmtDate(trip.start_date)}\r\nDTEND;VALUE=DATE:${endStr}\r\nSUMMARY:${esc(trip.title || 'Trip')}\r\n`; + if (trip.description) ics += `DESCRIPTION:${esc(trip.description)}\r\n`; + ics += `END:VEVENT\r\n`; + } + + // Days with assignments and notes + const days = this.db.prepare('SELECT * FROM days WHERE trip_id = ? ORDER BY day_number ASC').all(tripId) as any[]; + for (const day of days) { + if (!day.date) continue; + + const assignments = this.db.prepare(` + SELECT da.*, p.name as place_name, p.address as place_address, + p.lat as place_lat, p.lng as place_lng, + COALESCE(da.assignment_time, p.place_time) as effective_time, + COALESCE(da.assignment_end_time, p.end_time) as effective_end_time + FROM day_assignments da + JOIN places p ON da.place_id = p.id + WHERE da.day_id = ? + ORDER BY da.order_index ASC, da.created_at ASC + `).all(day.id) as any[]; + + const notes = this.db.prepare( + 'SELECT * FROM day_notes WHERE day_id = ? ORDER BY sort_order ASC, created_at ASC' + ).all(day.id) as any[]; + + const timed = assignments.filter(a => a.effective_time); + const untimed = assignments.filter(a => !a.effective_time); + + // Timed assignments → individual events + for (const a of timed) { + const zone = resolveTimeZone(a.place_lat, a.place_lng); + ics += `BEGIN:VEVENT\r\nUID:${uid(a.id, 'assign')}\r\nDTSTAMP:${now}\r\n`; + ics += dtLine('DTSTART', a.effective_time, zone, day.date + 'T00:00'); + if (a.effective_end_time) { + ics += dtLine('DTEND', a.effective_end_time, zone, day.date + 'T00:00'); + } + ics += `SUMMARY:${esc(a.place_name)}\r\n`; + let desc = ''; + if (a.notes) desc += a.notes; + if (a.place_address) desc += (desc ? '\n' : '') + a.place_address; + if (desc) ics += `DESCRIPTION:${esc(desc)}\r\n`; + if (a.place_address) ics += `LOCATION:${esc(a.place_address)}\r\n`; + ics += `END:VEVENT\r\n`; + } + + // Build all-day summary event if there are untimed activities or notes + if (untimed.length > 0 || notes.length > 0) { + const dayTitle = day.title || `Day ${day.day_number}`; + const endStr = fmtDate(addDays(day.date, 1)); + + ics += `BEGIN:VEVENT\r\nUID:${uid(day.id, 'day')}\r\nDTSTAMP:${now}\r\n`; + ics += `DTSTART;VALUE=DATE:${fmtDate(day.date)}\r\nDTEND;VALUE=DATE:${endStr}\r\n`; + ics += `SUMMARY:${esc(dayTitle)}\r\n`; + + let desc = ''; + if (untimed.length > 0) { + desc += untimed.map(a => { + let line = `• ${a.place_name}`; + if (a.place_address) line += ` (${a.place_address})`; + if (a.notes) line += ` — ${a.notes}`; + return line; + }).join('\n'); + } + if (notes.length > 0) { + if (desc) desc += '\n\n'; + desc += 'Notes:\n' + notes.map(n => { + const line = n.time ? `${n.time} — ${n.text}` : `• ${n.text}`; + return line; + }).join('\n'); + } + if (desc) ics += `DESCRIPTION:${esc(desc)}\r\n`; + ics += `END:VEVENT\r\n`; + } + } + + // Transport/flight reservations carry no top-level reservation_time; their + // times live per endpoint (local_date + local_time) in reservation_endpoints. + const endpointsMap = this.reservations.loadEndpointsByTrip(tripId); + const isDate = (s: string | null | undefined) => !!s && /^\d{4}-\d{2}-\d{2}$/.test(s); + const isTime = (s: string | null | undefined) => !!s && /^\d{2}:\d{2}/.test(s); + + // Build the DTSTART/DTEND lines for a reservation, or null when it has no + // calendar-placeable time. Hotels/restaurants use reservation_time; flights + // fall back to their first/last endpoint. + const buildReservationTimeLines = (r: any): string | null => { + if (r.reservation_time) { + const datePart = r.reservation_time.includes('T') ? r.reservation_time.split('T')[0] : r.reservation_time; + if (!isDate(datePart)) return null; // time-only (relative "Day N" trips) + if (r.reservation_time.includes('T')) { + // Hotels/restaurants: derive the zone from the linked place, if any. + const zone = resolveTimeZone(r.place_lat, r.place_lng); + let out = dtLine('DTSTART', r.reservation_time, zone); + if (r.reservation_end_time) { + const endDt = fmtDateTime(r.reservation_end_time, r.reservation_time); + if (endDt.length >= 15) out += dtLine('DTEND', r.reservation_end_time, zone, r.reservation_time); + } + return out; + } + return `DTSTART;VALUE=DATE:${fmtDate(r.reservation_time)}\r\n`; + } + + const eps = endpointsMap.get(r.id); + if (!eps || eps.length === 0) return null; + const ordered = [...eps].sort((a, b) => a.sequence - b.sequence); + const first = ordered[0]; + const last = ordered[ordered.length - 1]; + if (!isDate(first.local_date)) return null; + if (isTime(first.local_time)) { + // Transport: departure endpoint zone drives DTSTART, arrival drives DTEND. + // Prefer the stored IANA zone; fall back to the endpoint's coordinates. + const startZone = first.timezone || resolveTimeZone(first.lat, first.lng); + let out = dtLine('DTSTART', `${first.local_date}T${first.local_time}`, startZone); + if (last !== first && isDate(last.local_date) && isTime(last.local_time)) { + const endZone = last.timezone || resolveTimeZone(last.lat, last.lng); + out += dtLine('DTEND', `${last.local_date}T${last.local_time}`, endZone); + } + return out; + } + return `DTSTART;VALUE=DATE:${fmtDate(first.local_date)}\r\n`; + }; + + // Reservations as events + for (const r of reservations) { + const timeLines = buildReservationTimeLines(r); + if (!timeLines) continue; + const meta = r.metadata ? (typeof r.metadata === 'string' ? JSON.parse(r.metadata) : r.metadata) : {}; + + ics += `BEGIN:VEVENT\r\nUID:${uid(r.id, 'res')}\r\nDTSTAMP:${now}\r\n`; + ics += timeLines; + ics += `SUMMARY:${esc(r.title)}\r\n`; + + let desc = r.type ? `Type: ${r.type}` : ''; + if (r.confirmation_number) desc += `\nConfirmation: ${r.confirmation_number}`; + if (meta.airline) desc += `\nAirline: ${meta.airline}`; + if (meta.flight_number) desc += `\nFlight: ${meta.flight_number}`; + if (Array.isArray(meta.legs) && meta.legs.length > 1) { + // Multi-leg flight: show the whole route (FRA → BER → HND) on one event. + const stops = [meta.legs[0]?.from, ...meta.legs.map((l: { to?: string }) => l.to)].filter(Boolean); + if (stops.length) desc += `\nRoute: ${stops.join(' → ')}`; + } else if (meta.departure_airport || meta.arrival_airport) { + if (meta.departure_airport) desc += `\nFrom: ${meta.departure_airport}`; + if (meta.arrival_airport) desc += `\nTo: ${meta.arrival_airport}`; + } else { + // Endpoint-based transport without route metadata: derive it from endpoints. + const eps = endpointsMap.get(r.id); + if (eps && eps.length > 1) { + const stops = [...eps].sort((a, b) => a.sequence - b.sequence).map(e => e.code || e.name).filter(Boolean); + if (stops.length > 1) desc += `\nRoute: ${stops.join(' → ')}`; + } + } + if (meta.train_number) desc += `\nTrain: ${meta.train_number}`; + if (r.notes) desc += `\n${r.notes}`; + if (desc) ics += `DESCRIPTION:${esc(desc)}\r\n`; + if (r.location) ics += `LOCATION:${esc(r.location)}\r\n`; + ics += `END:VEVENT\r\n`; + } + + ics += 'END:VCALENDAR\r\n'; + + // Define every referenced zone with a VTIMEZONE, inserted before the first + // event so TZID references resolve. No-op when no timed event carried a zone. + if (usedZones.size > 0) { + let vtz = ''; + for (const [zone, yyyymmdd] of usedZones) vtz += buildVTimezone(zone, yyyymmdd); + ics = ics.replace('BEGIN:VEVENT', vtz + 'BEGIN:VEVENT'); + } + + const safeFilename = (trip.title || 'trek-trip').replace(/["\r\n]/g, '').replace(/[^\w\s.-]/g, '_'); + return { ics: foldICS(ics), filename: `${safeFilename}.ics` }; + } + + // ── Trip summary (used by MCP get_trip_summary tool) ────────────────────── + + getTripSummary(tripId: number, viewerUserId?: number) { + const trip = this.db.prepare('SELECT * FROM trips WHERE id = ?').get(tripId) as Record | undefined; + if (!trip) return null; + + const ownerRow = this.getOwner(tripId); + if (!ownerRow) return null; + const { owner, members } = this.listMembers(tripId, ownerRow.user_id); + + const { days: rawDays } = this.days.list(tripId); + const days = rawDays.map(({ notes_items, ...day }) => ({ ...day, notes: notes_items })); + + const accommodations = this.days.listAccommodations(tripId); + + const budgetItems = this.budget.listBudgetItems(tripId); + const budget = { + items: budgetItems, + item_count: budgetItems.length, + total: budgetItems.reduce((sum, i) => sum + (i.total_price || 0), 0), + currency: trip.currency, + }; + + // Thread the viewer so another member's private/personal packing items (#858) + // stay hidden — without it listItems returns the UNFILTERED list. + const packingItems = this.packing.listItems(tripId, viewerUserId); + const packing = { + items: packingItems, + total: packingItems.length, + checked: (packingItems as { checked: number }[]).filter(i => i.checked).length, + }; + + const reservations = this.reservations.list(tripId); + const collab_notes = this.collab.listNotes(tripId); + + return { + trip, + members: { owner, collaborators: members }, + days, + accommodations, + budget, + packing, + reservations, + collab_notes, + }; + } + + // ── Bundle / notifications (route helpers) ──────────────────────────────── + /** Aggregates every trip sub-collection for offline caching (legacy /:id/bundle). */ bundle(tripId: string, trip: { user_id: number }, viewerId: number) { const { days } = this.days.list(tripId); @@ -169,3 +1235,19 @@ export class TripsService { }); } } + +// ── Custom error types ──────────────────────────────────────────────────── + +export class NotFoundError extends Error { + constructor(message: string) { + super(message); + this.name = 'NotFoundError'; + } +} + +export class ValidationError extends Error { + constructor(message: string) { + super(message); + this.name = 'ValidationError'; + } +} diff --git a/server/tests/e2e/feeds.e2e.test.ts b/server/tests/e2e/feeds.e2e.test.ts index ccfcb0566..6c31e0f02 100644 --- a/server/tests/e2e/feeds.e2e.test.ts +++ b/server/tests/e2e/feeds.e2e.test.ts @@ -42,10 +42,13 @@ const SAMPLE_ICS = 'BEGIN:VEVENT\r\nUID:trek-trip-x@trek\r\nDTSTAMP:20260101T000000Z\r\n' + 'DTSTART;VALUE=DATE:20260101\r\nDTEND;VALUE=DATE:20260102\r\nSUMMARY:Sample\r\nEND:VEVENT\r\n' + 'END:VCALENDAR\r\n'; -const { exportICS } = vi.hoisted(() => ({ exportICS: vi.fn() })); -vi.mock('../../src/services/tripService', () => ({ exportICS })); +// Since the trip fold, FeedsService injects TripsService — the mock is a spy on +// the container singleton (created in beforeAll, after build()). +const exportICS = vi.fn(); import { FeedsModule } from '../../src/nest/feeds/feeds.module'; +import { TripsService } from '../../src/nest/trips/trips.service'; +import { RealtimeModule } from '../../src/nest/realtime/realtime.module'; import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter'; const BASE = 'https://trek.example.test'; @@ -56,7 +59,7 @@ describe('Calendar-feed e2e (real auth guard + temp SQLite)', () => { let prevAppUrl: string | undefined; async function build() { - const moduleRef = await Test.createTestingModule({ imports: [DatabaseModule, FeedsModule] }).compile(); + const moduleRef = await Test.createTestingModule({ imports: [DatabaseModule, RealtimeModule, FeedsModule] }).compile(); const nest = moduleRef.createNestApplication(); nest.use(cookieParser()); nest.useGlobalFilters(new TrekExceptionFilter()); @@ -70,6 +73,7 @@ describe('Calendar-feed e2e (real auth guard + temp SQLite)', () => { seedUser(db as never, { id: 1, username: 'e2e-user' }); seedUser(db as never, { id: 2, username: 'other-user', email: 'other@example.test' }); app = await build(); + vi.spyOn(app.get(TripsService), 'exportICS').mockImplementation(exportICS as never); server = app.getHttpServer(); }); diff --git a/server/tests/e2e/trips.e2e.test.ts b/server/tests/e2e/trips.e2e.test.ts index 58b3e31d5..e31dae3e8 100644 --- a/server/tests/e2e/trips.e2e.test.ts +++ b/server/tests/e2e/trips.e2e.test.ts @@ -1,8 +1,8 @@ /** * Trips module e2e — exercises the migrated /api/trips aggregate-root endpoints - * through the real JwtAuthGuard against a temp SQLite db. tripService, the bundle - * list-services, auditLog, demo, the permission check, canAccessTrip and the - * WebSocket broadcast are mocked. + * through the real JwtAuthGuard against a temp SQLite db. TripsService runs its + * real (DI-native) SQL — trips/trip_members/days DDL below; auditLog, demo, the + * permission check, canAccessTrip and the WebSocket broadcast are mocked. */ import { describe, it, expect, beforeAll, afterAll, beforeEach, vi, type MockInstance } from 'vitest'; import request from 'supertest'; @@ -21,7 +21,25 @@ const { db } = vi.hoisted(() => { tmp.exec(`CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL, email TEXT NOT NULL UNIQUE, role TEXT NOT NULL DEFAULT 'user', password_version INTEGER NOT NULL DEFAULT 0, avatar TEXT, display_name TEXT, is_guest INTEGER NOT NULL DEFAULT 0);`); - tmp.exec('CREATE TABLE trips (id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT);'); + // TripsService runs its real SQL (DI-native since the trip fold) — full trip + // column set for TRIP_SELECT + create/update/delete, plus the membership and + // day-content tables generateDays/listMembers/deleteTrip touch. + tmp.exec(`CREATE TABLE trips (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, title TEXT NOT NULL, + description TEXT, start_date TEXT, end_date TEXT, currency TEXT DEFAULT 'EUR', is_archived INTEGER DEFAULT 0, + cover_image TEXT, reminder_days INTEGER DEFAULT 3, feed_token TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP);`); + tmp.exec(`CREATE TABLE trip_members (id INTEGER PRIMARY KEY AUTOINCREMENT, trip_id INTEGER NOT NULL, + user_id INTEGER NOT NULL, invited_by INTEGER, added_at DATETIME DEFAULT CURRENT_TIMESTAMP);`); + tmp.exec(`CREATE TABLE day_assignments (id INTEGER PRIMARY KEY AUTOINCREMENT, day_id INTEGER NOT NULL, + place_id INTEGER NOT NULL, order_index INTEGER DEFAULT 0, notes TEXT, reservation_status TEXT, + reservation_notes TEXT, reservation_datetime TEXT, assignment_time TEXT, assignment_end_time TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP);`); + tmp.exec(`CREATE TABLE day_notes (id INTEGER PRIMARY KEY AUTOINCREMENT, day_id INTEGER NOT NULL, + trip_id INTEGER NOT NULL, text TEXT, time TEXT, icon TEXT, sort_order INTEGER DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP);`); + // deleteTrip cleans up synced journey entries before dropping the trip row. + tmp.exec(`CREATE TABLE journey_entries (id INTEGER PRIMARY KEY AUTOINCREMENT, journey_id INTEGER, + source_trip_id INTEGER, source_place_id INTEGER, type TEXT NOT NULL);`); // bundle()'s todoItems now runs TodoService's real SQL (DI-injected, no mock). tmp.exec(`CREATE TABLE todo_items (id INTEGER PRIMARY KEY AUTOINCREMENT, trip_id INTEGER NOT NULL, name TEXT NOT NULL, checked INTEGER NOT NULL DEFAULT 0, category TEXT, sort_order INTEGER NOT NULL DEFAULT 0, @@ -92,15 +110,8 @@ import { PermissionsService } from '../../src/nest/permissions/permissions.servi // PermissionsService singleton (created in beforeAll, after build()). let checkPermission: MockInstance; -const { tripSvc } = vi.hoisted(() => ({ - tripSvc: { - listTrips: vi.fn(), createTrip: vi.fn(), getTrip: vi.fn(), updateTrip: vi.fn(), deleteTrip: vi.fn(), - getTripRaw: vi.fn(), getTripOwner: vi.fn(), deleteOldCover: vi.fn(), updateCoverImage: vi.fn(), - listMembers: vi.fn(), addMember: vi.fn(), removeMember: vi.fn(), exportICS: vi.fn(), copyTripById: vi.fn(), - verifyTripAccess: vi.fn(), NotFoundError: class NotFoundError extends Error {}, ValidationError: class ValidationError extends Error {}, TRIP_SELECT: 'SELECT', - }, -})); -vi.mock('../../src/services/tripService', () => tripSvc); +// TripsService itself is real since the trip fold — no tripService mock; the +// trips/trip_members/days DDL above serves its SQL. // bundle()'s days + accommodations now run DaysService's real SQL (DI-injected, // no mock) — the days/places/day_accommodations/reservations DDL above serves them. vi.mock('../../src/services/placeService', () => ({ listPlaces: () => [] })); @@ -131,13 +142,13 @@ describe('Trips e2e (real auth guard + temp SQLite)', () => { vi.spyOn(app.get(BudgetService), 'listBudgetItems').mockReturnValue([]); vi.spyOn(app.get(BudgetService), 'rebaseTripCurrency').mockResolvedValue(); server = app.getHttpServer(); - tripSvc.listTrips.mockReturnValue([{ id: 1, title: 'T' }]); - tripSvc.createTrip.mockReturnValue({ trip: { id: 9 }, tripId: 9, reminderDays: 0 }); - tripSvc.getTrip.mockImplementation((id: string) => (id === '9' ? { id: 9, user_id: 1 } : undefined)); - tripSvc.listMembers.mockReturnValue({ owner: { id: 1 }, members: [] }); }); beforeEach(() => { + db.prepare('DELETE FROM trips').run(); + db.prepare('DELETE FROM trip_members').run(); + db.prepare('DELETE FROM days').run(); + db.prepare('DELETE FROM audit_log').run(); canAccessTrip.mockReturnValue({ user_id: 1 }); checkPermission.mockReturnValue(true); }); @@ -146,20 +157,28 @@ describe('Trips e2e (real auth guard + temp SQLite)', () => { await app.close(); }); + const seedTrip = (title = 'T', userId = 1) => + Number(db.prepare('INSERT INTO trips (user_id, title) VALUES (?, ?)').run(userId, title).lastInsertRowid); + it('401 without a cookie', async () => { expect((await request(server).get('/api/trips')).status).toBe(401); }); - it('200 list', async () => { + it('200 list (real TRIP_SELECT: is_owner + counts)', async () => { + const tripId = seedTrip('T'); const res = await request(server).get('/api/trips').set('Cookie', sessionCookie(1)); expect(res.status).toBe(200); - expect(res.body).toEqual({ trips: [{ id: 1, title: 'T' }] }); + expect(res.body.trips).toHaveLength(1); + expect(res.body.trips[0]).toMatchObject({ id: tripId, title: 'T', is_owner: 1, day_count: 0, place_count: 0, shared_count: 0 }); }); - it('201 create, 403 without permission', async () => { + it('201 create (real insert + day generation), 403 without permission', async () => { const ok = await request(server).post('/api/trips').set('Cookie', sessionCookie(1)).send({ title: 'T' }); expect(ok.status).toBe(201); - expect(ok.body).toEqual({ trip: { id: 9 } }); + // The dateless create seeds the default 7 placeholder days. + expect(ok.body.trip).toMatchObject({ title: 'T', currency: 'EUR', day_count: 7, is_owner: 1 }); + const dayRows = db.prepare('SELECT COUNT(*) AS n FROM days WHERE trip_id = ?').get(ok.body.trip.id) as { n: number }; + expect(dayRows.n).toBe(7); // The DI-native AuditService wrote the real row (audit_log DDL above). const audit = db.prepare("SELECT user_id FROM audit_log WHERE action = 'trip.create'").get() as { user_id: number }; expect(audit).toEqual({ user_id: 1 }); @@ -174,9 +193,22 @@ describe('Trips e2e (real auth guard + temp SQLite)', () => { expect(res.body).toEqual({ error: 'Trip not found' }); }); - it('200 bundle for an accessible trip', async () => { - const res = await request(server).get('/api/trips/9/bundle').set('Cookie', sessionCookie(1)); + it('200 bundle for an accessible trip (real member list)', async () => { + const tripId = seedTrip('B'); + const res = await request(server).get(`/api/trips/${tripId}/bundle`).set('Cookie', sessionCookie(1)); expect(res.status).toBe(200); - expect(res.body).toMatchObject({ trip: { id: 9 }, days: [], members: [{ id: 1 }] }); + expect(res.body).toMatchObject({ trip: { id: tripId }, days: [], members: [{ id: 1, role: 'owner' }] }); + }); + + it('200 delete cleans up synced journey entries (real SQL)', async () => { + const tripId = seedTrip('D'); + db.prepare("INSERT INTO journey_entries (journey_id, source_trip_id, type) VALUES (1, ?, 'skeleton')").run(tripId); + const filledId = Number(db.prepare("INSERT INTO journey_entries (journey_id, source_trip_id, type) VALUES (1, ?, 'story')").run(tripId).lastInsertRowid); + const res = await request(server).delete(`/api/trips/${tripId}`).set('Cookie', sessionCookie(1)); + expect(res.status).toBe(200); + expect(res.body).toEqual({ success: true }); + expect(db.prepare('SELECT id FROM trips WHERE id = ?').get(tripId)).toBeUndefined(); + expect(db.prepare("SELECT id FROM journey_entries WHERE type = 'skeleton'").get()).toBeUndefined(); + expect((db.prepare('SELECT source_trip_id FROM journey_entries WHERE id = ?').get(filledId) as { source_trip_id: number | null }).source_trip_id).toBeNull(); }); }); diff --git a/server/tests/helpers/mcp-harness.ts b/server/tests/helpers/mcp-harness.ts index 373b3525c..43f777890 100644 --- a/server/tests/helpers/mcp-harness.ts +++ b/server/tests/helpers/mcp-harness.ts @@ -34,10 +34,12 @@ export interface McpHarnessOptions { scopes?: string[] | null; /** Whether the session is authenticated via a static API token (default: false) */ isStaticToken?: boolean; + /** Fire-once deprecation-notice closure (default: registerTools' () => null) */ + getDeprecationNotice?: () => string | null; } export async function createMcpHarness(options: McpHarnessOptions): Promise { - const { userId, withResources = true, withTools = true, scopes = null, isStaticToken = false } = options; + const { userId, withResources = true, withTools = true, scopes = null, isStaticToken = false, getDeprecationNotice } = options; const server = new McpServer({ name: 'trek-test', version: '1.0.0' }); @@ -48,7 +50,7 @@ export async function createMcpHarness(options: McpHarnessOptions): Promise Promise< } } -describe('Resource: trek://trips', () => { - it('returns all trips the user owns or is a member of', async () => { - const { user } = createUser(testDb); - const { user: other } = createUser(testDb); - createTrip(testDb, user.id, { title: 'My Trip' }); - const sharedTrip = createTrip(testDb, other.id, { title: 'Shared Trip' }); - addTripMember(testDb, sharedTrip.id, user.id); - // Trip from another user (not accessible) - createTrip(testDb, other.id, { title: 'Other Trip' }); - - await withHarness(user.id, async (harness) => { - const result = await harness.client.readResource({ uri: 'trek://trips' }); - const trips = parseResourceResult(result) as any[]; - expect(trips).toHaveLength(2); - const titles = trips.map((t) => t.title); - expect(titles).toContain('My Trip'); - expect(titles).toContain('Shared Trip'); - expect(titles).not.toContain('Other Trip'); - }); - }); - - it('excludes archived trips', async () => { - const { user } = createUser(testDb); - createTrip(testDb, user.id, { title: 'Active Trip' }); - const archived = createTrip(testDb, user.id, { title: 'Archived Trip' }); - testDb.prepare('UPDATE trips SET is_archived = 1 WHERE id = ?').run(archived.id); - - await withHarness(user.id, async (harness) => { - const result = await harness.client.readResource({ uri: 'trek://trips' }); - const trips = parseResourceResult(result) as any[]; - expect(trips).toHaveLength(1); - expect(trips[0].title).toBe('Active Trip'); - }); - }); - - it('returns empty array when user has no trips', async () => { - const { user } = createUser(testDb); - await withHarness(user.id, async (harness) => { - const result = await harness.client.readResource({ uri: 'trek://trips' }); - const trips = parseResourceResult(result) as any[]; - expect(trips).toEqual([]); - }); - }); -}); - -describe('Resource: trek://trips/{tripId}', () => { - it('returns trip data for an accessible trip', async () => { - const { user } = createUser(testDb); - const trip = createTrip(testDb, user.id, { title: 'Paris Trip' }); - - await withHarness(user.id, async (harness) => { - const result = await harness.client.readResource({ uri: `trek://trips/${trip.id}` }); - const data = parseResourceResult(result) as any; - expect(data.title).toBe('Paris Trip'); - expect(data.id).toBe(trip.id); - }); - }); - - it('returns access denied for inaccessible trip', async () => { - const { user } = createUser(testDb); - const { user: other } = createUser(testDb); - const otherTrip = createTrip(testDb, other.id, { title: 'Private' }); - - await withHarness(user.id, async (harness) => { - const result = await harness.client.readResource({ uri: `trek://trips/${otherTrip.id}` }); - const data = parseResourceResult(result) as any; - expect(data.error).toBeTruthy(); - }); - }); - - it('returns access denied for non-existent ID', async () => { - const { user } = createUser(testDb); - await withHarness(user.id, async (harness) => { - const result = await harness.client.readResource({ uri: 'trek://trips/99999' }); - const data = parseResourceResult(result) as any; - expect(data.error).toBeTruthy(); - }); - }); -}); +// trek://trips and trek://trips/{tripId} moved to tools-trips.test.ts: they +// now register via the nest-mcp registry inside registerTools (TripsMcp +// @Resource/@ResourceTemplate), which this file's withTools: false harness +// never attaches. // The trek://trips/{tripId}/days resource moved to the DI-discovered // DaysMcp — see tools-days.test.ts. @@ -205,35 +132,8 @@ describe('Resource: trek://trips/{tripId}/places', () => { // The trek://trips/{tripId}/accommodations resource moved to the // DI-discovered DaysMcp — see tools-days-accommodations.test.ts. -describe('Resource: trek://trips/{tripId}/members', () => { - it('returns owner and collaborators', async () => { - const { user } = createUser(testDb); - const { user: member } = createUser(testDb); - const trip = createTrip(testDb, user.id); - addTripMember(testDb, trip.id, member.id); - - await withHarness(user.id, async (harness) => { - const result = await harness.client.readResource({ uri: `trek://trips/${trip.id}/members` }); - const data = parseResourceResult(result) as any; - expect(data.owner).toBeTruthy(); - expect(data.owner.id).toBe(user.id); - expect(data.members).toHaveLength(1); - expect(data.members[0].id).toBe(member.id); - }); - }); - - it('returns access denied for unauthorized trip', async () => { - const { user } = createUser(testDb); - const { user: other } = createUser(testDb); - const trip = createTrip(testDb, other.id); - - await withHarness(user.id, async (harness) => { - const result = await harness.client.readResource({ uri: `trek://trips/${trip.id}/members` }); - const data = parseResourceResult(result) as any; - expect(data.error).toBeTruthy(); - }); - }); -}); +// trek://trips/{tripId}/members moved to tools-trips.test.ts (TripsMcp +// @ResourceTemplate) for the same reason. // trek://trips/{tripId}/collab-notes moved to tools-notes.test.ts: it now // registers via the nest-mcp registry inside registerTools (CollabMcp diff --git a/server/tests/unit/mcp/tools-prompts.test.ts b/server/tests/unit/mcp/tools-prompts.test.ts index 018a899d9..5a31a36a1 100644 --- a/server/tests/unit/mcp/tools-prompts.test.ts +++ b/server/tests/unit/mcp/tools-prompts.test.ts @@ -50,7 +50,8 @@ vi.mock('../../../src/nest/addons/addons.bridge', () => ({ const { mockGetTripSummary } = vi.hoisted(() => ({ mockGetTripSummary: vi.fn(), })); -vi.mock('../../../src/services/tripService', () => ({ +// prompts.ts consumes getTripSummary via the trips bridge since the trip fold. +vi.mock('../../../src/nest/trips/trips.bridge', () => ({ getTripSummary: mockGetTripSummary, })); @@ -59,6 +60,21 @@ import { runMigrations } from '../../../src/db/migrations'; import { resetTestDb } from '../../helpers/test-db'; import { createUser, createTrip, addTripMember, createPackingItem, createBudgetItem } from '../../helpers/factories'; import { registerMcpPrompts } from '../../../src/mcp/tools/prompts'; +import { createTestRegistry } from '@trek/nest-mcp'; +import { trekMcpAccessPolicy, trekMcpValidateAccess } from '../../../src/mcp/nest-mcp-policy'; +import { TripsMcp } from '../../../src/nest/trips/trips.mcp'; +import type { TripsService } from '../../../src/nest/trips/trips.service'; +import type { TodoService } from '../../../src/nest/todo/todo.service'; +import type { CollabService } from '../../../src/nest/collab/collab.service'; + +// The trip-summary prompt moved to the DI-discovered TripsMcp — its cases below +// exercise it through a hand-built registry over a stub TripsService whose +// getTripSummary is the same controllable mock the legacy path used. +const tripsStub = { + canAccessTrip: (tripId: number, userId: number) => dbMock.canAccessTrip(tripId, userId), + getTripSummary: (tripId: number, viewerUserId?: number) => mockGetTripSummary(tripId, viewerUserId), +} as unknown as TripsService; +const tripsMcp = new TripsMcp(tripsStub, { listItems: () => [] } as unknown as TodoService, { listPolls: () => [], countMessages: () => 0 } as unknown as CollabService); beforeAll(() => { createTables(testDb); @@ -110,6 +126,10 @@ afterAll(() => { function buildServer(userId: number, opts: { isStaticToken?: boolean } = {}): McpServer { const server = new McpServer({ name: 'trek-test', version: '1.0.0' }); registerMcpPrompts(server, userId, opts.isStaticToken ?? false); + // Attach the DI-discovered trip surface (carries the trip-summary @Prompt) + // the same way registerTools does in production. + createTestRegistry([tripsMcp], { accessPolicy: trekMcpAccessPolicy, validateAccess: trekMcpValidateAccess }) + .attach(server, { userId, scopes: null, isStaticToken: opts.isStaticToken ?? false }); return server; } diff --git a/server/tests/unit/mcp/tools-share.test.ts b/server/tests/unit/mcp/tools-share.test.ts new file mode 100644 index 000000000..a21a6ed6e --- /dev/null +++ b/server/tests/unit/mcp/tools-share.test.ts @@ -0,0 +1,131 @@ +/** + * Unit tests for the share-link MCP surface (ShareMcp, DI-discovered): + * get_share_link, create_share_link, delete_share_link — moved here from the + * legacy src/mcp/tools/trips.ts registrar with the trip DI port. All three + * ride the canShareTrips predicate (no declarative trips:share mode exists). + */ +import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest'; + +const { testDb, dbMock } = vi.hoisted(() => { + const Database = require('better-sqlite3'); + const db = new Database(':memory:'); + db.exec('PRAGMA journal_mode = WAL'); + db.exec('PRAGMA foreign_keys = ON'); + db.exec('PRAGMA busy_timeout = 5000'); + const mock = { + db, + closeDb: () => {}, + reinitialize: () => {}, + getPlaceWithTags: () => null, + canAccessTrip: (tripId: any, userId: number) => + db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId), + isOwner: (tripId: any, userId: number) => + !!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId), + }; + return { testDb: db, dbMock: mock }; +}); + +vi.mock('../../../src/db/database', () => dbMock); +vi.mock('../../../src/config', () => ({ + JWT_SECRET: 'test-jwt-secret-for-trek-testing-only', + ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2', + updateJwtSecret: () => {}, +})); +vi.mock('../../../src/websocket', () => ({ broadcast: vi.fn() })); + +import { createTables } from '../../../src/db/schema'; +import { runMigrations } from '../../../src/db/migrations'; +import { resetTestDb } from '../../helpers/test-db'; +import { createUser, createTrip } from '../../helpers/factories'; +import { createMcpHarness, parseToolResult, type McpHarness } from '../../helpers/mcp-harness'; + +beforeAll(() => { + createTables(testDb); + runMigrations(testDb); +}); + +beforeEach(() => { + resetTestDb(testDb); + delete process.env.DEMO_MODE; +}); + +afterAll(() => { + testDb.close(); +}); + +async function withHarness(userId: number, fn: (h: McpHarness) => Promise) { + const h = await createMcpHarness({ userId, withResources: false }); + try { await fn(h); } finally { await h.cleanup(); } +} + +describe('Tool: get_share_link', () => { + it('returns null when the trip has no share link, then the token after creation', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + await withHarness(user.id, async (h) => { + const empty = parseToolResult(await h.client.callTool({ name: 'get_share_link', arguments: { tripId: trip.id } })) as any; + expect(empty.link).toBeNull(); + const created = parseToolResult(await h.client.callTool({ name: 'create_share_link', arguments: { tripId: trip.id } })) as any; + const link = parseToolResult(await h.client.callTool({ name: 'get_share_link', arguments: { tripId: trip.id } })) as any; + expect(link.link.token).toBe(created.token); + }); + }); + + it('returns access denied for a non-member trip', async () => { + const { user } = createUser(testDb); + const { user: other } = createUser(testDb); + const trip = createTrip(testDb, other.id); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ name: 'get_share_link', arguments: { tripId: trip.id } }); + expect(result.isError).toBe(true); + }); + }); +}); + +describe('Tool: create_share_link', () => { + it('creates with the legacy defaults, then updates in place (created=false)', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + await withHarness(user.id, async (h) => { + const first = parseToolResult(await h.client.callTool({ name: 'create_share_link', arguments: { tripId: trip.id } })) as any; + expect(first.created).toBe(true); + const row = testDb.prepare('SELECT share_map, share_bookings, share_packing, share_budget, share_collab FROM share_tokens WHERE trip_id = ?').get(trip.id) as any; + expect(row).toEqual({ share_map: 1, share_bookings: 1, share_packing: 0, share_budget: 0, share_collab: 0 }); + const second = parseToolResult(await h.client.callTool({ name: 'create_share_link', arguments: { tripId: trip.id, share_budget: true } })) as any; + expect(second.created).toBe(false); + expect(second.token).toBe(first.token); + }); + }); + + it('denies without trip access; blocks demo users', async () => { + const { user } = createUser(testDb); + const { user: other } = createUser(testDb); + const foreign = createTrip(testDb, other.id); + await withHarness(user.id, async (h) => { + expect((await h.client.callTool({ name: 'create_share_link', arguments: { tripId: foreign.id } })).isError).toBe(true); + }); + process.env.DEMO_MODE = 'true'; + const { user: demo } = createUser(testDb, { email: 'demo@nomad.app' }); + const trip = createTrip(testDb, demo.id); + await withHarness(demo.id, async (h) => { + expect((await h.client.callTool({ name: 'create_share_link', arguments: { tripId: trip.id } })).isError).toBe(true); + }); + }); +}); + +describe('Tool: delete_share_link', () => { + it('revokes the link; denies without access', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + await withHarness(user.id, async (h) => { + await h.client.callTool({ name: 'create_share_link', arguments: { tripId: trip.id } }); + const result = parseToolResult(await h.client.callTool({ name: 'delete_share_link', arguments: { tripId: trip.id } })) as any; + expect(result.success).toBe(true); + expect(testDb.prepare('SELECT id FROM share_tokens WHERE trip_id = ?').get(trip.id)).toBeUndefined(); + }); + const { user: stranger } = createUser(testDb); + await withHarness(stranger.id, async (h) => { + expect((await h.client.callTool({ name: 'delete_share_link', arguments: { tripId: trip.id } })).isError).toBe(true); + }); + }); +}); diff --git a/server/tests/unit/mcp/tools-trips.test.ts b/server/tests/unit/mcp/tools-trips.test.ts index ec395d59d..e47288a90 100644 --- a/server/tests/unit/mcp/tools-trips.test.ts +++ b/server/tests/unit/mcp/tools-trips.test.ts @@ -1,5 +1,11 @@ /** - * Unit tests for MCP trip tools: create_trip, update_trip, delete_trip, list_trips, get_trip_summary. + * Unit tests for the trip MCP surface (TripsMcp, DI-discovered): create_trip, + * update_trip, delete_trip, list_trips, get_trip_summary tools, the + * trek://trips / trek://trips/{tripId} resources (moved here from + * resources.test.ts with the DI port — the withTools harness attaches the + * nest-mcp registry that now registers them), the fire-once deprecation + * notice riding the attach ctx, and the scope gating (declarative + * trips:write markers + the canReadTrips/canDeleteTrips predicates). */ import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest'; @@ -36,7 +42,7 @@ import { createTables } from '../../../src/db/schema'; import { runMigrations } from '../../../src/db/migrations'; import { resetTestDb } from '../../helpers/test-db'; import { createUser, createTrip, createDay, createPlace, addTripMember, createBudgetItem, createPackingItem, createReservation, createDayNote, createCollabNote, createDayAssignment, createDayAccommodation } from '../../helpers/factories'; -import { createMcpHarness, parseToolResult, type McpHarness } from '../../helpers/mcp-harness'; +import { createMcpHarness, parseToolResult, parseResourceResult, type McpHarness } from '../../helpers/mcp-harness'; beforeAll(() => { createTables(testDb); @@ -503,3 +509,205 @@ describe('Tool: get_trip_summary', () => { }); }); }); + +// --------------------------------------------------------------------------- +// Resources (moved from resources.test.ts with the DI port — the registry the +// withTools harness attaches now registers them) +// --------------------------------------------------------------------------- + +describe('Resource: trek://trips', () => { + it('returns all trips the user owns or is a member of', async () => { + const { user } = createUser(testDb); + const { user: other } = createUser(testDb); + createTrip(testDb, user.id, { title: 'My Trip' }); + const sharedTrip = createTrip(testDb, other.id, { title: 'Shared Trip' }); + addTripMember(testDb, sharedTrip.id, user.id); + // Trip from another user (not accessible) + createTrip(testDb, other.id, { title: 'Other Trip' }); + + await withHarness(user.id, async (harness) => { + const result = await harness.client.readResource({ uri: 'trek://trips' }); + const trips = parseResourceResult(result) as any[]; + expect(trips).toHaveLength(2); + const titles = trips.map((t) => t.title); + expect(titles).toContain('My Trip'); + expect(titles).toContain('Shared Trip'); + expect(titles).not.toContain('Other Trip'); + }); + }); + + it('excludes archived trips', async () => { + const { user } = createUser(testDb); + createTrip(testDb, user.id, { title: 'Active Trip' }); + const archived = createTrip(testDb, user.id, { title: 'Archived Trip' }); + testDb.prepare('UPDATE trips SET is_archived = 1 WHERE id = ?').run(archived.id); + + await withHarness(user.id, async (harness) => { + const result = await harness.client.readResource({ uri: 'trek://trips' }); + const trips = parseResourceResult(result) as any[]; + expect(trips).toHaveLength(1); + expect(trips[0].title).toBe('Active Trip'); + }); + }); + + it('returns empty array when user has no trips', async () => { + const { user } = createUser(testDb); + await withHarness(user.id, async (harness) => { + const result = await harness.client.readResource({ uri: 'trek://trips' }); + const trips = parseResourceResult(result) as any[]; + expect(trips).toEqual([]); + }); + }); +}); + +describe('Resource: trek://trips/{tripId}', () => { + it('returns trip data for an accessible trip', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id, { title: 'Paris Trip' }); + + await withHarness(user.id, async (harness) => { + const result = await harness.client.readResource({ uri: `trek://trips/${trip.id}` }); + const data = parseResourceResult(result) as any; + expect(data.title).toBe('Paris Trip'); + expect(data.id).toBe(trip.id); + }); + }); + + it('returns access denied for inaccessible trip', async () => { + const { user } = createUser(testDb); + const { user: other } = createUser(testDb); + const otherTrip = createTrip(testDb, other.id, { title: 'Private' }); + + await withHarness(user.id, async (harness) => { + const result = await harness.client.readResource({ uri: `trek://trips/${otherTrip.id}` }); + const data = parseResourceResult(result) as any; + expect(data.error).toBeTruthy(); + }); + }); + + it('returns access denied for non-existent ID', async () => { + const { user } = createUser(testDb); + await withHarness(user.id, async (harness) => { + const result = await harness.client.readResource({ uri: 'trek://trips/99999' }); + const data = parseResourceResult(result) as any; + expect(data.error).toBeTruthy(); + }); + }); +}); + +describe('Resource: trek://trips/{tripId}/members', () => { + it('returns owner and collaborators', async () => { + const { user } = createUser(testDb); + const { user: member } = createUser(testDb); + const trip = createTrip(testDb, user.id); + addTripMember(testDb, trip.id, member.id); + + await withHarness(user.id, async (harness) => { + const result = await harness.client.readResource({ uri: `trek://trips/${trip.id}/members` }); + const data = parseResourceResult(result) as any; + expect(data.owner).toBeTruthy(); + expect(data.owner.id).toBe(user.id); + expect(data.members).toHaveLength(1); + expect(data.members[0].id).toBe(member.id); + }); + }); + + it('returns access denied for unauthorized trip', async () => { + const { user } = createUser(testDb); + const { user: other } = createUser(testDb); + const trip = createTrip(testDb, other.id); + + await withHarness(user.id, async (harness) => { + const result = await harness.client.readResource({ uri: `trek://trips/${trip.id}/members` }); + const data = parseResourceResult(result) as any; + expect(data.error).toBeTruthy(); + }); + }); +}); + +// --------------------------------------------------------------------------- +// Deprecation notice (fire-once closure riding the registry attach ctx) +// --------------------------------------------------------------------------- + +describe('static-token deprecation notice', () => { + it('list_trips surfaces the notice exactly once and still carries the payload', async () => { + const { user } = createUser(testDb); + createTrip(testDb, user.id, { title: 'Noticed' }); + // Mirror the fire-once closure built per session in src/mcp/index.ts. + let emitted = false; + const getDeprecationNotice = () => { + if (emitted) return null; + emitted = true; + return 'static tokens are deprecated'; + }; + const h = await createMcpHarness({ userId: user.id, withResources: false, isStaticToken: true, getDeprecationNotice }); + try { + const first = await h.client.callTool({ name: 'list_trips', arguments: {} }); + expect(first.isError).toBe(true); + const texts = (first.content as { type: string; text: string }[]).map((c) => c.text); + expect(texts[0]).toContain('deprecated'); + expect(JSON.parse(texts[1]).trips[0].title).toBe('Noticed'); + // Second call: the closure already fired — plain payload, no error. + const second = await h.client.callTool({ name: 'list_trips', arguments: {} }); + expect(second.isError).toBeFalsy(); + expect((parseToolResult(second) as any).trips).toHaveLength(1); + } finally { + await h.cleanup(); + } + }); +}); + +// --------------------------------------------------------------------------- +// Scope gating — declarative trips:write markers plus the predicate escape +// hatches (canReadTrips accepts trips:delete / trips:share; delete_trip rides +// trips:delete, the share tools trips:share) +// --------------------------------------------------------------------------- + +describe('scope gating', () => { + async function toolNames(scopes: string[] | null): Promise { + const h = await createMcpHarness({ userId: 1, withResources: false, scopes }); + try { + const { tools } = await h.client.listTools(); + return tools.map((t) => t.name); + } finally { + await h.cleanup(); + } + } + + const WRITE_TOOLS = ['create_trip', 'update_trip', 'add_trip_member', 'remove_trip_member', 'copy_trip']; + const READ_TOOLS = ['list_trip_members', 'export_trip_ics']; + const NAV_TOOLS = ['list_trips', 'get_trip_summary']; + const SHARE_TOOLS = ['get_share_link', 'create_share_link', 'delete_share_link']; + + it('null scopes (trek_ PAT) exposes the full surface', async () => { + const names = await toolNames(null); + for (const t of [...WRITE_TOOLS, ...READ_TOOLS, ...NAV_TOOLS, 'delete_trip', ...SHARE_TOOLS]) { + expect(names).toContain(t); + } + }); + + it('trips:read exposes reads + navigation but no writes/delete/share', async () => { + const names = await toolNames(['trips:read']); + for (const t of [...READ_TOOLS, ...NAV_TOOLS]) expect(names).toContain(t); + for (const t of [...WRITE_TOOLS, 'delete_trip', ...SHARE_TOOLS]) expect(names).not.toContain(t); + }); + + it('trips:delete alone still grants the trip reads (canReadTrips parity) plus delete_trip', async () => { + const names = await toolNames(['trips:delete']); + expect(names).toContain('delete_trip'); + for (const t of [...READ_TOOLS, ...NAV_TOOLS]) expect(names).toContain(t); + for (const t of WRITE_TOOLS) expect(names).not.toContain(t); + }); + + it('trips:share alone grants the share tools and the trip reads, nothing else', async () => { + const names = await toolNames(['trips:share']); + for (const t of [...SHARE_TOOLS, ...READ_TOOLS, ...NAV_TOOLS]) expect(names).toContain(t); + for (const t of [...WRITE_TOOLS, 'delete_trip']) expect(names).not.toContain(t); + }); + + it('an unrelated scope keeps only the navigation tools', async () => { + const names = await toolNames(['weather:read']); + for (const t of NAV_TOOLS) expect(names).toContain(t); + for (const t of [...WRITE_TOOLS, ...READ_TOOLS, 'delete_trip', ...SHARE_TOOLS]) expect(names).not.toContain(t); + }); +}); diff --git a/server/tests/unit/nest/budget.service.db.test.ts b/server/tests/unit/nest/budget.service.db.test.ts index 4e8364c11..ec94c523d 100644 --- a/server/tests/unit/nest/budget.service.db.test.ts +++ b/server/tests/unit/nest/budget.service.db.test.ts @@ -61,8 +61,15 @@ import { DatabaseService } from '../../../src/nest/database/database.service'; import { PermissionsService } from '../../../src/nest/permissions/permissions.service'; import { ExchangeRatesService } from '../../../src/nest/budget/exchange-rates.service'; import * as bridge from '../../../src/nest/budget/budget.bridge'; -import { createGuest, deleteGuest } from '../../../src/services/tripService'; import { RealtimeService } from '../../../src/nest/realtime/realtime.service'; +import { TripsService } from '../../../src/nest/trips/trips.service'; +import { TodoService } from '../../../src/nest/todo/todo.service'; +import { PackingService } from '../../../src/nest/packing/packing.service'; +import { FilesService } from '../../../src/nest/files/files.service'; +import { ReservationsService } from '../../../src/nest/reservations/reservations.service'; +import { DaysService } from '../../../src/nest/days/days.service'; +import { CollabService } from '../../../src/nest/collab/collab.service'; +import { VacayService } from '../../../src/nest/vacay/vacay.service'; const budget = new BudgetService( new DatabaseService(testDb), @@ -71,6 +78,26 @@ const budget = new BudgetService( new RealtimeService(), ); +// Guest fixtures come from the DI-native TripsService since the trip fold (was +// an import of the deleted services/tripService); deleteGuest routes through +// the SAME BudgetService domain SQL (removeUserFromBudgetItems) under test. +const dbs = () => new DatabaseService(testDb); +const tripsSvc = 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(), +); +const createGuest = tripsSvc.createGuest.bind(tripsSvc); +const deleteGuest = tripsSvc.deleteGuest.bind(tripsSvc); + beforeAll(() => { createTables(testDb); runMigrations(testDb); @@ -376,12 +403,15 @@ describe('rebaseTripCurrency', () => { }); describe('budget.bridge delegation', () => { - it('BUDGET-SVC-DB-015: listBudgetItems returns the hydrated list through the bridge', () => { + // The listBudgetItems / rebaseTripCurrency bridge exports were pruned when + // their last outside-container consumers (legacy tripService + trips MCP + // registrar) migrated — 015/017 pin the same behavior on the service. + it('BUDGET-SVC-DB-015: listBudgetItems returns the hydrated list', () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const item = budget.createBudgetItem(trip.id, { name: 'Hotel', total_price: 100, member_ids: [user.id] }); - const items = bridge.listBudgetItems(trip.id); + const items = budget.listBudgetItems(trip.id); expect(items.map(i => i.id)).toEqual([item.id]); expect(items[0].members.map(m => m.user_id)).toEqual([user.id]); @@ -399,13 +429,13 @@ describe('budget.bridge delegation', () => { expect(row.persons).toBe(1); }); - it('BUDGET-SVC-DB-017: rebaseTripCurrency pins the implicit currency through the bridge', async () => { + it('BUDGET-SVC-DB-017: rebaseTripCurrency pins the implicit currency', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); testDb.prepare("UPDATE trips SET currency = 'EUR' WHERE id = ?").run(trip.id); const item = budget.createBudgetItem(trip.id, { name: 'Implicit', total_price: 100, members: [{ user_id: user.id }] }); - await bridge.rebaseTripCurrency(trip.id, 'RUB'); + await budget.rebaseTripCurrency(trip.id, 'RUB'); const row = testDb.prepare('SELECT currency, exchange_rate FROM budget_items WHERE id = ?').get(item.id) as { currency: string | null; exchange_rate: number }; expect(row).toEqual({ currency: 'EUR', exchange_rate: RATES.RUB.EUR }); diff --git a/server/tests/unit/nest/share.service.test.ts b/server/tests/unit/nest/share.service.test.ts index 945c5af66..271d270bc 100644 --- a/server/tests/unit/nest/share.service.test.ts +++ b/server/tests/unit/nest/share.service.test.ts @@ -1,9 +1,10 @@ /** * Unit tests for the DI-native ShareService — SHARE-SVC-001 through - * SHARE-SVC-028 (the SHARE-001..026 range belongs to the HTTP parity suite in - * tests/integration/share.test.ts; these pin the service SQL directly, plus - * the share.bridge delegation). Uses a real in-memory SQLite DB so SQL logic - * is exercised faithfully. + * SHARE-SVC-025 (the SHARE-001..026 range belongs to the HTTP parity suite in + * tests/integration/share.test.ts; these pin the service SQL directly; the + * 026–028 bridge-delegation cases died with share.bridge when the legacy + * share-link tools moved to share.mcp.ts). Uses a real in-memory SQLite DB so + * SQL logic is exercised faithfully. */ import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest'; @@ -55,7 +56,6 @@ import { DatabaseService } from '../../../src/nest/database/database.service'; import type { PermissionsService } from '../../../src/nest/permissions/permissions.service'; import { ShareService } from '../../../src/nest/share/share.service'; import { SettingsService } from '../../../src/nest/settings/settings.service'; -import { createOrUpdateShareLink, getShareLink, deleteShareLink } from '../../../src/nest/share/share.bridge'; import type { User } from '../../../src/types'; const svc = new ShareService(new DatabaseService(testDb), new SettingsService(new DatabaseService(testDb)), permissionsStub); @@ -412,30 +412,6 @@ describe('getSharedPlacePhotoPath', () => { }); }); -// ── share.bridge delegation ─────────────────────────────────────────────────── - -describe('share.bridge delegation', () => { - it('SHARE-SVC-026: createOrUpdateShareLink delegates to ShareService.createOrUpdate', () => { - const { user } = createUser(testDb); - const trip = createTrip(testDb, user.id); - const result = createOrUpdateShareLink(String(trip.id), user.id, { share_budget: true }); - expect(result.created).toBe(true); - expect(shareRow(trip.id).share_budget).toBe(1); - }); - - it('SHARE-SVC-027: getShareLink delegates to ShareService.get', () => { - const { user } = createUser(testDb); - const trip = createTrip(testDb, user.id); - expect(getShareLink(String(trip.id))).toBeNull(); - const { token } = svc.createOrUpdate(String(trip.id), user.id, {}); - expect(getShareLink(String(trip.id))?.token).toBe(token); - }); - - it('SHARE-SVC-028: deleteShareLink delegates to ShareService.remove', () => { - const { user } = createUser(testDb); - const trip = createTrip(testDb, user.id); - svc.createOrUpdate(String(trip.id), user.id, {}); - deleteShareLink(String(trip.id)); - expect(shareRow(trip.id)).toBeUndefined(); - }); -}); +// SHARE-SVC-026..028 (share.bridge delegation) were deleted with the bridge — +// its last consumer, the legacy share-link tools in src/mcp/tools/trips.ts, +// moved to the DI-discovered share.mcp.ts. diff --git a/server/tests/unit/nest/todo.service.test.ts b/server/tests/unit/nest/todo.service.test.ts index e4e5de62d..5c142a730 100644 --- a/server/tests/unit/nest/todo.service.test.ts +++ b/server/tests/unit/nest/todo.service.test.ts @@ -1,8 +1,9 @@ /** - * Unit tests for the DI-native TodoService — TODO-SVC-001 through TODO-SVC-024 - * (001–020 moved 1:1 from the legacy tests/unit/services/todoService.test.ts; - * 021–024 pin the todo.bridge delegation). Uses a real in-memory SQLite DB so - * SQL logic is exercised faithfully. + * Unit tests for the DI-native TodoService — TODO-SVC-001 through TODO-SVC-020 + * (moved 1:1 from the legacy tests/unit/services/todoService.test.ts; the + * 021–024 bridge-delegation cases died with todo.bridge when the legacy trips + * registrar migrated). Uses a real in-memory SQLite DB so SQL logic is + * exercised faithfully. */ import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest'; @@ -46,7 +47,6 @@ import { createUser, createTrip, addTripMember } from '../../helpers/factories'; import { DatabaseService } from '../../../src/nest/database/database.service'; import { PermissionsService } from '../../../src/nest/permissions/permissions.service'; import { TodoService } from '../../../src/nest/todo/todo.service'; -import { listItems, createItem, updateItem, deleteItem } from '../../../src/nest/todo/todo.bridge'; import { RealtimeService } from '../../../src/nest/realtime/realtime.service'; const svc = new TodoService(new DatabaseService(testDb), new PermissionsService(new DatabaseService(testDb)), new RealtimeService()); @@ -286,43 +286,6 @@ describe('getCategoryAssignees / updateCategoryAssignees', () => { }); }); -// ── todo.bridge (delegation to the service — inside the src/nest coverage gate) - -describe('todo.bridge', () => { - it('TODO-SVC-021 — listItems delegates to the service', () => { - const { user } = createUser(testDb); - const trip = createTrip(testDb, user.id); - svc.createItem(trip.id, { name: 'Bridged' }); - const items = listItems(trip.id) as any[]; - expect(items).toHaveLength(1); - expect(items[0].name).toBe('Bridged'); - }); - - it('TODO-SVC-022 — createItem delegates with the legacy defaults', () => { - const { user } = createUser(testDb); - const trip = createTrip(testDb, user.id); - const item = createItem(trip.id, { name: 'Via bridge' }) as any; - expect(item.checked).toBe(0); - expect(item.priority).toBe(0); - expect(item.sort_order).toBe(0); - }); - - it('TODO-SVC-023 — updateItem delegates and keeps the bodyKeys sentinel protocol', () => { - const { user } = createUser(testDb); - const trip = createTrip(testDb, user.id); - const item = createItem(trip.id, { name: 'Task', due_date: '2026-06-01' }) as any; - const untouched = updateItem(trip.id, item.id, { name: 'Renamed' }, []) as any; - expect(untouched.due_date).toBe('2026-06-01'); - const cleared = updateItem(trip.id, item.id, { due_date: null }, ['due_date']) as any; - expect(cleared.due_date).toBeNull(); - }); - - it('TODO-SVC-024 — deleteItem delegates to the service', () => { - const { user } = createUser(testDb); - const trip = createTrip(testDb, user.id); - const item = createItem(trip.id, { name: 'Gone' }) as any; - expect(deleteItem(trip.id, item.id)).toBe(true); - expect(deleteItem(trip.id, item.id)).toBe(false); - expect(svc.listItems(trip.id)).toHaveLength(0); - }); -}); +// TODO-SVC-021..024 (todo.bridge delegation) were deleted with the bridge — +// its last consumer, the legacy get_trip_summary registrar in +// src/mcp/tools/trips.ts, moved to the DI-discovered trips.mcp.ts. diff --git a/server/tests/unit/nest/trips.controller.test.ts b/server/tests/unit/nest/trips.controller.test.ts index 5aaeb3ed3..63fa47824 100644 --- a/server/tests/unit/nest/trips.controller.test.ts +++ b/server/tests/unit/nest/trips.controller.test.ts @@ -15,8 +15,9 @@ vi.mock('../../../src/services/unsplashService', () => ({ import { TripsController } from '../../../src/nest/trips/trips.controller'; import type { TripsService } from '../../../src/nest/trips/trips.service'; -import { NotFoundError, ValidationError } from '../../../src/services/tripService'; +import { NotFoundError, ValidationError } from '../../../src/nest/trips/trips.service'; import type { User } from '../../../src/types'; +import { tripCreateRequestSchema, tripTransferOwnershipRequestSchema } from '@trek/shared'; const user = { id: 1, role: 'user', email: 'u@example.test' } as User; const req = { headers: {} } as Request; @@ -74,9 +75,12 @@ describe('TripsController (parity with the legacy /api/trips route)', () => { }); describe('POST / (create)', () => { - it('403 without trip_create, 400 without title', () => { + it('403 without trip_create; a missing title 400s in the ZodValidationPipe', () => { expect(thrown(() => tc(svc({ can: vi.fn().mockReturnValue(false) })).create(user, { title: 'T' }, req))).toEqual({ status: 403, body: { error: 'No permission to create trips' } }); - expect(thrown(() => tc(svc()).create(user, {}, req))).toEqual({ status: 400, body: { error: 'Title is required' } }); + // The hand-rolled 'Title is required' 400 moved into the global pipe + // (trips DTO ratchet) — the schema rejects a missing/empty title. + expect(tripCreateRequestSchema.safeParse({}).success).toBe(false); + expect(tripCreateRequestSchema.safeParse({ title: '' }).success).toBe(false); }); it('infers end_date from start_date (+6 days) and creates', () => { @@ -186,13 +190,13 @@ describe('TripsController (parity with the legacy /api/trips route)', () => { describe('POST /:id/copy', () => { it('403 without trip_create, 404 without access', () => { - expect(thrown(() => tc(svc({ can: vi.fn().mockReturnValue(false) })).copy(user, '9', undefined, req))).toEqual({ status: 403, body: { error: 'No permission to create trips' } }); - expect(thrown(() => tc(svc({ canAccessTrip: vi.fn().mockReturnValue(undefined) })).copy(user, '9', undefined, req))).toEqual({ status: 404, body: { error: 'Trip not found' } }); + expect(thrown(() => tc(svc({ can: vi.fn().mockReturnValue(false) })).copy(user, '9', {}, req))).toEqual({ status: 403, body: { error: 'No permission to create trips' } }); + expect(thrown(() => tc(svc({ canAccessTrip: vi.fn().mockReturnValue(undefined) })).copy(user, '9', {}, req))).toEqual({ status: 404, body: { error: 'Trip not found' } }); }); it('copies + returns the new trip', () => { const s = svc({ copy: vi.fn().mockReturnValue(42), getCopiedTrip: vi.fn().mockReturnValue({ id: 42 }) } as Partial); - expect(tc(s).copy(user, '9', 'Copy', req)).toEqual({ trip: { id: 42 } }); + expect(tc(s).copy(user, '9', { title: 'Copy' }, req)).toEqual({ trip: { id: 42 } }); }); }); @@ -227,26 +231,26 @@ describe('TripsController (parity with the legacy /api/trips route)', () => { }); it('POST 403 without member_manage, else adds + notifies', () => { - expect(thrown(() => tc(svc({ can: vi.fn().mockReturnValue(false) })).addMember(user, '9', 'bob@x.y'))).toEqual({ status: 403, body: { error: 'No permission to manage members' } }); + expect(thrown(() => tc(svc({ can: vi.fn().mockReturnValue(false) })).addMember(user, '9', { identifier: 'bob@x.y' }))).toEqual({ status: 403, body: { error: 'No permission to manage members' } }); const addMember = vi.fn().mockReturnValue({ member: { id: 2, email: 'bob@x.y' }, targetUserId: 2, tripTitle: 'T' }); const notifyInvite = vi.fn(); const s = svc({ addMember, notifyInvite } as Partial); - expect(tc(s).addMember(user, '9', 'bob@x.y')).toEqual({ member: { id: 2, email: 'bob@x.y' } }); + expect(tc(s).addMember(user, '9', { identifier: 'bob@x.y' })).toEqual({ member: { id: 2, email: 'bob@x.y' } }); expect(notifyInvite).toHaveBeenCalledWith('9', user, 2, 'T', 'bob@x.y'); }); it('POST 404 without trip access', () => { const s = svc({ canAccessTrip: vi.fn().mockReturnValue(undefined) }); - expect(thrown(() => tc(s).addMember(user, '9', 'bob@x.y'))).toEqual({ status: 404, body: { error: 'Trip not found' } }); + expect(thrown(() => tc(s).addMember(user, '9', { identifier: 'bob@x.y' }))).toEqual({ status: 404, body: { error: 'Trip not found' } }); }); it('POST maps NotFoundError to 404, ValidationError to 400, re-throws others', () => { const nf = svc({ addMember: vi.fn().mockImplementation(() => { throw new NotFoundError('no user'); }) } as Partial); - expect(thrown(() => tc(nf).addMember(user, '9', 'bob@x.y'))).toEqual({ status: 404, body: { error: 'no user' } }); + expect(thrown(() => tc(nf).addMember(user, '9', { identifier: 'bob@x.y' }))).toEqual({ status: 404, body: { error: 'no user' } }); const ve = svc({ addMember: vi.fn().mockImplementation(() => { throw new ValidationError('already a member'); }) } as Partial); - expect(thrown(() => tc(ve).addMember(user, '9', 'bob@x.y'))).toEqual({ status: 400, body: { error: 'already a member' } }); + expect(thrown(() => tc(ve).addMember(user, '9', { identifier: 'bob@x.y' }))).toEqual({ status: 400, body: { error: 'already a member' } }); const other = svc({ addMember: vi.fn().mockImplementation(() => { throw new Error('boom'); }) } as Partial); - expect(() => tc(other).addMember(user, '9', 'bob@x.y')).toThrow('boom'); + expect(() => tc(other).addMember(user, '9', { identifier: 'bob@x.y' })).toThrow('boom'); }); it('DELETE 404 without trip access', () => { @@ -266,18 +270,20 @@ describe('TripsController (parity with the legacy /api/trips route)', () => { describe('POST /:id/transfer (#973)', () => { it('404 without trip access', () => { const s = svc({ canAccessTrip: vi.fn().mockReturnValue(undefined) }); - expect(thrown(() => tc(s).transferOwnership(user, '9', 2, req))).toEqual({ status: 404, body: { error: 'Trip not found' } }); + expect(thrown(() => tc(s).transferOwnership(user, '9', { newOwnerId: 2 }, req))).toEqual({ status: 404, body: { error: 'Trip not found' } }); }); it('403 when the requester is not the owner', () => { // access.user_id (5) differs from the requesting user (1) const s = svc({ canAccessTrip: vi.fn().mockReturnValue({ user_id: 5 }) }); - expect(thrown(() => tc(s).transferOwnership(user, '9', 2, req))).toEqual({ status: 403, body: { error: 'Only the owner can transfer ownership' } }); + expect(thrown(() => tc(s).transferOwnership(user, '9', { newOwnerId: 2 }, req))).toEqual({ status: 403, body: { error: 'Only the owner can transfer ownership' } }); }); - it('400 when newOwnerId is not a number', () => { - const s = svc(); - expect(thrown(() => tc(s).transferOwnership(user, '9', 'nope' as unknown as number, req))).toEqual({ status: 400, body: { error: 'newOwnerId is required' } }); + it('a non-numeric newOwnerId 400s in the ZodValidationPipe', () => { + // The hand-rolled 'newOwnerId is required' 400 moved into the global + // pipe (trips DTO ratchet) — the schema rejects non-numeric ids. + expect(tripTransferOwnershipRequestSchema.safeParse({ newOwnerId: 'nope' }).success).toBe(false); + expect(tripTransferOwnershipRequestSchema.safeParse({}).success).toBe(false); }); it('transfers, audits and broadcasts the refreshed trip', () => { @@ -285,37 +291,40 @@ describe('TripsController (parity with the legacy /api/trips route)', () => { const get = vi.fn().mockReturnValue({ id: 9, user_id: 2 }); const broadcast = vi.fn(); const s = svc({ transferOwnership, get, broadcast } as Partial); - expect(tc(s).transferOwnership(user, '9', 2, req, 'sock')).toEqual({ success: true }); + expect(tc(s).transferOwnership(user, '9', { newOwnerId: 2 }, req, 'sock')).toEqual({ success: true }); expect(transferOwnership).toHaveBeenCalledWith('9', 2, user.id); expect(broadcast).toHaveBeenCalledWith('9', 'trip:updated', { trip: { id: 9, user_id: 2 } }, 'sock'); }); it('maps NotFoundError to 404 and ValidationError to 400', () => { const nf = svc({ transferOwnership: vi.fn().mockImplementation(() => { throw new NotFoundError('User not found'); }) } as Partial); - expect(thrown(() => tc(nf).transferOwnership(user, '9', 2, req))).toEqual({ status: 404, body: { error: 'User not found' } }); + expect(thrown(() => tc(nf).transferOwnership(user, '9', { newOwnerId: 2 }, req))).toEqual({ status: 404, body: { error: 'User not found' } }); const ve = svc({ transferOwnership: vi.fn().mockImplementation(() => { throw new ValidationError('New owner must be a trip member'); }) } as Partial); - expect(thrown(() => tc(ve).transferOwnership(user, '9', 2, req))).toEqual({ status: 400, body: { error: 'New owner must be a trip member' } }); + expect(thrown(() => tc(ve).transferOwnership(user, '9', { newOwnerId: 2 }, req))).toEqual({ status: 400, body: { error: 'New owner must be a trip member' } }); }); }); describe('guests (#1362)', () => { it('404 without access, 403 for a non-owner, 400 without a name; else creates', () => { - expect(thrown(() => tc(svc({ canAccessTrip: vi.fn().mockReturnValue(undefined) })).createGuest(user, '9', 'Anna'))).toEqual({ status: 404, body: { error: 'Trip not found' } }); + expect(thrown(() => tc(svc({ canAccessTrip: vi.fn().mockReturnValue(undefined) })).createGuest(user, '9', { name: 'Anna' }))).toEqual({ status: 404, body: { error: 'Trip not found' } }); // access.user_id (5) ≠ requester (1) → not the owner - expect(thrown(() => tc(svc({ canAccessTrip: vi.fn().mockReturnValue({ user_id: 5 }) })).createGuest(user, '9', 'Anna'))).toEqual({ status: 403, body: { error: 'Only the owner can manage guests' } }); - expect(thrown(() => tc(svc()).createGuest(user, '9', ' '))).toEqual({ status: 400, body: { error: 'Guest name is required' } }); + expect(thrown(() => tc(svc({ canAccessTrip: vi.fn().mockReturnValue({ user_id: 5 }) })).createGuest(user, '9', { name: 'Anna' }))).toEqual({ status: 403, body: { error: 'Only the owner can manage guests' } }); + // A whitespace-only name still 400s with the legacy body — the service + // throws after trimming (the schema only enforces 1..50 chars). + const wsGuest = svc({ createGuest: vi.fn().mockImplementation(() => { throw new ValidationError('Guest name is required'); }) } as Partial); + expect(thrown(() => tc(wsGuest).createGuest(user, '9', { name: ' ' }))).toEqual({ status: 400, body: { error: 'Guest name is required' } }); const createGuest = vi.fn().mockReturnValue({ member: { id: 7, username: 'Anna', is_guest: true } }); const s = svc({ createGuest } as Partial); - expect(tc(s).createGuest(user, '9', 'Anna')).toEqual({ member: { id: 7, username: 'Anna', is_guest: true } }); + expect(tc(s).createGuest(user, '9', { name: 'Anna' })).toEqual({ member: { id: 7, username: 'Anna', is_guest: true } }); expect(createGuest).toHaveBeenCalledWith('9', 'Anna', user.id); }); it('rename: 403 non-owner, 404 when the guest is missing, else success', () => { - expect(thrown(() => tc(svc({ canAccessTrip: vi.fn().mockReturnValue({ user_id: 5 }) })).renameGuest(user, '9', '7', 'Bob'))).toEqual({ status: 403, body: { error: 'Only the owner can manage guests' } }); + expect(thrown(() => tc(svc({ canAccessTrip: vi.fn().mockReturnValue({ user_id: 5 }) })).renameGuest(user, '9', '7', { name: 'Bob' }))).toEqual({ status: 403, body: { error: 'Only the owner can manage guests' } }); const miss = svc({ renameGuest: vi.fn().mockReturnValue(false) } as Partial); - expect(thrown(() => tc(miss).renameGuest(user, '9', '7', 'Bob'))).toEqual({ status: 404, body: { error: 'Guest not found' } }); + expect(thrown(() => tc(miss).renameGuest(user, '9', '7', { name: 'Bob' }))).toEqual({ status: 404, body: { error: 'Guest not found' } }); const ok = svc({ renameGuest: vi.fn().mockReturnValue(true) } as Partial); - expect(tc(ok).renameGuest(user, '9', '7', 'Bob')).toEqual({ success: true }); + expect(tc(ok).renameGuest(user, '9', '7', { name: 'Bob' })).toEqual({ success: true }); }); it('delete: 403 non-owner, 404 when the guest is missing, else success', () => { @@ -328,7 +337,7 @@ describe('TripsController (parity with the legacy /api/trips route)', () => { it('maps a ValidationError from createGuest to 400', () => { const ve = svc({ createGuest: vi.fn().mockImplementation(() => { throw new ValidationError('Guest name must be 50 characters or fewer'); }) } as Partial); - expect(thrown(() => tc(ve).createGuest(user, '9', 'x'.repeat(60)))).toEqual({ status: 400, body: { error: 'Guest name must be 50 characters or fewer' } }); + expect(thrown(() => tc(ve).createGuest(user, '9', { name: 'x'.repeat(60) }))).toEqual({ status: 400, body: { error: 'Guest name must be 50 characters or fewer' } }); }); }); @@ -390,6 +399,6 @@ describe('TripsController (parity with the legacy /api/trips route)', () => { it('POST /:id/copy maps a copy failure to 500', () => { const s = svc({ copy: vi.fn().mockImplementation(() => { throw new Error('boom'); }) } as Partial); - expect(thrown(() => tc(s).copy(user, '9', undefined, req))).toEqual({ status: 500, body: { error: 'Failed to copy trip' } }); + expect(thrown(() => tc(s).copy(user, '9', {}, req))).toEqual({ status: 500, body: { error: 'Failed to copy trip' } }); }); }); diff --git a/server/tests/unit/nest/trips.service.test.ts b/server/tests/unit/nest/trips.service.test.ts index ccc49f1ac..74802334e 100644 --- a/server/tests/unit/nest/trips.service.test.ts +++ b/server/tests/unit/nest/trips.service.test.ts @@ -1,132 +1,1246 @@ -import { describe, it, expect, vi, beforeAll, beforeEach } from 'vitest'; +/** + * Unit tests for the DI-native TripsService — TRIP-SVC-001 through TRIP-SVC-053 + * (001–038 moved 1:1 from the legacy tests/unit/services/tripService.test.ts; + * the exportICS cases that duplicated the generateDays 010–012 numbering were + * renumbered to 024–026 with the post-fold quirk-fix commit; 040–041 pin the + * trips.bridge delegation; 042–050 cover the folded + * summary/list/create/delete/copy SQL; 051–053 pin the post-fold quirk fixes + * (transactional deletes, owner display_name)). Uses a real in-memory SQLite + * DB so SQL logic is exercised faithfully. + */ +import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest'; -const { dbMock } = vi.hoisted(() => { - const stmt = { get: vi.fn(() => ({ id: 42 })), all: vi.fn(() => []), run: vi.fn() }; - return { dbMock: { prepare: vi.fn(() => stmt), _stmt: stmt } }; +// ── DB setup ────────────────────────────────────────────────────────────────── + +const { testDb, dbMock } = vi.hoisted(() => { + const Database = require('better-sqlite3'); + const db = new Database(':memory:'); + db.exec('PRAGMA journal_mode = WAL'); + db.exec('PRAGMA foreign_keys = ON'); + db.exec('PRAGMA busy_timeout = 5000'); + const mock = { + db, + closeDb: () => {}, + reinitialize: () => {}, + getPlaceWithTags: () => null, + canAccessTrip: (tripId: any, userId: number) => + db.prepare(` + SELECT t.id, t.user_id FROM trips t + LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? + WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL) + `).get(userId, tripId, userId), + isOwner: (tripId: any, userId: number) => + !!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId), + }; + return { testDb: db, dbMock: mock }; }); -const { canAccessTrip } = vi.hoisted(() => ({ canAccessTrip: vi.fn(() => ({ user_id: 1 })) })); -vi.mock('../../../src/db/database', () => ({ db: dbMock, canAccessTrip, closeDb: () => {}, reinitialize: () => {} })); -import { db as dbConn } from '../../../src/db/database'; -import { DatabaseService } from '../../../src/nest/database/database.service'; -import type { PermissionsService } from '../../../src/nest/permissions/permissions.service'; +vi.mock('../../../src/db/database', () => dbMock); +vi.mock('../../../src/config', () => ({ + JWT_SECRET: 'test-secret', + ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2', + updateJwtSecret: () => {}, +})); const { broadcast } = vi.hoisted(() => ({ broadcast: vi.fn() })); vi.mock('../../../src/websocket', () => ({ broadcast })); // notifyInvite fires a notification via a dynamic import — keep it out of unit scope vi.mock('../../../src/services/notificationService', () => ({ send: vi.fn().mockResolvedValue(undefined) })); -const checkPermission = vi.fn(() => true); -const permissionsStub = { checkPermission } as unknown as PermissionsService; -const { tripSvc } = vi.hoisted(() => ({ - tripSvc: { - listTrips: vi.fn(), createTrip: vi.fn(), getTrip: vi.fn(), updateTrip: vi.fn(), deleteTrip: vi.fn(), - getTripRaw: vi.fn(), getTripOwner: vi.fn(), deleteOldCover: vi.fn(), updateCoverImage: vi.fn(), - listMembers: vi.fn(() => ({ owner: { id: 1 }, members: [] })), addMember: vi.fn(), removeMember: vi.fn(), - transferOwnership: vi.fn(), - createGuest: vi.fn(), renameGuest: vi.fn(), deleteGuest: vi.fn(), - exportICS: vi.fn(), copyTripById: vi.fn(), TRIP_SELECT: 'SELECT * FROM trips t', - }, -})); -vi.mock('../../../src/services/tripService', () => tripSvc); -vi.mock('../../../src/services/placeService', () => ({ listPlaces: () => [] })); -import { TripsService } from '../../../src/nest/trips/trips.service'; -import type { TodoService } from '../../../src/nest/todo/todo.service'; -import type { PackingService } from '../../../src/nest/packing/packing.service'; -import type { FilesService } from '../../../src/nest/files/files.service'; -import type { ReservationsService } from '../../../src/nest/reservations/reservations.service'; -import type { DaysService } from '../../../src/nest/days/days.service'; -import type { BudgetService } from '../../../src/nest/budget/budget.service'; +import { createTables } from '../../../src/db/schema'; +import { runMigrations } from '../../../src/db/migrations'; +import { resetTestDb } from '../../helpers/test-db'; +import { createUser, createTrip, createReservation, createPlace, createDay, createDayAssignment, createDayNote, addTripMember } from '../../helpers/factories'; +import { DatabaseService } from '../../../src/nest/database/database.service'; +import { DaysService } from '../../../src/nest/days/days.service'; +import { PermissionsService } from '../../../src/nest/permissions/permissions.service'; import { RealtimeService } from '../../../src/nest/realtime/realtime.service'; +import { TodoService } from '../../../src/nest/todo/todo.service'; +import { PackingService } from '../../../src/nest/packing/packing.service'; +import { FilesService } from '../../../src/nest/files/files.service'; +import { ReservationsService } from '../../../src/nest/reservations/reservations.service'; +import { BudgetService } from '../../../src/nest/budget/budget.service'; +import { ExchangeRatesService } from '../../../src/nest/budget/exchange-rates.service'; +import { CollabService } from '../../../src/nest/collab/collab.service'; +import { VacayService } from '../../../src/nest/vacay/vacay.service'; +import { TripsService } from '../../../src/nest/trips/trips.service'; +import { getTripOwner, listMembers as bridgeListMembers } from '../../../src/nest/trips/trips.bridge'; +import fs from 'fs'; -// Constructor-injected since the budget fold (was a path mock of the deleted -// services/budgetService). -const rebaseTripCurrency = vi.fn(async () => {}); -const budgetStub = { listBudgetItems: () => [], rebaseTripCurrency } as unknown as BudgetService; +// Real sibling services over the same in-memory DB — updateTrip's date-shift +// resyncs and the summary/bundle aggregation run their actual SQL. +const dbs = () => new DatabaseService(testDb); +const budgetSvc = new BudgetService(dbs(), new PermissionsService(dbs()), new ExchangeRatesService(), new RealtimeService()); +const daysSvc = new DaysService(dbs(), new PermissionsService(dbs()), new RealtimeService()); +const createAccommodation = daysSvc.createAccommodation.bind(daysSvc); -const todoStub = { listItems: () => [] } as unknown as TodoService; -const packingListItems = vi.fn(() => []); -const packingStub = { listItems: packingListItems } as unknown as PackingService; -const filesStub = { listFiles: () => [] } as unknown as FilesService; -const reservationsStub = { list: () => [] } as unknown as ReservationsService; -const daysStub = { list: () => ({ days: [1] }), listAccommodations: () => [] } as unknown as DaysService; -function svc() { return new TripsService(new DatabaseService(dbConn), todoStub, packingStub, filesStub, reservationsStub, daysStub, permissionsStub, budgetStub, new RealtimeService()); } -beforeEach(() => vi.clearAllMocks()); -beforeAll(async () => { - // Warm the (mocked) notificationService module: notifyInvite does a - // fire-and-forget dynamic import of it, and a cold load can otherwise race - // the worker teardown ("Cannot load ... after the environment was torn down"). - await import('../../../src/services/notificationService'); +const svc = 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()), budgetSvc, new RealtimeService()), + daysSvc, + new PermissionsService(dbs()), + budgetSvc, + new CollabService(dbs(), new PermissionsService(dbs()), new RealtimeService()), + new VacayService(dbs(), new RealtimeService()), + new RealtimeService(), +); + +beforeAll(() => { + createTables(testDb); + runMigrations(testDb); }); -describe('TripsService (wrapper delegation + bundle/copy/notify helpers)', () => { - it('delegates the simple wrappers to tripService', async () => { - const s = svc(); - s.list(1, 0); expect(tripSvc.listTrips).toHaveBeenCalledWith(1, 0); - s.create(1, { title: 'T' } as never); expect(tripSvc.createTrip).toHaveBeenCalledWith(1, { title: 'T' }); - s.get('9', 1); expect(tripSvc.getTrip).toHaveBeenCalledWith('9', 1); - s.getRaw('9'); expect(tripSvc.getTripRaw).toHaveBeenCalledWith('9'); - s.getOwner('9'); expect(tripSvc.getTripOwner).toHaveBeenCalledWith('9'); - // update() first re-anchors the budget to any incoming currency, so it is async now. - await s.update('9', 1, {} as never, 'user'); expect(tripSvc.updateTrip).toHaveBeenCalledWith('9', 1, {}, 'user'); - expect(rebaseTripCurrency).toHaveBeenCalledWith('9', undefined); - s.remove('9', 1, 'user'); expect(tripSvc.deleteTrip).toHaveBeenCalledWith('9', 1, 'user'); - s.deleteOldCover('/old.jpg'); expect(tripSvc.deleteOldCover).toHaveBeenCalledWith('/old.jpg'); - s.updateCoverImage('9', '/n.jpg'); expect(tripSvc.updateCoverImage).toHaveBeenCalledWith('9', '/n.jpg'); - s.copy('9', 1, 'C'); expect(tripSvc.copyTripById).toHaveBeenCalledWith('9', 1, 'C'); - s.listMembers('9', 1); expect(tripSvc.listMembers).toHaveBeenCalledWith('9', 1); - s.addMember('9', 'b@x.y', 1, 1); expect(tripSvc.addMember).toHaveBeenCalledWith('9', 'b@x.y', 1, 1); - s.removeMember('9', 2); expect(tripSvc.removeMember).toHaveBeenCalledWith('9', 2); - s.transferOwnership('9', 2, 1); expect(tripSvc.transferOwnership).toHaveBeenCalledWith('9', 2, 1); - s.createGuest('9', 'Anna', 1); expect(tripSvc.createGuest).toHaveBeenCalledWith('9', 'Anna', 1); - s.renameGuest('9', 7, 'Bob'); expect(tripSvc.renameGuest).toHaveBeenCalledWith('9', 7, 'Bob'); - s.deleteGuest('9', 7); expect(tripSvc.deleteGuest).toHaveBeenCalledWith('9', 7); - s.exportICS('9'); expect(tripSvc.exportICS).toHaveBeenCalledWith('9'); +beforeEach(() => { + resetTestDb(testDb); +}); + +afterAll(() => { + testDb.close(); +}); + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +function getDays(tripId: number) { + return testDb.prepare('SELECT * FROM days WHERE trip_id = ? ORDER BY day_number').all(tripId) as { + id: number; trip_id: number; day_number: number; date: string | null; + }[]; +} + +function getAssignments(dayId: number) { + return testDb.prepare('SELECT * FROM day_assignments WHERE day_id = ?').all(dayId) as { id: number; day_id: number }[]; +} + +function getNotes(dayId: number) { + return testDb.prepare('SELECT * FROM day_notes WHERE day_id = ?').all(dayId) as { id: number; day_id: number }[]; +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe('generateDays', () => { + it('TRIP-SVC-010: full range shift preserves day assignments and notes positionally', () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id, { start_date: '2025-06-01', end_date: '2025-06-05' }); + const daysBefore = getDays(trip.id); + expect(daysBefore).toHaveLength(5); + + const place = createPlace(testDb, trip.id); + const assignment = createDayAssignment(testDb, daysBefore[0].id, place.id); + const note = createDayNote(testDb, daysBefore[1].id, trip.id, { text: 'packed' }); + + // Shift forward 9 days — zero overlap with original dates + svc.generateDays(trip.id, '2025-06-10', '2025-06-14'); + + const daysAfter = getDays(trip.id); + expect(daysAfter).toHaveLength(5); + expect(daysAfter.map(d => d.date)).toEqual([ + '2025-06-10', '2025-06-11', '2025-06-12', '2025-06-13', '2025-06-14', + ]); + + // day_number 1 (formerly June 1) now has date June 10 — assignment still attached + const day1 = daysAfter[0]; + const day2 = daysAfter[1]; + expect(getAssignments(day1.id)).toHaveLength(1); + expect(getAssignments(day1.id)[0].id).toBe(assignment.id); + expect(getNotes(day2.id)).toHaveLength(1); + expect(getNotes(day2.id)[0].id).toBe(note.id); }); + it('TRIP-SVC-011: shrinking range deletes overflow days and their assignments (issue #909)', () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id, { start_date: '2025-07-01', end_date: '2025-07-05' }); + const daysBefore = getDays(trip.id); + expect(daysBefore).toHaveLength(5); + + const place = createPlace(testDb, trip.id); + createDayAssignment(testDb, daysBefore[3].id, place.id); + createDayAssignment(testDb, daysBefore[4].id, place.id); + + // Shrink from 5 to 3 days — surplus days and their content are removed + svc.generateDays(trip.id, '2025-07-01', '2025-07-03'); + + const daysAfter = getDays(trip.id); + expect(daysAfter).toHaveLength(3); + expect(daysAfter.map(d => d.date)).toEqual(['2025-07-01', '2025-07-02', '2025-07-03']); + }); + + it('TRIP-SVC-016: shrinking range deletes empty overflow days (issue #909)', () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id, { start_date: '2025-07-01', end_date: '2025-07-07' }); + expect(getDays(trip.id)).toHaveLength(7); + + // Shrink 7 → 5; days 6 and 7 have no content + svc.generateDays(trip.id, '2025-07-01', '2025-07-05'); + + const daysAfter = getDays(trip.id); + expect(daysAfter).toHaveLength(5); + expect(daysAfter.map(d => d.date)).toEqual([ + '2025-07-01', '2025-07-02', '2025-07-03', '2025-07-04', '2025-07-05', + ]); + }); + + it('TRIP-SVC-012: growing range keeps existing day content and appends new empty days', () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id, { start_date: '2025-08-01', end_date: '2025-08-03' }); + const daysBefore = getDays(trip.id); + expect(daysBefore).toHaveLength(3); + + const place = createPlace(testDb, trip.id); + const assignment = createDayAssignment(testDb, daysBefore[0].id, place.id); + + // Grow to 5 days + svc.generateDays(trip.id, '2025-08-01', '2025-08-05'); + + const daysAfter = getDays(trip.id); + expect(daysAfter).toHaveLength(5); + expect(daysAfter.map(d => d.date)).toEqual([ + '2025-08-01', '2025-08-02', '2025-08-03', '2025-08-04', '2025-08-05', + ]); + + // Existing day 1 retains its assignment + expect(getAssignments(daysAfter[0].id)).toHaveLength(1); + expect(getAssignments(daysAfter[0].id)[0].id).toBe(assignment.id); + + // New days 4 and 5 are empty + expect(getAssignments(daysAfter[3].id)).toHaveLength(0); + expect(getAssignments(daysAfter[4].id)).toHaveLength(0); + }); + + it('TRIP-SVC-013: clearing dates converts all days to dateless without destroying assignments', () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id, { start_date: '2025-09-01', end_date: '2025-09-04' }); + const daysBefore = getDays(trip.id); + expect(daysBefore).toHaveLength(4); + + const place = createPlace(testDb, trip.id); + const assignment = createDayAssignment(testDb, daysBefore[1].id, place.id); + + // Clear both dates + svc.generateDays(trip.id, null, null); + + const daysAfter = getDays(trip.id); + expect(daysAfter).toHaveLength(4); + expect(daysAfter.every(d => d.date === null)).toBe(true); + + // The assignment on the former day 2 still exists + const formerDay2 = daysAfter.find(d => d.id === daysBefore[1].id); + expect(formerDay2).toBeDefined(); + expect(getAssignments(formerDay2!.id)).toHaveLength(1); + expect(getAssignments(formerDay2!.id)[0].id).toBe(assignment.id); + }); + + it('TRIP-SVC-014: partial overlap shift remaps by position (day 1→3 kept, 4-5 overflow)', () => { + // Original: Jun 1-5. New: Jun 3-7 (overlap on Jun 3-5, but we map by position) + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id, { start_date: '2025-10-01', end_date: '2025-10-05' }); + const daysBefore = getDays(trip.id); + const place = createPlace(testDb, trip.id); + // Assign to each of the 5 days + for (const day of daysBefore) createDayAssignment(testDb, day.id, place.id); + + // Shift forward 2 days (partial overlap with original range) + svc.generateDays(trip.id, '2025-10-03', '2025-10-07'); + + const daysAfter = getDays(trip.id); + expect(daysAfter).toHaveLength(5); + expect(daysAfter.map(d => d.date)).toEqual([ + '2025-10-03', '2025-10-04', '2025-10-05', '2025-10-06', '2025-10-07', + ]); + + // All 5 assignments survive + for (const day of daysAfter) { + expect(getAssignments(day.id)).toHaveLength(1); + } + }); + + it('TRIP-SVC-015: growing into dateless days reuses them; leftover dateless renumber without UNIQUE collision', () => { + // 3 dated days + 2 pre-existing dateless days. Resize to 4 dated days. + // Main loop: dated[0..2] → positions 1-3, dateless[0] → position 4 (consumed). + // Unused dateless: dateless[1] should land at position 5, NOT 4 (collision bug). + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id, { start_date: '2025-11-01', end_date: '2025-11-03' }); + + // Insert 2 dateless days directly + const daysBefore = getDays(trip.id); + testDb.prepare('INSERT INTO days (trip_id, day_number, date) VALUES (?, ?, NULL)').run(trip.id, 4); + testDb.prepare('INSERT INTO days (trip_id, day_number, date) VALUES (?, ?, NULL)').run(trip.id, 5); + + const allDays = getDays(trip.id); + expect(allDays).toHaveLength(5); + + const place = createPlace(testDb, trip.id); + // Put an assignment on the second dateless day (day_number=5) — it should survive + const assignment = createDayAssignment(testDb, allDays[4].id, place.id); + + // Grow from 3 to 4 dated days — consumes dateless[0], leaves dateless[1] unused + // This is the scenario that triggered the UNIQUE collision bug + svc.generateDays(trip.id, '2025-11-01', '2025-11-04'); + + const daysAfter = getDays(trip.id); + expect(daysAfter).toHaveLength(5); + + const dated = daysAfter.filter(d => d.date !== null); + const dateless = daysAfter.filter(d => d.date === null); + expect(dated).toHaveLength(4); + expect(dateless).toHaveLength(1); + + // The remaining dateless day still has its assignment + expect(getAssignments(dateless[0].id)).toHaveLength(1); + expect(getAssignments(dateless[0].id)[0].id).toBe(assignment.id); + + // All day_numbers are unique 1..5 + const nums = daysAfter.map(d => d.day_number).sort((a, b) => a - b); + expect(nums).toEqual([1, 2, 3, 4, 5]); + }); + + it('TRIP-SVC-017: switching a dateless trip to a shorter dated range drops empty leftover days but keeps ones with content (#1083)', () => { + const { user } = createUser(testDb); + // A 7-day trip, then cleared to dateless placeholders (day_count = 7). + const trip = createTrip(testDb, user.id, { start_date: '2025-12-01', end_date: '2025-12-07' }); + svc.generateDays(trip.id, null, null); + const dateless = getDays(trip.id); + expect(dateless).toHaveLength(7); + expect(dateless.every(d => d.date === null)).toBe(true); + + // Give the LAST dateless day real content so it must be preserved. + const place = createPlace(testDb, trip.id); + const assignment = createDayAssignment(testDb, dateless[6].id, place.id); + + // Now set an explicit 2-day range. The first two dateless days are reused for + // the dates; the four empty leftovers must be removed, the one with content kept. + svc.generateDays(trip.id, '2026-01-10', '2026-01-11'); + + const daysAfter = getDays(trip.id); + const dated = daysAfter.filter(d => d.date !== null); + const stillDateless = daysAfter.filter(d => d.date === null); + expect(dated.map(d => d.date)).toEqual(['2026-01-10', '2026-01-11']); + // day_count is COUNT(*) FROM days: 2 dated + 1 content-bearing dateless = 3 (not the stale 7) + expect(daysAfter).toHaveLength(3); + expect(stillDateless).toHaveLength(1); + expect(getAssignments(stillDateless[0].id)[0].id).toBe(assignment.id); + }); +}); + +describe('exportICS', () => { + it('TRIP-SVC-001: returns VCALENDAR wrapper', () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id, { + title: 'My Vacation', + start_date: '2025-06-01', + end_date: '2025-06-07', + }); + + const { ics } = svc.exportICS(trip.id); + + expect(ics).toContain('BEGIN:VCALENDAR'); + expect(ics).toContain('END:VCALENDAR'); + }); + + it('TRIP-SVC-002: trip with start_date + end_date includes all-day VEVENT', () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id, { + title: 'Summer Holiday', + start_date: '2025-06-01', + end_date: '2025-06-07', + }); + + const { ics } = svc.exportICS(trip.id); + + expect(ics).toContain('DTSTART;VALUE=DATE:20250601'); + // DTEND is exclusive — the day *after* the last day, or the trip loses a day. + expect(ics).toContain('DTEND;VALUE=DATE:20250608'); + expect(ics).toContain('SUMMARY:Summer Holiday'); + }); + + describe('#1453 all-day DTEND is timezone-independent', () => { + const originalTz = process.env.TZ; + + afterAll(() => { + process.env.TZ = originalTz; + }); + + // The old code did `new Date(date + 'T00:00:00')` — no Z, so parsed as *server-local* + // midnight — then setDate(+1) and .toISOString(). East of Greenwich that round-trip + // lands a day early, and since DTEND is exclusive the trip's last day was dropped. + // Only invisible in CI because containers default to TZ=UTC. + for (const tz of ['Europe/Berlin', 'Asia/Tokyo', 'Pacific/Kiritimati', 'America/New_York', 'UTC']) { + it(`TRIP-SVC-002b: DTEND is the day after the last day under TZ=${tz}`, () => { + process.env.TZ = tz; + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id, { + title: 'TZ Trip', + start_date: '2026-03-28', + end_date: '2026-03-30', + }); + + const { ics } = svc.exportICS(trip.id); + + expect(ics).toContain('DTSTART;VALUE=DATE:20260328'); + expect(ics).toContain('DTEND;VALUE=DATE:20260331'); + }); + } + + it('TRIP-SVC-002c: a per-day all-day summary event has the same exclusive DTEND', () => { + process.env.TZ = 'Asia/Tokyo'; + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id, { title: 'Day Note Trip' }); + const day = createDay(testDb, trip.id, { date: '2026-03-30', day_number: 1 }); + createDayNote(testDb, day.id, trip.id, { text: 'Pack the bags' }); + + const { ics } = svc.exportICS(trip.id); + + expect(ics).toContain('DTSTART;VALUE=DATE:20260330'); + expect(ics).toContain('DTEND;VALUE=DATE:20260331'); + }); + }); + + it('TRIP-SVC-003: reservation with full datetime (includes T) → DTSTART without VALUE=DATE', () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id, { title: 'Paris Trip' }); + const reservation = createReservation(testDb, trip.id, { + title: 'Morning Flight', + type: 'flight', + }); + testDb + .prepare('UPDATE reservations SET reservation_time=? WHERE id=?') + .run('2025-06-02T09:00', reservation.id); + + const { ics } = svc.exportICS(trip.id); + + expect(ics).toContain('DTSTART:20250602T090000'); + expect(ics).not.toContain('DTSTART;VALUE=DATE'); + }); + + it('TRIP-SVC-004: reservation with date-only → DTSTART;VALUE=DATE', () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id, { title: 'Paris Trip' }); + const reservation = createReservation(testDb, trip.id, { + title: 'Hotel Check-in', + type: 'hotel', + }); + testDb + .prepare('UPDATE reservations SET reservation_time=? WHERE id=?') + .run('2025-06-02', reservation.id); + + const { ics } = svc.exportICS(trip.id); + + expect(ics).toContain('DTSTART;VALUE=DATE:20250602'); + }); + + it('TRIP-SVC-005: reservation metadata with flight info appears in DESCRIPTION', () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id, { title: 'Paris Trip' }); + const reservation = createReservation(testDb, trip.id, { + title: 'CDG to JFK', + type: 'flight', + }); + testDb + .prepare('UPDATE reservations SET reservation_time=?, metadata=? WHERE id=?') + .run( + '2025-06-02T09:00', + JSON.stringify({ + airline: 'Air Test', + flight_number: 'AT100', + departure_airport: 'CDG', + arrival_airport: 'JFK', + }), + reservation.id + ); + + const { ics } = svc.exportICS(trip.id); + + expect(ics).toContain('Airline: Air Test'); + expect(ics).toContain('Flight: AT100'); + }); + + it('TRIP-SVC-006: special characters in title are escaped', () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id, { title: 'Trip; First, Best' }); + + const { ics } = svc.exportICS(trip.id); + + expect(ics).toContain('Trip\\; First\\, Best'); + }); + + it('TRIP-SVC-007: throws NotFoundError for non-existent trip', () => { + expect(() => svc.exportICS(99999)).toThrow(); + }); + + it('TRIP-SVC-008: returns a filename derived from trip title', () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id, { title: 'My Trip 2025' }); + + const { filename } = svc.exportICS(trip.id); + + expect(filename).toMatch(/My.Trip.2025\.ics/); + }); + + it('TRIP-SVC-009: reservation with end time includes DTEND', () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id, { title: 'Paris Trip' }); + const reservation = createReservation(testDb, trip.id, { + title: 'Afternoon Tour', + type: 'activity', + }); + testDb + .prepare('UPDATE reservations SET reservation_time=?, reservation_end_time=? WHERE id=?') + .run('2025-06-02T14:00', '2025-06-02T16:00', reservation.id); + + const { ics } = svc.exportICS(trip.id); + + expect(ics).toContain('DTEND:20250602T160000'); + }); + + it('TRIP-SVC-024: flight with endpoint times but no reservation_time is included', () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id, { title: 'Paris Trip' }); + const reservation = createReservation(testDb, trip.id, { + title: 'CDG → JFK', + type: 'flight', + }); + // Confirmed flights store times per endpoint, never as reservation_time. + testDb.prepare('UPDATE reservations SET reservation_time=NULL, reservation_end_time=NULL WHERE id=?').run(reservation.id); + const insertEp = testDb.prepare( + 'INSERT INTO reservation_endpoints (reservation_id, role, sequence, name, code, lat, lng, timezone, local_time, local_date) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)' + ); + insertEp.run(reservation.id, 'from', 0, 'Paris CDG', 'CDG', 49.0, 2.5, 'Europe/Paris', '09:00', '2025-06-02'); + insertEp.run(reservation.id, 'to', 1, 'New York JFK', 'JFK', 40.6, -73.8, 'America/New_York', '12:00', '2025-06-02'); + + const { ics } = svc.exportICS(trip.id); + + expect(ics).toContain('SUMMARY:CDG → JFK'); + // Departure endpoint zone drives DTSTART, arrival zone drives DTEND, so the + // subscriber sees TREK's zones instead of their own (#1453). + expect(ics).toContain('DTSTART;TZID=Europe/Paris:20250602T090000'); + expect(ics).toContain('DTEND;TZID=America/New_York:20250602T120000'); + expect(ics).not.toContain('DTSTART:20250602T090000'); + // Each referenced zone gets a VTIMEZONE definition. + expect(ics).toContain('BEGIN:VTIMEZONE\r\nTZID:Europe/Paris'); + expect(ics).toContain('BEGIN:VTIMEZONE\r\nTZID:America/New_York'); + expect(ics).toContain('Route: CDG → JFK'); + }); + + it('TRIP-SVC-024b: an invalid endpoint timezone degrades to floating time instead of crashing the export', () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id, { title: 'Bad TZ Trip' }); + const reservation = createReservation(testDb, trip.id, { title: 'CDG → JFK', type: 'flight' }); + testDb.prepare('UPDATE reservations SET reservation_time=NULL, reservation_end_time=NULL WHERE id=?').run(reservation.id); + const insertEp = testDb.prepare( + 'INSERT INTO reservation_endpoints (reservation_id, role, sequence, name, code, lat, lng, timezone, local_time, local_date) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)' + ); + // A stored/plugin-written timezone can be any string; it must never reach Intl. + // The bogus zone takes precedence over the coordinates (first.timezone || resolveZone). + insertEp.run(reservation.id, 'from', 0, 'Paris CDG', 'CDG', 49.0, 2.5, 'Not/AZone', '09:00', '2025-06-02'); + insertEp.run(reservation.id, 'to', 1, 'New York JFK', 'JFK', 40.6, -73.8, 'garbage', '12:00', '2025-06-02'); + + let ics = ''; + expect(() => { ics = svc.exportICS(trip.id).ics; }).not.toThrow(); + // Falls back to a floating local time (no TZID) and never emits a bogus VTIMEZONE. + expect(ics).toContain('DTSTART:20250602T090000'); + expect(ics).not.toContain('TZID=Not/AZone'); + expect(ics).not.toContain('garbage'); + }); + + it('TRIP-SVC-025: flight endpoint with no local_date is skipped (relative Day-N trips)', () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id, { title: 'Relative Trip' }); + const reservation = createReservation(testDb, trip.id, { + title: 'Timeless Flight', + type: 'flight', + }); + testDb.prepare('UPDATE reservations SET reservation_time=NULL WHERE id=?').run(reservation.id); + testDb.prepare( + 'INSERT INTO reservation_endpoints (reservation_id, role, sequence, name, code, lat, lng, timezone, local_time, local_date) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)' + ).run(reservation.id, 'from', 0, 'Origin', 'AAA', 1.0, 1.0, null, '09:00', null); + + const { ics } = svc.exportICS(trip.id); + + expect(ics).not.toContain('SUMMARY:Timeless Flight'); + }); + + it('TRIP-SVC-026: timed assignment gets a TZID derived from the place coordinates', () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id, { title: 'Tokyo Trip' }); + const day = createDay(testDb, trip.id, { date: '2025-06-02' }); + // Tokyo coordinates → Asia/Tokyo via tz-lookup. + const place = createPlace(testDb, trip.id, { name: 'Senso-ji', lat: 35.7148, lng: 139.7967 }); + const assignment = createDayAssignment(testDb, day.id, place.id); + testDb + .prepare('UPDATE day_assignments SET assignment_time=? WHERE id=?') + .run('09:00', assignment.id); + + const { ics } = svc.exportICS(trip.id); + + expect(ics).toContain('DTSTART;TZID=Asia/Tokyo:20250602T090000'); + expect(ics).toContain('BEGIN:VTIMEZONE\r\nTZID:Asia/Tokyo'); + expect(ics).not.toContain('DTSTART:20250602T090000'); + }); +}); + +// ── deleteOldCover — path containment ────────────────────────────────────────── + +describe('deleteOldCover', () => { + it('TRIP-SVC-COVER-001: never unlinks outside uploads/covers for a crafted cover_image', () => { + const existsSpy = vi.spyOn(fs, 'existsSync').mockReturnValue(true); + const unlinkSpy = vi.spyOn(fs, 'unlinkSync').mockImplementation(() => {}); + try { + // Attacker-controlled values aimed at auth-gated sibling upload dirs. + svc.deleteOldCover('/uploads/files/victim.pdf'); + svc.deleteOldCover('/uploads/covers/../files/secret.pdf'); + svc.deleteOldCover('/uploads/avatars/someone.png'); + + for (const call of unlinkSpy.mock.calls) { + const target = String(call[0]); + expect(target).toMatch(/[\\/]uploads[\\/]covers[\\/]/); // stays in covers + expect(target).not.toMatch(/[\\/]files[\\/]/); + expect(target).not.toMatch(/[\\/]avatars[\\/]/); + } + } finally { + existsSpy.mockRestore(); + unlinkSpy.mockRestore(); + } + }); + + it('TRIP-SVC-COVER-002: deletes a legitimate cover file', () => { + const existsSpy = vi.spyOn(fs, 'existsSync').mockReturnValue(true); + const unlinkSpy = vi.spyOn(fs, 'unlinkSync').mockImplementation(() => {}); + try { + svc.deleteOldCover('/uploads/covers/abc123.jpg'); + expect(unlinkSpy).toHaveBeenCalledTimes(1); + expect(String(unlinkSpy.mock.calls[0][0])).toMatch(/[\\/]covers[\\/]abc123\.jpg$/); + } finally { + existsSpy.mockRestore(); + unlinkSpy.mockRestore(); + } + }); +}); + +describe('resyncReservationDays (#1288)', () => { + const dayFor = (tripId: number, date: string) => + (testDb.prepare('SELECT id FROM days WHERE trip_id = ? AND date = ?').get(tripId, date) as { id: number }).id; + const insertDatedReservation = (tripId: number, dayId: number, time: string) => + Number(testDb.prepare( + "INSERT INTO reservations (trip_id, day_id, title, reservation_time, type, status) VALUES (?, ?, 'Dinner', ?, 'restaurant', 'pending')", + ).run(tripId, dayId, time).lastInsertRowid); + + it('TRIP-SVC-018: changing the start date re-anchors a dated reservation to the day matching its time', () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id, { start_date: '2025-06-01', end_date: '2025-06-05' }); + const resId = insertDatedReservation(trip.id, dayFor(trip.id, '2025-06-02'), '2025-06-02T19:00:00'); + // Shift the whole range one day forward (days become 2025-06-02..06). + svc.updateTrip(trip.id, user.id, { start_date: '2025-06-02', end_date: '2025-06-06' }, 'user'); + const res = testDb.prepare('SELECT day_id FROM reservations WHERE id = ?').get(resId) as { day_id: number }; + // The booking stays on its absolute date (2025-06-02) instead of shifting with its old day row. + expect(res.day_id).toBe(dayFor(trip.id, '2025-06-02')); + }); + + it('TRIP-SVC-019: a reservation whose date falls outside the new range keeps its day_id (not nulled)', () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id, { start_date: '2025-06-01', end_date: '2025-06-05' }); + const origDayId = dayFor(trip.id, '2025-06-02'); + const resId = insertDatedReservation(trip.id, origDayId, '2025-06-02T19:00:00'); + // Shift far forward so 2025-06-02 is no longer covered by any day. + svc.updateTrip(trip.id, user.id, { start_date: '2025-06-10', end_date: '2025-06-14' }, 'user'); + const res = testDb.prepare('SELECT day_id FROM reservations WHERE id = ?').get(resId) as { day_id: number }; + expect(res.day_id).toBe(origDayId); + }); +}); + +describe('resyncAccommodationDays (#1288)', () => { + const dayFor = (tripId: number, date: string) => + (testDb.prepare('SELECT id FROM days WHERE trip_id = ? AND date = ?').get(tripId, date) as { id: number }).id; + + const insertAccommodation = (tripId: number, startDayId: number, endDayId: number) => { + const place = createPlace(testDb, tripId, { name: 'Grand Hotel' }); + const acc = createAccommodation(tripId, { + place_id: place.id, start_day_id: startDayId, end_day_id: endDayId, + }) as { id: number }; + const linkedRes = testDb.prepare( + 'SELECT id FROM reservations WHERE accommodation_id = ?', + ).get(acc.id) as { id: number }; + return { accId: acc.id, linkedResId: linkedRes.id }; + }; + + const getAcc = (id: number) => + testDb.prepare('SELECT start_day_id, end_day_id FROM day_accommodations WHERE id = ?').get(id) as + { start_day_id: number; end_day_id: number }; + const getRes = (id: number) => + testDb.prepare('SELECT day_id, reservation_time FROM reservations WHERE id = ?').get(id) as + { day_id: number | null; reservation_time: string | null }; + + it('TRIP-SVC-035: extending the start keeps an accommodation on its absolute dates', () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id, { start_date: '2025-06-10', end_date: '2025-06-14' }); + const { accId, linkedResId } = insertAccommodation(trip.id, dayFor(trip.id, '2025-06-11'), dayFor(trip.id, '2025-06-13')); + // Add a day at the start: days re-date positionally (old 06-11 row becomes 06-10, …). + svc.updateTrip(trip.id, user.id, { start_date: '2025-06-09', end_date: '2025-06-14' }, 'user'); + const acc = getAcc(accId); + expect(acc.start_day_id).toBe(dayFor(trip.id, '2025-06-11')); + expect(acc.end_day_id).toBe(dayFor(trip.id, '2025-06-13')); + const res = getRes(linkedResId); + expect(res.day_id).toBe(acc.start_day_id); + expect(res.reservation_time?.slice(0, 10)).toBe('2025-06-11'); + }); + + it('TRIP-SVC-036: moving the whole trip out of the old range keeps the accommodation glued to its days', () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id, { start_date: '2025-06-01', end_date: '2025-06-05' }); + const startDayId = dayFor(trip.id, '2025-06-02'); + const endDayId = dayFor(trip.id, '2025-06-03'); + const { accId, linkedResId } = insertAccommodation(trip.id, startDayId, endDayId); + svc.updateTrip(trip.id, user.id, { start_date: '2025-07-01', end_date: '2025-07-05' }, 'user'); + const acc = getAcc(accId); + expect(acc.start_day_id).toBe(startDayId); + expect(acc.end_day_id).toBe(endDayId); + // The linked reservation follows the (re-dated) start day instead of keeping a stale date snapshot. + const res = getRes(linkedResId); + expect(res.day_id).toBe(startDayId); + expect(res.reservation_time?.slice(0, 10)).toBe('2025-07-02'); + }); + + it("TRIP-SVC-038: date_shift_mode 'shift_all' glues bookings to their days and restamps their times", () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id, { start_date: '2025-06-01', end_date: '2025-06-05' }); + const origDayId = dayFor(trip.id, '2025-06-02'); + const resId = Number(testDb.prepare( + "INSERT INTO reservations (trip_id, day_id, title, reservation_time, type, status) VALUES (?, ?, 'Dinner', '2025-06-02T19:00:00', 'restaurant', 'pending')", + ).run(trip.id, origDayId).lastInsertRowid); + const { accId } = insertAccommodation(trip.id, origDayId, dayFor(trip.id, '2025-06-03')); + svc.updateTrip(trip.id, user.id, { start_date: '2025-06-03', end_date: '2025-06-07', date_shift_mode: 'shift_all' }, 'user'); + // The booking stays on its day row (now 2025-06-04) and its time follows. + const res = testDb.prepare('SELECT day_id, reservation_time FROM reservations WHERE id = ?').get(resId) as + { day_id: number; reservation_time: string }; + expect(res.day_id).toBe(origDayId); + expect(res.reservation_time).toBe('2025-06-04T19:00:00'); + // The accommodation stays glued to its (re-dated) day rows too. + const acc = getAcc(accId); + expect(acc.start_day_id).toBe(origDayId); + }); + + it('TRIP-SVC-037: a dated hotel reservation without a linked accommodation is re-anchored like other bookings', () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id, { start_date: '2025-06-01', end_date: '2025-06-05' }); + const resId = Number(testDb.prepare( + "INSERT INTO reservations (trip_id, day_id, title, reservation_time, type, status) VALUES (?, ?, 'Imported hotel', ?, 'hotel', 'pending')", + ).run(trip.id, dayFor(trip.id, '2025-06-02'), '2025-06-02T15:00:00').lastInsertRowid); + svc.updateTrip(trip.id, user.id, { start_date: '2025-06-02', end_date: '2025-06-06' }, 'user'); + const res = testDb.prepare('SELECT day_id FROM reservations WHERE id = ?').get(resId) as { day_id: number }; + expect(res.day_id).toBe(dayFor(trip.id, '2025-06-02')); + }); +}); + +describe('transferOwnership (#973)', () => { + it('TRIP-SVC-020: hands the trip to a member and demotes the former owner to a member', () => { + const { user: owner } = createUser(testDb); + const { user: member } = createUser(testDb); + const trip = createTrip(testDb, owner.id); + addTripMember(testDb, trip.id, member.id); + + const result = svc.transferOwnership(trip.id, member.id, owner.id); + expect(result.toEmail).toBe(member.email); + + const updated = testDb.prepare('SELECT user_id FROM trips WHERE id = ?').get(trip.id) as { user_id: number }; + expect(updated.user_id).toBe(member.id); + + // New owner no longer sits in trip_members, former owner now does. + const memberIds = (testDb.prepare('SELECT user_id FROM trip_members WHERE trip_id = ?').all(trip.id) as { user_id: number }[]).map(r => r.user_id); + expect(memberIds).toContain(owner.id); + expect(memberIds).not.toContain(member.id); + }); + + it('TRIP-SVC-021: rejects a transfer from a non-owner', () => { + const { user: owner } = createUser(testDb); + const { user: member } = createUser(testDb); + const trip = createTrip(testDb, owner.id); + addTripMember(testDb, trip.id, member.id); + // member (not the owner) attempts the transfer + expect(() => svc.transferOwnership(trip.id, member.id, member.id)).toThrow(); + }); + + it('TRIP-SVC-022: rejects a transfer to someone who is not a member', () => { + const { user: owner } = createUser(testDb); + const { user: stranger } = createUser(testDb); + const trip = createTrip(testDb, owner.id); + expect(() => svc.transferOwnership(trip.id, stranger.id, owner.id)).toThrow('New owner must be a trip member'); + }); + + it('TRIP-SVC-023: rejects transferring to yourself', () => { + const { user: owner } = createUser(testDb); + const trip = createTrip(testDb, owner.id); + expect(() => svc.transferOwnership(trip.id, owner.id, owner.id)).toThrow('You already own this trip'); + }); +}); + +describe('guest members (#1362)', () => { + it('TRIP-SVC-030: createGuest adds a credential-less user joined into the trip', () => { + const { user: owner } = createUser(testDb); + const trip = createTrip(testDb, owner.id); + + const { member } = svc.createGuest(trip.id, ' Anna ', owner.id); + expect(member.username).toBe('Anna'); + expect(member.is_guest).toBe(true); + + const row = testDb.prepare('SELECT username, email, password_hash, is_guest, role FROM users WHERE id = ?').get(member.id) as any; + expect(row.is_guest).toBe(1); + expect(row.password_hash).toBe(''); + expect(row.email).toMatch(/@guests\.invalid$/); + expect(row.role).toBe('user'); + + // Joined as a trip member. + const m = testDb.prepare('SELECT id FROM trip_members WHERE trip_id = ? AND user_id = ?').get(trip.id, member.id); + expect(m).toBeTruthy(); + + // Surfaces in listMembers with is_guest=true and the typed display name. + const { members } = svc.listMembers(trip.id, owner.id) as any; + const guest = members.find((x: any) => x.id === member.id); + expect(guest.username).toBe('Anna'); + expect(guest.is_guest).toBe(true); + }); + + it('TRIP-SVC-031: the same guest name is allowed, not suffixed (#1446)', () => { + const { user: owner } = createUser(testDb); + const trip = createTrip(testDb, owner.id); + const a = svc.createGuest(trip.id, 'Sam', owner.id); + const b = svc.createGuest(trip.id, 'Sam', owner.id); + // both keep the plain display name; only the internal (uuid) username differs + expect(a.member.username).toBe('Sam'); + expect(b.member.username).toBe('Sam'); + expect(b.member.id).not.toBe(a.member.id); + const usernames = testDb.prepare('SELECT username FROM users WHERE id IN (?, ?)').all(a.member.id, b.member.id) as { username: string }[]; + expect(usernames[0].username).not.toBe(usernames[1].username); + }); + + it('TRIP-SVC-032: renameGuest updates the display name (trip-scoped, guest-only)', () => { + const { user: owner } = createUser(testDb); + const { user: other } = createUser(testDb); + const otherTrip = createTrip(testDb, other.id); + const trip = createTrip(testDb, owner.id); + const { member } = svc.createGuest(trip.id, 'Bob', owner.id); + + expect(svc.renameGuest(trip.id, member.id, 'Robert')).toBe(true); + expect((testDb.prepare('SELECT display_name FROM users WHERE id = ?').get(member.id) as any).display_name).toBe('Robert'); + + // A real user cannot be renamed through the guest path… + expect(svc.renameGuest(trip.id, owner.id, 'Hacked')).toBe(false); + // …and a guest cannot be renamed from a different trip. + expect(svc.renameGuest(otherTrip.id, member.id, 'Nope')).toBe(false); + }); + + it('TRIP-SVC-033: deleteGuest removes the user (cascading membership), guest-only + trip-scoped', () => { + const { user: owner } = createUser(testDb); + const trip = createTrip(testDb, owner.id); + const { member } = svc.createGuest(trip.id, 'Carol', owner.id); + + // Real members are not deletable via the guest path. + expect(svc.deleteGuest(trip.id, owner.id)).toBe(false); + + expect(svc.deleteGuest(trip.id, member.id)).toBe(true); + expect(testDb.prepare('SELECT id FROM users WHERE id = ?').get(member.id)).toBeUndefined(); + expect(testDb.prepare('SELECT id FROM trip_members WHERE user_id = ?').get(member.id)).toBeUndefined(); + }); + + it('TRIP-SVC-034: a guest is never invitable (addMember) nor a transfer target', () => { + const { user: owner } = createUser(testDb); + const trip = createTrip(testDb, owner.id); + const { member } = svc.createGuest(trip.id, 'Dora', owner.id); + + // The synthetic username/email must not resolve through the invite box. + expect(() => svc.addMember(trip.id, 'Dora', owner.id, owner.id)).toThrow('User not found'); + // Ownership can never be handed to a guest. + expect(() => svc.transferOwnership(trip.id, member.id, owner.id)).toThrow('Cannot transfer ownership to a guest'); + }); +}); + +// ── trips.bridge delegation (coverage gate) ─────────────────────────────────── + +describe('trips.bridge delegation', () => { + it('TRIP-SVC-040: getTripOwner delegates 1:1', () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + expect(getTripOwner(trip.id)).toEqual({ user_id: user.id }); + expect(getTripOwner(99999)).toBeUndefined(); + }); + + it('TRIP-SVC-041: listMembers delegates 1:1', () => { + const { user: owner } = createUser(testDb); + const { user: member } = createUser(testDb); + const trip = createTrip(testDb, owner.id); + addTripMember(testDb, trip.id, member.id); + const result = bridgeListMembers(trip.id, owner.id); + expect(result.owner.id).toBe(owner.id); + expect(result.members.map(m => m.id)).toEqual([member.id]); + }); +}); + +// ── Folded CRUD SQL (summary / list / create / delete / copy) ───────────────── + +describe('folded trip CRUD', () => { + it('TRIP-SVC-042: getTripSummary aggregates members, days, budget, packing and reservations', () => { + const { user: owner } = createUser(testDb); + const { user: member } = createUser(testDb); + const trip = createTrip(testDb, owner.id, { start_date: '2025-06-01', end_date: '2025-06-02' }); + addTripMember(testDb, trip.id, member.id); + testDb.prepare("INSERT INTO budget_items (trip_id, category, name, total_price) VALUES (?, 'food', 'Dinner', 40)").run(trip.id); + testDb.prepare("INSERT INTO packing_items (trip_id, name, checked) VALUES (?, 'Socks', 1)").run(trip.id); + + const summary = svc.getTripSummary(trip.id, owner.id)!; + expect(summary).toBeTruthy(); + expect((summary.trip as any).id).toBe(trip.id); + expect(summary.members.owner.id).toBe(owner.id); + expect(summary.members.collaborators.map((m: any) => m.id)).toEqual([member.id]); + expect(summary.days).toHaveLength(2); + expect(summary.budget.item_count).toBe(1); + expect(summary.budget.total).toBe(40); + expect(summary.packing.total).toBe(1); + expect(summary.packing.checked).toBe(1); + expect(summary.reservations).toEqual([]); + expect(summary.collab_notes).toEqual([]); + + // Missing trips return null instead of throwing. + expect(svc.getTripSummary(99999)).toBeNull(); + }); + + it('TRIP-SVC-043: list returns owned + shared trips with is_owner, honoring the archived filter', () => { + const { user: owner } = createUser(testDb); + const { user: other } = createUser(testDb); + const own = createTrip(testDb, owner.id, { title: 'Mine' }); + const shared = createTrip(testDb, other.id, { title: 'Shared' }); + addTripMember(testDb, shared.id, owner.id); + const archived = createTrip(testDb, owner.id, { title: 'Old' }); + testDb.prepare('UPDATE trips SET is_archived = 1 WHERE id = ?').run(archived.id); + + const active = svc.list(owner.id, 0) as any[]; + expect(active.map(t => t.id).sort()).toEqual([own.id, shared.id].sort()); + expect(active.find(t => t.id === own.id).is_owner).toBe(1); + expect(active.find(t => t.id === shared.id).is_owner).toBe(0); + + const all = svc.list(owner.id, null) as any[]; + expect(all.map(t => t.id).sort()).toEqual([own.id, shared.id, archived.id].sort()); + }); + + it('TRIP-SVC-044: create applies || defaults, clamps reminder_days and generates days', () => { + const { user } = createUser(testDb); + const { trip, tripId, reminderDays } = svc.create(user.id, { + title: 'New Trip', start_date: '2025-06-01', end_date: '2025-06-03', reminder_days: 99, + }); + expect(reminderDays).toBe(3); // out-of-range → default 3 + expect((trip as any).currency).toBe('EUR'); // no currency → 'EUR' + expect(getDays(tripId)).toHaveLength(3); + }); + + it('TRIP-SVC-045: remove deletes the trip, cleans skeleton journey entries and detaches filled ones', () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const journeyId = Number(testDb.prepare( + "INSERT INTO journeys (user_id, title, created_at, updated_at) VALUES (?, 'J', 0, 0)", + ).run(user.id).lastInsertRowid); + testDb.prepare( + "INSERT INTO journey_entries (journey_id, source_trip_id, author_id, type, title, entry_date, created_at, updated_at) VALUES (?, ?, ?, 'skeleton', 'S', '2025-06-01', 0, 0)", + ).run(journeyId, trip.id, user.id); + const filledId = Number(testDb.prepare( + "INSERT INTO journey_entries (journey_id, source_trip_id, author_id, type, title, entry_date, created_at, updated_at) VALUES (?, ?, ?, 'story', 'F', '2025-06-01', 0, 0)", + ).run(journeyId, trip.id, user.id).lastInsertRowid); + + const info = svc.remove(trip.id, user.id, 'user'); + expect(info).toMatchObject({ tripId: trip.id, ownerId: user.id, isAdminDelete: false }); + + expect(testDb.prepare('SELECT id FROM trips WHERE id = ?').get(trip.id)).toBeUndefined(); + expect(testDb.prepare("SELECT id FROM journey_entries WHERE type = 'skeleton'").get()).toBeUndefined(); + const filled = testDb.prepare('SELECT source_trip_id FROM journey_entries WHERE id = ?').get(filledId) as any; + expect(filled.source_trip_id).toBeNull(); + + // Missing trips throw the byte-identical error. + expect(() => svc.remove(99999, user.id, 'user')).toThrow('Trip not found'); + }); + + it('TRIP-SVC-046: copy duplicates days/places/assignments and resets packing to unchecked', () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id, { title: 'Origin', start_date: '2025-06-01', end_date: '2025-06-02' }); + const days = getDays(trip.id); + const place = createPlace(testDb, trip.id, { name: 'Louvre' }); + createDayAssignment(testDb, days[0].id, place.id); + testDb.prepare("INSERT INTO packing_items (trip_id, name, checked) VALUES (?, 'Socks', 1)").run(trip.id); + + const newTripId = svc.copy(trip.id, user.id, 'Clone'); + + const copied = testDb.prepare('SELECT title, is_archived FROM trips WHERE id = ?').get(newTripId) as any; + expect(copied.title).toBe('Clone'); + expect(copied.is_archived).toBe(0); + expect(getDays(newTripId)).toHaveLength(2); + const newPlaces = testDb.prepare('SELECT id, name FROM places WHERE trip_id = ?').all(newTripId) as any[]; + expect(newPlaces.map(p => p.name)).toEqual(['Louvre']); + expect(getAssignments(getDays(newTripId)[0].id)).toHaveLength(1); + const packing = testDb.prepare('SELECT checked FROM packing_items WHERE trip_id = ?').all(newTripId) as any[]; + expect(packing).toEqual([{ checked: 0 }]); + + // No title → source title (|| fallback). + const secondCopy = svc.copy(trip.id, user.id); + expect((testDb.prepare('SELECT title FROM trips WHERE id = ?').get(secondCopy) as any).title).toBe('Origin'); + }); +}); + +// ── Wrapper helpers (delegating members of the aggregate root) ──────────────── + +describe('TripsService wrapper helpers', () => { it('re-anchors the budget before the trip row leaves its old currency (#1543)', async () => { const order: string[] = []; - (rebaseTripCurrency as ReturnType).mockImplementationOnce(async () => { order.push('rebase'); }); - (tripSvc.updateTrip as ReturnType).mockImplementationOnce(() => { order.push('update'); return {} as never; }); - - await svc().update('9', 1, { currency: 'RUB' } as never, 'user'); - - // The rebase reads the outgoing currency off the trip row, so it has to run first. - expect(rebaseTripCurrency).toHaveBeenCalledWith('9', 'RUB'); - expect(order).toEqual(['rebase', 'update']); + const rebaseSpy = vi.spyOn(budgetSvc, 'rebaseTripCurrency').mockImplementation(async () => { order.push('rebase'); }); + const updateSpy = vi.spyOn(svc, 'updateTrip').mockImplementation(() => { order.push('update'); return {} as never; }); + try { + await svc.update('9', 1, { currency: 'RUB' } as never, 'user'); + // The rebase reads the outgoing currency off the trip row, so it has to run first. + expect(rebaseSpy).toHaveBeenCalledWith('9', 'RUB'); + expect(order).toEqual(['rebase', 'update']); + } finally { + rebaseSpy.mockRestore(); + updateSpy.mockRestore(); + } }); - it('canAccessTrip delegates to the db helper', () => { - canAccessTrip.mockReturnValueOnce({ user_id: 7 }); - expect(svc().canAccessTrip('9', 7)).toEqual({ user_id: 7 }); - expect(canAccessTrip).toHaveBeenCalledWith('9', 7); - }); + it('canAccessTrip delegates to the db helper; can() delegates to checkPermission; broadcast forwards', () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + expect(svc.canAccessTrip(String(trip.id), user.id)).toMatchObject({ user_id: user.id }); - it('can() delegates to checkPermission; broadcast forwards', () => { - svc().can('trip_edit', 'user', 1, 1, false); - expect(checkPermission).toHaveBeenCalledWith('trip_edit', 'user', 1, 1, false); - svc().broadcast('9', 'trip:updated', { a: 1 }, 'sock'); + expect(svc.can('trip_edit', 'user', user.id, user.id, false)).toBe(true); + + svc.broadcast('9', 'trip:updated', { a: 1 } as never, 'sock'); expect(broadcast).toHaveBeenCalledWith('9', 'trip:updated', { a: 1 }, 'sock'); }); it('getCopiedTrip re-reads via the TRIP_SELECT query', () => { - expect(svc().getCopiedTrip(42, 1)).toEqual({ id: 42 }); - expect(dbMock.prepare).toHaveBeenCalledWith(expect.stringContaining('SELECT * FROM trips t')); + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id, { title: 'Copied' }); + const row = svc.getCopiedTrip(trip.id, user.id) as any; + expect(row.id).toBe(trip.id); + expect(row.is_owner).toBe(1); }); it('bundle aggregates every sub-collection + the member list, scoping packing to the viewer (#858)', () => { - const result = svc().bundle('9', { user_id: 1 }, 7); - expect(result).toMatchObject({ trip: { user_id: 1 }, days: [1], places: [], members: [{ id: 1 }] }); - // The viewer id is forwarded so other members' private items stay out of the offline cache. - expect(packingListItems).toHaveBeenCalledWith('9', 7); - }); + const { user: owner } = createUser(testDb); + const { user: viewer } = createUser(testDb); + const trip = createTrip(testDb, owner.id, { start_date: '2025-06-01', end_date: '2025-06-02' }); + addTripMember(testDb, trip.id, viewer.id); + // A personal item of the OWNER must stay out of the other member's bundle. + testDb.prepare("INSERT INTO packing_items (trip_id, name, is_private, owner_id) VALUES (?, 'Secret', 1, ?)").run(trip.id, owner.id); + testDb.prepare("INSERT INTO packing_items (trip_id, name) VALUES (?, 'Shared')").run(trip.id); - it('bundle tolerates a null member list', () => { - tripSvc.listMembers.mockReturnValueOnce({ owner: { id: 1 }, members: null }); - const result = svc().bundle('9', { user_id: 1 }, 1); - expect(result).toMatchObject({ members: [{ id: 1 }] }); + const result = svc.bundle(String(trip.id), { user_id: owner.id }, viewer.id) as any; + expect(result.days).toHaveLength(2); + expect(result.members.map((m: any) => m.id).sort()).toEqual([owner.id, viewer.id].sort()); + expect(result.packingItems.map((p: any) => p.name)).toEqual(['Shared']); }); it('notifyInvite is fire-and-forget (no throw)', () => { - expect(() => svc().notifyInvite('9', { id: 1, email: 'a@b.c' } as never, 2, 'T', 'b@x.y')).not.toThrow(); + expect(() => svc.notifyInvite('9', { id: 1, email: 'a@b.c' } as never, 2, 'T', 'b@x.y')).not.toThrow(); + }); +}); + +// ── Branch coverage for the folded update/export/copy quirks ───────────────── + +describe('folded quirk branches', () => { + it('TRIP-SVC-047: updateTrip admin edit collects changes and the owner email; reminder 0 reads "none"', () => { + const { user: owner } = createUser(testDb); + const { user: admin } = createUser(testDb); + testDb.prepare("UPDATE users SET role = 'admin' WHERE id = ?").run(admin.id); + const trip = createTrip(testDb, owner.id, { title: 'Old' }); + + const result = svc.updateTrip(trip.id, admin.id, { title: 'New', is_archived: true, reminder_days: 0 }, 'admin'); + + expect(result.isAdminEdit).toBe(true); + expect(result.ownerEmail).toBe(owner.email); + expect(result.changes).toMatchObject({ title: 'New', archived: true, reminder_days: 'none' }); + expect(result.newTitle).toBe('New'); + // || coercion: an empty-string title falls back to the stored one. + const kept = svc.updateTrip(trip.id, owner.id, { title: '' }, 'user'); + expect(kept.newTitle).toBe('New'); + // Missing trips throw the byte-identical error; invalid ranges reject. + expect(() => svc.updateTrip(99999, owner.id, {}, 'user')).toThrow('Trip not found'); + expect(() => svc.updateTrip(trip.id, owner.id, { start_date: '2025-06-10', end_date: '2025-06-01' }, 'user')).toThrow('End date must be after start date'); + }); + + it('TRIP-SVC-048: exportICS renders untimed/notes all-day summaries, multi-leg routes, endpoint routes and train/location fields', () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id, { title: 'Branchy' }); + const day = createDay(testDb, trip.id, { date: '2025-06-02' }); + const place = createPlace(testDb, trip.id, { name: 'Untimed Spot' }); + testDb.prepare("UPDATE places SET address = '1 Rue Test' WHERE id = ?").run(place.id); + const assignment = createDayAssignment(testDb, day.id, place.id); + testDb.prepare("UPDATE day_assignments SET notes = 'bring hat' WHERE id = ?").run(assignment.id); + createDayNote(testDb, day.id, trip.id, { text: 'timed note', time: '10:00' }); + createDayNote(testDb, day.id, trip.id, { text: 'plain note' }); + + // Multi-leg flight metadata → Route: A → B → C, plus train + notes + location. + const flight = createReservation(testDb, trip.id, { title: 'Legs', type: 'flight' }); + testDb.prepare('UPDATE reservations SET reservation_time=?, metadata=?, notes=?, location=?, confirmation_number=? WHERE id=?').run( + '2025-06-02T09:00', + JSON.stringify({ legs: [{ from: 'FRA', to: 'BER' }, { to: 'HND' }], train_number: 'ICE 100' }), + 'window seat', 'Gate 4', 'ABC123', flight.id, + ); + // Endpoint-derived route (no route metadata) with a date-only endpoint fallback. + const transport = createReservation(testDb, trip.id, { title: 'Ferry', type: 'transport' }); + testDb.prepare('UPDATE reservations SET reservation_time=NULL WHERE id=?').run(transport.id); + const insertEp = testDb.prepare( + 'INSERT INTO reservation_endpoints (reservation_id, role, sequence, name, code, lat, lng, timezone, local_time, local_date) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)' + ); + insertEp.run(transport.id, 'from', 0, 'Pier A', null, 1.0, 1.0, null, null, '2025-06-03'); + insertEp.run(transport.id, 'to', 1, 'Pier B', 'PB', 1.1, 1.1, null, null, '2025-06-03'); + + // Unfold the RFC 5545 75-octet folding so substring assertions see whole lines. + const ics = svc.exportICS(trip.id).ics.replace(/\r\n /g, ''); + + expect(ics).toContain('SUMMARY:Day 1'); + expect(ics).toContain('• Untimed Spot (1 Rue Test) — bring hat'); + expect(ics).toContain('Notes:\\n10:00 — timed note\\n• plain note'); + expect(ics).toContain('Route: FRA → BER → HND'); + expect(ics).toContain('Train: ICE 100'); + expect(ics).toContain('Confirmation: ABC123'); + expect(ics).toContain('window seat'); + expect(ics).toContain('LOCATION:Gate 4'); + // Date-only endpoint → all-day DTSTART for the transport. + expect(ics).toContain('SUMMARY:Ferry'); + expect(ics).toContain('DTSTART;VALUE=DATE:20250603'); + }); + + it('TRIP-SVC-049: addMember inserts the membership and reports the trip title; removeMember deletes it', () => { + const { user: owner } = createUser(testDb); + const { user: invitee } = createUser(testDb); + const trip = createTrip(testDb, owner.id, { title: 'Joinable' }); + + const result = svc.addMember(trip.id, invitee.email, owner.id, owner.id); + expect(result.member.id).toBe(invitee.id); + expect(result.tripTitle).toBe('Joinable'); + expect(result.targetUserId).toBe(invitee.id); + + // Duplicate + owner + missing identifier reject with the byte-identical errors. + expect(() => svc.addMember(trip.id, invitee.email, owner.id, owner.id)).toThrow('User already has access'); + expect(() => svc.addMember(trip.id, owner.email, owner.id, owner.id)).toThrow('Trip owner is already a member'); + expect(() => svc.addMember(trip.id, '', owner.id, owner.id)).toThrow('Email or username required'); + + svc.removeMember(trip.id, invitee.id); + expect(testDb.prepare('SELECT id FROM trip_members WHERE trip_id = ? AND user_id = ?').get(trip.id, invitee.id)).toBeUndefined(); + }); + + it('TRIP-SVC-050: copy remaps tags, accommodations, reservations, day notes, budget, bags and category order', () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id, { title: 'Deep', start_date: '2025-06-01', end_date: '2025-06-02' }); + const days = getDays(trip.id); + const place = createPlace(testDb, trip.id, { name: 'Hotel Zed' }); + const assignment = createDayAssignment(testDb, days[0].id, place.id); + const tagId = Number(testDb.prepare("INSERT INTO tags (name, user_id) VALUES ('beach', ?)").run(user.id).lastInsertRowid); + testDb.prepare('INSERT INTO place_tags (place_id, tag_id) VALUES (?, ?)').run(place.id, tagId); + const accomId = Number(testDb.prepare( + "INSERT INTO day_accommodations (trip_id, place_id, start_day_id, end_day_id, check_in, check_out) VALUES (?, ?, ?, ?, '15:00', '11:00')", + ).run(trip.id, place.id, days[0].id, days[1].id).lastInsertRowid); + testDb.prepare( + 'INSERT INTO reservations (trip_id, day_id, end_day_id, place_id, assignment_id, accommodation_id, title, type) VALUES (?, ?, ?, ?, ?, ?, ?, ?)', + ).run(trip.id, days[0].id, days[1].id, place.id, assignment.id, accomId, 'Stay', 'hotel'); + testDb.prepare("INSERT INTO budget_items (trip_id, category, name, total_price) VALUES (?, 'stay', 'Hotel', 120)").run(trip.id); + const bagId = Number(testDb.prepare("INSERT INTO packing_bags (trip_id, name) VALUES (?, 'Backpack')").run(trip.id).lastInsertRowid); + testDb.prepare("INSERT INTO packing_items (trip_id, name, checked, bag_id) VALUES (?, 'Towel', 1, ?)").run(trip.id, bagId); + createDayNote(testDb, days[0].id, trip.id, { text: 'note' }); + testDb.prepare("INSERT INTO todo_items (trip_id, name, checked) VALUES (?, 'Book', 1)").run(trip.id); + testDb.prepare("INSERT INTO budget_category_order (trip_id, category, sort_order) VALUES (?, 'stay', 2)").run(trip.id); + + const newTripId = svc.copy(trip.id, user.id); + + const newDays = getDays(newTripId); + expect(newDays).toHaveLength(2); + const newPlace = testDb.prepare('SELECT id FROM places WHERE trip_id = ?').get(newTripId) as { id: number }; + expect(testDb.prepare('SELECT tag_id FROM place_tags WHERE place_id = ?').get(newPlace.id)).toEqual({ tag_id: tagId }); + const newAccom = testDb.prepare('SELECT id, place_id, start_day_id, end_day_id FROM day_accommodations WHERE trip_id = ?').get(newTripId) as any; + expect(newAccom.place_id).toBe(newPlace.id); + expect(newAccom.start_day_id).toBe(newDays[0].id); + const newRes = testDb.prepare('SELECT day_id, end_day_id, place_id, accommodation_id FROM reservations WHERE trip_id = ?').get(newTripId) as any; + // Quirk preserved from the legacy copyTripById: reservations.accommodation_id + // is a TEXT column, so the number-keyed accomMap lookup never matches and the + // copied reservation loses its accommodation link (nulled, not remapped). + expect(newRes).toEqual({ day_id: newDays[0].id, end_day_id: newDays[1].id, place_id: newPlace.id, accommodation_id: null }); + expect((testDb.prepare('SELECT COUNT(*) AS n FROM budget_items WHERE trip_id = ?').get(newTripId) as any).n).toBe(1); + const newItem = testDb.prepare('SELECT checked, bag_id FROM packing_items WHERE trip_id = ?').get(newTripId) as any; + expect(newItem.checked).toBe(0); + expect(newItem.bag_id).not.toBeNull(); + expect((testDb.prepare('SELECT checked, assigned_user_id FROM todo_items WHERE trip_id = ?').get(newTripId) as any)).toEqual({ checked: 0, assigned_user_id: null }); + expect((testDb.prepare('SELECT sort_order FROM budget_category_order WHERE trip_id = ?').get(newTripId) as any).sort_order).toBe(2); + expect((testDb.prepare('SELECT COUNT(*) AS n FROM day_notes WHERE trip_id = ?').get(newTripId) as any).n).toBe(1); + + // Missing source throws the byte-identical error. + expect(() => svc.copy(99999, user.id)).toThrow('Trip not found'); + }); +}); + +// ── Post-fold quirk fixes (transactions, owner display name) ───────────────── + +describe('quirk fixes', () => { + /** A TripsService whose connection throws when preparing SQL matching `match`. */ + function failingTrips(match: string) { + const conn = new Proxy(testDb, { + get(target, prop) { + if (prop === 'prepare') { + return (sql: string) => { + if (sql.includes(match)) throw new Error('boom'); + return target.prepare(sql); + }; + } + const v = (target as any)[prop]; + return typeof v === 'function' ? v.bind(target) : v; + }, + }); + const fdbs = { connection: conn, canAccessTrip: dbMock.canAccessTrip, isOwner: dbMock.isOwner } as unknown as import('../../../src/nest/database/database.service').DatabaseService; + return new TripsService( + fdbs, + 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()), budgetSvc, new RealtimeService()), + daysSvc, + new PermissionsService(dbs()), + budgetSvc, + new CollabService(dbs(), new PermissionsService(dbs()), new RealtimeService()), + new VacayService(dbs(), new RealtimeService()), + new RealtimeService(), + ); + } + + it('TRIP-SVC-051: remove is atomic — a failed trip DELETE keeps the journey entries intact', () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const journeyId = Number(testDb.prepare( + "INSERT INTO journeys (user_id, title, created_at, updated_at) VALUES (?, 'J', 0, 0)", + ).run(user.id).lastInsertRowid); + testDb.prepare( + "INSERT INTO journey_entries (journey_id, source_trip_id, author_id, type, title, entry_date, created_at, updated_at) VALUES (?, ?, ?, 'skeleton', 'S', '2025-06-01', 0, 0)", + ).run(journeyId, trip.id, user.id); + + const broken = failingTrips('DELETE FROM trips WHERE id = ?'); + expect(() => broken.remove(trip.id, user.id, 'user')).toThrow('boom'); + + // The skeleton cleanup rolled back with the failed delete. + expect(testDb.prepare("SELECT id FROM journey_entries WHERE type = 'skeleton'").get()).toBeDefined(); + expect(testDb.prepare('SELECT id FROM trips WHERE id = ?').get(trip.id)).toBeDefined(); + }); + + it('TRIP-SVC-052: deleteGuest is atomic — a failed user DELETE rolls the budget re-split back', () => { + const { user: owner } = createUser(testDb); + const trip = createTrip(testDb, owner.id); + const { member: guest } = svc.createGuest(trip.id, 'Gia', owner.id); + const item = budgetSvc.createBudgetItem(trip.id, { name: 'Dinner', total_price: 80, member_ids: [owner.id, guest.id] }); + + const broken = failingTrips('DELETE FROM users WHERE id = ? AND is_guest = 1'); + expect(() => broken.deleteGuest(trip.id, guest.id)).toThrow('boom'); + + // Neither the guest nor their split membership was touched. + expect(testDb.prepare('SELECT id FROM users WHERE id = ?').get(guest.id)).toBeDefined(); + const row = testDb.prepare('SELECT persons FROM budget_items WHERE id = ?').get(item.id) as { persons: number | null }; + expect(row.persons).toBe(2); + }); + + it('TRIP-SVC-053: listMembers prefers the owner display_name over the raw username (quirk fix)', () => { + const { user: owner } = createUser(testDb, { username: 'owner-handle' }); + testDb.prepare('UPDATE users SET display_name = ? WHERE id = ?').run('Olive Displayed', owner.id); + const trip = createTrip(testDb, owner.id); + const { owner: row } = svc.listMembers(trip.id, owner.id); + expect(row.username).toBe('Olive Displayed'); }); }); diff --git a/server/tests/unit/services/tripService.test.ts b/server/tests/unit/services/tripService.test.ts deleted file mode 100644 index f24e4a44e..000000000 --- a/server/tests/unit/services/tripService.test.ts +++ /dev/null @@ -1,810 +0,0 @@ -/** - * Unit tests for tripService — exportICS function (TRIP-SVC-001 through TRIP-SVC-009). - * Uses a real in-memory SQLite DB so SQL logic is exercised faithfully. - */ -import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest'; - -// ── DB setup ────────────────────────────────────────────────────────────────── - -const { testDb, dbMock } = vi.hoisted(() => { - const Database = require('better-sqlite3'); - const db = new Database(':memory:'); - db.exec('PRAGMA journal_mode = WAL'); - db.exec('PRAGMA foreign_keys = ON'); - db.exec('PRAGMA busy_timeout = 5000'); - const mock = { - db, - closeDb: () => {}, - reinitialize: () => {}, - getPlaceWithTags: () => null, - canAccessTrip: () => null, - isOwner: () => false, - }; - return { testDb: db, dbMock: mock }; -}); - -vi.mock('../../../src/db/database', () => dbMock); -vi.mock('../../../src/config', () => ({ - JWT_SECRET: 'test-secret', - ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2', - updateJwtSecret: () => {}, -})); - -import { createTables } from '../../../src/db/schema'; -import { runMigrations } from '../../../src/db/migrations'; -import { resetTestDb } from '../../helpers/test-db'; -import { createUser, createTrip, createReservation, createPlace, createDay, createDayAssignment, createDayNote, addTripMember } from '../../helpers/factories'; -import { exportICS, generateDays, deleteOldCover, updateTrip, transferOwnership, createGuest, renameGuest, deleteGuest, listMembers, addMember } from '../../../src/services/tripService'; -import { DatabaseService } from '../../../src/nest/database/database.service'; -import { DaysService } from '../../../src/nest/days/days.service'; -import { PermissionsService } from '../../../src/nest/permissions/permissions.service'; -import fs from 'fs'; -import { RealtimeService } from '../../../src/nest/realtime/realtime.service'; - -const daysSvc = new DaysService(new DatabaseService(testDb), new PermissionsService(new DatabaseService(testDb)), new RealtimeService()); -const createAccommodation = daysSvc.createAccommodation.bind(daysSvc); - -beforeAll(() => { - createTables(testDb); - runMigrations(testDb); -}); - -beforeEach(() => { - resetTestDb(testDb); -}); - -afterAll(() => { - testDb.close(); -}); - -// ── Helpers ─────────────────────────────────────────────────────────────────── - -function getDays(tripId: number) { - return testDb.prepare('SELECT * FROM days WHERE trip_id = ? ORDER BY day_number').all(tripId) as { - id: number; trip_id: number; day_number: number; date: string | null; - }[]; -} - -function getAssignments(dayId: number) { - return testDb.prepare('SELECT * FROM day_assignments WHERE day_id = ?').all(dayId) as { id: number; day_id: number }[]; -} - -function getNotes(dayId: number) { - return testDb.prepare('SELECT * FROM day_notes WHERE day_id = ?').all(dayId) as { id: number; day_id: number }[]; -} - -// ── Tests ───────────────────────────────────────────────────────────────────── - -describe('generateDays', () => { - it('TRIP-SVC-010: full range shift preserves day assignments and notes positionally', () => { - const { user } = createUser(testDb); - const trip = createTrip(testDb, user.id, { start_date: '2025-06-01', end_date: '2025-06-05' }); - const daysBefore = getDays(trip.id); - expect(daysBefore).toHaveLength(5); - - const place = createPlace(testDb, trip.id); - const assignment = createDayAssignment(testDb, daysBefore[0].id, place.id); - const note = createDayNote(testDb, daysBefore[1].id, trip.id, { text: 'packed' }); - - // Shift forward 9 days — zero overlap with original dates - generateDays(trip.id, '2025-06-10', '2025-06-14'); - - const daysAfter = getDays(trip.id); - expect(daysAfter).toHaveLength(5); - expect(daysAfter.map(d => d.date)).toEqual([ - '2025-06-10', '2025-06-11', '2025-06-12', '2025-06-13', '2025-06-14', - ]); - - // day_number 1 (formerly June 1) now has date June 10 — assignment still attached - const day1 = daysAfter[0]; - const day2 = daysAfter[1]; - expect(getAssignments(day1.id)).toHaveLength(1); - expect(getAssignments(day1.id)[0].id).toBe(assignment.id); - expect(getNotes(day2.id)).toHaveLength(1); - expect(getNotes(day2.id)[0].id).toBe(note.id); - }); - - it('TRIP-SVC-011: shrinking range deletes overflow days and their assignments (issue #909)', () => { - const { user } = createUser(testDb); - const trip = createTrip(testDb, user.id, { start_date: '2025-07-01', end_date: '2025-07-05' }); - const daysBefore = getDays(trip.id); - expect(daysBefore).toHaveLength(5); - - const place = createPlace(testDb, trip.id); - createDayAssignment(testDb, daysBefore[3].id, place.id); - createDayAssignment(testDb, daysBefore[4].id, place.id); - - // Shrink from 5 to 3 days — surplus days and their content are removed - generateDays(trip.id, '2025-07-01', '2025-07-03'); - - const daysAfter = getDays(trip.id); - expect(daysAfter).toHaveLength(3); - expect(daysAfter.map(d => d.date)).toEqual(['2025-07-01', '2025-07-02', '2025-07-03']); - }); - - it('TRIP-SVC-016: shrinking range deletes empty overflow days (issue #909)', () => { - const { user } = createUser(testDb); - const trip = createTrip(testDb, user.id, { start_date: '2025-07-01', end_date: '2025-07-07' }); - expect(getDays(trip.id)).toHaveLength(7); - - // Shrink 7 → 5; days 6 and 7 have no content - generateDays(trip.id, '2025-07-01', '2025-07-05'); - - const daysAfter = getDays(trip.id); - expect(daysAfter).toHaveLength(5); - expect(daysAfter.map(d => d.date)).toEqual([ - '2025-07-01', '2025-07-02', '2025-07-03', '2025-07-04', '2025-07-05', - ]); - }); - - it('TRIP-SVC-012: growing range keeps existing day content and appends new empty days', () => { - const { user } = createUser(testDb); - const trip = createTrip(testDb, user.id, { start_date: '2025-08-01', end_date: '2025-08-03' }); - const daysBefore = getDays(trip.id); - expect(daysBefore).toHaveLength(3); - - const place = createPlace(testDb, trip.id); - const assignment = createDayAssignment(testDb, daysBefore[0].id, place.id); - - // Grow to 5 days - generateDays(trip.id, '2025-08-01', '2025-08-05'); - - const daysAfter = getDays(trip.id); - expect(daysAfter).toHaveLength(5); - expect(daysAfter.map(d => d.date)).toEqual([ - '2025-08-01', '2025-08-02', '2025-08-03', '2025-08-04', '2025-08-05', - ]); - - // Existing day 1 retains its assignment - expect(getAssignments(daysAfter[0].id)).toHaveLength(1); - expect(getAssignments(daysAfter[0].id)[0].id).toBe(assignment.id); - - // New days 4 and 5 are empty - expect(getAssignments(daysAfter[3].id)).toHaveLength(0); - expect(getAssignments(daysAfter[4].id)).toHaveLength(0); - }); - - it('TRIP-SVC-013: clearing dates converts all days to dateless without destroying assignments', () => { - const { user } = createUser(testDb); - const trip = createTrip(testDb, user.id, { start_date: '2025-09-01', end_date: '2025-09-04' }); - const daysBefore = getDays(trip.id); - expect(daysBefore).toHaveLength(4); - - const place = createPlace(testDb, trip.id); - const assignment = createDayAssignment(testDb, daysBefore[1].id, place.id); - - // Clear both dates - generateDays(trip.id, null, null); - - const daysAfter = getDays(trip.id); - expect(daysAfter).toHaveLength(4); - expect(daysAfter.every(d => d.date === null)).toBe(true); - - // The assignment on the former day 2 still exists - const formerDay2 = daysAfter.find(d => d.id === daysBefore[1].id); - expect(formerDay2).toBeDefined(); - expect(getAssignments(formerDay2!.id)).toHaveLength(1); - expect(getAssignments(formerDay2!.id)[0].id).toBe(assignment.id); - }); - - it('TRIP-SVC-014: partial overlap shift remaps by position (day 1→3 kept, 4-5 overflow)', () => { - // Original: Jun 1-5. New: Jun 3-7 (overlap on Jun 3-5, but we map by position) - const { user } = createUser(testDb); - const trip = createTrip(testDb, user.id, { start_date: '2025-10-01', end_date: '2025-10-05' }); - const daysBefore = getDays(trip.id); - const place = createPlace(testDb, trip.id); - // Assign to each of the 5 days - for (const day of daysBefore) createDayAssignment(testDb, day.id, place.id); - - // Shift forward 2 days (partial overlap with original range) - generateDays(trip.id, '2025-10-03', '2025-10-07'); - - const daysAfter = getDays(trip.id); - expect(daysAfter).toHaveLength(5); - expect(daysAfter.map(d => d.date)).toEqual([ - '2025-10-03', '2025-10-04', '2025-10-05', '2025-10-06', '2025-10-07', - ]); - - // All 5 assignments survive - for (const day of daysAfter) { - expect(getAssignments(day.id)).toHaveLength(1); - } - }); - - it('TRIP-SVC-015: growing into dateless days reuses them; leftover dateless renumber without UNIQUE collision', () => { - // 3 dated days + 2 pre-existing dateless days. Resize to 4 dated days. - // Main loop: dated[0..2] → positions 1-3, dateless[0] → position 4 (consumed). - // Unused dateless: dateless[1] should land at position 5, NOT 4 (collision bug). - const { user } = createUser(testDb); - const trip = createTrip(testDb, user.id, { start_date: '2025-11-01', end_date: '2025-11-03' }); - - // Insert 2 dateless days directly - const daysBefore = getDays(trip.id); - testDb.prepare('INSERT INTO days (trip_id, day_number, date) VALUES (?, ?, NULL)').run(trip.id, 4); - testDb.prepare('INSERT INTO days (trip_id, day_number, date) VALUES (?, ?, NULL)').run(trip.id, 5); - - const allDays = getDays(trip.id); - expect(allDays).toHaveLength(5); - - const place = createPlace(testDb, trip.id); - // Put an assignment on the second dateless day (day_number=5) — it should survive - const assignment = createDayAssignment(testDb, allDays[4].id, place.id); - - // Grow from 3 to 4 dated days — consumes dateless[0], leaves dateless[1] unused - // This is the scenario that triggered the UNIQUE collision bug - generateDays(trip.id, '2025-11-01', '2025-11-04'); - - const daysAfter = getDays(trip.id); - expect(daysAfter).toHaveLength(5); - - const dated = daysAfter.filter(d => d.date !== null); - const dateless = daysAfter.filter(d => d.date === null); - expect(dated).toHaveLength(4); - expect(dateless).toHaveLength(1); - - // The remaining dateless day still has its assignment - expect(getAssignments(dateless[0].id)).toHaveLength(1); - expect(getAssignments(dateless[0].id)[0].id).toBe(assignment.id); - - // All day_numbers are unique 1..5 - const nums = daysAfter.map(d => d.day_number).sort((a, b) => a - b); - expect(nums).toEqual([1, 2, 3, 4, 5]); - }); - - it('TRIP-SVC-017: switching a dateless trip to a shorter dated range drops empty leftover days but keeps ones with content (#1083)', () => { - const { user } = createUser(testDb); - // A 7-day trip, then cleared to dateless placeholders (day_count = 7). - const trip = createTrip(testDb, user.id, { start_date: '2025-12-01', end_date: '2025-12-07' }); - generateDays(trip.id, null, null); - const dateless = getDays(trip.id); - expect(dateless).toHaveLength(7); - expect(dateless.every(d => d.date === null)).toBe(true); - - // Give the LAST dateless day real content so it must be preserved. - const place = createPlace(testDb, trip.id); - const assignment = createDayAssignment(testDb, dateless[6].id, place.id); - - // Now set an explicit 2-day range. The first two dateless days are reused for - // the dates; the four empty leftovers must be removed, the one with content kept. - generateDays(trip.id, '2026-01-10', '2026-01-11'); - - const daysAfter = getDays(trip.id); - const dated = daysAfter.filter(d => d.date !== null); - const stillDateless = daysAfter.filter(d => d.date === null); - expect(dated.map(d => d.date)).toEqual(['2026-01-10', '2026-01-11']); - // day_count is COUNT(*) FROM days: 2 dated + 1 content-bearing dateless = 3 (not the stale 7) - expect(daysAfter).toHaveLength(3); - expect(stillDateless).toHaveLength(1); - expect(getAssignments(stillDateless[0].id)[0].id).toBe(assignment.id); - }); -}); - -describe('exportICS', () => { - it('TRIP-SVC-001: returns VCALENDAR wrapper', () => { - const { user } = createUser(testDb); - const trip = createTrip(testDb, user.id, { - title: 'My Vacation', - start_date: '2025-06-01', - end_date: '2025-06-07', - }); - - const { ics } = exportICS(trip.id); - - expect(ics).toContain('BEGIN:VCALENDAR'); - expect(ics).toContain('END:VCALENDAR'); - }); - - it('TRIP-SVC-002: trip with start_date + end_date includes all-day VEVENT', () => { - const { user } = createUser(testDb); - const trip = createTrip(testDb, user.id, { - title: 'Summer Holiday', - start_date: '2025-06-01', - end_date: '2025-06-07', - }); - - const { ics } = exportICS(trip.id); - - expect(ics).toContain('DTSTART;VALUE=DATE:20250601'); - // DTEND is exclusive — the day *after* the last day, or the trip loses a day. - expect(ics).toContain('DTEND;VALUE=DATE:20250608'); - expect(ics).toContain('SUMMARY:Summer Holiday'); - }); - - describe('#1453 all-day DTEND is timezone-independent', () => { - const originalTz = process.env.TZ; - - afterAll(() => { - process.env.TZ = originalTz; - }); - - // The old code did `new Date(date + 'T00:00:00')` — no Z, so parsed as *server-local* - // midnight — then setDate(+1) and .toISOString(). East of Greenwich that round-trip - // lands a day early, and since DTEND is exclusive the trip's last day was dropped. - // Only invisible in CI because containers default to TZ=UTC. - for (const tz of ['Europe/Berlin', 'Asia/Tokyo', 'Pacific/Kiritimati', 'America/New_York', 'UTC']) { - it(`TRIP-SVC-002b: DTEND is the day after the last day under TZ=${tz}`, () => { - process.env.TZ = tz; - const { user } = createUser(testDb); - const trip = createTrip(testDb, user.id, { - title: 'TZ Trip', - start_date: '2026-03-28', - end_date: '2026-03-30', - }); - - const { ics } = exportICS(trip.id); - - expect(ics).toContain('DTSTART;VALUE=DATE:20260328'); - expect(ics).toContain('DTEND;VALUE=DATE:20260331'); - }); - } - - it('TRIP-SVC-002c: a per-day all-day summary event has the same exclusive DTEND', () => { - process.env.TZ = 'Asia/Tokyo'; - const { user } = createUser(testDb); - const trip = createTrip(testDb, user.id, { title: 'Day Note Trip' }); - const day = createDay(testDb, trip.id, { date: '2026-03-30', day_number: 1 }); - createDayNote(testDb, day.id, trip.id, { text: 'Pack the bags' }); - - const { ics } = exportICS(trip.id); - - expect(ics).toContain('DTSTART;VALUE=DATE:20260330'); - expect(ics).toContain('DTEND;VALUE=DATE:20260331'); - }); - }); - - it('TRIP-SVC-003: reservation with full datetime (includes T) → DTSTART without VALUE=DATE', () => { - const { user } = createUser(testDb); - const trip = createTrip(testDb, user.id, { title: 'Paris Trip' }); - const reservation = createReservation(testDb, trip.id, { - title: 'Morning Flight', - type: 'flight', - }); - testDb - .prepare('UPDATE reservations SET reservation_time=? WHERE id=?') - .run('2025-06-02T09:00', reservation.id); - - const { ics } = exportICS(trip.id); - - expect(ics).toContain('DTSTART:20250602T090000'); - expect(ics).not.toContain('DTSTART;VALUE=DATE'); - }); - - it('TRIP-SVC-004: reservation with date-only → DTSTART;VALUE=DATE', () => { - const { user } = createUser(testDb); - const trip = createTrip(testDb, user.id, { title: 'Paris Trip' }); - const reservation = createReservation(testDb, trip.id, { - title: 'Hotel Check-in', - type: 'hotel', - }); - testDb - .prepare('UPDATE reservations SET reservation_time=? WHERE id=?') - .run('2025-06-02', reservation.id); - - const { ics } = exportICS(trip.id); - - expect(ics).toContain('DTSTART;VALUE=DATE:20250602'); - }); - - it('TRIP-SVC-005: reservation metadata with flight info appears in DESCRIPTION', () => { - const { user } = createUser(testDb); - const trip = createTrip(testDb, user.id, { title: 'Paris Trip' }); - const reservation = createReservation(testDb, trip.id, { - title: 'CDG to JFK', - type: 'flight', - }); - testDb - .prepare('UPDATE reservations SET reservation_time=?, metadata=? WHERE id=?') - .run( - '2025-06-02T09:00', - JSON.stringify({ - airline: 'Air Test', - flight_number: 'AT100', - departure_airport: 'CDG', - arrival_airport: 'JFK', - }), - reservation.id - ); - - const { ics } = exportICS(trip.id); - - expect(ics).toContain('Airline: Air Test'); - expect(ics).toContain('Flight: AT100'); - }); - - it('TRIP-SVC-006: special characters in title are escaped', () => { - const { user } = createUser(testDb); - const trip = createTrip(testDb, user.id, { title: 'Trip; First, Best' }); - - const { ics } = exportICS(trip.id); - - expect(ics).toContain('Trip\\; First\\, Best'); - }); - - it('TRIP-SVC-007: throws NotFoundError for non-existent trip', () => { - expect(() => exportICS(99999)).toThrow(); - }); - - it('TRIP-SVC-008: returns a filename derived from trip title', () => { - const { user } = createUser(testDb); - const trip = createTrip(testDb, user.id, { title: 'My Trip 2025' }); - - const { filename } = exportICS(trip.id); - - expect(filename).toMatch(/My.Trip.2025\.ics/); - }); - - it('TRIP-SVC-009: reservation with end time includes DTEND', () => { - const { user } = createUser(testDb); - const trip = createTrip(testDb, user.id, { title: 'Paris Trip' }); - const reservation = createReservation(testDb, trip.id, { - title: 'Afternoon Tour', - type: 'activity', - }); - testDb - .prepare('UPDATE reservations SET reservation_time=?, reservation_end_time=? WHERE id=?') - .run('2025-06-02T14:00', '2025-06-02T16:00', reservation.id); - - const { ics } = exportICS(trip.id); - - expect(ics).toContain('DTEND:20250602T160000'); - }); - - it('TRIP-SVC-010: flight with endpoint times but no reservation_time is included', () => { - const { user } = createUser(testDb); - const trip = createTrip(testDb, user.id, { title: 'Paris Trip' }); - const reservation = createReservation(testDb, trip.id, { - title: 'CDG → JFK', - type: 'flight', - }); - // Confirmed flights store times per endpoint, never as reservation_time. - testDb.prepare('UPDATE reservations SET reservation_time=NULL, reservation_end_time=NULL WHERE id=?').run(reservation.id); - const insertEp = testDb.prepare( - 'INSERT INTO reservation_endpoints (reservation_id, role, sequence, name, code, lat, lng, timezone, local_time, local_date) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)' - ); - insertEp.run(reservation.id, 'from', 0, 'Paris CDG', 'CDG', 49.0, 2.5, 'Europe/Paris', '09:00', '2025-06-02'); - insertEp.run(reservation.id, 'to', 1, 'New York JFK', 'JFK', 40.6, -73.8, 'America/New_York', '12:00', '2025-06-02'); - - const { ics } = exportICS(trip.id); - - expect(ics).toContain('SUMMARY:CDG → JFK'); - // Departure endpoint zone drives DTSTART, arrival zone drives DTEND, so the - // subscriber sees TREK's zones instead of their own (#1453). - expect(ics).toContain('DTSTART;TZID=Europe/Paris:20250602T090000'); - expect(ics).toContain('DTEND;TZID=America/New_York:20250602T120000'); - expect(ics).not.toContain('DTSTART:20250602T090000'); - // Each referenced zone gets a VTIMEZONE definition. - expect(ics).toContain('BEGIN:VTIMEZONE\r\nTZID:Europe/Paris'); - expect(ics).toContain('BEGIN:VTIMEZONE\r\nTZID:America/New_York'); - expect(ics).toContain('Route: CDG → JFK'); - }); - - it('TRIP-SVC-010b: an invalid endpoint timezone degrades to floating time instead of crashing the export', () => { - const { user } = createUser(testDb); - const trip = createTrip(testDb, user.id, { title: 'Bad TZ Trip' }); - const reservation = createReservation(testDb, trip.id, { title: 'CDG → JFK', type: 'flight' }); - testDb.prepare('UPDATE reservations SET reservation_time=NULL, reservation_end_time=NULL WHERE id=?').run(reservation.id); - const insertEp = testDb.prepare( - 'INSERT INTO reservation_endpoints (reservation_id, role, sequence, name, code, lat, lng, timezone, local_time, local_date) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)' - ); - // A stored/plugin-written timezone can be any string; it must never reach Intl. - // The bogus zone takes precedence over the coordinates (first.timezone || resolveZone). - insertEp.run(reservation.id, 'from', 0, 'Paris CDG', 'CDG', 49.0, 2.5, 'Not/AZone', '09:00', '2025-06-02'); - insertEp.run(reservation.id, 'to', 1, 'New York JFK', 'JFK', 40.6, -73.8, 'garbage', '12:00', '2025-06-02'); - - let ics = ''; - expect(() => { ics = exportICS(trip.id).ics; }).not.toThrow(); - // Falls back to a floating local time (no TZID) and never emits a bogus VTIMEZONE. - expect(ics).toContain('DTSTART:20250602T090000'); - expect(ics).not.toContain('TZID=Not/AZone'); - expect(ics).not.toContain('garbage'); - }); - - it('TRIP-SVC-011: flight endpoint with no local_date is skipped (relative Day-N trips)', () => { - const { user } = createUser(testDb); - const trip = createTrip(testDb, user.id, { title: 'Relative Trip' }); - const reservation = createReservation(testDb, trip.id, { - title: 'Timeless Flight', - type: 'flight', - }); - testDb.prepare('UPDATE reservations SET reservation_time=NULL WHERE id=?').run(reservation.id); - testDb.prepare( - 'INSERT INTO reservation_endpoints (reservation_id, role, sequence, name, code, lat, lng, timezone, local_time, local_date) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)' - ).run(reservation.id, 'from', 0, 'Origin', 'AAA', 1.0, 1.0, null, '09:00', null); - - const { ics } = exportICS(trip.id); - - expect(ics).not.toContain('SUMMARY:Timeless Flight'); - }); - - it('TRIP-SVC-012: timed assignment gets a TZID derived from the place coordinates', () => { - const { user } = createUser(testDb); - const trip = createTrip(testDb, user.id, { title: 'Tokyo Trip' }); - const day = createDay(testDb, trip.id, { date: '2025-06-02' }); - // Tokyo coordinates → Asia/Tokyo via tz-lookup. - const place = createPlace(testDb, trip.id, { name: 'Senso-ji', lat: 35.7148, lng: 139.7967 }); - const assignment = createDayAssignment(testDb, day.id, place.id); - testDb - .prepare('UPDATE day_assignments SET assignment_time=? WHERE id=?') - .run('09:00', assignment.id); - - const { ics } = exportICS(trip.id); - - expect(ics).toContain('DTSTART;TZID=Asia/Tokyo:20250602T090000'); - expect(ics).toContain('BEGIN:VTIMEZONE\r\nTZID:Asia/Tokyo'); - expect(ics).not.toContain('DTSTART:20250602T090000'); - }); -}); - -// ── deleteOldCover — path containment ────────────────────────────────────────── - -describe('deleteOldCover', () => { - it('TRIP-SVC-COVER-001: never unlinks outside uploads/covers for a crafted cover_image', () => { - const existsSpy = vi.spyOn(fs, 'existsSync').mockReturnValue(true); - const unlinkSpy = vi.spyOn(fs, 'unlinkSync').mockImplementation(() => {}); - try { - // Attacker-controlled values aimed at auth-gated sibling upload dirs. - deleteOldCover('/uploads/files/victim.pdf'); - deleteOldCover('/uploads/covers/../files/secret.pdf'); - deleteOldCover('/uploads/avatars/someone.png'); - - for (const call of unlinkSpy.mock.calls) { - const target = String(call[0]); - expect(target).toMatch(/[\\/]uploads[\\/]covers[\\/]/); // stays in covers - expect(target).not.toMatch(/[\\/]files[\\/]/); - expect(target).not.toMatch(/[\\/]avatars[\\/]/); - } - } finally { - existsSpy.mockRestore(); - unlinkSpy.mockRestore(); - } - }); - - it('TRIP-SVC-COVER-002: deletes a legitimate cover file', () => { - const existsSpy = vi.spyOn(fs, 'existsSync').mockReturnValue(true); - const unlinkSpy = vi.spyOn(fs, 'unlinkSync').mockImplementation(() => {}); - try { - deleteOldCover('/uploads/covers/abc123.jpg'); - expect(unlinkSpy).toHaveBeenCalledTimes(1); - expect(String(unlinkSpy.mock.calls[0][0])).toMatch(/[\\/]covers[\\/]abc123\.jpg$/); - } finally { - existsSpy.mockRestore(); - unlinkSpy.mockRestore(); - } - }); -}); - -describe('resyncReservationDays (#1288)', () => { - const dayFor = (tripId: number, date: string) => - (testDb.prepare('SELECT id FROM days WHERE trip_id = ? AND date = ?').get(tripId, date) as { id: number }).id; - const insertDatedReservation = (tripId: number, dayId: number, time: string) => - Number(testDb.prepare( - "INSERT INTO reservations (trip_id, day_id, title, reservation_time, type, status) VALUES (?, ?, 'Dinner', ?, 'restaurant', 'pending')", - ).run(tripId, dayId, time).lastInsertRowid); - - it('TRIP-SVC-018: changing the start date re-anchors a dated reservation to the day matching its time', () => { - const { user } = createUser(testDb); - const trip = createTrip(testDb, user.id, { start_date: '2025-06-01', end_date: '2025-06-05' }); - const resId = insertDatedReservation(trip.id, dayFor(trip.id, '2025-06-02'), '2025-06-02T19:00:00'); - // Shift the whole range one day forward (days become 2025-06-02..06). - updateTrip(trip.id, user.id, { start_date: '2025-06-02', end_date: '2025-06-06' }, 'user'); - const res = testDb.prepare('SELECT day_id FROM reservations WHERE id = ?').get(resId) as { day_id: number }; - // The booking stays on its absolute date (2025-06-02) instead of shifting with its old day row. - expect(res.day_id).toBe(dayFor(trip.id, '2025-06-02')); - }); - - it('TRIP-SVC-019: a reservation whose date falls outside the new range keeps its day_id (not nulled)', () => { - const { user } = createUser(testDb); - const trip = createTrip(testDb, user.id, { start_date: '2025-06-01', end_date: '2025-06-05' }); - const origDayId = dayFor(trip.id, '2025-06-02'); - const resId = insertDatedReservation(trip.id, origDayId, '2025-06-02T19:00:00'); - // Shift far forward so 2025-06-02 is no longer covered by any day. - updateTrip(trip.id, user.id, { start_date: '2025-06-10', end_date: '2025-06-14' }, 'user'); - const res = testDb.prepare('SELECT day_id FROM reservations WHERE id = ?').get(resId) as { day_id: number }; - expect(res.day_id).toBe(origDayId); - }); -}); - -describe('resyncAccommodationDays (#1288)', () => { - const dayFor = (tripId: number, date: string) => - (testDb.prepare('SELECT id FROM days WHERE trip_id = ? AND date = ?').get(tripId, date) as { id: number }).id; - - const insertAccommodation = (tripId: number, startDayId: number, endDayId: number) => { - const place = createPlace(testDb, tripId, { name: 'Grand Hotel' }); - const acc = createAccommodation(tripId, { - place_id: place.id, start_day_id: startDayId, end_day_id: endDayId, - }) as { id: number }; - const linkedRes = testDb.prepare( - 'SELECT id FROM reservations WHERE accommodation_id = ?', - ).get(acc.id) as { id: number }; - return { accId: acc.id, linkedResId: linkedRes.id }; - }; - - const getAcc = (id: number) => - testDb.prepare('SELECT start_day_id, end_day_id FROM day_accommodations WHERE id = ?').get(id) as - { start_day_id: number; end_day_id: number }; - const getRes = (id: number) => - testDb.prepare('SELECT day_id, reservation_time FROM reservations WHERE id = ?').get(id) as - { day_id: number | null; reservation_time: string | null }; - - it('TRIP-SVC-035: extending the start keeps an accommodation on its absolute dates', () => { - const { user } = createUser(testDb); - const trip = createTrip(testDb, user.id, { start_date: '2025-06-10', end_date: '2025-06-14' }); - const { accId, linkedResId } = insertAccommodation(trip.id, dayFor(trip.id, '2025-06-11'), dayFor(trip.id, '2025-06-13')); - // Add a day at the start: days re-date positionally (old 06-11 row becomes 06-10, …). - updateTrip(trip.id, user.id, { start_date: '2025-06-09', end_date: '2025-06-14' }, 'user'); - const acc = getAcc(accId); - expect(acc.start_day_id).toBe(dayFor(trip.id, '2025-06-11')); - expect(acc.end_day_id).toBe(dayFor(trip.id, '2025-06-13')); - const res = getRes(linkedResId); - expect(res.day_id).toBe(acc.start_day_id); - expect(res.reservation_time?.slice(0, 10)).toBe('2025-06-11'); - }); - - it('TRIP-SVC-036: moving the whole trip out of the old range keeps the accommodation glued to its days', () => { - const { user } = createUser(testDb); - const trip = createTrip(testDb, user.id, { start_date: '2025-06-01', end_date: '2025-06-05' }); - const startDayId = dayFor(trip.id, '2025-06-02'); - const endDayId = dayFor(trip.id, '2025-06-03'); - const { accId, linkedResId } = insertAccommodation(trip.id, startDayId, endDayId); - updateTrip(trip.id, user.id, { start_date: '2025-07-01', end_date: '2025-07-05' }, 'user'); - const acc = getAcc(accId); - expect(acc.start_day_id).toBe(startDayId); - expect(acc.end_day_id).toBe(endDayId); - // The linked reservation follows the (re-dated) start day instead of keeping a stale date snapshot. - const res = getRes(linkedResId); - expect(res.day_id).toBe(startDayId); - expect(res.reservation_time?.slice(0, 10)).toBe('2025-07-02'); - }); - - it("TRIP-SVC-038: date_shift_mode 'shift_all' glues bookings to their days and restamps their times", () => { - const { user } = createUser(testDb); - const trip = createTrip(testDb, user.id, { start_date: '2025-06-01', end_date: '2025-06-05' }); - const origDayId = dayFor(trip.id, '2025-06-02'); - const resId = Number(testDb.prepare( - "INSERT INTO reservations (trip_id, day_id, title, reservation_time, type, status) VALUES (?, ?, 'Dinner', '2025-06-02T19:00:00', 'restaurant', 'pending')", - ).run(trip.id, origDayId).lastInsertRowid); - const { accId } = insertAccommodation(trip.id, origDayId, dayFor(trip.id, '2025-06-03')); - updateTrip(trip.id, user.id, { start_date: '2025-06-03', end_date: '2025-06-07', date_shift_mode: 'shift_all' }, 'user'); - // The booking stays on its day row (now 2025-06-04) and its time follows. - const res = testDb.prepare('SELECT day_id, reservation_time FROM reservations WHERE id = ?').get(resId) as - { day_id: number; reservation_time: string }; - expect(res.day_id).toBe(origDayId); - expect(res.reservation_time).toBe('2025-06-04T19:00:00'); - // The accommodation stays glued to its (re-dated) day rows too. - const acc = getAcc(accId); - expect(acc.start_day_id).toBe(origDayId); - }); - - it('TRIP-SVC-037: a dated hotel reservation without a linked accommodation is re-anchored like other bookings', () => { - const { user } = createUser(testDb); - const trip = createTrip(testDb, user.id, { start_date: '2025-06-01', end_date: '2025-06-05' }); - const resId = Number(testDb.prepare( - "INSERT INTO reservations (trip_id, day_id, title, reservation_time, type, status) VALUES (?, ?, 'Imported hotel', ?, 'hotel', 'pending')", - ).run(trip.id, dayFor(trip.id, '2025-06-02'), '2025-06-02T15:00:00').lastInsertRowid); - updateTrip(trip.id, user.id, { start_date: '2025-06-02', end_date: '2025-06-06' }, 'user'); - const res = testDb.prepare('SELECT day_id FROM reservations WHERE id = ?').get(resId) as { day_id: number }; - expect(res.day_id).toBe(dayFor(trip.id, '2025-06-02')); - }); -}); - -describe('transferOwnership (#973)', () => { - it('TRIP-SVC-020: hands the trip to a member and demotes the former owner to a member', () => { - const { user: owner } = createUser(testDb); - const { user: member } = createUser(testDb); - const trip = createTrip(testDb, owner.id); - addTripMember(testDb, trip.id, member.id); - - const result = transferOwnership(trip.id, member.id, owner.id); - expect(result.toEmail).toBe(member.email); - - const updated = testDb.prepare('SELECT user_id FROM trips WHERE id = ?').get(trip.id) as { user_id: number }; - expect(updated.user_id).toBe(member.id); - - // New owner no longer sits in trip_members, former owner now does. - const memberIds = (testDb.prepare('SELECT user_id FROM trip_members WHERE trip_id = ?').all(trip.id) as { user_id: number }[]).map(r => r.user_id); - expect(memberIds).toContain(owner.id); - expect(memberIds).not.toContain(member.id); - }); - - it('TRIP-SVC-021: rejects a transfer from a non-owner', () => { - const { user: owner } = createUser(testDb); - const { user: member } = createUser(testDb); - const trip = createTrip(testDb, owner.id); - addTripMember(testDb, trip.id, member.id); - // member (not the owner) attempts the transfer - expect(() => transferOwnership(trip.id, member.id, member.id)).toThrow(); - }); - - it('TRIP-SVC-022: rejects a transfer to someone who is not a member', () => { - const { user: owner } = createUser(testDb); - const { user: stranger } = createUser(testDb); - const trip = createTrip(testDb, owner.id); - expect(() => transferOwnership(trip.id, stranger.id, owner.id)).toThrow('New owner must be a trip member'); - }); - - it('TRIP-SVC-023: rejects transferring to yourself', () => { - const { user: owner } = createUser(testDb); - const trip = createTrip(testDb, owner.id); - expect(() => transferOwnership(trip.id, owner.id, owner.id)).toThrow('You already own this trip'); - }); -}); - -describe('guest members (#1362)', () => { - it('TRIP-SVC-030: createGuest adds a credential-less user joined into the trip', () => { - const { user: owner } = createUser(testDb); - const trip = createTrip(testDb, owner.id); - - const { member } = createGuest(trip.id, ' Anna ', owner.id); - expect(member.username).toBe('Anna'); - expect(member.is_guest).toBe(true); - - const row = testDb.prepare('SELECT username, email, password_hash, is_guest, role FROM users WHERE id = ?').get(member.id) as any; - expect(row.is_guest).toBe(1); - expect(row.password_hash).toBe(''); - expect(row.email).toMatch(/@guests\.invalid$/); - expect(row.role).toBe('user'); - - // Joined as a trip member. - const m = testDb.prepare('SELECT id FROM trip_members WHERE trip_id = ? AND user_id = ?').get(trip.id, member.id); - expect(m).toBeTruthy(); - - // Surfaces in listMembers with is_guest=true and the typed display name. - const { members } = listMembers(trip.id, owner.id) as any; - const guest = members.find((x: any) => x.id === member.id); - expect(guest.username).toBe('Anna'); - expect(guest.is_guest).toBe(true); - }); - - it('TRIP-SVC-031: the same guest name is allowed, not suffixed (#1446)', () => { - const { user: owner } = createUser(testDb); - const trip = createTrip(testDb, owner.id); - const a = createGuest(trip.id, 'Sam', owner.id); - const b = createGuest(trip.id, 'Sam', owner.id); - // both keep the plain display name; only the internal (uuid) username differs - expect(a.member.username).toBe('Sam'); - expect(b.member.username).toBe('Sam'); - expect(b.member.id).not.toBe(a.member.id); - const usernames = testDb.prepare('SELECT username FROM users WHERE id IN (?, ?)').all(a.member.id, b.member.id) as { username: string }[]; - expect(usernames[0].username).not.toBe(usernames[1].username); - }); - - it('TRIP-SVC-032: renameGuest updates the display name (trip-scoped, guest-only)', () => { - const { user: owner } = createUser(testDb); - const { user: other } = createUser(testDb); - const otherTrip = createTrip(testDb, other.id); - const trip = createTrip(testDb, owner.id); - const { member } = createGuest(trip.id, 'Bob', owner.id); - - expect(renameGuest(trip.id, member.id, 'Robert')).toBe(true); - expect((testDb.prepare('SELECT display_name FROM users WHERE id = ?').get(member.id) as any).display_name).toBe('Robert'); - - // A real user cannot be renamed through the guest path… - expect(renameGuest(trip.id, owner.id, 'Hacked')).toBe(false); - // …and a guest cannot be renamed from a different trip. - expect(renameGuest(otherTrip.id, member.id, 'Nope')).toBe(false); - }); - - it('TRIP-SVC-033: deleteGuest removes the user (cascading membership), guest-only + trip-scoped', () => { - const { user: owner } = createUser(testDb); - const trip = createTrip(testDb, owner.id); - const { member } = createGuest(trip.id, 'Carol', owner.id); - - // Real members are not deletable via the guest path. - expect(deleteGuest(trip.id, owner.id)).toBe(false); - - expect(deleteGuest(trip.id, member.id)).toBe(true); - expect(testDb.prepare('SELECT id FROM users WHERE id = ?').get(member.id)).toBeUndefined(); - expect(testDb.prepare('SELECT id FROM trip_members WHERE user_id = ?').get(member.id)).toBeUndefined(); - }); - - it('TRIP-SVC-034: a guest is never invitable (addMember) nor a transfer target', () => { - const { user: owner } = createUser(testDb); - const trip = createTrip(testDb, owner.id); - const { member } = createGuest(trip.id, 'Dora', owner.id); - - // The synthetic username/email must not resolve through the invite box. - expect(() => addMember(trip.id, 'Dora', owner.id, owner.id)).toThrow('User not found'); - // Ownership can never be handed to a guest. - expect(() => transferOwnership(trip.id, member.id, owner.id)).toThrow('Cannot transfer ownership to a guest'); - }); -});