diff --git a/server/src/mcp/resources.ts b/server/src/mcp/resources.ts index b7fd44dbf..86e8bd23b 100644 --- a/server/src/mcp/resources.ts +++ b/server/src/mcp/resources.ts @@ -1,7 +1,6 @@ import { McpServer, ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp'; import { canAccessTrip } from '../db/database'; import { listTrips, getTrip, getTripOwner, listMembers } from '../services/tripService'; -import { listDays, listAccommodations } from '../services/dayService'; import { listPlaces } from '../services/placeService'; import { listBudgetItems, getPerPersonSummary, calculateSettlement } from '../services/budgetService'; import { listBucketList, listVisitedCountries, getStats as getAtlasStats, listManuallyVisitedRegions } from '../services/atlasService'; @@ -71,19 +70,8 @@ export function registerResources(server: McpServer, userId: number, scopes: str } ); - // Days with assigned places - if (canReadTrips(scopes)) server.registerResource( - 'trip-days', - new ResourceTemplate('trek://trips/{tripId}/days', { list: undefined }), - { description: 'Days of a trip with their assigned places', mimeType: 'application/json' }, - async (uri, { tripId }) => { - const id = parseId(tripId); - if (id === null || !canAccessTrip(id, userId)) return accessDenied(uri.href); - - const { days } = listDays(id); - return jsonContent(uri.href, days); - } - ); + // The trip-days resource moved to the DI-discovered + // src/nest/days/days.mcp.ts (@ResourceTemplate). // Places in a trip if (canRead(scopes, 'places')) server.registerResource( @@ -121,18 +109,8 @@ export function registerResources(server: McpServer, userId: number, scopes: str // The day-notes resource moved to the DI-discovered // src/nest/days/day-notes.mcp.ts (@ResourceTemplate). - // Accommodations (hotels, rentals) per trip - if (canReadTrips(scopes)) server.registerResource( - 'trip-accommodations', - new ResourceTemplate('trek://trips/{tripId}/accommodations', { list: undefined }), - { description: 'Accommodations (hotels, rentals) for a trip with check-in/out details', mimeType: 'application/json' }, - async (uri, { tripId }) => { - const id = parseId(tripId); - if (id === null || !canAccessTrip(id, userId)) return accessDenied(uri.href); - const accommodations = listAccommodations(id); - return jsonContent(uri.href, accommodations); - } - ); + // 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( diff --git a/server/src/mcp/tools.ts b/server/src/mcp/tools.ts index 78c129adb..b0cfff544 100644 --- a/server/src/mcp/tools.ts +++ b/server/src/mcp/tools.ts @@ -5,7 +5,6 @@ import { registerNotificationTools } from './tools/notifications'; import { registerAtlasTools } from './tools/atlas'; import { registerPlaceTools } from './tools/places'; import { registerCollectionTools } from './tools/collections'; -import { registerDayTools } from './tools/days'; import { registerBudgetTools } from './tools/budget'; import { registerTripTools } from './tools/trips'; import { registerTransportTools } from './tools/transports'; @@ -28,7 +27,8 @@ export function registerTools(server: McpServer, userId: number, scopes: string[ // The reservation tools moved to the DI-discovered src/nest/reservations/ // reservations.mcp.ts (@McpController, attached via the nest-mcp registry below). - registerDayTools(server, userId, scopes); + // The day + accommodation tools moved to the DI-discovered src/nest/days/ + // days.mcp.ts (@McpController, attached via the nest-mcp registry below). // The assignment tools moved to the DI-discovered src/nest/assignments/ // assignments.mcp.ts (@McpController, attached via the nest-mcp registry below). diff --git a/server/src/mcp/tools/days.ts b/server/src/mcp/tools/days.ts deleted file mode 100644 index 30fc7962b..000000000 --- a/server/src/mcp/tools/days.ts +++ /dev/null @@ -1,222 +0,0 @@ -import { McpServer } from '@modelcontextprotocol/sdk/server/mcp'; -import { z } from 'zod'; -import { canAccessTrip, db } from '../../db/database'; -import { isDemoUser } from '../../services/authService'; -import { - getDay, updateDay, validateAccommodationRefs, - createDay, deleteDay, - createAccommodation, getAccommodation, updateAccommodation, deleteAccommodation, -} from '../../services/dayService'; -import { createPlace } from '../../services/placeService'; -import { - safeBroadcast, TOOL_ANNOTATIONS_WRITE, TOOL_ANNOTATIONS_DELETE, - TOOL_ANNOTATIONS_NON_IDEMPOTENT, - demoDenied, noAccess, ok, hasTripPermission, permissionDenied, -} from './_shared'; -import { canWrite } from '../scopes'; - -export function registerDayTools(server: McpServer, userId: number, scopes: string[] | null): void { - if (!canWrite(scopes, 'trips')) return; - - // --- DAYS --- - - server.registerTool( - 'update_day', - { - description: 'Set the title of a day in a trip (e.g. "Arrival in Paris", "Free day").', - inputSchema: { - tripId: z.number().int().positive(), - dayId: z.number().int().positive(), - title: z.string().max(200).nullable().describe('Day title, or null to clear it'), - }, - annotations: TOOL_ANNOTATIONS_WRITE, - }, - async ({ tripId, dayId, title }) => { - if (isDemoUser(userId)) return demoDenied(); - if (!canAccessTrip(tripId, userId)) return noAccess(); - if (!hasTripPermission('day_edit', tripId, userId)) return permissionDenied(); - const current = getDay(dayId, tripId); - if (!current) return { content: [{ type: 'text' as const, text: 'Day not found.' }], isError: true }; - const updated = updateDay(dayId, current, title !== undefined ? { title } : {}); - safeBroadcast(tripId, 'day:updated', { day: updated }); - return ok({ day: updated }); - } - ); - - server.registerTool( - 'create_day', - { - description: 'Add a new day to a trip (optionally with a specific date and notes).', - inputSchema: { - tripId: z.number().int().positive(), - date: z.string().optional().describe('ISO date string YYYY-MM-DD, optional for dateless trips'), - notes: z.string().optional(), - }, - annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT, - }, - async ({ tripId, date, notes }) => { - if (isDemoUser(userId)) return demoDenied(); - if (!canAccessTrip(tripId, userId)) return noAccess(); - if (!hasTripPermission('day_edit', tripId, userId)) return permissionDenied(); - const day = createDay(tripId, date, notes); - safeBroadcast(tripId, 'day:created', { day }); - return ok({ day }); - } - ); - - server.registerTool( - 'delete_day', - { - description: 'Delete a day from a trip.', - inputSchema: { - tripId: z.number().int().positive(), - dayId: z.number().int().positive(), - }, - annotations: TOOL_ANNOTATIONS_DELETE, - }, - async ({ tripId, dayId }) => { - if (isDemoUser(userId)) return demoDenied(); - if (!canAccessTrip(tripId, userId)) return noAccess(); - if (!hasTripPermission('day_edit', tripId, userId)) return permissionDenied(); - if (!getDay(dayId, tripId)) return { content: [{ type: 'text' as const, text: 'Day not found.' }], isError: true }; - deleteDay(dayId); - safeBroadcast(tripId, 'day:deleted', { id: dayId }); - return ok({ success: true }); - } - ); - - server.registerTool( - 'create_accommodation', - { - description: 'Add an accommodation (hotel, Airbnb, etc.) to a trip, linked to a place and a date range.', - inputSchema: { - tripId: z.number().int().positive(), - place_id: z.number().int().positive().describe('The place to use as the accommodation'), - start_day_id: z.number().int().positive().describe('Check-in day ID'), - end_day_id: z.number().int().positive().describe('Check-out day ID'), - check_in: z.string().max(10).optional().describe('Check-in time e.g. "15:00"'), - check_in_end: z.string().max(10).optional().describe('Check-in window end time e.g. "20:00"'), - check_out: z.string().max(10).optional().describe('Check-out time e.g. "11:00"'), - confirmation: z.string().max(100).optional(), - notes: z.string().max(1000).optional(), - }, - annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT, - }, - async ({ tripId, place_id, start_day_id, end_day_id, check_in, check_in_end, check_out, confirmation, notes }) => { - if (isDemoUser(userId)) return demoDenied(); - if (!canAccessTrip(tripId, userId)) return noAccess(); - if (!hasTripPermission('day_edit', tripId, userId)) return permissionDenied(); - const errors = validateAccommodationRefs(tripId, place_id, start_day_id, end_day_id); - if (errors.length > 0) return { content: [{ type: 'text' as const, text: errors.map(e => e.message).join(', ') }], isError: true }; - const accommodation = createAccommodation(tripId, { place_id, start_day_id, end_day_id, check_in, check_in_end, check_out, confirmation, notes }); - safeBroadcast(tripId, 'accommodation:created', { accommodation }); - return ok({ accommodation }); - } - ); - - server.registerTool( - 'create_place_accommodation', - { - description: 'Create a new place and immediately set it as an accommodation for a date range in one atomic operation. Use place details from search_place results. Only use when the place does not yet exist — if it already exists, use create_accommodation directly. Set price + currency to record the accommodation cost so it shows on the item.', - inputSchema: { - tripId: z.number().int().positive(), - name: z.string().min(1).max(200), - description: z.string().max(2000).optional(), - lat: z.number().optional(), - lng: z.number().optional(), - address: z.string().max(500).optional(), - category_id: z.number().int().positive().optional().describe('Category ID — use list_categories to see available options'), - google_place_id: z.string().optional().describe('Google Place ID from search_place — enables opening hours display'), - google_ftid: z.string().optional().describe('Google Maps feature ID from search_place — enables direct Google Maps links'), - osm_id: z.string().optional().describe('OpenStreetMap ID from search_place (e.g. "way:12345")'), - place_notes: z.string().max(2000).optional().describe('Notes for the place'), - website: z.string().max(500).optional(), - phone: z.string().max(50).optional(), - start_day_id: z.number().int().positive().describe('Check-in day ID'), - end_day_id: z.number().int().positive().describe('Check-out day ID'), - check_in: z.string().max(10).optional().describe('Check-in time e.g. "15:00"'), - check_in_end: z.string().max(10).optional().describe('Check-in window end time e.g. "20:00"'), - check_out: z.string().max(10).optional().describe('Check-out time e.g. "11:00"'), - confirmation: z.string().max(100).optional(), - accommodation_notes: z.string().max(1000).optional().describe('Notes for the accommodation'), - price: z.number().nonnegative().optional().describe('Total accommodation cost (shown on the item)'), - currency: z.string().length(3).optional().describe('ISO 4217 currency code (e.g. "EUR", "USD")'), - }, - annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT, - }, - async ({ tripId, name, description, lat, lng, address, category_id, google_place_id, google_ftid, osm_id, place_notes, website, phone, start_day_id, end_day_id, check_in, check_in_end, check_out, confirmation, accommodation_notes, price, currency }) => { - if (isDemoUser(userId)) return demoDenied(); - if (!canAccessTrip(tripId, userId)) return noAccess(); - if (!hasTripPermission('day_edit', tripId, userId)) return permissionDenied(); - const dayErrors = validateAccommodationRefs(tripId, undefined, start_day_id, end_day_id); - if (dayErrors.length > 0) return { content: [{ type: 'text' as const, text: dayErrors.map(e => e.message).join(', ') }], isError: true }; - try { - const run = db.transaction(() => { - const place = createPlace(String(tripId), { name, description, lat, lng, address, category_id, google_place_id, google_ftid, osm_id, notes: place_notes, website, phone, price, currency }); - const accommodation = createAccommodation(tripId, { place_id: place.id, start_day_id, end_day_id, check_in, check_in_end, check_out, confirmation, notes: accommodation_notes }); - return { place, accommodation }; - }); - const result = run(); - safeBroadcast(tripId, 'place:created', { place: result.place }); - safeBroadcast(tripId, 'accommodation:created', { accommodation: result.accommodation }); - return ok(result); - } catch { - return { content: [{ type: 'text' as const, text: 'Failed to create place and accommodation.' }], isError: true }; - } - } - ); - - server.registerTool( - 'update_accommodation', - { - description: 'Update fields on an existing accommodation.', - inputSchema: { - tripId: z.number().int().positive(), - accommodationId: z.number().int().positive(), - place_id: z.number().int().positive().optional(), - start_day_id: z.number().int().positive().optional(), - end_day_id: z.number().int().positive().optional(), - check_in: z.string().max(10).optional(), - check_in_end: z.string().max(10).optional().describe('Check-in window end time e.g. "20:00"'), - check_out: z.string().max(10).optional(), - confirmation: z.string().max(100).optional(), - notes: z.string().max(1000).optional(), - }, - annotations: TOOL_ANNOTATIONS_WRITE, - }, - async ({ tripId, accommodationId, place_id, start_day_id, end_day_id, check_in, check_in_end, check_out, confirmation, notes }) => { - if (isDemoUser(userId)) return demoDenied(); - if (!canAccessTrip(tripId, userId)) return noAccess(); - if (!hasTripPermission('day_edit', tripId, userId)) return permissionDenied(); - const existing = getAccommodation(accommodationId, tripId); - if (!existing) return { content: [{ type: 'text' as const, text: 'Accommodation not found.' }], isError: true }; - const accommodation = updateAccommodation(accommodationId, existing, { place_id, start_day_id, end_day_id, check_in, check_in_end, check_out, confirmation, notes }); - safeBroadcast(tripId, 'accommodation:updated', { accommodation }); - return ok({ accommodation }); - } - ); - - server.registerTool( - 'delete_accommodation', - { - description: 'Delete an accommodation from a trip.', - inputSchema: { - tripId: z.number().int().positive(), - accommodationId: z.number().int().positive(), - }, - annotations: TOOL_ANNOTATIONS_DELETE, - }, - async ({ tripId, accommodationId }) => { - if (isDemoUser(userId)) return demoDenied(); - if (!canAccessTrip(tripId, userId)) return noAccess(); - if (!hasTripPermission('day_edit', tripId, userId)) return permissionDenied(); - if (!getAccommodation(accommodationId, tripId)) return { content: [{ type: 'text' as const, text: 'Accommodation not found.' }], isError: true }; - const { linkedReservationId } = deleteAccommodation(accommodationId); - safeBroadcast(tripId, 'accommodation:deleted', { id: accommodationId, linkedReservationId }); - return ok({ success: true, linkedReservationId }); - } - ); - - // The day-note tools (create/update/delete_day_note) moved to the - // DI-discovered src/nest/days/day-notes.mcp.ts (@McpController). -} diff --git a/server/src/mcp/tools/transit.ts b/server/src/mcp/tools/transit.ts index a25bb2d6e..29c23a037 100644 --- a/server/src/mcp/tools/transit.ts +++ b/server/src/mcp/tools/transit.ts @@ -1,7 +1,7 @@ import { canAccessTrip } from '../../db/database'; import { RateLimitService } from '../../nest/auth/rate-limit.service'; import { isDemoUser } from '../../services/authService'; -import { getDay, listDays } from '../../services/dayService'; +import { getDay, listDays } from '../../nest/days/days.bridge'; import { createReservation, notifyBookingChange } from '../../nest/reservations/reservations.bridge'; import { buildTransitReservationParts, diff --git a/server/src/mcp/tools/transports.ts b/server/src/mcp/tools/transports.ts index 38fea052d..3772cf847 100644 --- a/server/src/mcp/tools/transports.ts +++ b/server/src/mcp/tools/transports.ts @@ -7,7 +7,7 @@ import { type EndpointInput, } from '../../nest/reservations/reservations.bridge'; import { linkBudgetItemToReservation } from '../../services/budgetService'; -import { getDay } from '../../services/dayService'; +import { getDay } from '../../nest/days/days.bridge'; import { findByIata } from '../../services/airportService'; import { safeBroadcast, TOOL_ANNOTATIONS_DELETE, TOOL_ANNOTATIONS_NON_IDEMPOTENT, diff --git a/server/src/nest/README.md b/server/src/nest/README.md index f40125d4f..ffb39185d 100644 --- a/server/src/nest/README.md +++ b/server/src/nest/README.md @@ -28,7 +28,7 @@ mount to Nest and leaves the sibling trip routes (days, places, ...) on Express. - **Phase 2 (trip sub-domains):** vacay (addon), packing, todo. - **DI-native services (legacy `src/services/*` deleted):** tags, categories, todo, packing, day-notes, trip-invite, assignments, share, settings, files, - collab, vacay, reservations — see the migration recipe below. + collab, vacay, reservations, day — see the migration recipe below. ## Cross-cutting Foundation pieces @@ -110,8 +110,8 @@ surface, no plugin-host import and no bridge — the SQL folded straight into `trip-invite.service.ts`); assignments followed (a 7-tool `assignments.mcp.ts`, the plugin-host swap, and a bridge kept only for the two legacy registrars — places and reservations — that borrow its existence checks; the batch loaders -stay in `services/queryHelpers.ts`, shared with the unmigrated day/place -services); share followed (never imported by the plugin host, and its three MCP +stay in `services/queryHelpers.ts`, shared with the unmigrated place +service); share followed (never imported by the plugin host, and its three MCP tools stay in the legacy trips registrar — their `trips:share` scope gate has no declarative `access: { group, mode }` equivalent — so the port is the SQL fold plus a 3-export `share.bridge.ts` for `mcp/tools/trips.ts`); settings @@ -144,10 +144,22 @@ injection, and a 9-export + 3-type `reservations.bridge.ts` for the legacy `tripService`, the airtrail import/sync pair and the still-legacy transit + transports registrars; the DTO ratchet for its 4 grandfathered body contracts landed as a sibling commit, which also loosened the shared positions schema to -the real wire contract — `day_plan_position` optional, pinned by RESV-006). +the real wire contract — `day_plan_position` optional, pinned by RESV-006); +day followed (the 592-line legacy `dayService` folded into the existing +wrapper `DaysService` — including the accommodation SQL that +`nest/reservations/accommodations.service.ts` now reaches via an injected +`DaysService` — with the hand-rolled `BEGIN`/`COMMIT` blocks in +reorder/insert converted to `db.transaction()`; a 7-tool + 2-resource +`days.mcp.ts`, the plugin host's 11-symbol import swapped for the injected +service, `TripsService` + the assignments/reservations MCP controllers +converted to injection, and a 6-export `days.bridge.ts` for the legacy +`tripService` and the still-legacy transit + transports registrars; the DTO +ratchet for its 4 day + 2 accommodation grandfathered body contracts landed +as a sibling commit). Repeat these steps per -service (next up: dayService — the dependency-honest order in -`migration-graph.md` promotes it now that reservations is folded). This is a +service (next up: permissions + auditLog — the Wave-2 cross-cutting pair the +dependency-honest order in `migration-graph.md` promotes now that dayService +is folded; then the exchangeRateService fold → budgetService). This is a **pure relocation** — byte-identical SQL, statuses, bodies, and error strings. The plugin RPC host is **no longer a bridge consumer**: since Option A of `src/nest/plugins/DI-MIGRATION.md` it diff --git a/server/src/nest/assignments/assignments.mcp.ts b/server/src/nest/assignments/assignments.mcp.ts index 45bfe33cd..4449c0765 100644 --- a/server/src/nest/assignments/assignments.mcp.ts +++ b/server/src/nest/assignments/assignments.mcp.ts @@ -6,9 +6,9 @@ import { } from '@trek/nest-mcp'; import { z } from 'zod'; import { isDemoUser } from '../../services/authService'; -import { getDay } from '../../services/dayService'; import { safeBroadcast, noAccess, hasTripPermission, permissionDenied } from '../../mcp/tools/_shared'; import { AssignmentsService } from './assignments.service'; +import { DaysService } from '../days/days.service'; /** * Assignment MCP surface — ported 1:1 from the legacy registrar in @@ -16,12 +16,15 @@ import { AssignmentsService } from './assignments.service'; * annotations, error/payload shapes and broadcasts). The legacy `if (R)` / * `if (W)` registration gates on the 'places' scope group map to the * declarative access markers (resolved by trekMcpAccessPolicy); there is no - * addon gate, so no `when`. The target-day check on move keeps its legacy - * dayService.getDay call path. + * addon gate, so no `when`. The target-day check on move uses the injected + * DaysService.getDay. */ @McpController() export class AssignmentsMcp { - constructor(private readonly assignments: AssignmentsService) {} + constructor( + private readonly assignments: AssignmentsService, + private readonly days: DaysService, + ) {} @Tool({ name: 'assign_place_to_day', @@ -132,7 +135,7 @@ export class AssignmentsMcp { if (!this.assignments.verifyTripAccess(tripId, ctx.userId)) return noAccess(); if (!hasTripPermission('day_edit', tripId, ctx.userId)) return permissionDenied(); if (!this.assignments.getAssignmentForTrip(assignmentId, tripId)) return errorResult('Assignment not found.'); - if (!getDay(newDayId, tripId)) return errorResult('Day not found.'); + if (!this.days.getDay(newDayId, tripId)) return errorResult('Day not found.'); const result = this.assignments.moveAssignment(assignmentId, newDayId, orderIndex ?? 0); safeBroadcast(tripId, 'assignment:moved', { assignment: result.assignment, oldDayId: result.oldDayId }); this.assignments.reconcile(tripId); @@ -201,7 +204,7 @@ export class AssignmentsMcp { if (isDemoUser(ctx.userId)) return demoDenied(); if (!this.assignments.verifyTripAccess(tripId, ctx.userId)) return noAccess(); if (!hasTripPermission('day_edit', tripId, ctx.userId)) return permissionDenied(); - if (!getDay(dayId, tripId)) return errorResult('Day not found.'); + if (!this.days.getDay(dayId, tripId)) return errorResult('Day not found.'); this.assignments.reorderAssignments(dayId, assignmentIds); safeBroadcast(tripId, 'assignment:reordered', { dayId, assignmentIds }); return ok({ success: true, dayId, order: assignmentIds }); diff --git a/server/src/nest/assignments/assignments.module.ts b/server/src/nest/assignments/assignments.module.ts index b192a74ed..69866ef64 100644 --- a/server/src/nest/assignments/assignments.module.ts +++ b/server/src/nest/assignments/assignments.module.ts @@ -1,4 +1,5 @@ import { Module } from '@nestjs/common'; +import { DaysModule } from '../days/days.module'; import { DayAssignmentsController, AssignmentOpsController } from './assignments.controller'; import { AssignmentsService } from './assignments.service'; import { AssignmentsMcp } from './assignments.mcp'; @@ -10,6 +11,8 @@ import { AssignmentsMcp } from './assignments.mcp'; * the plugin RPC host (PluginHostDepsFactory injects it). */ @Module({ + // DaysModule: AssignmentsMcp injects DaysService for the target-day checks. + imports: [DaysModule], controllers: [DayAssignmentsController, AssignmentOpsController], providers: [AssignmentsService, AssignmentsMcp], exports: [AssignmentsService], diff --git a/server/src/nest/days/days.bridge.ts b/server/src/nest/days/days.bridge.ts new file mode 100644 index 000000000..411b25dce --- /dev/null +++ b/server/src/nest/days/days.bridge.ts @@ -0,0 +1,48 @@ +import { db } from '../../db/database'; +import { DatabaseService } from '../database/database.service'; +import { DaysService, addDays } from './days.service'; + +/** + * Non-Nest entry point for the day domain — for code running OUTSIDE the + * Nest container (the legacy services/tripService.ts and the legacy + * transit/transports MCP registrars in src/mcp/tools/; the day MCP tools and + * resources moved to the DI-discovered days.mcp.ts, and the plugin RPC host + * injects DaysService via PluginHostDepsFactory). Exports only the legacy + * services/dayService names still consumed outside the container, 1:1, so + * repointing a consumer is an import-path-only diff. Inside the container, + * inject DaysService instead. Delete this file when tripService and the + * transit/transports registrars migrate. + * + * Module-level construction is safe: `db` is the reinitialize-proof Proxy onto + * the shared better-sqlite3 singleton. + */ +const days = new DaysService(new DatabaseService(db)); + +export { addDays }; + +export function getDay(id: string | number, tripId: string | number) { + return days.getDay(id, tripId); +} + +export function listDays(tripId: string | number) { + return days.list(tripId); +} + +export function listAccommodations(tripId: string | number) { + return days.listAccommodations(tripId); +} + +export function restampReservationDates( + tripId: string | number, + oldDateById: Map, + newDateById: Map, +): void { + days.restampReservationDates(tripId, oldDateById, newDateById); +} + +export function resyncAccommodationDays( + tripId: string | number, + prevDateByDayId: Map, +): void { + days.resyncAccommodationDays(tripId, prevDateByDayId); +} diff --git a/server/src/nest/days/days.mcp.ts b/server/src/nest/days/days.mcp.ts new file mode 100644 index 000000000..e94a94210 --- /dev/null +++ b/server/src/nest/days/days.mcp.ts @@ -0,0 +1,309 @@ +import { + McpController, Tool, ResourceTemplate, type McpContext, + 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 { createPlace } from '../../services/placeService'; +import { safeBroadcast, noAccess, hasTripPermission, permissionDenied } from '../../mcp/tools/_shared'; +import { DatabaseService } from '../database/database.service'; +import { DaysService } from './days.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; +} + +/** + * Day MCP surface — ported 1:1 from the legacy registrars: the seven tools of + * src/mcp/tools/days.ts and the trek://trips/{tripId}/days + + * trek://trips/{tripId}/accommodations resources from src/mcp/resources.ts + * (identical names, descriptions, schemas, annotations, error/payload shapes + * and broadcasts). The legacy registration-time gates map to the declarative + * trips write/read access markers (registerDayTools' whole-registrar + * `canWrite(scopes, 'trips')` early return and the resources' canReadTrips + * checks, resolved by trekMcpAccessPolicy — note canReadTrips also accepted + * trips:delete / trips:share-only tokens; the declarative read marker is + * marginally narrower for those, same trade day-notes made). No addon gate — + * days are core. + */ +@McpController() +export class DaysMcp { + constructor( + private readonly days: DaysService, + private readonly db: DatabaseService, + ) {} + + @Tool({ + name: 'update_day', + description: 'Set the title of a day in a trip (e.g. "Arrival in Paris", "Free day").', + inputSchema: { + tripId: z.number().int().positive(), + dayId: z.number().int().positive(), + title: z.string().max(200).nullable().describe('Day title, or null to clear it'), + }, + annotations: TOOL_ANNOTATIONS_WRITE, + access: { group: 'trips', mode: 'write' }, + }) + async updateDay( + { tripId, dayId, title }: { tripId: number; dayId: number; title: string | null }, + ctx: McpContext, + ) { + if (isDemoUser(ctx.userId)) return demoDenied(); + if (!this.days.verifyTripAccess(tripId, ctx.userId)) return noAccess(); + if (!hasTripPermission('day_edit', tripId, ctx.userId)) return permissionDenied(); + const current = this.days.getDay(dayId, tripId); + if (!current) return { content: [{ type: 'text' as const, text: 'Day not found.' }], isError: true }; + const updated = this.days.update(dayId, current, title !== undefined ? { title } : {}); + safeBroadcast(tripId, 'day:updated', { day: updated }); + return ok({ day: updated }); + } + + @Tool({ + name: 'create_day', + description: 'Add a new day to a trip (optionally with a specific date and notes).', + inputSchema: { + tripId: z.number().int().positive(), + date: z.string().optional().describe('ISO date string YYYY-MM-DD, optional for dateless trips'), + notes: z.string().optional(), + }, + annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT, + access: { group: 'trips', mode: 'write' }, + }) + async createDay( + { tripId, date, notes }: { tripId: number; date?: string; notes?: string }, + ctx: McpContext, + ) { + if (isDemoUser(ctx.userId)) return demoDenied(); + if (!this.days.verifyTripAccess(tripId, ctx.userId)) return noAccess(); + if (!hasTripPermission('day_edit', tripId, ctx.userId)) return permissionDenied(); + const day = this.days.create(tripId, date, notes); + safeBroadcast(tripId, 'day:created', { day }); + return ok({ day }); + } + + @Tool({ + name: 'delete_day', + description: 'Delete a day from a trip.', + inputSchema: { + tripId: z.number().int().positive(), + dayId: z.number().int().positive(), + }, + annotations: TOOL_ANNOTATIONS_DELETE, + access: { group: 'trips', mode: 'write' }, + }) + async deleteDay({ tripId, dayId }: { tripId: number; dayId: number }, ctx: McpContext) { + if (isDemoUser(ctx.userId)) return demoDenied(); + if (!this.days.verifyTripAccess(tripId, ctx.userId)) return noAccess(); + if (!hasTripPermission('day_edit', tripId, ctx.userId)) return permissionDenied(); + if (!this.days.getDay(dayId, tripId)) return { content: [{ type: 'text' as const, text: 'Day not found.' }], isError: true }; + this.days.remove(dayId); + safeBroadcast(tripId, 'day:deleted', { id: dayId }); + return ok({ success: true }); + } + + @Tool({ + name: 'create_accommodation', + description: 'Add an accommodation (hotel, Airbnb, etc.) to a trip, linked to a place and a date range.', + inputSchema: { + tripId: z.number().int().positive(), + place_id: z.number().int().positive().describe('The place to use as the accommodation'), + start_day_id: z.number().int().positive().describe('Check-in day ID'), + end_day_id: z.number().int().positive().describe('Check-out day ID'), + check_in: z.string().max(10).optional().describe('Check-in time e.g. "15:00"'), + check_in_end: z.string().max(10).optional().describe('Check-in window end time e.g. "20:00"'), + check_out: z.string().max(10).optional().describe('Check-out time e.g. "11:00"'), + confirmation: z.string().max(100).optional(), + notes: z.string().max(1000).optional(), + }, + annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT, + access: { group: 'trips', mode: 'write' }, + }) + async createAccommodation( + { tripId, place_id, start_day_id, end_day_id, check_in, check_in_end, check_out, confirmation, notes }: { + tripId: number; place_id: number; start_day_id: number; end_day_id: number; + check_in?: string; check_in_end?: string; check_out?: string; confirmation?: string; notes?: string; + }, + ctx: McpContext, + ) { + if (isDemoUser(ctx.userId)) return demoDenied(); + if (!this.days.verifyTripAccess(tripId, ctx.userId)) return noAccess(); + if (!hasTripPermission('day_edit', tripId, ctx.userId)) return permissionDenied(); + const errors = this.days.validateAccommodationRefs(tripId, place_id, start_day_id, end_day_id); + if (errors.length > 0) return { content: [{ type: 'text' as const, text: errors.map(e => e.message).join(', ') }], isError: true }; + const accommodation = this.days.createAccommodation(tripId, { place_id, start_day_id, end_day_id, check_in, check_in_end, check_out, confirmation, notes }); + safeBroadcast(tripId, 'accommodation:created', { accommodation }); + return ok({ accommodation }); + } + + @Tool({ + name: 'create_place_accommodation', + description: 'Create a new place and immediately set it as an accommodation for a date range in one atomic operation. Use place details from search_place results. Only use when the place does not yet exist — if it already exists, use create_accommodation directly. Set price + currency to record the accommodation cost so it shows on the item.', + inputSchema: { + tripId: z.number().int().positive(), + name: z.string().min(1).max(200), + description: z.string().max(2000).optional(), + lat: z.number().optional(), + lng: z.number().optional(), + address: z.string().max(500).optional(), + category_id: z.number().int().positive().optional().describe('Category ID — use list_categories to see available options'), + google_place_id: z.string().optional().describe('Google Place ID from search_place — enables opening hours display'), + google_ftid: z.string().optional().describe('Google Maps feature ID from search_place — enables direct Google Maps links'), + osm_id: z.string().optional().describe('OpenStreetMap ID from search_place (e.g. "way:12345")'), + place_notes: z.string().max(2000).optional().describe('Notes for the place'), + website: z.string().max(500).optional(), + phone: z.string().max(50).optional(), + start_day_id: z.number().int().positive().describe('Check-in day ID'), + end_day_id: z.number().int().positive().describe('Check-out day ID'), + check_in: z.string().max(10).optional().describe('Check-in time e.g. "15:00"'), + check_in_end: z.string().max(10).optional().describe('Check-in window end time e.g. "20:00"'), + check_out: z.string().max(10).optional().describe('Check-out time e.g. "11:00"'), + confirmation: z.string().max(100).optional(), + accommodation_notes: z.string().max(1000).optional().describe('Notes for the accommodation'), + price: z.number().nonnegative().optional().describe('Total accommodation cost (shown on the item)'), + currency: z.string().length(3).optional().describe('ISO 4217 currency code (e.g. "EUR", "USD")'), + }, + annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT, + access: { group: 'trips', mode: 'write' }, + }) + async createPlaceAccommodation( + { tripId, name, description, lat, lng, address, category_id, google_place_id, google_ftid, osm_id, place_notes, website, phone, start_day_id, end_day_id, check_in, check_in_end, check_out, confirmation, accommodation_notes, price, currency }: { + tripId: number; name: string; description?: string; lat?: number; lng?: number; address?: string; + category_id?: number; google_place_id?: string; google_ftid?: string; osm_id?: string; + place_notes?: string; website?: string; phone?: string; start_day_id: number; end_day_id: number; + check_in?: string; check_in_end?: string; check_out?: string; confirmation?: string; + accommodation_notes?: string; price?: number; currency?: string; + }, + ctx: McpContext, + ) { + if (isDemoUser(ctx.userId)) return demoDenied(); + if (!this.days.verifyTripAccess(tripId, ctx.userId)) return noAccess(); + if (!hasTripPermission('day_edit', tripId, ctx.userId)) return permissionDenied(); + const dayErrors = this.days.validateAccommodationRefs(tripId, undefined, start_day_id, end_day_id); + if (dayErrors.length > 0) return { content: [{ type: 'text' as const, text: dayErrors.map(e => e.message).join(', ') }], isError: true }; + try { + const result = this.db.transaction(() => { + const place = createPlace(String(tripId), { name, description, lat, lng, address, category_id, google_place_id, google_ftid, osm_id, notes: place_notes, website, phone, price, currency }); + const accommodation = this.days.createAccommodation(tripId, { place_id: place.id, start_day_id, end_day_id, check_in, check_in_end, check_out, confirmation, notes: accommodation_notes }); + return { place, accommodation }; + }); + safeBroadcast(tripId, 'place:created', { place: result.place }); + safeBroadcast(tripId, 'accommodation:created', { accommodation: result.accommodation }); + return ok(result); + } catch { + return { content: [{ type: 'text' as const, text: 'Failed to create place and accommodation.' }], isError: true }; + } + } + + @Tool({ + name: 'update_accommodation', + description: 'Update fields on an existing accommodation.', + inputSchema: { + tripId: z.number().int().positive(), + accommodationId: z.number().int().positive(), + place_id: z.number().int().positive().optional(), + start_day_id: z.number().int().positive().optional(), + end_day_id: z.number().int().positive().optional(), + check_in: z.string().max(10).optional(), + check_in_end: z.string().max(10).optional().describe('Check-in window end time e.g. "20:00"'), + check_out: z.string().max(10).optional(), + confirmation: z.string().max(100).optional(), + notes: z.string().max(1000).optional(), + }, + annotations: TOOL_ANNOTATIONS_WRITE, + access: { group: 'trips', mode: 'write' }, + }) + async updateAccommodation( + { tripId, accommodationId, place_id, start_day_id, end_day_id, check_in, check_in_end, check_out, confirmation, notes }: { + tripId: number; accommodationId: number; place_id?: number; start_day_id?: number; end_day_id?: number; + check_in?: string; check_in_end?: string; check_out?: string; confirmation?: string; notes?: string; + }, + ctx: McpContext, + ) { + if (isDemoUser(ctx.userId)) return demoDenied(); + if (!this.days.verifyTripAccess(tripId, ctx.userId)) return noAccess(); + if (!hasTripPermission('day_edit', tripId, ctx.userId)) return permissionDenied(); + const existing = this.days.getAccommodation(accommodationId, tripId); + if (!existing) return { content: [{ type: 'text' as const, text: 'Accommodation not found.' }], isError: true }; + const accommodation = this.days.updateAccommodation(accommodationId, existing, { place_id, start_day_id, end_day_id, check_in, check_in_end, check_out, confirmation, notes }); + safeBroadcast(tripId, 'accommodation:updated', { accommodation }); + return ok({ accommodation }); + } + + @Tool({ + name: 'delete_accommodation', + description: 'Delete an accommodation from a trip.', + inputSchema: { + tripId: z.number().int().positive(), + accommodationId: z.number().int().positive(), + }, + annotations: TOOL_ANNOTATIONS_DELETE, + access: { group: 'trips', mode: 'write' }, + }) + async deleteAccommodation({ tripId, accommodationId }: { tripId: number; accommodationId: number }, ctx: McpContext) { + if (isDemoUser(ctx.userId)) return demoDenied(); + if (!this.days.verifyTripAccess(tripId, ctx.userId)) return noAccess(); + if (!hasTripPermission('day_edit', tripId, ctx.userId)) return permissionDenied(); + if (!this.days.getAccommodation(accommodationId, tripId)) return { content: [{ type: 'text' as const, text: 'Accommodation not found.' }], isError: true }; + const { linkedReservationId } = this.days.deleteAccommodation(accommodationId); + safeBroadcast(tripId, 'accommodation:deleted', { id: accommodationId, linkedReservationId }); + return ok({ success: true, linkedReservationId }); + } + + @ResourceTemplate({ + name: 'trip-days', + uriTemplate: 'trek://trips/{tripId}/days', + description: 'Days of a trip with their assigned places', + mimeType: 'application/json', + access: { group: 'trips', mode: 'read' }, + }) + async tripDaysResource(uri: URL, { tripId }: { tripId: string | string[] }, ctx: McpContext) { + const id = parseId(tripId); + if (id === null || !this.days.verifyTripAccess(id, ctx.userId)) { + return { + contents: [{ + uri: uri.href, + mimeType: 'application/json', + text: JSON.stringify({ error: 'Trip not found or access denied' }), + }], + }; + } + const { days } = this.days.list(id); + return { + contents: [{ + uri: uri.href, + mimeType: 'application/json', + text: JSON.stringify(days, null, 2), + }], + }; + } + + @ResourceTemplate({ + name: 'trip-accommodations', + uriTemplate: 'trek://trips/{tripId}/accommodations', + description: 'Accommodations (hotels, rentals) for a trip with check-in/out details', + mimeType: 'application/json', + access: { group: 'trips', mode: 'read' }, + }) + async tripAccommodationsResource(uri: URL, { tripId }: { tripId: string | string[] }, ctx: McpContext) { + const id = parseId(tripId); + if (id === null || !this.days.verifyTripAccess(id, ctx.userId)) { + return { + contents: [{ + uri: uri.href, + mimeType: 'application/json', + text: JSON.stringify({ error: 'Trip not found or access denied' }), + }], + }; + } + const accommodations = this.days.listAccommodations(id); + return { + contents: [{ + uri: uri.href, + mimeType: 'application/json', + text: JSON.stringify(accommodations, null, 2), + }], + }; + } +} diff --git a/server/src/nest/days/days.module.ts b/server/src/nest/days/days.module.ts index 84389896c..54ff9cf30 100644 --- a/server/src/nest/days/days.module.ts +++ b/server/src/nest/days/days.module.ts @@ -1,6 +1,7 @@ import { Module } from '@nestjs/common'; import { DaysController } from './days.controller'; import { DaysService } from './days.service'; +import { DaysMcp } from './days.mcp'; import { DayNotesController } from './day-notes.controller'; import { DayNotesService } from './day-notes.service'; import { DayNotesMcp } from './day-notes.mcp'; @@ -8,12 +9,15 @@ import { DayNotesMcp } from './day-notes.mcp'; /** * Days + day-notes domain (S6 — Phase 2 trip sub-domain). The single prefix * /api/trips/:tripId/days covers both the days mount and the nested - * /days/:dayId/notes mount. DayNotesService is exported for the plugin RPC - * host (PluginHostDepsFactory). + * /days/:dayId/notes mount. DaysMcp/DayNotesMcp carry the + * decorator-registered MCP tools + resources. DaysService and DayNotesService + * are exported for in-container consumers (PluginHostDepsFactory, + * AccommodationsService, TripsService, the assignments/reservations MCP + * controllers). */ @Module({ controllers: [DaysController, DayNotesController], - providers: [DaysService, DayNotesService, DayNotesMcp], - exports: [DayNotesService], + providers: [DaysService, DaysMcp, DayNotesService, DayNotesMcp], + exports: [DaysService, DayNotesService], }) export class DaysModule {} diff --git a/server/src/nest/days/days.service.ts b/server/src/nest/days/days.service.ts index f314adfa6..4e7b3b128 100644 --- a/server/src/nest/days/days.service.ts +++ b/server/src/nest/days/days.service.ts @@ -2,23 +2,87 @@ import { Injectable } from '@nestjs/common'; import { broadcast } from '../../websocket'; import { DatabaseService } from '../database/database.service'; import { checkPermission } from '../../services/permissions'; -import type { User } from '../../types'; -import * as dayService from '../../services/dayService'; +import { loadTagsByPlaceIds, loadParticipantsByAssignmentIds, formatAssignmentWithPlace } from '../../services/queryHelpers'; +import type { AssignmentRow, Day, DayNote, User } from '../../types'; type Trip = { user_id: number }; +const MS_PER_DAY = 24 * 60 * 60 * 1000; + /** - * Thin Nest wrapper around the day parts of the existing day service. Trip access + * Add `n` days to a YYYY-MM-DD date string, staying entirely in UTC. + * + * Deliberately never builds a local-time Date: `new Date('2026-06-07T00:00:00')` + * parses as *server-local* midnight, so a later .toISOString() round-trips through + * UTC and lands on the previous day whenever the server sits east of Greenwich. + */ +export function addDays(date: string, n: number): string { + const [y, m, d] = date.split('-').map(Number); + const t = Date.UTC(y, m - 1, d) + n * MS_PER_DAY; + const dt = new Date(t); + const yyyy = dt.getUTCFullYear(); + const mm = String(dt.getUTCMonth() + 1).padStart(2, '0'); + const dd = String(dt.getUTCDate()).padStart(2, '0'); + return `${yyyy}-${mm}-${dd}`; +} + +function dayDelta(from: string, to: string): number { + const [fy, fm, fd] = from.split('-').map(Number); + const [ty, tm, td] = to.split('-').map(Number); + return Math.round((Date.UTC(ty, tm - 1, td) - Date.UTC(fy, fm - 1, fd)) / MS_PER_DAY); +} + +/** Replace the date part of an ISO-ish timestamp, keeping any time suffix. */ +function withDatePart(timestamp: string, date: string): string { + return date + (timestamp.length > 10 ? timestamp.slice(10) : ''); +} + +/** Thrown for invalid reorder/insert requests; mapped to HTTP 400 by the controller. */ +export class DayReorderError extends Error {} + +export interface DayAccommodation { + id: number; + trip_id: number; + place_id: number | null; + start_day_id: number; + end_day_id: number; + check_in: string | null; + check_in_end: string | null; + check_out: string | null; + confirmation: string | null; + notes: string | null; +} + +export interface CreateAccommodationData { + place_id: number; + start_day_id: number; + end_day_id: number; + check_in?: string; + check_in_end?: string; + check_out?: string; + confirmation?: string; + notes?: string; +} + +/** + * Day domain service — owns the day + accommodation SQL (moved 1:1 from the + * legacy services/dayService.ts: identical statements, the `||` falsy-coercion + * defaults, the post-write re-selects, the two-phase negative-day_number + * renumber and the reservation re-stamping). The legacy hand-rolled + * BEGIN/COMMIT blocks in reorder/insert became db.transaction() (same + * rollback-on-throw semantics, savepoint-safe when nested). Trip access * mirrors the requireTripAccess middleware (canAccessTrip); mutations use the - * 'day_edit' permission. The SQL and the day/assignment shaping reuse the legacy - * code unchanged. + * 'day_edit' permission; the WebSocket broadcast keeps its legacy call path. + * Non-Nest consumers (legacy tripService and the transit/transports MCP + * registrars) go through days.bridge.ts instead of importing this class + * directly. */ @Injectable() export class DaysService { - constructor(private readonly dbs: DatabaseService) {} + constructor(private readonly db: DatabaseService) {} - verifyTripAccess(tripId: string, userId: number) { - return this.dbs.canAccessTrip(Number(tripId), userId) as Trip | null | undefined; + verifyTripAccess(tripId: string | number, userId: number) { + return this.db.canAccessTrip(Number(tripId), userId) as Trip | null | undefined; } canEdit(trip: Trip, user: User): boolean { @@ -29,35 +93,522 @@ export class DaysService { broadcast(tripId, event, payload, socketId); } - list(tripId: string) { - return dayService.listDays(tripId); + // ------------------------------------------------------------------------- + // Day assignment helpers + // ------------------------------------------------------------------------- + + getAssignmentsForDay(dayId: number | string) { + const assignments = this.db.all(` + SELECT da.*, p.id as place_id, p.name as place_name, p.description as place_description, + p.lat, p.lng, p.address, p.category_id, p.price, p.currency as place_currency, + COALESCE(da.assignment_time, p.place_time) as place_time, + COALESCE(da.assignment_end_time, p.end_time) as end_time, + p.duration_minutes, p.notes as place_notes, + p.image_url, p.transport_mode, p.google_place_id, p.google_ftid, p.osm_id, p.website, p.phone, + c.name as category_name, c.color as category_color, c.icon as category_icon + FROM day_assignments da + JOIN places p ON da.place_id = p.id + LEFT JOIN categories c ON p.category_id = c.id + WHERE da.day_id = ? + ORDER BY da.order_index ASC, da.created_at ASC + `, dayId); + + return assignments.map(a => { + const tags = this.db.all(` + SELECT t.* FROM tags t + JOIN place_tags pt ON t.id = pt.tag_id + WHERE pt.place_id = ? + `, a.place_id); + + return { + id: a.id, + day_id: a.day_id, + order_index: a.order_index, + notes: a.notes, + created_at: a.created_at, + place: { + id: a.place_id, + name: a.place_name, + description: a.place_description, + lat: a.lat, + lng: a.lng, + address: a.address, + category_id: a.category_id, + price: a.price, + currency: a.place_currency, + place_time: a.place_time, + end_time: a.end_time, + duration_minutes: a.duration_minutes, + notes: a.place_notes, + image_url: a.image_url, + transport_mode: a.transport_mode, + google_place_id: a.google_place_id, + google_ftid: a.google_ftid, + osm_id: a.osm_id, + website: a.website, + phone: a.phone, + category: a.category_id ? { + id: a.category_id, + name: a.category_name, + color: a.category_color, + icon: a.category_icon, + } : null, + tags, + } + }; + }); } - getDay(id: string, tripId: string) { - return dayService.getDay(id, tripId); + // ------------------------------------------------------------------------- + // Day CRUD + // ------------------------------------------------------------------------- + + list(tripId: string | number) { + const days = this.db.all('SELECT * FROM days WHERE trip_id = ? ORDER BY day_number ASC', tripId); + + if (days.length === 0) { + return { days: [] }; + } + + const dayIds = days.map(d => d.id); + const dayPlaceholders = dayIds.map(() => '?').join(','); + + const allAssignments = this.db.all(` + SELECT da.*, p.id as place_id, p.name as place_name, p.description as place_description, + p.lat, p.lng, p.address, p.category_id, p.price, p.currency as place_currency, + COALESCE(da.assignment_time, p.place_time) as place_time, + COALESCE(da.assignment_end_time, p.end_time) as end_time, + p.duration_minutes, p.notes as place_notes, + p.image_url, p.transport_mode, p.google_place_id, p.google_ftid, p.osm_id, p.website, p.phone, + c.name as category_name, c.color as category_color, c.icon as category_icon + FROM day_assignments da + JOIN places p ON da.place_id = p.id + LEFT JOIN categories c ON p.category_id = c.id + WHERE da.day_id IN (${dayPlaceholders}) + ORDER BY da.order_index ASC, da.created_at ASC + `, ...dayIds); + + const placeIds = [...new Set(allAssignments.map(a => a.place_id))]; + const tagsByPlaceId = loadTagsByPlaceIds(placeIds, { compact: true }); + + const allAssignmentIds = allAssignments.map(a => a.id); + const participantsByAssignment = loadParticipantsByAssignmentIds(allAssignmentIds); + + const assignmentsByDayId: Record[]> = {}; + for (const a of allAssignments) { + if (!assignmentsByDayId[a.day_id]) assignmentsByDayId[a.day_id] = []; + assignmentsByDayId[a.day_id].push(formatAssignmentWithPlace(a, tagsByPlaceId[a.place_id] || [], participantsByAssignment[a.id] || [])); + } + + const allNotes = this.db.all( + `SELECT * FROM day_notes WHERE day_id IN (${dayPlaceholders}) ORDER BY sort_order ASC, created_at ASC`, + ...dayIds + ); + const notesByDayId: Record = {}; + for (const note of allNotes) { + if (!notesByDayId[note.day_id]) notesByDayId[note.day_id] = []; + notesByDayId[note.day_id].push(note); + } + + const daysWithAssignments = days.map(day => ({ + ...day, + assignments: assignmentsByDayId[day.id] || [], + notes_items: notesByDayId[day.id] || [], + })); + + return { days: daysWithAssignments }; } - create(tripId: string, date?: string, notes?: string) { - return dayService.createDay(tripId, date, notes); + create(tripId: string | number, date?: string, notes?: string) { + const maxDay = this.db.get<{ max: number | null }>('SELECT MAX(day_number) as max FROM days WHERE trip_id = ?', tripId)!; + const dayNumber = (maxDay.max || 0) + 1; + + const result = this.db.run( + 'INSERT INTO days (trip_id, day_number, date, notes) VALUES (?, ?, ?, ?)', + tripId, dayNumber, date || null, notes || null + ); + + const day = this.db.get('SELECT * FROM days WHERE id = ?', result.lastInsertRowid)!; + return { ...day, assignments: [] }; } - insert(tripId: string, position?: number) { - return dayService.insertDay(tripId, position); + getDay(id: string | number, tripId: string | number) { + return this.db.get('SELECT * FROM days WHERE id = ? AND trip_id = ?', id, tripId); } - reorder(tripId: string, orderedIds: number[]) { - return dayService.reorderDays(tripId, orderedIds); + update(id: string | number, current: Day, fields: { notes?: string; title?: string | null }) { + this.db.run('UPDATE days SET notes = ?, title = ? WHERE id = ?', + fields.notes || null, + 'title' in fields ? (fields.title ?? null) : current.title, + id + ); + const updatedDay = this.db.get('SELECT * FROM days WHERE id = ?', id)!; + return { ...updatedDay, assignments: this.getAssignmentsForDay(id) }; } - update(id: string, current: Parameters[1], fields: { notes?: string; title?: string | null }) { - return dayService.updateDay(id, current, fields); + /** + * Set the whole-day default route mode (#1281). Its own endpoint so it can't wipe + * notes/title the way the general day update would, and symmetric with the + * per-leg assignment transport setter. Per-segment leg modes still override it. + */ + setDefaultTransportMode(id: string | number, mode: string | null) { + this.db.run('UPDATE days SET default_transport_mode = ? WHERE id = ?', mode ?? null, id); + const updatedDay = this.db.get('SELECT * FROM days WHERE id = ?', id)!; + return { ...updatedDay, assignments: this.getAssignmentsForDay(id) }; } - setDefaultTransportMode(id: string, mode: string | null) { - return dayService.setDefaultTransportMode(id, mode); + remove(id: string | number): void { + this.db.run('DELETE FROM days WHERE id = ?', id); } - remove(id: string): void { - dayService.deleteDay(id); + // ------------------------------------------------------------------------- + // Day reorder / insert (#589) + // + // Reordering keeps every day ROW stable (so assignments, notes, accommodations, + // photos and multi-day reservation positions ride along by id) and only changes + // each row's day_number — its position. On a dated trip the calendar dates stay + // pinned to their slots (position i keeps the i-th date) and the day's content + // moves across them. Because a booking's day is derived from the date part of + // reservation_time, every booking on a day whose date changed gets that date + // re-stamped onto the day's new date (time-of-day preserved), so day_id stays + // consistent and the booking moves with its day. + // ------------------------------------------------------------------------- + + /** + * After day dates have been re-pinned, re-stamp the date of every booking on a + * moved day so reservation_time/reservation_end_time follow their day's new + * date (time-of-day preserved). Transport endpoints (flight legs) shift by the + * same per-booking day delta so multi-leg timing stays internally consistent. + */ + restampReservationDates( + tripId: string | number, + oldDateById: Map, + newDateById: Map, + ): void { + const reservations = this.db.all<{ + id: number; day_id: number | null; end_day_id: number | null; + reservation_time: string | null; reservation_end_time: string | null; + }>( + 'SELECT id, day_id, end_day_id, reservation_time, reservation_end_time FROM reservations WHERE trip_id = ?', + tripId + ); + + const setTime = this.db.prepare('UPDATE reservations SET reservation_time = ? WHERE id = ?'); + const setEndTime = this.db.prepare('UPDATE reservations SET reservation_end_time = ? WHERE id = ?'); + const endpoints = this.db.prepare('SELECT id, local_date FROM reservation_endpoints WHERE reservation_id = ?'); + const setEndpointDate = this.db.prepare('UPDATE reservation_endpoints SET local_date = ? WHERE id = ?'); + + for (const r of reservations) { + if (r.day_id != null && r.reservation_time) { + const oldDate = oldDateById.get(r.day_id); + const newDate = newDateById.get(r.day_id); + if (oldDate && newDate && oldDate !== newDate) { + setTime.run(withDatePart(r.reservation_time, newDate), r.id); + // Shift each transport leg's local_date by the same number of days. + const delta = dayDelta(oldDate, newDate); + if (delta !== 0) { + for (const ep of endpoints.all(r.id) as { id: number; local_date: string | null }[]) { + if (ep.local_date) setEndpointDate.run(addDays(ep.local_date, delta), ep.id); + } + } + } + } + if (r.end_day_id != null && r.reservation_end_time) { + const oldDate = oldDateById.get(r.end_day_id); + const newDate = newDateById.get(r.end_day_id); + if (oldDate && newDate && oldDate !== newDate) { + setEndTime.run(withDatePart(r.reservation_end_time, newDate), r.id); + } + } + } + } + + /** A stay must not end before it begins after a reorder/insert. */ + private assertNoInvertedAccommodation(tripId: string | number): void { + const spans = this.db.all<{ id: number; start_no: number; end_no: number }>(` + SELECT a.id, s.day_number AS start_no, e.day_number AS end_no + FROM day_accommodations a + JOIN days s ON a.start_day_id = s.id + JOIN days e ON a.end_day_id = e.id + WHERE a.trip_id = ? + `, tripId); + for (const span of spans) { + if (span.start_no > span.end_no) { + throw new DayReorderError('This move would make an accommodation end before it starts.'); + } + } + } + + /** + * After a trip's date range changes, generateDays positionally re-dates the day rows + * (keeping their ids), so an accommodation — which has no absolute date, only + * start_day_id/end_day_id — visually shifts with the range (#1288). Re-anchor each + * stay to the days now holding its pre-change dates (from the snapshot taken before + * generateDays ran). A stay whose dates fall outside the new range is left glued to + * its day rows, mirroring resyncReservationDays' out-of-range semantics, so moving a + * whole trip still shifts everything together. The linked hotel reservation follows + * its accommodation's start day in both branches. + */ + resyncAccommodationDays( + tripId: string | number, + prevDateByDayId: Map, + ): void { + const stays = this.db.all<{ id: number; start_day_id: number; end_day_id: number }>( + 'SELECT id, start_day_id, end_day_id FROM day_accommodations WHERE trip_id = ?', + tripId + ); + if (stays.length === 0) return; + + const dayByDate = this.db.prepare('SELECT id, day_number FROM days WHERE trip_id = ? AND date = ? LIMIT 1'); + const updateStay = this.db.prepare('UPDATE day_accommodations SET start_day_id = ?, end_day_id = ? WHERE id = ?'); + const restampLinkedRes = this.db.prepare(` + UPDATE reservations SET day_id = :dayId, + reservation_time = CASE WHEN reservation_time IS NULL THEN :date + ELSE :date || SUBSTR(reservation_time, 11) END + WHERE accommodation_id = :accId AND type = 'hotel' + `); + + for (const stay of stays) { + const oldStartDate = prevDateByDayId.get(stay.start_day_id); + const oldEndDate = prevDateByDayId.get(stay.end_day_id); + if (oldStartDate && oldEndDate) { + const newStart = dayByDate.get(tripId, oldStartDate) as { id: number; day_number: number } | undefined; + const newEnd = dayByDate.get(tripId, oldEndDate) as { id: number; day_number: number } | undefined; + if (newStart && newEnd && newStart.day_number <= newEnd.day_number + && (newStart.id !== stay.start_day_id || newEnd.id !== stay.end_day_id)) { + updateStay.run(newStart.id, newEnd.id, stay.id); + stay.start_day_id = newStart.id; + } + } + // Keep the linked reservation on the stay's (possibly re-dated) start day — its + // reservation_time is a snapshot of that day's date, stale after any range change. + const startDayDate = this.db.get<{ date: string | null }>('SELECT date FROM days WHERE id = ?', stay.start_day_id)?.date; + if (startDayDate) { + restampLinkedRes.run({ dayId: stay.start_day_id, date: startDayDate, accId: stay.id }); + } + } + } + + /** + * Reorder whole days. `orderedIds` is the desired full sequence of this trip's + * day ids (a permutation of the current ids). + */ + reorder(tripId: string | number, orderedIds: number[]) { + const rows = this.db.all<{ id: number; day_number: number; date: string | null }>( + 'SELECT id, day_number, date FROM days WHERE trip_id = ? ORDER BY day_number', + tripId + ); + + const existingIds = new Set(rows.map(r => r.id)); + if (orderedIds.length !== rows.length || !orderedIds.every(id => existingIds.has(id))) { + throw new DayReorderError('orderedIds must be a permutation of the trip day ids.'); + } + + const oldDateById = new Map(rows.map(r => [r.id, r.date])); + // Dates stay pinned to slots: position i keeps the i-th date (ascending). + const sortedDates = rows.map(r => r.date).filter((d): d is string => !!d).sort(); + const isDated = sortedDates.length > 0; + + const setDayNumber = this.db.prepare('UPDATE days SET day_number = ? WHERE id = ?'); + const setDayNumberAndDate = this.db.prepare('UPDATE days SET day_number = ?, date = ? WHERE id = ?'); + + this.db.transaction(() => { + // Two-phase renumber to dodge UNIQUE(trip_id, day_number) collisions. + orderedIds.forEach((id, i) => setDayNumber.run(-(i + 1), id)); + const newDateById = new Map(); + orderedIds.forEach((id, i) => { + const date = isDated ? (sortedDates[i] ?? null) : null; + setDayNumberAndDate.run(i + 1, date, id); + newDateById.set(id, date); + }); + + if (isDated) this.restampReservationDates(tripId, oldDateById, newDateById); + this.assertNoInvertedAccommodation(tripId); + }); + + return this.list(tripId); + } + + /** + * Insert a new empty day at a 1-based position (default: append at the end). + * On a dated trip the trip gains one calendar day: dates re-pin so the slots + * stay contiguous, the trip's end_date extends by one day, and bookings on + * shifted days have their dates re-stamped (same rules as reorder). + */ + insert(tripId: string | number, position?: number) { + const rows = this.db.all<{ id: number; day_number: number; date: string | null }>( + 'SELECT id, day_number, date FROM days WHERE trip_id = ? ORDER BY day_number', + tripId + ); + const n = rows.length; + const pos = Math.min(Math.max(position ?? n + 1, 1), n + 1); + const datedRows = rows.filter(r => r.date) as { id: number; day_number: number; date: string }[]; + const isDated = datedRows.length > 0; + + const setDayNumber = this.db.prepare('UPDATE days SET day_number = ? WHERE id = ?'); + + if (!isDated) { + const newRowid = this.db.transaction(() => { + const toShift = rows.filter(r => r.day_number >= pos); + toShift.forEach(r => setDayNumber.run(-r.day_number, r.id)); + const result = this.db.run('INSERT INTO days (trip_id, day_number, date) VALUES (?, ?, NULL)', tripId, pos); + toShift.forEach(r => setDayNumber.run(r.day_number + 1, r.id)); + return result.lastInsertRowid; + }); + const day = this.db.get('SELECT * FROM days WHERE id = ?', newRowid)!; + return { ...day, assignments: [], notes_items: [] }; + } + + // Dated trip: rebuild N+1 contiguous dates from the earliest date. + const start = datedRows.map(r => r.date).sort()[0]; + const dates = Array.from({ length: n + 1 }, (_, i) => addDays(start, i)); + const oldDateById = new Map(rows.map(r => [r.id, r.date])); + const setDayNumberAndDate = this.db.prepare('UPDATE days SET day_number = ?, date = ? WHERE id = ?'); + + const newId = this.db.transaction(() => { + rows.forEach((r, i) => setDayNumber.run(-(i + 1), r.id)); + const result = this.db.run('INSERT INTO days (trip_id, day_number, date) VALUES (?, ?, ?)', tripId, pos, dates[pos - 1]); + const insertedId = Number(result.lastInsertRowid); + + const orderedIds = rows.map(r => r.id); + orderedIds.splice(pos - 1, 0, insertedId); + const newDateById = new Map(); + orderedIds.forEach((id, i) => { + setDayNumberAndDate.run(i + 1, dates[i], id); + newDateById.set(id, dates[i]); + }); + + this.restampReservationDates(tripId, oldDateById, newDateById); + this.assertNoInvertedAccommodation(tripId); + this.db.run('UPDATE trips SET end_date = ? WHERE id = ?', dates[dates.length - 1], tripId); + + return insertedId; + }); + const day = this.db.get('SELECT * FROM days WHERE id = ?', newId)!; + return { ...day, assignments: [], notes_items: [] }; + } + + // ------------------------------------------------------------------------- + // Accommodation CRUD + // ------------------------------------------------------------------------- + + private getAccommodationWithPlace(id: number | bigint) { + return this.db.get(` + SELECT a.*, p.name as place_name, p.address as place_address, p.image_url as place_image, p.lat as place_lat, p.lng as place_lng + FROM day_accommodations a + LEFT JOIN places p ON a.place_id = p.id + WHERE a.id = ? + `, id); + } + + listAccommodations(tripId: string | number) { + return this.db.all(` + SELECT a.*, p.name as place_name, p.address as place_address, p.image_url as place_image, p.lat as place_lat, p.lng as place_lng, + r.title as reservation_title + FROM day_accommodations a + LEFT JOIN places p ON a.place_id = p.id + LEFT JOIN reservations r ON r.accommodation_id = a.id + WHERE a.trip_id = ? + ORDER BY a.created_at ASC + `, tripId); + } + + validateAccommodationRefs(tripId: string | number, placeId?: number, startDayId?: number, endDayId?: number) { + const errors: { field: string; message: string }[] = []; + if (placeId !== undefined) { + const place = this.db.get('SELECT id FROM places WHERE id = ? AND trip_id = ?', placeId, tripId); + if (!place) errors.push({ field: 'place_id', message: 'Place not found' }); + } + if (startDayId !== undefined) { + const startDay = this.db.get('SELECT id FROM days WHERE id = ? AND trip_id = ?', startDayId, tripId); + if (!startDay) errors.push({ field: 'start_day_id', message: 'Start day not found' }); + } + if (endDayId !== undefined) { + const endDay = this.db.get('SELECT id FROM days WHERE id = ? AND trip_id = ?', endDayId, tripId); + if (!endDay) errors.push({ field: 'end_day_id', message: 'End day not found' }); + } + return errors; + } + + createAccommodation(tripId: string | number, data: CreateAccommodationData) { + const { place_id, start_day_id, end_day_id, check_in, check_in_end, check_out, confirmation, notes } = data; + + const result = this.db.run( + 'INSERT INTO day_accommodations (trip_id, place_id, start_day_id, end_day_id, check_in, check_in_end, check_out, confirmation, notes) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)', + tripId, place_id, start_day_id, end_day_id, check_in || null, check_in_end || null, check_out || null, confirmation || null, notes || null + ); + + const accommodationId = result.lastInsertRowid; + + // Auto-create linked reservation for this accommodation + const placeName = this.db.get<{ name: string }>('SELECT name FROM places WHERE id = ?', place_id)?.name || 'Hotel'; + const startDayDate = this.db.get<{ date: string }>('SELECT date FROM days WHERE id = ?', start_day_id)?.date || null; + const meta: Record = {}; + if (check_in) meta.check_in_time = check_in; + if (check_in_end) meta.check_in_end_time = check_in_end; + if (check_out) meta.check_out_time = check_out; + this.db.run(` + INSERT INTO reservations (trip_id, day_id, title, reservation_time, location, confirmation_number, notes, status, type, accommodation_id, metadata) + VALUES (?, ?, ?, ?, ?, ?, ?, 'confirmed', 'hotel', ?, ?) + `, + tripId, start_day_id, placeName, startDayDate || null, null, + confirmation || null, notes || null, accommodationId, + Object.keys(meta).length > 0 ? JSON.stringify(meta) : null + ); + + return this.getAccommodationWithPlace(accommodationId); + } + + getAccommodation(id: string | number, tripId: string | number) { + return this.db.get('SELECT * FROM day_accommodations WHERE id = ? AND trip_id = ?', id, tripId); + } + + updateAccommodation(id: string | number, existing: DayAccommodation, fields: { + place_id?: number; start_day_id?: number; end_day_id?: number; + check_in?: string; check_in_end?: string; check_out?: string; confirmation?: string; notes?: string; + }) { + const newPlaceId = fields.place_id !== undefined ? fields.place_id : existing.place_id; + const newStartDayId = fields.start_day_id !== undefined ? fields.start_day_id : existing.start_day_id; + const newEndDayId = fields.end_day_id !== undefined ? fields.end_day_id : existing.end_day_id; + const newCheckIn = fields.check_in !== undefined ? fields.check_in : existing.check_in; + const newCheckInEnd = fields.check_in_end !== undefined ? fields.check_in_end : existing.check_in_end; + const newCheckOut = fields.check_out !== undefined ? fields.check_out : existing.check_out; + const newConfirmation = fields.confirmation !== undefined ? fields.confirmation : existing.confirmation; + const newNotes = fields.notes !== undefined ? fields.notes : existing.notes; + + this.db.run( + 'UPDATE day_accommodations SET place_id = ?, start_day_id = ?, end_day_id = ?, check_in = ?, check_in_end = ?, check_out = ?, confirmation = ?, notes = ? WHERE id = ?', + newPlaceId, newStartDayId, newEndDayId, newCheckIn, newCheckInEnd, newCheckOut, newConfirmation, newNotes, id + ); + + // Sync check-in/out/confirmation to linked reservation + const linkedRes = this.db.get<{ id: number; metadata: string | null }>('SELECT id, metadata FROM reservations WHERE accommodation_id = ?', Number(id)); + if (linkedRes) { + const meta = linkedRes.metadata ? JSON.parse(linkedRes.metadata) : {}; + if (newCheckIn) meta.check_in_time = newCheckIn; + if (newCheckInEnd) meta.check_in_end_time = newCheckInEnd; + if (newCheckOut) meta.check_out_time = newCheckOut; + this.db.run('UPDATE reservations SET metadata = ?, confirmation_number = COALESCE(?, confirmation_number) WHERE id = ?', + JSON.stringify(meta), newConfirmation || null, linkedRes.id); + } + + return this.getAccommodationWithPlace(Number(id)); + } + + /** Delete accommodation and its linked reservation (and any linked budget item). */ + deleteAccommodation(id: string | number): { linkedReservationId: number | null; deletedBudgetItemId: number | null } { + const linkedRes = this.db.get<{ id: number }>('SELECT id FROM reservations WHERE accommodation_id = ?', Number(id)); + let deletedBudgetItemId: number | null = null; + if (linkedRes) { + const linkedBudget = this.db.get<{ id: number }>('SELECT id FROM budget_items WHERE reservation_id = ?', linkedRes.id); + if (linkedBudget) { + this.db.run('DELETE FROM budget_items WHERE id = ?', linkedBudget.id); + deletedBudgetItemId = linkedBudget.id; + } + this.db.run('DELETE FROM reservations WHERE id = ?', linkedRes.id); + } + + this.db.run('DELETE FROM day_accommodations WHERE id = ?', id); + return { linkedReservationId: linkedRes ? linkedRes.id : null, deletedBudgetItemId }; } } diff --git a/server/src/nest/plugins/DI-MIGRATION.md b/server/src/nest/plugins/DI-MIGRATION.md index 8b16e5a4c..4757c45e4 100644 --- a/server/src/nest/plugins/DI-MIGRATION.md +++ b/server/src/nest/plugins/DI-MIGRATION.md @@ -19,7 +19,7 @@ The plugin runtime has three layers. Only the third is "un-Nest". |---|---|---|---| | Sandbox / transport | `supervisor/plugin-supervisor.ts`, `runtime/plugin-host-entry.ts`, `protocol/envelope.ts` | Forked child per plugin, IPC envelopes, permission tables, actor resolution, rate limiting, RSS policing | Intentionally not — this is the security boundary | | Enforcement | `host/rpc-host.ts` (~1330 lines) | `PluginRpcHost`: builds a per-plugin method map at spawn — a handler is registered **only if the plugin holds the unlocking permission** (registration = authorization). Depends only on the `HostDeps` interface | Neutral — decoupled by design | -| **Wiring** | `host/plugin-host-deps.factory.ts` (was `create-rpc-host.ts`) | Fills the ~130-member `HostDeps` object. Since Option A: an `@Injectable() PluginHostDepsFactory` with the DI-native domain services (`BudgetService`, `ReservationsService`, `TagsService`, `CategoriesService`, `TodoService`, `PluginOAuthService`) constructor-injected; legacy `services/*` domains stay plain function imports until their own migration. Raw `db`/`broadcast` also stay until injectable equivalents exist | **Yes (Option A)** — drains further per §3 | +| **Wiring** | `host/plugin-host-deps.factory.ts` (was `create-rpc-host.ts`) | Fills the ~130-member `HostDeps` object. Since Option A: an `@Injectable() PluginHostDepsFactory` with the DI-native domain services (`BudgetService`, `ReservationsService`, `TagsService`, `CategoriesService`, `TodoService`, `PluginOAuthService`) constructor-injected, fourteen domains as of 2026-07; legacy `services/*` domains stay plain function imports until their own migration. Raw `db`/`broadcast` also stay until injectable equivalents exist | **Yes (Option A)** — drains further per §3 | | Host state | `host/plugin-host-state.ts` | Deliberately module-level (NOT a provider): the per-plugin data-DB map (`getPluginDataDb`/`closePluginDataDb`) and daily broker budgets (`budgetFor`/`pluginBudgetUsage`). Must be one shared instance across host recreations, and is read by `PluginsService`, which the factory imports from — folding it into the injectable would create a provider cycle | Intentionally not | Key fact that made Option A cheap: the wiring is **called from inside a Nest @@ -109,9 +109,10 @@ fileService swapped in 2026-07 — its ten imported symbols became the injected `FilesService` plus the load-time constants from `files.constants.ts`; collabService swapped in 2026-07 — its seven imported symbols became the injected `CollabService`, completing Wave 3; vacayService swapped in 2026-07 — -its four imported symbols became the injected `VacayService`; the remaining -factory imports are all Wave-4+ domains: trips, places, days, journeys, atlas, -collections). +its four imported symbols became the injected `VacayService`; dayService +swapped in 2026-07 — its eleven imported symbols (the heaviest single import) +became the injected `DaysService`; the remaining factory imports are all +Wave-4+ domains: trips, places, journeys, atlas, collections). ### Test impact (as landed) @@ -119,7 +120,7 @@ collections). `PluginRpcHost` directly with hand-built `HostDeps`). - `tests/unit/plugins/plugin-host-deps.factory.test.ts` (was `create-rpc-host.test.ts`) — the six DI-domain path mocks became constructor - stubs (eleven stubs as of the 2026-07 vacay migration); the ~24 + stubs (fourteen stubs as of the 2026-07 day migration); the ~23 legacy-service path mocks remain; a file-local shim keeps the historical `createRealRpcHost(id, granted)` call sites and supplies a default no-op router. @@ -233,8 +234,9 @@ none of it is throwaway. done 2026-07 with no factory impact — it was never imported here; `reservationService` done 2026-07, draining the factory's last plain-function reservation import — `notifyBookingChange` became a private - method over the already-injected `ReservationsService`; next per - `src/nest/README.md`): migrate the + method over the already-injected `ReservationsService`; `dayService` done + 2026-07, the heaviest factory import — eleven symbols became the injected + `DaysService`; next per `src/nest/README.md`): migrate the service to DI as usual; the factory swaps one legacy import for one injected service. Optionally pilot `tags.rpc.ts` here — tags is small and already fully DI on HTTP + MCP. diff --git a/server/src/nest/plugins/host/plugin-host-deps.factory.ts b/server/src/nest/plugins/host/plugin-host-deps.factory.ts index 1ecbe5605..be2ba2269 100644 --- a/server/src/nest/plugins/host/plugin-host-deps.factory.ts +++ b/server/src/nest/plugins/host/plugin-host-deps.factory.ts @@ -17,7 +17,6 @@ import { randomUUID } from 'node:crypto'; import { checkPermission } from '../../../services/permissions'; import { listTrips, updateTrip, createTrip, removeMember as removeTripMemberSvc, NotFoundError, ValidationError } from '../../../services/tripService'; import { createPlace, updatePlace, deletePlace } from '../../../services/placeService'; -import { createDay, getDay, updateDay, deleteDay, listDays, listAccommodations, validateAccommodationRefs, createAccommodation as createAccommodationSvc, getAccommodation, updateAccommodation as updateAccommodationSvc, deleteAccommodation as deleteAccommodationSvc } from '../../../services/dayService'; import { isAddonEnabled } from '../../../services/adminService'; import { isDemoEmail } from '../../../services/demo'; import { ADDON_IDS } from '../../../addons'; @@ -31,6 +30,7 @@ import { CategoriesService } from '../../categories/categories.service'; import { TodoService } from '../../todo/todo.service'; import { PackingService } from '../../packing/packing.service'; import { DayNotesService } from '../../days/day-notes.service'; +import { DaysService } from '../../days/days.service'; import { AssignmentsService } from '../../assignments/assignments.service'; import { LlmConfigResolver } from '../../llm-parse/llm-config.resolver'; import { DatabaseService } from '../../database/database.service'; @@ -154,6 +154,7 @@ export class PluginHostDepsFactory { private readonly files: FilesService, private readonly collab: CollabService, private readonly vacay: VacayService, + private readonly days: DaysService, ) {} /** @@ -479,21 +480,21 @@ export class PluginHostDepsFactory { canEditDays: (tripId, userId) => this.canEditTripAs('day_edit', tripId, userId), createDay: (tripId, input) => { const i = input as { date?: string; notes?: string }; - const day = createDay(tripId, i.date, i.notes); + const day = this.days.create(tripId, i.date, i.notes); broadcast(tripId, 'day:created', { day }); return day; }, updateDay: (tripId, dayId, input) => { - const current = getDay(dayId, tripId); + const current = this.days.getDay(dayId, tripId); if (!current) throw new ForbiddenResource(`no day ${dayId} on trip ${tripId}`); - const day = updateDay(dayId, current, input as { notes?: string; title?: string | null }); + const day = this.days.update(dayId, current, input as { notes?: string; title?: string | null }); broadcast(tripId, 'day:updated', { day }); return day; }, deleteDay: (tripId, dayId) => { - const current = getDay(dayId, tripId); + const current = this.days.getDay(dayId, tripId); if (!current) throw new ForbiddenResource(`no day ${dayId} on trip ${tripId}`); - deleteDay(dayId); + this.days.remove(dayId); broadcast(tripId, 'day:deleted', { dayId }); return { deleted: true }; }, @@ -567,10 +568,10 @@ export class PluginHostDepsFactory { }, // --- Trip-scoped hydrated reads (membership already checked by tripRead). Same // services as the REST GETs, so plugins see the exact planner shapes. --- - listTripDays: (tripId) => (listDays(tripId) as { days: unknown[] }).days, + listTripDays: (tripId) => (this.days.list(tripId) as { days: unknown[] }).days, listTripReservations: (tripId) => this.reservations.list(String(tripId)), - listTripAccommodations: (tripId) => listAccommodations(tripId) as unknown[], - // --- Accommodations (lodging blocks, day_edit). Delegates to dayService so the + listTripAccommodations: (tripId) => this.days.listAccommodations(tripId) as unknown[], + // --- Accommodations (lodging blocks, day_edit). Delegates to DaysService so the // partner hotel reservation, the metadata sync and the delete cascade behave // exactly like the accommodations REST controller, cascade broadcasts included. --- createAccommodation: (tripId, input) => { @@ -579,9 +580,9 @@ export class PluginHostDepsFactory { const startDayId = Math.trunc(Number(i.start_day_id)); const endDayId = Math.trunc(Number(i.end_day_id)); if (!placeId || !startDayId || !endDayId) throw new BadParams('place_id, start_day_id, and end_day_id are required'); - const errors = validateAccommodationRefs(tripId, placeId, startDayId, endDayId); + const errors = this.days.validateAccommodationRefs(tripId, placeId, startDayId, endDayId); if (errors.length > 0) throw new ForbiddenResource(errors[0].message); - const accommodation = createAccommodationSvc(tripId, { + const accommodation = this.days.createAccommodation(tripId, { place_id: placeId, start_day_id: startDayId, end_day_id: endDayId, check_in: i.check_in ?? undefined, check_in_end: i.check_in_end ?? undefined, check_out: i.check_out ?? undefined, confirmation: i.confirmation ?? undefined, notes: i.notes ?? undefined, @@ -591,18 +592,18 @@ export class PluginHostDepsFactory { return accommodation; }, updateAccommodation: (tripId, accommodationId, input) => { - const existing = getAccommodation(accommodationId, tripId); + const existing = this.days.getAccommodation(accommodationId, tripId); if (!existing) throw new ForbiddenResource(`no accommodation ${accommodationId} on trip ${tripId}`); const i = input as { place_id?: number; start_day_id?: number; end_day_id?: number; check_in?: string; check_in_end?: string; check_out?: string; confirmation?: string; notes?: string }; - const errors = validateAccommodationRefs(tripId, i.place_id, i.start_day_id, i.end_day_id); + const errors = this.days.validateAccommodationRefs(tripId, i.place_id, i.start_day_id, i.end_day_id); if (errors.length > 0) throw new ForbiddenResource(errors[0].message); - const accommodation = updateAccommodationSvc(accommodationId, existing, i); + const accommodation = this.days.updateAccommodation(accommodationId, existing, i); broadcast(tripId, 'accommodation:updated', { accommodation }); return accommodation; }, deleteAccommodation: (tripId, accommodationId) => { - if (!getAccommodation(accommodationId, tripId)) throw new ForbiddenResource(`no accommodation ${accommodationId} on trip ${tripId}`); - const { linkedReservationId, deletedBudgetItemId } = deleteAccommodationSvc(accommodationId); + if (!this.days.getAccommodation(accommodationId, tripId)) throw new ForbiddenResource(`no accommodation ${accommodationId} on trip ${tripId}`); + const { linkedReservationId, deletedBudgetItemId } = this.days.deleteAccommodation(accommodationId); if (linkedReservationId) broadcast(tripId, 'reservation:deleted', { reservationId: linkedReservationId }); if (deletedBudgetItemId) broadcast(tripId, 'budget:deleted', { itemId: deletedBudgetItemId }); broadcast(tripId, 'accommodation:deleted', { accommodationId }); diff --git a/server/src/nest/reservations/accommodations.service.ts b/server/src/nest/reservations/accommodations.service.ts index 57488b12b..23ce6f393 100644 --- a/server/src/nest/reservations/accommodations.service.ts +++ b/server/src/nest/reservations/accommodations.service.ts @@ -3,19 +3,22 @@ import { broadcast } from '../../websocket'; import { DatabaseService } from '../database/database.service'; import { checkPermission } from '../../services/permissions'; import type { User } from '../../types'; -import * as dayService from '../../services/dayService'; +import { DaysService } from '../days/days.service'; type Trip = { user_id: number }; /** - * Thin Nest wrapper around the accommodation parts of the existing day service. + * Thin Nest wrapper around the accommodation parts of the day domain. * Accommodations are gated by the 'day_edit' permission (same as days) and the - * SQL + cascade (linked reservation / budget cleanup on delete) reuse the legacy - * code unchanged. + * SQL + cascade (linked reservation / budget cleanup on delete) live on the + * injected DaysService (the DI-native home of the legacy dayService). */ @Injectable() export class AccommodationsService { - constructor(private readonly dbs: DatabaseService) {} + constructor( + private readonly dbs: DatabaseService, + private readonly days: DaysService, + ) {} /** Mirrors the requireTripAccess middleware (owner or member), returning the trip. */ verifyTripAccess(tripId: string, userId: number) { @@ -31,26 +34,26 @@ export class AccommodationsService { } list(tripId: string) { - return dayService.listAccommodations(tripId); + return this.days.listAccommodations(tripId); } validateRefs(tripId: string, placeId?: number, startDayId?: number, endDayId?: number) { - return dayService.validateAccommodationRefs(tripId, placeId, startDayId, endDayId); + return this.days.validateAccommodationRefs(tripId, placeId, startDayId, endDayId); } get(id: string, tripId: string) { - return dayService.getAccommodation(id, tripId); + return this.days.getAccommodation(id, tripId); } - create(tripId: string, data: Parameters[1]) { - return dayService.createAccommodation(tripId, data); + create(tripId: string, data: Parameters[1]) { + return this.days.createAccommodation(tripId, data); } - update(id: string, existing: Parameters[1], fields: Parameters[2]) { - return dayService.updateAccommodation(id, existing, fields); + update(id: string, existing: Parameters[1], fields: Parameters[2]) { + return this.days.updateAccommodation(id, existing, fields); } remove(id: string) { - return dayService.deleteAccommodation(id); + return this.days.deleteAccommodation(id); } } diff --git a/server/src/nest/reservations/reservations.mcp.ts b/server/src/nest/reservations/reservations.mcp.ts index e13f6553b..7ac01c264 100644 --- a/server/src/nest/reservations/reservations.mcp.ts +++ b/server/src/nest/reservations/reservations.mcp.ts @@ -6,10 +6,10 @@ import { import { z } from 'zod'; import { isDemoUser } from '../../services/authService'; import { linkBudgetItemToReservation } from '../../services/budgetService'; -import { getDay } from '../../services/dayService'; import { placeExists, getAssignmentForTrip } from '../assignments/assignments.bridge'; import { safeBroadcast, noAccess, hasTripPermission, permissionDenied } from '../../mcp/tools/_shared'; import { ReservationsService } from './reservations.service'; +import { DaysService } from '../days/days.service'; function parseId(value: string | string[]): number | null { const n = Number(Array.isArray(value) ? value[0] : value); @@ -28,7 +28,10 @@ function parseId(value: string | string[]): number | null { */ @McpController() export class ReservationsMcp { - constructor(private readonly reservations: ReservationsService) {} + constructor( + private readonly reservations: ReservationsService, + private readonly days: DaysService, + ) {} @Tool({ name: 'create_reservation', @@ -68,13 +71,13 @@ export class ReservationsMcp { if (!hasTripPermission('reservation_edit', tripId, ctx.userId)) return permissionDenied(); // Validate that all referenced IDs belong to this trip - if (day_id && !getDay(day_id, tripId)) + if (day_id && !this.days.getDay(day_id, tripId)) return errorResult('day_id does not belong to this trip.'); if (place_id && !placeExists(place_id, tripId)) return errorResult('place_id does not belong to this trip.'); - if (start_day_id && !getDay(start_day_id, tripId)) + if (start_day_id && !this.days.getDay(start_day_id, tripId)) return errorResult('start_day_id does not belong to this trip.'); - if (end_day_id && !getDay(end_day_id, tripId)) + if (end_day_id && !this.days.getDay(end_day_id, tripId)) return errorResult('end_day_id does not belong to this trip.'); if (assignment_id && !getAssignmentForTrip(assignment_id, tripId)) return errorResult('assignment_id does not belong to this trip.'); @@ -237,9 +240,9 @@ export class ReservationsMcp { if (!placeExists(place_id, tripId)) return errorResult('place_id does not belong to this trip.'); - if (!getDay(start_day_id, tripId)) + if (!this.days.getDay(start_day_id, tripId)) return errorResult('start_day_id does not belong to this trip.'); - if (!getDay(end_day_id, tripId)) + if (!this.days.getDay(end_day_id, tripId)) return errorResult('end_day_id does not belong to this trip.'); const isNewAccommodation = !current.accommodation_id; diff --git a/server/src/nest/reservations/reservations.module.ts b/server/src/nest/reservations/reservations.module.ts index b491d719d..8814b8f00 100644 --- a/server/src/nest/reservations/reservations.module.ts +++ b/server/src/nest/reservations/reservations.module.ts @@ -1,4 +1,5 @@ import { Module } from '@nestjs/common'; +import { DaysModule } from '../days/days.module'; import { ReservationsController } from './reservations.controller'; import { ReservationsService } from './reservations.service'; import { ReservationsMcp } from './reservations.mcp'; @@ -13,6 +14,8 @@ import { UpcomingReservationsController } from './upcoming-reservations.controll * decorator-registered MCP tools + resource for the domain. */ @Module({ + // DaysModule: AccommodationsService + ReservationsMcp inject DaysService. + imports: [DaysModule], controllers: [ReservationsController, AccommodationsController, UpcomingReservationsController], providers: [ReservationsService, AccommodationsService, ReservationsMcp], // For in-container consumers (PluginHostDepsFactory, TripsService, BookingImportService). diff --git a/server/src/nest/trips/trips.module.ts b/server/src/nest/trips/trips.module.ts index 281169a6b..0d1da19b0 100644 --- a/server/src/nest/trips/trips.module.ts +++ b/server/src/nest/trips/trips.module.ts @@ -1,4 +1,5 @@ import { Module } from '@nestjs/common'; +import { DaysModule } from '../days/days.module'; import { FilesModule } from '../files/files.module'; import { PackingModule } from '../packing/packing.module'; import { ReservationsModule } from '../reservations/reservations.module'; @@ -9,7 +10,7 @@ import { TripsService } from './trips.service'; /** 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], + imports: [TodoModule, PackingModule, FilesModule, ReservationsModule, DaysModule], controllers: [TripsController], providers: [TripsService], }) diff --git a/server/src/nest/trips/trips.service.ts b/server/src/nest/trips/trips.service.ts index c9970fb1b..24ad68853 100644 --- a/server/src/nest/trips/trips.service.ts +++ b/server/src/nest/trips/trips.service.ts @@ -4,8 +4,8 @@ import { broadcast } from '../../websocket'; import { checkPermission } from '../../services/permissions'; import type { User } from '../../types'; import * as tripSvc from '../../services/tripService'; -import { listDays, listAccommodations } from '../../services/dayService'; import { listPlaces } from '../../services/placeService'; +import { DaysService } from '../days/days.service'; import { PackingService } from '../packing/packing.service'; import { TodoService } from '../todo/todo.service'; import { listBudgetItems, rebaseTripCurrency } from '../../services/budgetService'; @@ -28,6 +28,7 @@ export class TripsService { private readonly packing: PackingService, private readonly files: FilesService, private readonly reservations: ReservationsService, + private readonly days: DaysService, ) {} private get db() { @@ -133,7 +134,7 @@ export class TripsService { /** Aggregates every trip sub-collection for offline caching (legacy /:id/bundle). */ bundle(tripId: string, trip: { user_id: number }, viewerId: number) { - const { days } = listDays(tripId); + const { days } = this.days.list(tripId); const { owner, members } = this.listMembers(tripId, trip.user_id); return { trip, @@ -146,7 +147,7 @@ export class TripsService { budgetItems: listBudgetItems(tripId), reservations: this.reservations.list(tripId), files: this.files.listFiles(tripId, false), - accommodations: listAccommodations(tripId), + accommodations: this.days.listAccommodations(tripId), members: [owner, ...(members || [])].filter(Boolean), }; } diff --git a/server/src/services/dayService.ts b/server/src/services/dayService.ts deleted file mode 100644 index 3445d06ce..000000000 --- a/server/src/services/dayService.ts +++ /dev/null @@ -1,592 +0,0 @@ -import { db } from '../db/database'; -import { loadTagsByPlaceIds, loadParticipantsByAssignmentIds, formatAssignmentWithPlace } from './queryHelpers'; -import { AssignmentRow, Day, DayNote } from '../types'; - -export { verifyTripAccess } from './tripAccess'; - -// --------------------------------------------------------------------------- -// Day assignment helpers -// --------------------------------------------------------------------------- - -export function getAssignmentsForDay(dayId: number | string) { - const assignments = db.prepare(` - SELECT da.*, p.id as place_id, p.name as place_name, p.description as place_description, - p.lat, p.lng, p.address, p.category_id, p.price, p.currency as place_currency, - COALESCE(da.assignment_time, p.place_time) as place_time, - COALESCE(da.assignment_end_time, p.end_time) as end_time, - p.duration_minutes, p.notes as place_notes, - p.image_url, p.transport_mode, p.google_place_id, p.google_ftid, p.osm_id, p.website, p.phone, - c.name as category_name, c.color as category_color, c.icon as category_icon - FROM day_assignments da - JOIN places p ON da.place_id = p.id - LEFT JOIN categories c ON p.category_id = c.id - WHERE da.day_id = ? - ORDER BY da.order_index ASC, da.created_at ASC - `).all(dayId) as AssignmentRow[]; - - return assignments.map(a => { - const tags = db.prepare(` - SELECT t.* FROM tags t - JOIN place_tags pt ON t.id = pt.tag_id - WHERE pt.place_id = ? - `).all(a.place_id); - - return { - id: a.id, - day_id: a.day_id, - order_index: a.order_index, - notes: a.notes, - created_at: a.created_at, - place: { - id: a.place_id, - name: a.place_name, - description: a.place_description, - lat: a.lat, - lng: a.lng, - address: a.address, - category_id: a.category_id, - price: a.price, - currency: a.place_currency, - place_time: a.place_time, - end_time: a.end_time, - duration_minutes: a.duration_minutes, - notes: a.place_notes, - image_url: a.image_url, - transport_mode: a.transport_mode, - google_place_id: a.google_place_id, - google_ftid: a.google_ftid, - osm_id: a.osm_id, - website: a.website, - phone: a.phone, - category: a.category_id ? { - id: a.category_id, - name: a.category_name, - color: a.category_color, - icon: a.category_icon, - } : null, - tags, - } - }; - }); -} - -// --------------------------------------------------------------------------- -// Day CRUD -// --------------------------------------------------------------------------- - -export function listDays(tripId: string | number) { - const days = db.prepare('SELECT * FROM days WHERE trip_id = ? ORDER BY day_number ASC').all(tripId) as Day[]; - - if (days.length === 0) { - return { days: [] }; - } - - const dayIds = days.map(d => d.id); - const dayPlaceholders = dayIds.map(() => '?').join(','); - - const allAssignments = db.prepare(` - SELECT da.*, p.id as place_id, p.name as place_name, p.description as place_description, - p.lat, p.lng, p.address, p.category_id, p.price, p.currency as place_currency, - COALESCE(da.assignment_time, p.place_time) as place_time, - COALESCE(da.assignment_end_time, p.end_time) as end_time, - p.duration_minutes, p.notes as place_notes, - p.image_url, p.transport_mode, p.google_place_id, p.google_ftid, p.osm_id, p.website, p.phone, - c.name as category_name, c.color as category_color, c.icon as category_icon - FROM day_assignments da - JOIN places p ON da.place_id = p.id - LEFT JOIN categories c ON p.category_id = c.id - WHERE da.day_id IN (${dayPlaceholders}) - ORDER BY da.order_index ASC, da.created_at ASC - `).all(...dayIds) as AssignmentRow[]; - - const placeIds = [...new Set(allAssignments.map(a => a.place_id))]; - const tagsByPlaceId = loadTagsByPlaceIds(placeIds, { compact: true }); - - const allAssignmentIds = allAssignments.map(a => a.id); - const participantsByAssignment = loadParticipantsByAssignmentIds(allAssignmentIds); - - const assignmentsByDayId: Record[]> = {}; - for (const a of allAssignments) { - if (!assignmentsByDayId[a.day_id]) assignmentsByDayId[a.day_id] = []; - assignmentsByDayId[a.day_id].push(formatAssignmentWithPlace(a, tagsByPlaceId[a.place_id] || [], participantsByAssignment[a.id] || [])); - } - - const allNotes = db.prepare( - `SELECT * FROM day_notes WHERE day_id IN (${dayPlaceholders}) ORDER BY sort_order ASC, created_at ASC` - ).all(...dayIds) as DayNote[]; - const notesByDayId: Record = {}; - for (const note of allNotes) { - if (!notesByDayId[note.day_id]) notesByDayId[note.day_id] = []; - notesByDayId[note.day_id].push(note); - } - - const daysWithAssignments = days.map(day => ({ - ...day, - assignments: assignmentsByDayId[day.id] || [], - notes_items: notesByDayId[day.id] || [], - })); - - return { days: daysWithAssignments }; -} - -export function createDay(tripId: string | number, date?: string, notes?: string) { - const maxDay = db.prepare('SELECT MAX(day_number) as max FROM days WHERE trip_id = ?').get(tripId) as { max: number | null }; - const dayNumber = (maxDay.max || 0) + 1; - - const result = db.prepare( - 'INSERT INTO days (trip_id, day_number, date, notes) VALUES (?, ?, ?, ?)' - ).run(tripId, dayNumber, date || null, notes || null); - - const day = db.prepare('SELECT * FROM days WHERE id = ?').get(result.lastInsertRowid) as Day; - return { ...day, assignments: [] }; -} - -export function getDay(id: string | number, tripId: string | number) { - return db.prepare('SELECT * FROM days WHERE id = ? AND trip_id = ?').get(id, tripId) as Day | undefined; -} - -export function updateDay(id: string | number, current: Day, fields: { notes?: string; title?: string | null }) { - db.prepare('UPDATE days SET notes = ?, title = ? WHERE id = ?').run( - fields.notes || null, - 'title' in fields ? (fields.title ?? null) : current.title, - id - ); - const updatedDay = db.prepare('SELECT * FROM days WHERE id = ?').get(id) as Day; - return { ...updatedDay, assignments: getAssignmentsForDay(id) }; -} - -/** - * Set the whole-day default route mode (#1281). Its own endpoint so it can't wipe - * notes/title the way the general day update would, and symmetric with the - * per-leg assignment transport setter. Per-segment leg modes still override it. - */ -export function setDefaultTransportMode(id: string | number, mode: string | null) { - db.prepare('UPDATE days SET default_transport_mode = ? WHERE id = ?').run(mode ?? null, id); - const updatedDay = db.prepare('SELECT * FROM days WHERE id = ?').get(id) as Day; - return { ...updatedDay, assignments: getAssignmentsForDay(id) }; -} - -export function deleteDay(id: string | number) { - db.prepare('DELETE FROM days WHERE id = ?').run(id); -} - -// --------------------------------------------------------------------------- -// Day reorder / insert (#589) -// -// Reordering keeps every day ROW stable (so assignments, notes, accommodations, -// photos and multi-day reservation positions ride along by id) and only changes -// each row's day_number — its position. On a dated trip the calendar dates stay -// pinned to their slots (position i keeps the i-th date) and the day's content -// moves across them. Because a booking's day is derived from the date part of -// reservation_time, every booking on a day whose date changed gets that date -// re-stamped onto the day's new date (time-of-day preserved), so day_id stays -// consistent and the booking moves with its day. -// --------------------------------------------------------------------------- - -const MS_PER_DAY = 24 * 60 * 60 * 1000; - -/** - * Add `n` days to a YYYY-MM-DD date string, staying entirely in UTC. - * - * Deliberately never builds a local-time Date: `new Date('2026-06-07T00:00:00')` - * parses as *server-local* midnight, so a later .toISOString() round-trips through - * UTC and lands on the previous day whenever the server sits east of Greenwich. - */ -export function addDays(date: string, n: number): string { - const [y, m, d] = date.split('-').map(Number); - const t = Date.UTC(y, m - 1, d) + n * MS_PER_DAY; - const dt = new Date(t); - const yyyy = dt.getUTCFullYear(); - const mm = String(dt.getUTCMonth() + 1).padStart(2, '0'); - const dd = String(dt.getUTCDate()).padStart(2, '0'); - return `${yyyy}-${mm}-${dd}`; -} - -function dayDelta(from: string, to: string): number { - const [fy, fm, fd] = from.split('-').map(Number); - const [ty, tm, td] = to.split('-').map(Number); - return Math.round((Date.UTC(ty, tm - 1, td) - Date.UTC(fy, fm - 1, fd)) / MS_PER_DAY); -} - -/** Replace the date part of an ISO-ish timestamp, keeping any time suffix. */ -function withDatePart(timestamp: string, date: string): string { - return date + (timestamp.length > 10 ? timestamp.slice(10) : ''); -} - -/** - * After day dates have been re-pinned, re-stamp the date of every booking on a - * moved day so reservation_time/reservation_end_time follow their day's new - * date (time-of-day preserved). Transport endpoints (flight legs) shift by the - * same per-booking day delta so multi-leg timing stays internally consistent. - */ -export function restampReservationDates( - tripId: string | number, - oldDateById: Map, - newDateById: Map, -): void { - const reservations = db.prepare( - 'SELECT id, day_id, end_day_id, reservation_time, reservation_end_time FROM reservations WHERE trip_id = ?' - ).all(tripId) as { - id: number; day_id: number | null; end_day_id: number | null; - reservation_time: string | null; reservation_end_time: string | null; - }[]; - - const setTime = db.prepare('UPDATE reservations SET reservation_time = ? WHERE id = ?'); - const setEndTime = db.prepare('UPDATE reservations SET reservation_end_time = ? WHERE id = ?'); - const endpoints = db.prepare('SELECT id, local_date FROM reservation_endpoints WHERE reservation_id = ?'); - const setEndpointDate = db.prepare('UPDATE reservation_endpoints SET local_date = ? WHERE id = ?'); - - for (const r of reservations) { - if (r.day_id != null && r.reservation_time) { - const oldDate = oldDateById.get(r.day_id); - const newDate = newDateById.get(r.day_id); - if (oldDate && newDate && oldDate !== newDate) { - setTime.run(withDatePart(r.reservation_time, newDate), r.id); - // Shift each transport leg's local_date by the same number of days. - const delta = dayDelta(oldDate, newDate); - if (delta !== 0) { - for (const ep of endpoints.all(r.id) as { id: number; local_date: string | null }[]) { - if (ep.local_date) setEndpointDate.run(addDays(ep.local_date, delta), ep.id); - } - } - } - } - if (r.end_day_id != null && r.reservation_end_time) { - const oldDate = oldDateById.get(r.end_day_id); - const newDate = newDateById.get(r.end_day_id); - if (oldDate && newDate && oldDate !== newDate) { - setEndTime.run(withDatePart(r.reservation_end_time, newDate), r.id); - } - } - } -} - -/** A stay must not end before it begins after a reorder/insert. */ -function assertNoInvertedAccommodation(tripId: string | number): void { - const spans = db.prepare(` - SELECT a.id, s.day_number AS start_no, e.day_number AS end_no - FROM day_accommodations a - JOIN days s ON a.start_day_id = s.id - JOIN days e ON a.end_day_id = e.id - WHERE a.trip_id = ? - `).all(tripId) as { id: number; start_no: number; end_no: number }[]; - for (const span of spans) { - if (span.start_no > span.end_no) { - throw new DayReorderError('This move would make an accommodation end before it starts.'); - } - } -} - -/** - * After a trip's date range changes, generateDays positionally re-dates the day rows - * (keeping their ids), so an accommodation — which has no absolute date, only - * start_day_id/end_day_id — visually shifts with the range (#1288). Re-anchor each - * stay to the days now holding its pre-change dates (from the snapshot taken before - * generateDays ran). A stay whose dates fall outside the new range is left glued to - * its day rows, mirroring resyncReservationDays' out-of-range semantics, so moving a - * whole trip still shifts everything together. The linked hotel reservation follows - * its accommodation's start day in both branches. - */ -export function resyncAccommodationDays( - tripId: string | number, - prevDateByDayId: Map, -): void { - const stays = db.prepare( - 'SELECT id, start_day_id, end_day_id FROM day_accommodations WHERE trip_id = ?' - ).all(tripId) as { id: number; start_day_id: number; end_day_id: number }[]; - if (stays.length === 0) return; - - const dayByDate = db.prepare('SELECT id, day_number FROM days WHERE trip_id = ? AND date = ? LIMIT 1'); - const updateStay = db.prepare('UPDATE day_accommodations SET start_day_id = ?, end_day_id = ? WHERE id = ?'); - const restampLinkedRes = db.prepare(` - UPDATE reservations SET day_id = :dayId, - reservation_time = CASE WHEN reservation_time IS NULL THEN :date - ELSE :date || SUBSTR(reservation_time, 11) END - WHERE accommodation_id = :accId AND type = 'hotel' - `); - - for (const stay of stays) { - const oldStartDate = prevDateByDayId.get(stay.start_day_id); - const oldEndDate = prevDateByDayId.get(stay.end_day_id); - if (oldStartDate && oldEndDate) { - const newStart = dayByDate.get(tripId, oldStartDate) as { id: number; day_number: number } | undefined; - const newEnd = dayByDate.get(tripId, oldEndDate) as { id: number; day_number: number } | undefined; - if (newStart && newEnd && newStart.day_number <= newEnd.day_number - && (newStart.id !== stay.start_day_id || newEnd.id !== stay.end_day_id)) { - updateStay.run(newStart.id, newEnd.id, stay.id); - stay.start_day_id = newStart.id; - } - } - // Keep the linked reservation on the stay's (possibly re-dated) start day — its - // reservation_time is a snapshot of that day's date, stale after any range change. - const startDayDate = (db.prepare('SELECT date FROM days WHERE id = ?') - .get(stay.start_day_id) as { date: string | null } | undefined)?.date; - if (startDayDate) { - restampLinkedRes.run({ dayId: stay.start_day_id, date: startDayDate, accId: stay.id }); - } - } -} - -/** Thrown for invalid reorder/insert requests; mapped to HTTP 400 by the controller. */ -export class DayReorderError extends Error {} - -/** - * Reorder whole days. `orderedIds` is the desired full sequence of this trip's - * day ids (a permutation of the current ids). - */ -export function reorderDays(tripId: string | number, orderedIds: number[]) { - const rows = db.prepare( - 'SELECT id, day_number, date FROM days WHERE trip_id = ? ORDER BY day_number' - ).all(tripId) as { id: number; day_number: number; date: string | null }[]; - - const existingIds = new Set(rows.map(r => r.id)); - if (orderedIds.length !== rows.length || !orderedIds.every(id => existingIds.has(id))) { - throw new DayReorderError('orderedIds must be a permutation of the trip day ids.'); - } - - const oldDateById = new Map(rows.map(r => [r.id, r.date])); - // Dates stay pinned to slots: position i keeps the i-th date (ascending). - const sortedDates = rows.map(r => r.date).filter((d): d is string => !!d).sort(); - const isDated = sortedDates.length > 0; - - const setDayNumber = db.prepare('UPDATE days SET day_number = ? WHERE id = ?'); - const setDayNumberAndDate = db.prepare('UPDATE days SET day_number = ?, date = ? WHERE id = ?'); - - db.exec('BEGIN'); - try { - // Two-phase renumber to dodge UNIQUE(trip_id, day_number) collisions. - orderedIds.forEach((id, i) => setDayNumber.run(-(i + 1), id)); - const newDateById = new Map(); - orderedIds.forEach((id, i) => { - const date = isDated ? (sortedDates[i] ?? null) : null; - setDayNumberAndDate.run(i + 1, date, id); - newDateById.set(id, date); - }); - - if (isDated) restampReservationDates(tripId, oldDateById, newDateById); - assertNoInvertedAccommodation(tripId); - - db.exec('COMMIT'); - } catch (e) { - db.exec('ROLLBACK'); - throw e; - } - - return listDays(tripId); -} - -/** - * Insert a new empty day at a 1-based position (default: append at the end). - * On a dated trip the trip gains one calendar day: dates re-pin so the slots - * stay contiguous, the trip's end_date extends by one day, and bookings on - * shifted days have their dates re-stamped (same rules as reorderDays). - */ -export function insertDay(tripId: string | number, position?: number) { - const rows = db.prepare( - 'SELECT id, day_number, date FROM days WHERE trip_id = ? ORDER BY day_number' - ).all(tripId) as { id: number; day_number: number; date: string | null }[]; - const n = rows.length; - const pos = Math.min(Math.max(position ?? n + 1, 1), n + 1); - const datedRows = rows.filter(r => r.date) as { id: number; day_number: number; date: string }[]; - const isDated = datedRows.length > 0; - - const setDayNumber = db.prepare('UPDATE days SET day_number = ? WHERE id = ?'); - - if (!isDated) { - db.exec('BEGIN'); - try { - const toShift = rows.filter(r => r.day_number >= pos); - toShift.forEach(r => setDayNumber.run(-r.day_number, r.id)); - const result = db.prepare('INSERT INTO days (trip_id, day_number, date) VALUES (?, ?, NULL)').run(tripId, pos); - toShift.forEach(r => setDayNumber.run(r.day_number + 1, r.id)); - db.exec('COMMIT'); - const day = db.prepare('SELECT * FROM days WHERE id = ?').get(result.lastInsertRowid) as Day; - return { ...day, assignments: [], notes_items: [] }; - } catch (e) { - db.exec('ROLLBACK'); - throw e; - } - } - - // Dated trip: rebuild N+1 contiguous dates from the earliest date. - const start = datedRows.map(r => r.date).sort()[0]; - const dates = Array.from({ length: n + 1 }, (_, i) => addDays(start, i)); - const oldDateById = new Map(rows.map(r => [r.id, r.date])); - const setDayNumberAndDate = db.prepare('UPDATE days SET day_number = ?, date = ? WHERE id = ?'); - - db.exec('BEGIN'); - try { - rows.forEach((r, i) => setDayNumber.run(-(i + 1), r.id)); - const result = db.prepare('INSERT INTO days (trip_id, day_number, date) VALUES (?, ?, ?)').run(tripId, pos, dates[pos - 1]); - const newId = Number(result.lastInsertRowid); - - const orderedIds = rows.map(r => r.id); - orderedIds.splice(pos - 1, 0, newId); - const newDateById = new Map(); - orderedIds.forEach((id, i) => { - setDayNumberAndDate.run(i + 1, dates[i], id); - newDateById.set(id, dates[i]); - }); - - restampReservationDates(tripId, oldDateById, newDateById); - assertNoInvertedAccommodation(tripId); - db.prepare('UPDATE trips SET end_date = ? WHERE id = ?').run(dates[dates.length - 1], tripId); - - db.exec('COMMIT'); - const day = db.prepare('SELECT * FROM days WHERE id = ?').get(newId) as Day; - return { ...day, assignments: [], notes_items: [] }; - } catch (e) { - db.exec('ROLLBACK'); - throw e; - } -} - -// --------------------------------------------------------------------------- -// Accommodation helpers -// --------------------------------------------------------------------------- - -export interface DayAccommodation { - id: number; - trip_id: number; - place_id: number | null; - start_day_id: number; - end_day_id: number; - check_in: string | null; - check_in_end: string | null; - check_out: string | null; - confirmation: string | null; - notes: string | null; -} - -function getAccommodationWithPlace(id: number | bigint) { - return db.prepare(` - SELECT a.*, p.name as place_name, p.address as place_address, p.image_url as place_image, p.lat as place_lat, p.lng as place_lng - FROM day_accommodations a - LEFT JOIN places p ON a.place_id = p.id - WHERE a.id = ? - `).get(id); -} - -// --------------------------------------------------------------------------- -// Accommodation CRUD -// --------------------------------------------------------------------------- - -export function listAccommodations(tripId: string | number) { - return db.prepare(` - SELECT a.*, p.name as place_name, p.address as place_address, p.image_url as place_image, p.lat as place_lat, p.lng as place_lng, - r.title as reservation_title - FROM day_accommodations a - LEFT JOIN places p ON a.place_id = p.id - LEFT JOIN reservations r ON r.accommodation_id = a.id - WHERE a.trip_id = ? - ORDER BY a.created_at ASC - `).all(tripId); -} - -export function validateAccommodationRefs(tripId: string | number, placeId?: number, startDayId?: number, endDayId?: number) { - const errors: { field: string; message: string }[] = []; - if (placeId !== undefined) { - const place = db.prepare('SELECT id FROM places WHERE id = ? AND trip_id = ?').get(placeId, tripId); - if (!place) errors.push({ field: 'place_id', message: 'Place not found' }); - } - if (startDayId !== undefined) { - const startDay = db.prepare('SELECT id FROM days WHERE id = ? AND trip_id = ?').get(startDayId, tripId); - if (!startDay) errors.push({ field: 'start_day_id', message: 'Start day not found' }); - } - if (endDayId !== undefined) { - const endDay = db.prepare('SELECT id FROM days WHERE id = ? AND trip_id = ?').get(endDayId, tripId); - if (!endDay) errors.push({ field: 'end_day_id', message: 'End day not found' }); - } - return errors; -} - -interface CreateAccommodationData { - place_id: number; - start_day_id: number; - end_day_id: number; - check_in?: string; - check_in_end?: string; - check_out?: string; - confirmation?: string; - notes?: string; -} - -export function createAccommodation(tripId: string | number, data: CreateAccommodationData) { - const { place_id, start_day_id, end_day_id, check_in, check_in_end, check_out, confirmation, notes } = data; - - const result = db.prepare( - 'INSERT INTO day_accommodations (trip_id, place_id, start_day_id, end_day_id, check_in, check_in_end, check_out, confirmation, notes) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)' - ).run(tripId, place_id, start_day_id, end_day_id, check_in || null, check_in_end || null, check_out || null, confirmation || null, notes || null); - - const accommodationId = result.lastInsertRowid; - - // Auto-create linked reservation for this accommodation - const placeName = (db.prepare('SELECT name FROM places WHERE id = ?').get(place_id) as { name: string } | undefined)?.name || 'Hotel'; - const startDayDate = (db.prepare('SELECT date FROM days WHERE id = ?').get(start_day_id) as { date: string } | undefined)?.date || null; - const meta: Record = {}; - if (check_in) meta.check_in_time = check_in; - if (check_in_end) meta.check_in_end_time = check_in_end; - if (check_out) meta.check_out_time = check_out; - db.prepare(` - INSERT INTO reservations (trip_id, day_id, title, reservation_time, location, confirmation_number, notes, status, type, accommodation_id, metadata) - VALUES (?, ?, ?, ?, ?, ?, ?, 'confirmed', 'hotel', ?, ?) - `).run( - tripId, start_day_id, placeName, startDayDate || null, null, - confirmation || null, notes || null, accommodationId, - Object.keys(meta).length > 0 ? JSON.stringify(meta) : null - ); - - return getAccommodationWithPlace(accommodationId); -} - -export function getAccommodation(id: string | number, tripId: string | number) { - return db.prepare('SELECT * FROM day_accommodations WHERE id = ? AND trip_id = ?').get(id, tripId) as DayAccommodation | undefined; -} - -export function updateAccommodation(id: string | number, existing: DayAccommodation, fields: { - place_id?: number; start_day_id?: number; end_day_id?: number; - check_in?: string; check_in_end?: string; check_out?: string; confirmation?: string; notes?: string; -}) { - const newPlaceId = fields.place_id !== undefined ? fields.place_id : existing.place_id; - const newStartDayId = fields.start_day_id !== undefined ? fields.start_day_id : existing.start_day_id; - const newEndDayId = fields.end_day_id !== undefined ? fields.end_day_id : existing.end_day_id; - const newCheckIn = fields.check_in !== undefined ? fields.check_in : existing.check_in; - const newCheckInEnd = fields.check_in_end !== undefined ? fields.check_in_end : existing.check_in_end; - const newCheckOut = fields.check_out !== undefined ? fields.check_out : existing.check_out; - const newConfirmation = fields.confirmation !== undefined ? fields.confirmation : existing.confirmation; - const newNotes = fields.notes !== undefined ? fields.notes : existing.notes; - - db.prepare( - 'UPDATE day_accommodations SET place_id = ?, start_day_id = ?, end_day_id = ?, check_in = ?, check_in_end = ?, check_out = ?, confirmation = ?, notes = ? WHERE id = ?' - ).run(newPlaceId, newStartDayId, newEndDayId, newCheckIn, newCheckInEnd, newCheckOut, newConfirmation, newNotes, id); - - // Sync check-in/out/confirmation to linked reservation - const linkedRes = db.prepare('SELECT id, metadata FROM reservations WHERE accommodation_id = ?').get(Number(id)) as { id: number; metadata: string | null } | undefined; - if (linkedRes) { - const meta = linkedRes.metadata ? JSON.parse(linkedRes.metadata) : {}; - if (newCheckIn) meta.check_in_time = newCheckIn; - if (newCheckInEnd) meta.check_in_end_time = newCheckInEnd; - if (newCheckOut) meta.check_out_time = newCheckOut; - db.prepare('UPDATE reservations SET metadata = ?, confirmation_number = COALESCE(?, confirmation_number) WHERE id = ?') - .run(JSON.stringify(meta), newConfirmation || null, linkedRes.id); - } - - return getAccommodationWithPlace(Number(id)); -} - -/** Delete accommodation and its linked reservation (and any linked budget item). */ -export function deleteAccommodation(id: string | number): { linkedReservationId: number | null; deletedBudgetItemId: number | null } { - const linkedRes = db.prepare('SELECT id FROM reservations WHERE accommodation_id = ?').get(Number(id)) as { id: number } | undefined; - let deletedBudgetItemId: number | null = null; - if (linkedRes) { - const linkedBudget = db.prepare('SELECT id FROM budget_items WHERE reservation_id = ?').get(linkedRes.id) as { id: number } | undefined; - if (linkedBudget) { - db.prepare('DELETE FROM budget_items WHERE id = ?').run(linkedBudget.id); - deletedBudgetItemId = linkedBudget.id; - } - db.prepare('DELETE FROM reservations WHERE id = ?').run(linkedRes.id); - } - - db.prepare('DELETE FROM day_accommodations WHERE id = ?').run(id); - return { linkedReservationId: linkedRes ? linkedRes.id : null, deletedBudgetItemId }; -} diff --git a/server/src/services/tripService.ts b/server/src/services/tripService.ts index 4c9f23f02..380ea28e7 100644 --- a/server/src/services/tripService.ts +++ b/server/src/services/tripService.ts @@ -6,7 +6,7 @@ import { db, isOwner } from '../db/database'; import { erasePluginUserData } from './userCleanupService'; import { emitUserDeleted } from '../plugin-user-lifecycle'; import { Trip, User } from '../types'; -import { listDays, listAccommodations, addDays, resyncAccommodationDays, restampReservationDates } from './dayService'; +import { listDays, listAccommodations, addDays, resyncAccommodationDays, restampReservationDates } from '../nest/days/days.bridge'; import { listBudgetItems, removeUserFromBudgetItems } from './budgetService'; import { listItems as listPackingItems } from '../nest/packing/packing.bridge'; import { listReservations, loadEndpointsByTrip, resyncReservationDays } from '../nest/reservations/reservations.bridge'; diff --git a/server/tests/e2e/days.e2e.test.ts b/server/tests/e2e/days.e2e.test.ts index cc6e83ddd..de31ef3cb 100644 --- a/server/tests/e2e/days.e2e.test.ts +++ b/server/tests/e2e/days.e2e.test.ts @@ -1,8 +1,10 @@ /** * Days + day-notes module e2e — exercises both migrated mounts through the real - * JwtAuthGuard against a temp SQLite db. The day service (still legacy), the - * permission check, canAccessTrip and the WebSocket broadcast are mocked; the - * DI-native DayNotesService runs real SQL against the temp db. + * JwtAuthGuard against a temp SQLite db. DaysService and DayNotesService run + * their real SQL via DatabaseModule (the DATABASE_CONNECTION factory picks up + * the mocked db singleton); trip access resolves through a real-SQL + * canAccessTrip over the temp db. Only the permission check and the WebSocket + * broadcast stay mocked. */ import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest'; import request from 'supertest'; @@ -18,34 +20,67 @@ const { db } = vi.hoisted(() => { const tmp = new Database(':memory:'); tmp.exec('PRAGMA journal_mode = WAL'); 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);`); - // The tables DayNotesService really queries (real SQL, no service mock). - tmp.exec('CREATE TABLE days (id INTEGER PRIMARY KEY AUTOINCREMENT, trip_id INTEGER NOT NULL);'); + email TEXT NOT NULL UNIQUE, role TEXT NOT NULL DEFAULT 'user', password_version INTEGER NOT NULL DEFAULT 0, + avatar TEXT);`); + tmp.exec('CREATE TABLE trips (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER, title TEXT, end_date TEXT);'); + tmp.exec('CREATE TABLE trip_members (trip_id INTEGER NOT NULL, user_id INTEGER NOT NULL);'); + // The tables DaysService really queries (real SQL, no service mock). + tmp.exec(`CREATE TABLE days (id INTEGER PRIMARY KEY AUTOINCREMENT, trip_id INTEGER NOT NULL, + day_number INTEGER, date TEXT, title TEXT, notes TEXT, default_transport_mode TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE(trip_id, day_number));`); + tmp.exec('CREATE TABLE categories (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT, color TEXT, icon TEXT);'); + tmp.exec(`CREATE TABLE places (id INTEGER PRIMARY KEY AUTOINCREMENT, trip_id INTEGER NOT NULL, name TEXT, + description TEXT, lat REAL, lng REAL, address TEXT, category_id INTEGER, price REAL, currency TEXT, + place_time TEXT, end_time TEXT, duration_minutes INTEGER, notes TEXT, image_url TEXT, transport_mode TEXT, + google_place_id TEXT, google_ftid TEXT, osm_id TEXT, website TEXT, phone TEXT);`); + 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, + assignment_time TEXT, assignment_end_time TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP);`); + tmp.exec('CREATE TABLE tags (id INTEGER PRIMARY KEY AUTOINCREMENT, trip_id INTEGER, name TEXT, color TEXT);'); + tmp.exec('CREATE TABLE place_tags (place_id INTEGER NOT NULL, tag_id INTEGER NOT NULL);'); + tmp.exec(`CREATE TABLE assignment_participants (assignment_id INTEGER NOT NULL, user_id INTEGER NOT NULL, + PRIMARY KEY (assignment_id, user_id));`); tmp.exec(`CREATE TABLE day_notes (id INTEGER PRIMARY KEY AUTOINCREMENT, day_id INTEGER NOT NULL, trip_id INTEGER NOT NULL, text TEXT NOT NULL, time TEXT, icon TEXT DEFAULT '📝', sort_order REAL DEFAULT 0, created_at DATETIME DEFAULT CURRENT_TIMESTAMP);`); + // Reorder/insert touch accommodations + reservation restamping. + tmp.exec(`CREATE TABLE day_accommodations (id INTEGER PRIMARY KEY AUTOINCREMENT, trip_id INTEGER NOT NULL, + place_id INTEGER, start_day_id INTEGER, end_day_id INTEGER, check_in TEXT, check_in_end TEXT, + check_out TEXT, confirmation TEXT, notes TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP);`); + tmp.exec(`CREATE TABLE reservations (id INTEGER PRIMARY KEY AUTOINCREMENT, trip_id INTEGER, title TEXT, + day_id INTEGER, end_day_id INTEGER, type TEXT, status TEXT DEFAULT 'pending', reservation_time TEXT, + reservation_end_time TEXT, location TEXT, confirmation_number TEXT, notes TEXT, accommodation_id TEXT, + metadata TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP);`); + tmp.exec(`CREATE TABLE reservation_endpoints (id INTEGER PRIMARY KEY AUTOINCREMENT, reservation_id INTEGER NOT NULL, + local_date TEXT);`); return { db: tmp }; }); -const { canAccessTrip } = vi.hoisted(() => ({ canAccessTrip: vi.fn() })); vi.mock('../../src/db/database', () => ({ - db, canAccessTrip, isOwner: vi.fn(() => true), getPlaceWithTags: vi.fn(), closeDb: () => {}, reinitialize: () => {}, + db, + // Real-SQL trip access over the temp db — DaysService.verifyTripAccess and + // DatabaseModule both read the mocked singleton. + canAccessTrip: (tripId: number | string, 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: () => false, + getPlaceWithTags: () => null, + closeDb: () => {}, + reinitialize: () => {}, })); vi.mock('../../src/websocket', () => ({ broadcast: vi.fn() })); const { checkPermission } = vi.hoisted(() => ({ checkPermission: vi.fn() })); vi.mock('../../src/services/permissions', () => ({ checkPermission })); -const { day } = vi.hoisted(() => ({ - day: { listDays: vi.fn(), createDay: vi.fn(), getDay: vi.fn(), updateDay: vi.fn(), deleteDay: vi.fn() }, -})); -vi.mock('../../src/services/dayService', () => day); - import { DaysModule } from '../../src/nest/days/days.module'; import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter'; import { ZodValidationPipe } from '../../src/nest/common/zod-validation.pipe'; -describe('Days + day-notes e2e (real auth guard + temp SQLite)', () => { +describe('Days + day-notes e2e (real auth guard + temp SQLite, real day SQL)', () => { let server: Server; let app: Awaited>; @@ -61,16 +96,14 @@ describe('Days + day-notes e2e (real auth guard + temp SQLite)', () => { beforeAll(async () => { seedUser(db as never, { id: 1 }); - db.prepare('INSERT INTO days (id, trip_id) VALUES (3, 5)').run(); + db.prepare('INSERT INTO trips (id, user_id, title) VALUES (5, 1, ?)').run('Trip'); + db.prepare('INSERT INTO days (id, trip_id, day_number) VALUES (3, 5, 1)').run(); app = await build(); server = app.getHttpServer(); - day.listDays.mockReturnValue({ days: [{ id: 1 }] }); - day.createDay.mockReturnValue({ id: 9 }); }); beforeEach(() => { db.prepare('DELETE FROM day_notes').run(); - canAccessTrip.mockReturnValue({ id: 5, user_id: 1 }); checkPermission.mockReturnValue(true); }); @@ -82,22 +115,74 @@ describe('Days + day-notes e2e (real auth guard + temp SQLite)', () => { expect((await request(server).get('/api/trips/5/days')).status).toBe(401); }); - it('200 list days (the { days } envelope)', async () => { + it('200 list days (the { days } envelope, real rows with assignments + notes_items)', async () => { const res = await request(server).get('/api/trips/5/days').set('Cookie', sessionCookie(1)); expect(res.status).toBe(200); - expect(res.body).toEqual({ days: [{ id: 1 }] }); + expect(res.body.days).toHaveLength(1); + expect(res.body.days[0]).toMatchObject({ id: 3, trip_id: 5, day_number: 1, assignments: [], notes_items: [] }); }); - it('201 create day, 404 trip when not accessible', async () => { + it('201 create day (real insert, auto day_number), 404 trip when not accessible', async () => { const ok = await request(server).post('/api/trips/5/days').set('Cookie', sessionCookie(1)).send({ date: '2026-07-01' }); expect(ok.status).toBe(201); - expect(ok.body).toEqual({ day: { id: 9 } }); - canAccessTrip.mockReturnValue(undefined); - const miss = await request(server).get('/api/trips/5/days').set('Cookie', sessionCookie(1)); + expect(ok.body.day).toMatchObject({ trip_id: 5, day_number: 2, date: '2026-07-01', assignments: [] }); + const row = db.prepare('SELECT * FROM days WHERE id = ?').get(ok.body.day.id); + expect(row).toMatchObject({ trip_id: 5, day_number: 2, date: '2026-07-01' }); + db.prepare('DELETE FROM days WHERE id = ?').run(ok.body.day.id); + const miss = await request(server).get('/api/trips/77/days').set('Cookie', sessionCookie(1)); expect(miss.status).toBe(404); expect(miss.body).toEqual({ error: 'Trip not found' }); }); + it('200 update day notes/title, 404 Day not found, 403 without permission', async () => { + const res = await request(server).put('/api/trips/5/days/3').set('Cookie', sessionCookie(1)) + .send({ notes: 'Walking day', title: 'Arrival' }); + expect(res.status).toBe(200); + expect(res.body.day).toMatchObject({ id: 3, notes: 'Walking day', title: 'Arrival', assignments: [] }); + const miss = await request(server).put('/api/trips/5/days/99').set('Cookie', sessionCookie(1)).send({ notes: 'x' }); + expect(miss.status).toBe(404); + expect(miss.body).toEqual({ error: 'Day not found' }); + checkPermission.mockReturnValue(false); + const forbidden = await request(server).put('/api/trips/5/days/3').set('Cookie', sessionCookie(1)).send({ notes: 'x' }); + expect(forbidden.status).toBe(403); + expect(forbidden.body).toEqual({ error: 'No permission' }); + }); + + it('200 transport setter changes only default_transport_mode', async () => { + db.prepare('UPDATE days SET notes = ?, title = ? WHERE id = 3').run('Keep', 'Kept'); + const res = await request(server).put('/api/trips/5/days/3/transport').set('Cookie', sessionCookie(1)) + .send({ transport_mode: 'walk' }); + expect(res.status).toBe(200); + expect(res.body.day).toMatchObject({ id: 3, default_transport_mode: 'walk', notes: 'Keep', title: 'Kept' }); + }); + + it('200 reorder permutes day_number, 400 on a non-permutation', async () => { + db.prepare('INSERT INTO trips (id, user_id, title) VALUES (6, 1, ?)').run('Reorder'); + const a = Number(db.prepare('INSERT INTO days (trip_id, day_number, date) VALUES (6, 1, ?)').run('2026-03-01').lastInsertRowid); + const b = Number(db.prepare('INSERT INTO days (trip_id, day_number, date) VALUES (6, 2, ?)').run('2026-03-02').lastInsertRowid); + const ok = await request(server).put('/api/trips/6/days/reorder').set('Cookie', sessionCookie(1)) + .send({ orderedIds: [b, a] }); + expect(ok.status).toBe(200); + expect(ok.body).toEqual({ success: true }); + const after = db.prepare('SELECT id, date FROM days WHERE trip_id = 6 ORDER BY day_number').all() as { id: number; date: string }[]; + // Dates stay pinned to slots; the rows swapped positions. + expect(after.map(d => d.id)).toEqual([b, a]); + expect(after.map(d => d.date)).toEqual(['2026-03-01', '2026-03-02']); + const bad = await request(server).put('/api/trips/6/days/reorder').set('Cookie', sessionCookie(1)) + .send({ orderedIds: [b] }); + expect(bad.status).toBe(400); + expect(bad.body).toEqual({ error: 'orderedIds must be a permutation of the trip day ids.' }); + }); + + it('200 delete day removes the row', async () => { + db.prepare('INSERT INTO trips (id, user_id, title) VALUES (7, 1, ?)').run('Delete'); + const id = Number(db.prepare('INSERT INTO days (trip_id, day_number) VALUES (7, 1)').run().lastInsertRowid); + const res = await request(server).delete(`/api/trips/7/days/${id}`).set('Cookie', sessionCookie(1)); + expect(res.status).toBe(200); + expect(res.body).toEqual({ success: true }); + expect(db.prepare('SELECT * FROM days WHERE id = ?').get(id)).toBeUndefined(); + }); + it('201 create note (real insert: trim, empty-string coercions), 400 on over-long text (before access)', async () => { const ok = await request(server).post('/api/trips/5/days/3/notes').set('Cookie', sessionCookie(1)) .send({ text: ' Lunch ', time: '', icon: '', sort_order: 0 }); diff --git a/server/tests/e2e/reservations.e2e.test.ts b/server/tests/e2e/reservations.e2e.test.ts index 05570dd55..fa5f4af8a 100644 --- a/server/tests/e2e/reservations.e2e.test.ts +++ b/server/tests/e2e/reservations.e2e.test.ts @@ -2,7 +2,8 @@ * Reservations + accommodations module e2e — exercises both migrated mounts * through the real JwtAuthGuard against a temp SQLite db. Reservation SQL runs * for real (ReservationsService is DI-native, no mock — the temp db carries the - * full schema via createTables + runMigrations); the day/budget services, the + * full schema via createTables + runMigrations), and so does the accommodation + * SQL (the injected DaysService is DI-native too); the budget service, the * permission check and the WebSocket broadcast stay mocked. */ import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter'; @@ -41,24 +42,15 @@ vi.mock('../../src/services/notificationService', () => ({ send: notificationSen const { checkPermission } = vi.hoisted(() => ({ checkPermission: vi.fn() })); vi.mock('../../src/services/permissions', () => ({ checkPermission })); -const { budget, day } = vi.hoisted(() => ({ +const { budget } = vi.hoisted(() => ({ budget: { createBudgetItem: vi.fn(), updateBudgetItem: vi.fn(), deleteBudgetItem: vi.fn(), linkBudgetItemToReservation: vi.fn(), }, - day: { - listAccommodations: vi.fn(), - validateAccommodationRefs: vi.fn(), - createAccommodation: vi.fn(), - getAccommodation: vi.fn(), - updateAccommodation: vi.fn(), - deleteAccommodation: vi.fn(), - }, })); vi.mock('../../src/services/budgetService', () => budget); -vi.mock('../../src/services/dayService', () => day); import { createTables } from '../../src/db/schema'; import { runMigrations } from '../../src/db/migrations'; @@ -89,9 +81,6 @@ describe('Reservations + accommodations e2e (real auth guard + temp SQLite, real tripId = Number(db.prepare("INSERT INTO trips (user_id, title) VALUES (1, 'E2E Trip')").run().lastInsertRowid); app = await build(); server = app.getHttpServer(); - day.listAccommodations.mockReturnValue([{ id: 1 }]); - day.validateAccommodationRefs.mockReturnValue([]); - day.createAccommodation.mockReturnValue({ id: 9 }); }); beforeEach(() => { @@ -151,16 +140,28 @@ describe('Reservations + accommodations e2e (real auth guard + temp SQLite, real expect(bad.body.error).toContain('title'); }); - it('200 list accommodations + 201 create', async () => { - const list = await request(server).get(`/api/trips/${tripId}/accommodations`).set('Cookie', sessionCookie(1)); - expect(list.status).toBe(200); - expect(list.body).toEqual({ accommodations: [{ id: 1 }] }); + it('200 list accommodations + 201 create (real insert + auto hotel reservation), 404 on bad refs', async () => { + const placeId = Number(db.prepare('INSERT INTO places (trip_id, name) VALUES (?, ?)').run(tripId, 'Grand Hotel').lastInsertRowid); + const dayId = Number(db.prepare('INSERT INTO days (trip_id, day_number, date) VALUES (?, 1, ?)').run(tripId, '2026-03-01').lastInsertRowid); const create = await request(server) .post(`/api/trips/${tripId}/accommodations`) .set('Cookie', sessionCookie(1)) - .send({ place_id: 2, start_day_id: 10, end_day_id: 11 }); + .send({ place_id: placeId, start_day_id: dayId, end_day_id: dayId, check_in: '15:00' }); expect(create.status).toBe(201); - expect(create.body).toEqual({ accommodation: { id: 9 } }); + expect(create.body.accommodation).toMatchObject({ place_id: placeId, start_day_id: dayId, end_day_id: dayId, place_name: 'Grand Hotel' }); + // The partner hotel reservation is auto-created by the real DaysService SQL. + const linked = db.prepare('SELECT * FROM reservations WHERE accommodation_id = ?').get(create.body.accommodation.id) as { type: string; status: string }; + expect(linked).toMatchObject({ type: 'hotel', status: 'confirmed' }); + const list = await request(server).get(`/api/trips/${tripId}/accommodations`).set('Cookie', sessionCookie(1)); + expect(list.status).toBe(200); + expect(list.body.accommodations).toHaveLength(1); + expect(list.body.accommodations[0]).toMatchObject({ id: create.body.accommodation.id, place_name: 'Grand Hotel' }); + const badRefs = await request(server) + .post(`/api/trips/${tripId}/accommodations`) + .set('Cookie', sessionCookie(1)) + .send({ place_id: 99999, start_day_id: dayId, end_day_id: dayId }); + expect(badRefs.status).toBe(404); + expect(badRefs.body).toEqual({ error: 'Place not found' }); }); it('404 when trip not accessible (accommodations)', async () => { diff --git a/server/tests/e2e/trips.e2e.test.ts b/server/tests/e2e/trips.e2e.test.ts index f00581450..60d4f2704 100644 --- a/server/tests/e2e/trips.e2e.test.ts +++ b/server/tests/e2e/trips.e2e.test.ts @@ -54,10 +54,11 @@ const { db } = vi.hoisted(() => { needs_review INTEGER DEFAULT 0, day_plan_position REAL, external_source TEXT, sync_enabled INTEGER, created_at DATETIME DEFAULT CURRENT_TIMESTAMP);`); tmp.exec('CREATE TABLE days (id INTEGER PRIMARY KEY AUTOINCREMENT, trip_id INTEGER NOT NULL, day_number INTEGER, date TEXT);'); - tmp.exec('CREATE TABLE places (id INTEGER PRIMARY KEY AUTOINCREMENT, trip_id INTEGER NOT NULL, name TEXT, image_url TEXT);'); + tmp.exec(`CREATE TABLE places (id INTEGER PRIMARY KEY AUTOINCREMENT, trip_id INTEGER NOT NULL, name TEXT, + image_url TEXT, address TEXT, lat REAL, lng REAL);`); tmp.exec(`CREATE TABLE day_accommodations (id INTEGER PRIMARY KEY AUTOINCREMENT, trip_id INTEGER NOT NULL, place_id INTEGER, start_day_id INTEGER, end_day_id INTEGER, check_in TEXT, check_in_end TEXT, - check_out TEXT, confirmation TEXT);`); + check_out TEXT, confirmation TEXT, notes TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP);`); tmp.exec(`CREATE TABLE reservation_day_positions (reservation_id INTEGER NOT NULL, day_id INTEGER NOT NULL, position REAL, PRIMARY KEY (reservation_id, day_id));`); tmp.exec(`CREATE TABLE reservation_endpoints (id INTEGER PRIMARY KEY AUTOINCREMENT, reservation_id INTEGER NOT NULL, @@ -89,7 +90,8 @@ const { tripSvc } = vi.hoisted(() => ({ }, })); vi.mock('../../src/services/tripService', () => tripSvc); -vi.mock('../../src/services/dayService', () => ({ listDays: () => ({ days: [] }), listAccommodations: () => [] })); +// 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: () => [] })); vi.mock('../../src/services/budgetService', () => ({ listBudgetItems: () => [] })); diff --git a/server/tests/helpers/mcp-test-controllers.ts b/server/tests/helpers/mcp-test-controllers.ts index c35356ce0..e0b53f179 100644 --- a/server/tests/helpers/mcp-test-controllers.ts +++ b/server/tests/helpers/mcp-test-controllers.ts @@ -10,6 +10,8 @@ import { CollabService } from '../../src/nest/collab/collab.service'; import { DatabaseService } from '../../src/nest/database/database.service'; import { DayNotesMcp } from '../../src/nest/days/day-notes.mcp'; import { DayNotesService } from '../../src/nest/days/day-notes.service'; +import { DaysMcp } from '../../src/nest/days/days.mcp'; +import { DaysService } from '../../src/nest/days/days.service'; import { PackingMcp } from '../../src/nest/packing/packing.mcp'; import { PackingService } from '../../src/nest/packing/packing.service'; import { ReservationsMcp } from '../../src/nest/reservations/reservations.mcp'; @@ -30,15 +32,17 @@ import { VacayService } from '../../src/nest/vacay/vacay.service'; */ export function createMcpTestRegistry(): McpRegistry { const dbService = new DatabaseService(db); + const daysService = new DaysService(dbService); return createTestRegistry( [ new TagsMcp(new TagsService(dbService)), new CategoriesMcp(new CategoriesService(dbService)), new TodoMcp(new TodoService(dbService)), new PackingMcp(new PackingService(dbService)), - new ReservationsMcp(new ReservationsService(dbService)), + new ReservationsMcp(new ReservationsService(dbService), daysService), new DayNotesMcp(new DayNotesService(dbService)), - new AssignmentsMcp(new AssignmentsService(dbService)), + new DaysMcp(daysService, dbService), + new AssignmentsMcp(new AssignmentsService(dbService), daysService), new CollabMcp(new CollabService(dbService)), new VacayMcp(new VacayService(dbService)), ], diff --git a/server/tests/helpers/plugin-host.ts b/server/tests/helpers/plugin-host.ts index 634f2e3d0..3635efdf2 100644 --- a/server/tests/helpers/plugin-host.ts +++ b/server/tests/helpers/plugin-host.ts @@ -10,6 +10,7 @@ import { CategoriesService } from '../../src/nest/categories/categories.service' import { TodoService } from '../../src/nest/todo/todo.service'; import { PackingService } from '../../src/nest/packing/packing.service'; import { DayNotesService } from '../../src/nest/days/day-notes.service'; +import { DaysService } from '../../src/nest/days/days.service'; import { AssignmentsService } from '../../src/nest/assignments/assignments.service'; import { LlmConfigResolver } from '../../src/nest/llm-parse/llm-config.resolver'; import { SettingsService } from '../../src/nest/settings/settings.service'; @@ -39,6 +40,7 @@ export function createHostDepsFactory(dbs: DatabaseService): PluginHostDepsFacto new FilesService(dbs), new CollabService(dbs), new VacayService(dbs), + new DaysService(dbs), ); } diff --git a/server/tests/integration/dayReorder.test.ts b/server/tests/integration/dayReorder.test.ts index bf7ebb441..f9f5e0449 100644 --- a/server/tests/integration/dayReorder.test.ts +++ b/server/tests/integration/dayReorder.test.ts @@ -1,6 +1,6 @@ /** * Day reorder + insert integration tests (#589) — exercises the real - * dayService against the real schema. Covers: position renumber, dates pinned + * DaysService against the real schema. Covers: position renumber, dates pinned * to slots while content rides along by id, booking-date re-stamp, permutation * validation, the accommodation-inversion guard, and insert (dated + dateless). */ @@ -28,7 +28,12 @@ import { createTables } from '../../src/db/schema'; import { runMigrations } from '../../src/db/migrations'; import { resetTestDb } from '../helpers/test-db'; import { createUser, createTrip, createPlace, createDay, createDayAssignment, createReservation, createDayAccommodation } from '../helpers/factories'; -import { reorderDays, insertDay, DayReorderError } from '../../src/services/dayService'; +import { DatabaseService } from '../../src/nest/database/database.service'; +import { DaysService, DayReorderError } from '../../src/nest/days/days.service'; + +const svc = new DaysService(new DatabaseService(testDb)); +const reorderDays = (tripId: number, orderedIds: number[]) => svc.reorder(tripId, orderedIds); +const insertDay = (tripId: number, position?: number) => svc.insert(tripId, position); let userId: number; diff --git a/server/tests/unit/mcp/resources.test.ts b/server/tests/unit/mcp/resources.test.ts index 6782f9db4..bb29d7344 100644 --- a/server/tests/unit/mcp/resources.test.ts +++ b/server/tests/unit/mcp/resources.test.ts @@ -1,13 +1,15 @@ /** * Unit tests for MCP resources (resources.ts). - * Tests all 9 legacy-registrar resources via InMemoryTransport + Client + * Tests all 7 legacy-registrar resources via InMemoryTransport + Client * (trek://categories moved to the DI-discovered CategoriesMcp — see * tools-categories.test.ts; trek://trips/{tripId}/todos moved to TodoMcp — * see tools-todos.test.ts; trek://trips/{tripId}/packing and .../packing/bags * moved to PackingMcp — see tools-packing.test.ts; * trek://trips/{tripId}/days/{dayId}/notes moved to DayNotesMcp — see * tools-notes.test.ts; trek://trips/{tripId}/collab-notes moved to CollabMcp — - * see tools-notes.test.ts). + * see tools-notes.test.ts; trek://trips/{tripId}/days and + * .../accommodations moved to DaysMcp — see tools-days.test.ts and + * tools-days-accommodations.test.ts). */ import { describe, it, expect, vi, beforeAll, beforeEach, afterAll, afterEach } from 'vitest'; @@ -46,7 +48,7 @@ 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, createDay, createPlace, addTripMember, createBudgetItem, createReservation, createCollabNote, createBucketListItem, createVisitedCountry, createDayAssignment, createDayAccommodation } from '../../helpers/factories'; +import { createUser, createTrip, createPlace, addTripMember, createBudgetItem, createReservation, createCollabNote, createBucketListItem, createVisitedCountry } from '../../helpers/factories'; import { createMcpHarness, parseResourceResult, type McpHarness } from '../../helpers/mcp-harness'; beforeAll(() => { @@ -151,38 +153,8 @@ describe('Resource: trek://trips/{tripId}', () => { }); }); -describe('Resource: trek://trips/{tripId}/days', () => { - it('returns days with assignments in order', async () => { - const { user } = createUser(testDb); - const trip = createTrip(testDb, user.id); - const day1 = createDay(testDb, trip.id, { day_number: 1 }); - const day2 = createDay(testDb, trip.id, { day_number: 2 }); - const place = createPlace(testDb, trip.id); - createDayAssignment(testDb, day1.id, place.id); - - await withHarness(user.id, async (harness) => { - const result = await harness.client.readResource({ uri: `trek://trips/${trip.id}/days` }); - const days = parseResourceResult(result) as any[]; - expect(days).toHaveLength(2); - expect(days[0].day_number).toBe(1); - expect(days[0].assignments).toHaveLength(1); - expect(days[1].day_number).toBe(2); - expect(days[1].assignments).toHaveLength(0); - }); - }); - - 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}/days` }); - const data = parseResourceResult(result) as any; - expect(data.error).toBeTruthy(); - }); - }); -}); +// The trek://trips/{tripId}/days resource moved to the DI-discovered +// DaysMcp — see tools-days.test.ts. describe('Resource: trek://trips/{tripId}/places', () => { it('returns all places for a trip', async () => { @@ -250,35 +222,8 @@ describe('Resource: trek://trips/{tripId}/budget', () => { // The trek://trips/{tripId}/days/{dayId}/notes resource moved to the // DI-discovered DayNotesMcp — see tools-notes.test.ts. -describe('Resource: trek://trips/{tripId}/accommodations', () => { - it('returns accommodations for a trip', async () => { - const { user } = createUser(testDb); - const trip = createTrip(testDb, user.id); - const day1 = createDay(testDb, trip.id, { day_number: 1 }); - const day2 = createDay(testDb, trip.id, { day_number: 2 }); - const place = createPlace(testDb, trip.id, { name: 'Grand Hotel' }); - createDayAccommodation(testDb, trip.id, place.id, day1.id, day2.id); - - await withHarness(user.id, async (harness) => { - const result = await harness.client.readResource({ uri: `trek://trips/${trip.id}/accommodations` }); - const items = parseResourceResult(result) as any[]; - expect(items).toHaveLength(1); - expect(items[0].place_name).toBe('Grand Hotel'); - }); - }); - - 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}/accommodations` }); - const data = parseResourceResult(result) as any; - expect(data.error).toBeTruthy(); - }); - }); -}); +// 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 () => { diff --git a/server/tests/unit/mcp/tools-days-accommodations.test.ts b/server/tests/unit/mcp/tools-days-accommodations.test.ts index 4016ee6fa..855af5af6 100644 --- a/server/tests/unit/mcp/tools-days-accommodations.test.ts +++ b/server/tests/unit/mcp/tools-days-accommodations.test.ts @@ -1,7 +1,10 @@ /** * Unit tests for MCP day and accommodation tools: * create_day, delete_day, - * create_accommodation, update_accommodation, delete_accommodation. + * create_accommodation, create_place_accommodation, update_accommodation, + * delete_accommodation — plus the trek://trips/{tripId}/accommodations + * resource (moved from resources.test.ts when the legacy registrar was + * ported to the DI-discovered DaysMcp). */ import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest'; @@ -15,7 +18,18 @@ const { testDb, dbMock } = vi.hoisted(() => { db, closeDb: () => {}, reinitialize: () => {}, - getPlaceWithTags: () => null, + // Real-SQL implementation: create_place_accommodation's createPlace + // returns getPlaceWithTags(placeId) — a null stub would make the tool's + // atomic branch throw before the accommodation insert. + getPlaceWithTags: (placeId: number | string) => { + const place = db.prepare(` + SELECT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon + FROM places p LEFT JOIN categories c ON p.category_id = c.id WHERE p.id = ? + `).get(placeId) as { category_id: number | null; category_name: string; category_color: string; category_icon: string } | undefined; + if (!place) return null; + const tags = db.prepare(`SELECT t.* FROM tags t JOIN place_tags pt ON t.id = pt.tag_id WHERE pt.place_id = ?`).all(placeId); + return { ...place, category: place.category_id ? { id: place.category_id, name: place.category_name, color: place.category_color, icon: place.category_icon } : null, tags }; + }, 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) => @@ -38,7 +52,7 @@ import { createTables } from '../../../src/db/schema'; import { runMigrations } from '../../../src/db/migrations'; import { resetTestDb } from '../../helpers/test-db'; import { createUser, createTrip, createDay, createPlace, 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); @@ -294,3 +308,79 @@ describe('Tool: delete_accommodation', () => { }); }); }); + +// --------------------------------------------------------------------------- +// create_place_accommodation +// --------------------------------------------------------------------------- + +describe('Tool: create_place_accommodation', () => { + it('creates the place and the accommodation atomically and broadcasts both', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const day = createDay(testDb, trip.id); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'create_place_accommodation', + arguments: { + tripId: trip.id, name: 'Ryokan Sakura', start_day_id: day.id, end_day_id: day.id, + check_in: '15:00', accommodation_notes: 'Tatami room', + }, + }); + const data = parseToolResult(result) as { place: { id: number; name: string }; accommodation: { id: number } }; + expect(data.place.name).toBe('Ryokan Sakura'); + expect(data.accommodation).toMatchObject({ place_id: data.place.id, start_day_id: day.id, end_day_id: day.id, notes: 'Tatami room' }); + expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'place:created', expect.objectContaining({ place: expect.anything() })); + expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'accommodation:created', expect.objectContaining({ accommodation: expect.anything() })); + // The partner hotel reservation rides along. + const linked = testDb.prepare('SELECT type FROM reservations WHERE accommodation_id = ?').get(data.accommodation.id) as { type: string }; + expect(linked.type).toBe('hotel'); + }); + }); + + it('reports the day-validation errors without creating anything', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'create_place_accommodation', + arguments: { tripId: trip.id, name: 'Nowhere Inn', start_day_id: 99999, end_day_id: 99999 }, + }); + expect(result.isError).toBe(true); + expect(testDb.prepare('SELECT COUNT(*) as n FROM places WHERE trip_id = ?').get(trip.id)).toMatchObject({ n: 0 }); + }); + }); +}); + +// --------------------------------------------------------------------------- +// trek://trips/{tripId}/accommodations resource (moved from resources.test.ts) +// --------------------------------------------------------------------------- + +describe('Resource: trek://trips/{tripId}/accommodations', () => { + it('returns accommodations for a trip', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const day1 = createDay(testDb, trip.id, { day_number: 1 }); + const day2 = createDay(testDb, trip.id, { day_number: 2 }); + const place = createPlace(testDb, trip.id, { name: 'Grand Hotel' }); + createDayAccommodation(testDb, trip.id, place.id, day1.id, day2.id); + + await withHarness(user.id, async (h) => { + const result = await h.client.readResource({ uri: `trek://trips/${trip.id}/accommodations` }); + const items = parseResourceResult(result) as any[]; + expect(items).toHaveLength(1); + expect(items[0].place_name).toBe('Grand Hotel'); + }); + }); + + it('returns access denied for unauthorized trip', async () => { + const { user } = createUser(testDb); + const { user: other } = createUser(testDb); + const trip = createTrip(testDb, other.id); + + await withHarness(user.id, async (h) => { + const result = await h.client.readResource({ uri: `trek://trips/${trip.id}/accommodations` }); + const data = parseResourceResult(result) as any; + expect(data.error).toBeTruthy(); + }); + }); +}); diff --git a/server/tests/unit/mcp/tools-days.test.ts b/server/tests/unit/mcp/tools-days.test.ts index 23e9dfe89..b4a83d48e 100644 --- a/server/tests/unit/mcp/tools-days.test.ts +++ b/server/tests/unit/mcp/tools-days.test.ts @@ -1,5 +1,7 @@ /** - * Unit tests for MCP day tools: update_day. + * Unit tests for the DI-discovered DaysMcp: the update_day tool and the + * trek://trips/{tripId}/days resource (moved from resources.test.ts when the + * legacy registrar was ported). */ import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest'; @@ -35,8 +37,8 @@ vi.mock('../../../src/websocket', () => ({ broadcast: broadcastMock })); import { createTables } from '../../../src/db/schema'; import { runMigrations } from '../../../src/db/migrations'; import { resetTestDb } from '../../helpers/test-db'; -import { createUser, createTrip, createDay } from '../../helpers/factories'; -import { createMcpHarness, parseToolResult, type McpHarness } from '../../helpers/mcp-harness'; +import { createUser, createTrip, createDay, createPlace, createDayAssignment } from '../../helpers/factories'; +import { createMcpHarness, parseToolResult, parseResourceResult, type McpHarness } from '../../helpers/mcp-harness'; beforeAll(() => { createTables(testDb); @@ -140,3 +142,41 @@ describe('Tool: update_day', () => { }); }); }); + +// --------------------------------------------------------------------------- +// trek://trips/{tripId}/days resource (moved from resources.test.ts) +// --------------------------------------------------------------------------- + +describe('Resource: trek://trips/{tripId}/days', () => { + it('returns days with assignments in order', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const day1 = createDay(testDb, trip.id, { day_number: 1 }); + const day2 = createDay(testDb, trip.id, { day_number: 2 }); + const place = createPlace(testDb, trip.id); + createDayAssignment(testDb, day1.id, place.id); + void day2; + + await withHarness(user.id, async (h) => { + const result = await h.client.readResource({ uri: `trek://trips/${trip.id}/days` }); + const days = parseResourceResult(result) as any[]; + expect(days).toHaveLength(2); + expect(days[0].day_number).toBe(1); + expect(days[0].assignments).toHaveLength(1); + expect(days[1].day_number).toBe(2); + expect(days[1].assignments).toHaveLength(0); + }); + }); + + it('returns access denied for unauthorized trip', async () => { + const { user } = createUser(testDb); + const { user: other } = createUser(testDb); + const trip = createTrip(testDb, other.id); + + await withHarness(user.id, async (h) => { + const result = await h.client.readResource({ uri: `trek://trips/${trip.id}/days` }); + const data = parseResourceResult(result) as any; + expect(data.error).toBeTruthy(); + }); + }); +}); diff --git a/server/tests/unit/nest/days.controller.test.ts b/server/tests/unit/nest/days.controller.test.ts index b57c92cf3..6d3506ad7 100644 --- a/server/tests/unit/nest/days.controller.test.ts +++ b/server/tests/unit/nest/days.controller.test.ts @@ -3,7 +3,7 @@ import { HttpException } from '@nestjs/common'; import { DaysController } from '../../../src/nest/days/days.controller'; import { DayNotesController } from '../../../src/nest/days/day-notes.controller'; import { DayNoteCreateDto, DayNoteUpdateDto } from '../../../src/nest/days/day-notes.dto'; -import { DayReorderError } from '../../../src/services/dayService'; +import { DayReorderError } from '../../../src/nest/days/days.service'; import type { DaysService } from '../../../src/nest/days/days.service'; import type { DayNotesService } from '../../../src/nest/days/day-notes.service'; import type { User } from '../../../src/types'; diff --git a/server/tests/unit/services/dayService.test.ts b/server/tests/unit/nest/days.service.test.ts similarity index 66% rename from server/tests/unit/services/dayService.test.ts rename to server/tests/unit/nest/days.service.test.ts index ef247b3c2..298d9c908 100644 --- a/server/tests/unit/services/dayService.test.ts +++ b/server/tests/unit/nest/days.service.test.ts @@ -1,5 +1,7 @@ /** - * Unit tests for dayService — DAY-SVC-001 through DAY-SVC-030. + * Unit tests for the DI-native DaysService — DAY-SVC-001 through DAY-SVC-026 + * moved 1:1 from the legacy tests/unit/services/dayService.test.ts; + * DAY-SVC-027 through DAY-SVC-032 pin the days.bridge delegation. * Uses a real in-memory SQLite DB so SQL logic is exercised faithfully. */ import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest'; @@ -39,26 +41,23 @@ vi.mock('../../../src/config', () => ({ 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, createDay, createPlace, createDayAssignment, createDayAccommodation } from '../../helpers/factories'; +import { DatabaseService } from '../../../src/nest/database/database.service'; +import { DaysService, addDays } from '../../../src/nest/days/days.service'; import { - verifyTripAccess, - getAssignmentsForDay, - listDays, - createDay as svcCreateDay, - getDay, - updateDay, - deleteDay, - listAccommodations, - validateAccommodationRefs, - createAccommodation, - getAccommodation, - updateAccommodation, - deleteAccommodation, -} from '../../../src/services/dayService'; + getDay as bridgeGetDay, + listDays as bridgeListDays, + listAccommodations as bridgeListAccommodations, + restampReservationDates as bridgeRestampReservationDates, + resyncAccommodationDays as bridgeResyncAccommodationDays, +} from '../../../src/nest/days/days.bridge'; + +const svc = new DaysService(new DatabaseService(testDb)); beforeAll(() => { createTables(testDb); @@ -79,7 +78,7 @@ describe('verifyTripAccess', () => { it('DAY-SVC-001 — returns trip row for owner', () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); - const result = verifyTripAccess(trip.id, user.id) as any; + const result = svc.verifyTripAccess(trip.id, user.id) as any; expect(result).toBeDefined(); expect(result.id).toBe(trip.id); }); @@ -88,7 +87,7 @@ describe('verifyTripAccess', () => { const { user: owner } = createUser(testDb); const { user: stranger } = createUser(testDb); const trip = createTrip(testDb, owner.id); - expect(verifyTripAccess(trip.id, stranger.id)).toBeFalsy(); + expect(svc.verifyTripAccess(trip.id, stranger.id)).toBeFalsy(); }); }); @@ -99,7 +98,7 @@ describe('getAssignmentsForDay', () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const day = createDay(testDb, trip.id) as any; - expect(getAssignmentsForDay(day.id)).toEqual([]); + expect(svc.getAssignmentsForDay(day.id)).toEqual([]); }); it('DAY-SVC-004 — returns assignments with nested place object', () => { @@ -109,7 +108,7 @@ describe('getAssignmentsForDay', () => { const place = createPlace(testDb, trip.id, { name: 'Eiffel Tower', lat: 48.8, lng: 2.3 }) as any; createDayAssignment(testDb, day.id, place.id, { order_index: 0 }); - const assignments = getAssignmentsForDay(day.id) as any[]; + const assignments = svc.getAssignmentsForDay(day.id) as any[]; expect(assignments).toHaveLength(1); expect(assignments[0].place).toBeDefined(); expect(assignments[0].place.name).toBe('Eiffel Tower'); @@ -123,7 +122,7 @@ describe('getAssignmentsForDay', () => { const place = createPlace(testDb, trip.id, { name: 'No Tags' }) as any; createDayAssignment(testDb, day.id, place.id); - const assignments = getAssignmentsForDay(day.id) as any[]; + const assignments = svc.getAssignmentsForDay(day.id) as any[]; expect(Array.isArray(assignments[0].place.tags)).toBe(true); }); @@ -136,19 +135,19 @@ describe('getAssignmentsForDay', () => { createDayAssignment(testDb, day.id, p1.id, { order_index: 2 }); createDayAssignment(testDb, day.id, p2.id, { order_index: 1 }); - const assignments = getAssignmentsForDay(day.id) as any[]; + const assignments = svc.getAssignmentsForDay(day.id) as any[]; expect(assignments[0].place.name).toBe('First'); expect(assignments[1].place.name).toBe('Second'); }); }); -// ── listDays ────────────────────────────────────────────────────────────────── +// ── list ────────────────────────────────────────────────────────────────────── -describe('listDays', () => { +describe('list', () => { it('DAY-SVC-007 — returns { days: [] } for trip with no days', () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); - const result = listDays(trip.id) as any; + const result = svc.list(trip.id) as any; expect(result.days).toEqual([]); }); @@ -156,20 +155,20 @@ describe('listDays', () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); createDay(testDb, trip.id); - const result = listDays(trip.id) as any; + const result = svc.list(trip.id) as any; expect(result.days).toHaveLength(1); expect(Array.isArray(result.days[0].assignments)).toBe(true); }); }); -// ── createDay ───────────────────────────────────────────────────────────────── +// ── create ──────────────────────────────────────────────────────────────────── -describe('createDay (service)', () => { +describe('create (service)', () => { it('DAY-SVC-009 — creates a day with auto-incremented day_number', () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); - const d1 = svcCreateDay(trip.id) as any; - const d2 = svcCreateDay(trip.id) as any; + const d1 = svc.create(trip.id) as any; + const d2 = svc.create(trip.id) as any; expect(d1.day_number).toBe(1); expect(d2.day_number).toBe(2); }); @@ -177,20 +176,20 @@ describe('createDay (service)', () => { it('DAY-SVC-010 — returns day with empty assignments array', () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); - const day = svcCreateDay(trip.id) as any; + const day = svc.create(trip.id) as any; expect(Array.isArray(day.assignments)).toBe(true); expect(day.assignments).toHaveLength(0); }); }); -// ── getDay / updateDay / deleteDay ──────────────────────────────────────────── +// ── getDay / update / remove ────────────────────────────────────────────────── describe('getDay', () => { it('DAY-SVC-011 — returns day when id and tripId match', () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const day = createDay(testDb, trip.id) as any; - const found = getDay(day.id, trip.id) as any; + const found = svc.getDay(day.id, trip.id) as any; expect(found).toBeDefined(); expect(found.id).toBe(day.id); }); @@ -198,16 +197,16 @@ describe('getDay', () => { it('DAY-SVC-012 — returns undefined for non-existent day', () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); - expect(getDay(99999, trip.id)).toBeUndefined(); + expect(svc.getDay(99999, trip.id)).toBeUndefined(); }); }); -describe('updateDay', () => { +describe('update', () => { it('DAY-SVC-013 — updates notes and returns updated day with assignments', () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const day = createDay(testDb, trip.id) as any; - const updated = updateDay(day.id, day, { notes: 'Updated notes' }) as any; + const updated = svc.update(day.id, day, { notes: 'Updated notes' }) as any; expect(updated.notes).toBe('Updated notes'); expect(Array.isArray(updated.assignments)).toBe(true); }); @@ -216,18 +215,18 @@ describe('updateDay', () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const day = createDay(testDb, trip.id) as any; - const updated = updateDay(day.id, day, { title: 'Day 1 - City Tour' }) as any; + const updated = svc.update(day.id, day, { title: 'Day 1 - City Tour' }) as any; expect(updated.title).toBe('Day 1 - City Tour'); }); }); -describe('deleteDay', () => { +describe('remove', () => { it('DAY-SVC-015 — deletes the day', () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const day = createDay(testDb, trip.id) as any; - deleteDay(day.id); - expect(getDay(day.id, trip.id)).toBeUndefined(); + svc.remove(day.id); + expect(svc.getDay(day.id, trip.id)).toBeUndefined(); }); }); @@ -239,7 +238,7 @@ describe('validateAccommodationRefs', () => { const trip = createTrip(testDb, user.id); const day = createDay(testDb, trip.id) as any; const place = createPlace(testDb, trip.id, { name: 'Hotel' }) as any; - const errors = validateAccommodationRefs(trip.id, place.id, day.id, day.id); + const errors = svc.validateAccommodationRefs(trip.id, place.id, day.id, day.id); expect(errors).toHaveLength(0); }); @@ -247,7 +246,7 @@ describe('validateAccommodationRefs', () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const day = createDay(testDb, trip.id) as any; - const errors = validateAccommodationRefs(trip.id, 99999, day.id, day.id); + const errors = svc.validateAccommodationRefs(trip.id, 99999, day.id, day.id); expect(errors.some((e: any) => e.field === 'place_id')).toBe(true); }); @@ -256,7 +255,7 @@ describe('validateAccommodationRefs', () => { const trip = createTrip(testDb, user.id); const day = createDay(testDb, trip.id) as any; const place = createPlace(testDb, trip.id, { name: 'Hotel' }) as any; - const errors = validateAccommodationRefs(trip.id, place.id, 99999, day.id); + const errors = svc.validateAccommodationRefs(trip.id, place.id, 99999, day.id); expect(errors.some((e: any) => e.field === 'start_day_id')).toBe(true); }); }); @@ -270,7 +269,7 @@ describe('createAccommodation', () => { const day = createDay(testDb, trip.id) as any; const place = createPlace(testDb, trip.id, { name: 'Grand Hotel' }) as any; - const accom = createAccommodation(trip.id, { + const accom = svc.createAccommodation(trip.id, { place_id: place.id, start_day_id: day.id, end_day_id: day.id, @@ -288,7 +287,7 @@ describe('createAccommodation', () => { const day = createDay(testDb, trip.id) as any; const place = createPlace(testDb, trip.id, { name: 'City Hotel' }) as any; - const accom = createAccommodation(trip.id, { + const accom = svc.createAccommodation(trip.id, { place_id: place.id, start_day_id: day.id, end_day_id: day.id, }) as any; @@ -308,7 +307,7 @@ describe('getAccommodation', () => { const day = createDay(testDb, trip.id) as any; const place = createPlace(testDb, trip.id, { name: 'Hotel' }) as any; const accom = createDayAccommodation(testDb, trip.id, place.id, day.id, day.id) as any; - const found = getAccommodation(accom.id, trip.id) as any; + const found = svc.getAccommodation(accom.id, trip.id) as any; expect(found).toBeDefined(); expect(found.id).toBe(accom.id); }); @@ -316,7 +315,7 @@ describe('getAccommodation', () => { it('DAY-SVC-022 — returns undefined for non-existent accommodation', () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); - expect(getAccommodation(99999, trip.id)).toBeUndefined(); + expect(svc.getAccommodation(99999, trip.id)).toBeUndefined(); }); }); @@ -328,12 +327,12 @@ describe('updateAccommodation', () => { const trip = createTrip(testDb, user.id); const day = createDay(testDb, trip.id) as any; const place = createPlace(testDb, trip.id, { name: 'Hotel' }) as any; - const accom = createAccommodation(trip.id, { + const accom = svc.createAccommodation(trip.id, { place_id: place.id, start_day_id: day.id, end_day_id: day.id, }) as any; - const existing = getAccommodation(accom.id, trip.id)!; - const updated = updateAccommodation(accom.id, existing as any, { check_in: '16:00', check_out: '12:00' }) as any; + const existing = svc.getAccommodation(accom.id, trip.id)!; + const updated = svc.updateAccommodation(accom.id, existing as any, { check_in: '16:00', check_out: '12:00' }) as any; expect(updated).toBeDefined(); // Verify linked reservation metadata was synced @@ -349,15 +348,15 @@ describe('updateAccommodation', () => { const trip = createTrip(testDb, user.id); const day = createDay(testDb, trip.id) as any; const place = createPlace(testDb, trip.id, { name: 'Hotel' }) as any; - const accom = createAccommodation(trip.id, { + const accom = svc.createAccommodation(trip.id, { place_id: place.id, start_day_id: day.id, end_day_id: day.id, confirmation: 'ABC123', }) as any; - const existing = getAccommodation(accom.id, trip.id)!; - updateAccommodation(accom.id, existing as any, { check_in: '14:00' }); + const existing = svc.getAccommodation(accom.id, trip.id)!; + svc.updateAccommodation(accom.id, existing as any, { check_in: '14:00' }); - const row = getAccommodation(accom.id, trip.id) as any; + const row = svc.getAccommodation(accom.id, trip.id) as any; expect(row.confirmation).toBe('ABC123'); }); }); @@ -370,17 +369,17 @@ describe('deleteAccommodation', () => { const trip = createTrip(testDb, user.id); const day = createDay(testDb, trip.id) as any; const place = createPlace(testDb, trip.id, { name: 'Hotel' }) as any; - const accom = createAccommodation(trip.id, { + const accom = svc.createAccommodation(trip.id, { place_id: place.id, start_day_id: day.id, end_day_id: day.id, }) as any; const reservation = testDb.prepare('SELECT id FROM reservations WHERE accommodation_id = ?').get(accom.id) as any; - const result = deleteAccommodation(accom.id); + const result = svc.deleteAccommodation(accom.id); expect(result.linkedReservationId).toBe(reservation.id); // Accommodation is gone - expect(getAccommodation(accom.id, trip.id)).toBeUndefined(); + expect(svc.getAccommodation(accom.id, trip.id)).toBeUndefined(); // Reservation is gone const deletedRes = testDb.prepare('SELECT id FROM reservations WHERE id = ?').get(reservation.id); @@ -397,7 +396,86 @@ describe('deleteAccommodation', () => { // Remove the auto-created reservation so there's no linked one testDb.prepare('DELETE FROM reservations WHERE accommodation_id = ?').run(accom.id); - const result = deleteAccommodation(accom.id); + const result = svc.deleteAccommodation(accom.id); expect(result.linkedReservationId).toBeNull(); }); }); + +// ── days.bridge delegation (out-of-container consumers) ─────────────────────── + +describe('days.bridge', () => { + it('DAY-SVC-027 — getDay delegates to DaysService.getDay', () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const day = createDay(testDb, trip.id); + expect(bridgeGetDay(day.id, trip.id)!.id).toBe(day.id); + expect(bridgeGetDay(99999, trip.id)).toBeUndefined(); + }); + + it('DAY-SVC-028 — listDays delegates to DaysService.list', () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + createDay(testDb, trip.id); + const result = bridgeListDays(trip.id); + expect(result.days).toHaveLength(1); + expect(Array.isArray(result.days[0].assignments)).toBe(true); + }); + + it('DAY-SVC-029 — listAccommodations delegates to DaysService.listAccommodations', () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const day = createDay(testDb, trip.id); + const place = createPlace(testDb, trip.id, { name: 'Ryokan' }); + createDayAccommodation(testDb, trip.id, place.id, day.id, day.id); + const rows = bridgeListAccommodations(trip.id) as { place_name: string }[]; + expect(rows).toHaveLength(1); + expect(rows[0].place_name).toBe('Ryokan'); + }); + + it('DAY-SVC-030 — addDays stays UTC-only across month and year rollovers', () => { + expect(addDays('2026-02-27', 3)).toBe('2026-03-02'); + expect(addDays('2026-12-31', 1)).toBe('2027-01-01'); + expect(addDays('2026-06-07', -7)).toBe('2026-05-31'); + }); + + it('DAY-SVC-031 — restampReservationDates re-stamps a booking onto its day\'s new date', () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const day = createDay(testDb, trip.id, { date: '2026-01-01' }); + testDb.prepare( + 'INSERT INTO reservations (trip_id, day_id, title, reservation_time) VALUES (?, ?, ?, ?)' + ).run(trip.id, day.id, 'Dinner', '2026-01-01T19:00'); + + bridgeRestampReservationDates( + trip.id, + new Map([[day.id, '2026-01-01']]), + new Map([[day.id, '2026-01-05']]), + ); + + const row = testDb.prepare('SELECT reservation_time FROM reservations WHERE trip_id = ?').get(trip.id) as { reservation_time: string }; + expect(row.reservation_time).toBe('2026-01-05T19:00'); + }); + + it('DAY-SVC-032 — resyncAccommodationDays re-anchors a stay to the day holding its old date', () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const d1 = createDay(testDb, trip.id, { date: '2026-01-01' }); + const d2 = createDay(testDb, trip.id, { date: '2026-01-02' }); + const place = createPlace(testDb, trip.id, { name: 'Hotel' }); + const accom = createDayAccommodation(testDb, trip.id, place.id, d1.id, d1.id); + + // The trip's range shifted: d1 now holds 01-02 and d2 holds 01-03; the day + // holding the stay's old date (01-01) no longer exists → the stay stays + // glued to its rows, but a range where d2 takes over 01-01 re-anchors it. + testDb.prepare('UPDATE days SET date = ? WHERE id = ?').run('2026-01-02', d1.id); + testDb.prepare('UPDATE days SET date = ? WHERE id = ?').run('2026-01-01', d2.id); + testDb.prepare('UPDATE days SET day_number = 0 WHERE id = ?').run(d2.id); + + bridgeResyncAccommodationDays(trip.id, new Map([[d1.id, '2026-01-01'], [d2.id, '2026-01-02']])); + + const row = testDb.prepare('SELECT start_day_id, end_day_id FROM day_accommodations WHERE id = ?').get(accom.id) as { start_day_id: number; end_day_id: number }; + expect(row.start_day_id).toBe(d2.id); + expect(row.end_day_id).toBe(d2.id); + }); +}); + diff --git a/server/tests/unit/nest/trips.service.test.ts b/server/tests/unit/nest/trips.service.test.ts index 490fa40b9..89b2d2883 100644 --- a/server/tests/unit/nest/trips.service.test.ts +++ b/server/tests/unit/nest/trips.service.test.ts @@ -25,7 +25,6 @@ const { tripSvc } = vi.hoisted(() => ({ }, })); vi.mock('../../../src/services/tripService', () => tripSvc); -vi.mock('../../../src/services/dayService', () => ({ listDays: () => ({ days: [1] }), listAccommodations: () => [] })); vi.mock('../../../src/services/placeService', () => ({ listPlaces: () => [] })); vi.mock('../../../src/services/budgetService', () => ({ listBudgetItems: () => [], @@ -36,6 +35,7 @@ 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 { rebaseTripCurrency } from '../../../src/services/budgetService'; const todoStub = { listItems: () => [] } as unknown as TodoService; @@ -43,7 +43,8 @@ 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; -function svc() { return new TripsService(new DatabaseService(dbConn), todoStub, packingStub, filesStub, reservationsStub); } +const daysStub = { list: () => ({ days: [1] }), listAccommodations: () => [] } as unknown as DaysService; +function svc() { return new TripsService(new DatabaseService(dbConn), todoStub, packingStub, filesStub, reservationsStub, daysStub); } beforeEach(() => vi.clearAllMocks()); describe('TripsService (wrapper delegation + bundle/copy/notify helpers)', () => { diff --git a/server/tests/unit/plugins/plugin-host-deps.factory.test.ts b/server/tests/unit/plugins/plugin-host-deps.factory.test.ts index 481af84b5..e46628d32 100644 --- a/server/tests/unit/plugins/plugin-host-deps.factory.test.ts +++ b/server/tests/unit/plugins/plugin-host-deps.factory.test.ts @@ -4,7 +4,7 @@ * per-plugin data db is cached, a granted db:own call works through the wired * host, and trip broadcasts are force-namespaced to plugin:{id}:{event}. * DI-native domains (budget/reservations/tags/categories/todo/packing/ - * day-notes/assignments/oauth/llm-config/files/collab/vacay) are + * day-notes/days/assignments/oauth/llm-config/files/collab/vacay) are * constructor-injected stubs; legacy services/* domains stay path-mocked until * their own DI migration lands. */ @@ -91,12 +91,14 @@ vi.mock('../../../src/services/placeService', () => ({ updatePlace: vi.fn((_tid: string, pid: string) => (pid === '99' ? null : { id: Number(pid) })), deletePlace: vi.fn((_tid: string, pid: string) => pid !== '99'), })); -vi.mock('../../../src/services/dayService', () => ({ - createDay: vi.fn((tid: number) => ({ id: 20, trip_id: tid, assignments: [] })), +// Days are a constructor-injected stub (same behaviors as the old path mock, +// mapped onto the DaysService method names). +const daysStub = { + create: vi.fn((tid: number) => ({ id: 20, trip_id: tid, assignments: [] })), getDay: vi.fn((id: number) => (id === 99 ? undefined : { id, title: null })), - updateDay: vi.fn((id: number) => ({ id, assignments: [] })), - deleteDay: vi.fn(), - listDays: vi.fn((tid: number) => ({ days: [{ id: 3, trip_id: Number(tid), day_number: 1, assignments: [], notes_items: [] }] })), + update: vi.fn((id: number) => ({ id, assignments: [] })), + remove: vi.fn(), + list: vi.fn((tid: number) => ({ days: [{ id: 3, trip_id: Number(tid), day_number: 1, assignments: [], notes_items: [] }] })), listAccommodations: vi.fn((tid: number) => [{ id: 11, trip_id: Number(tid), place_name: 'Ryokan' }]), // place 999 / day 88 don't belong to the trip validateAccommodationRefs: vi.fn((_tid: number, placeId?: number, startDayId?: number, endDayId?: number) => { @@ -109,7 +111,7 @@ vi.mock('../../../src/services/dayService', () => ({ getAccommodation: vi.fn((id: number) => (Number(id) === 404 ? undefined : { id: Number(id), place_id: 7, start_day_id: 3, end_day_id: 4 })), updateAccommodation: vi.fn((id: number, _existing: unknown, fields: Record) => ({ id: Number(id), ...fields })), deleteAccommodation: vi.fn((id: number) => (Number(id) === 61 ? { linkedReservationId: 40, deletedBudgetItemId: 9 } : { linkedReservationId: null, deletedBudgetItemId: null })), -})); +} as unknown as DaysService; // Assignments is a constructor-injected stub (same behaviors as the old path mock). const assignmentsStub = { createAssignment: vi.fn((dayId: number, placeId: number, notes: string | null) => ({ id: 30, day_id: dayId, place_id: placeId, notes })), @@ -311,6 +313,7 @@ import type { CategoriesService } from '../../../src/nest/categories/categories. import type { TodoService } from '../../../src/nest/todo/todo.service'; import type { PackingService } from '../../../src/nest/packing/packing.service'; import type { DayNotesService } from '../../../src/nest/days/day-notes.service'; +import type { DaysService } from '../../../src/nest/days/days.service'; import type { AssignmentsService } from '../../../src/nest/assignments/assignments.service'; import type { PluginOAuthService } from '../../../src/nest/plugins/plugin-oauth.service'; import type { LlmConfigResolver } from '../../../src/nest/llm-parse/llm-config.resolver'; @@ -322,7 +325,7 @@ import { DatabaseService } from '../../../src/nest/database/database.service'; // The factory under test, wired exactly like PluginsModule does — but with the // DI-native domain services replaced by the stubs above. The shim keeps the // ~45 historical call sites unchanged and supplies a default no-op router. -const factory = new PluginHostDepsFactory(budgetStub, reservationsStub, tagsStub, categoriesStub, todoStub, packingStub, oauthStub, dayNotesStub, assignmentsStub, llmConfigStub, new DatabaseService(mockDb), filesStub, collabStub, vacayStub); +const factory = new PluginHostDepsFactory(budgetStub, reservationsStub, tagsStub, categoriesStub, todoStub, packingStub, oauthStub, dayNotesStub, assignmentsStub, llmConfigStub, new DatabaseService(mockDb), filesStub, collabStub, vacayStub, daysStub); const stubRouter: PluginCallRouter = { callPlugin: async () => undefined, emitPluginEvent: () => {} }; const createRealRpcHost = (id: string, granted: ReadonlySet, router: PluginCallRouter = stubRouter) => factory.create(id, granted, router); @@ -591,7 +594,7 @@ describe('host-deps factory — reservations, day notes, cross-trip + addon read expect(acc.result).toEqual([{ id: 11, trip_id: 1, place_name: 'Ryokan' }]); }); - it('accommodations create validates refs, creates via dayService and emits the cascade broadcasts', async () => { + it('accommodations create validates refs, creates via DaysService and emits the cascade broadcasts', async () => { const h = host('db:write:accommodations'); const good = await call(h, 'accommodations.create', { tripId: 1, input: { place_id: 7, start_day_id: 3, end_day_id: 4, check_in: '15:00' } }); expect(good.ok).toBe(true); diff --git a/server/tests/unit/services/tripService.test.ts b/server/tests/unit/services/tripService.test.ts index 88d27150f..0bbf148ff 100644 --- a/server/tests/unit/services/tripService.test.ts +++ b/server/tests/unit/services/tripService.test.ts @@ -35,9 +35,13 @@ 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 { createAccommodation } from '../../../src/services/dayService'; +import { DatabaseService } from '../../../src/nest/database/database.service'; +import { DaysService } from '../../../src/nest/days/days.service'; import fs from 'fs'; +const daysSvc = new DaysService(new DatabaseService(testDb)); +const createAccommodation = daysSvc.createAccommodation.bind(daysSvc); + beforeAll(() => { createTables(testDb); runMigrations(testDb);