From 3d522be0e5092ded0994cee46351de2f7a48ba25 Mon Sep 17 00:00:00 2001 From: Maurice Date: Mon, 10 Aug 2026 09:57:50 +0200 Subject: [PATCH] Give accommodations one home instead of two halves The routes lived in reservations/ while the SQL lived on DaysService, so the controller delegated every call across a domain boundary to reach its own data, and neither module owned the thing outright. The seven statements move into AccommodationsService unchanged and the domain gets its own directory, module, DTO, RPC and MCP surface. What stays in days/ is the reorder side: assertNoInvertedAccommodation and resyncAccommodationDays are about re-dating day rows, and the stay is the thing being carried, not the thing doing the carrying. Two edges fall out of this. AccommodationsService drops its DaysService dependency, and DaysMcp drops DatabaseService and PlacesService, because create_place_accommodation was their only user - which lets DaysModule stop importing PlacesModule and closes one leg of the latent Days -> Places -> Assignments -> Days cycle. DAY-SVC-016..026, 029 and 035 move to accommodations.service.test.ts with their case ids intact. The reservations e2e container assembles both modules now: it builds a single domain module on purpose, which is exactly what catches a move like this, and it caught it. --- .../accommodations.controller.ts | 2 +- .../nest/accommodations/accommodations.dto.ts | 5 + .../nest/accommodations/accommodations.mcp.ts | 212 +++++++++++++ .../accommodations/accommodations.module.ts | 29 ++ .../accommodations.rpc.ts | 8 +- .../accommodations/accommodations.service.ts | 233 +++++++++++++++ server/src/nest/app.module.ts | 3 +- server/src/nest/days/days.mcp.ts | 183 +----------- server/src/nest/days/days.module.ts | 3 +- server/src/nest/days/days.service.ts | 155 ---------- .../nest/plugins/plugins-runtime.module.ts | 3 +- .../reservations/accommodations.service.ts | 62 ---- .../src/nest/reservations/reservations.dto.ts | 4 - .../nest/reservations/reservations.module.ts | 8 +- server/src/nest/trips/trips.bridge.ts | 2 + server/src/nest/trips/trips.module.ts | 3 +- server/src/nest/trips/trips.rpc.ts | 5 +- server/src/nest/trips/trips.service.ts | 7 +- server/tests/e2e/reservations.e2e.test.ts | 5 +- server/tests/helpers/mcp-test-controllers.ts | 7 +- server/tests/helpers/plugin-host.ts | 12 +- server/tests/helpers/rpc-host-deps.ts | 4 +- .../nest/accommodations.controller.test.ts | 4 +- .../unit/nest/accommodations.service.test.ts | 279 ++++++++++++++++++ .../tests/unit/nest/budget.service.db.test.ts | 1 + server/tests/unit/nest/days.service.test.ts | 190 ------------ server/tests/unit/nest/trips.service.test.ts | 6 +- .../tests/unit/plugins/rpc-fallbacks.test.ts | 10 +- .../unit/plugins/rpc-host-inv-strip.test.ts | 2 +- .../unit/reservations/bookings.rpc.test.ts | 6 +- server/tests/unit/trips/trips.rpc.test.ts | 10 +- server/vitest.config.ts | 1 + 32 files changed, 830 insertions(+), 634 deletions(-) rename server/src/nest/{reservations => accommodations}/accommodations.controller.ts (99%) create mode 100644 server/src/nest/accommodations/accommodations.dto.ts create mode 100644 server/src/nest/accommodations/accommodations.mcp.ts create mode 100644 server/src/nest/accommodations/accommodations.module.ts rename server/src/nest/{days => accommodations}/accommodations.rpc.ts (94%) create mode 100644 server/src/nest/accommodations/accommodations.service.ts delete mode 100644 server/src/nest/reservations/accommodations.service.ts create mode 100644 server/tests/unit/nest/accommodations.service.test.ts diff --git a/server/src/nest/reservations/accommodations.controller.ts b/server/src/nest/accommodations/accommodations.controller.ts similarity index 99% rename from server/src/nest/reservations/accommodations.controller.ts rename to server/src/nest/accommodations/accommodations.controller.ts index 11a4bf881..c5fd4cea5 100644 --- a/server/src/nest/reservations/accommodations.controller.ts +++ b/server/src/nest/accommodations/accommodations.controller.ts @@ -12,7 +12,7 @@ import { } from '@nestjs/common'; import type { User } from '../../types'; import { AccommodationsService } from './accommodations.service'; -import { AccommodationCreateDto, AccommodationUpdateDto } from './reservations.dto'; +import { AccommodationCreateDto, AccommodationUpdateDto } from './accommodations.dto'; import { JwtAuthGuard } from '../auth/jwt-auth.guard'; import { CurrentUser } from '../auth/current-user.decorator'; import { RequirePermission, TripAccessGuard } from '../permissions/trip-access.guard'; diff --git a/server/src/nest/accommodations/accommodations.dto.ts b/server/src/nest/accommodations/accommodations.dto.ts new file mode 100644 index 000000000..89a7caa33 --- /dev/null +++ b/server/src/nest/accommodations/accommodations.dto.ts @@ -0,0 +1,5 @@ +import { createZodDto } from 'nestjs-zod'; +import { accommodationCreateBodySchema, accommodationUpdateRequestSchema } from '@trek/shared'; + +export class AccommodationCreateDto extends createZodDto(accommodationCreateBodySchema) {} +export class AccommodationUpdateDto extends createZodDto(accommodationUpdateRequestSchema) {} diff --git a/server/src/nest/accommodations/accommodations.mcp.ts b/server/src/nest/accommodations/accommodations.mcp.ts new file mode 100644 index 000000000..0a8cb705c --- /dev/null +++ b/server/src/nest/accommodations/accommodations.mcp.ts @@ -0,0 +1,212 @@ +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 { AuthService } from '../auth/auth.service'; +import { PlacesService } from '../places/places.service'; +import { safeBroadcast, noAccess, hasTripPermission, permissionDenied } from '../../mcp/tools/_shared'; +import { DatabaseService } from '../database/database.service'; +import { AccommodationsService } from './accommodations.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; +} + +/** + * Accommodation MCP surface. The four tools and the + * trek://trips/{tripId}/accommodations resource moved here verbatim from + * DaysMcp when accommodations became their own domain; names, descriptions, + * schemas, annotations, error shapes and broadcasts are unchanged. + * + * It keeps DatabaseService and PlacesService because create_place_accommodation + * creates the place and the stay in one transaction. That is also what let + * DaysMcp drop both of them, and DaysModule its PlacesModule import. + */ +@McpController() +export class AccommodationsMcp { + constructor( + private readonly accommodations: AccommodationsService, + private readonly db: DatabaseService, + private readonly places: PlacesService, + private readonly auth: AuthService, + ) {} + + @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 (this.auth.isDemoUser(ctx.userId)) return demoDenied(); + if (!this.accommodations.verifyTripAccess(tripId, ctx.userId)) return noAccess(); + if (!hasTripPermission('day_edit', tripId, ctx.userId)) return permissionDenied(); + const errors = this.accommodations.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.accommodations.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 (this.auth.isDemoUser(ctx.userId)) return demoDenied(); + if (!this.accommodations.verifyTripAccess(tripId, ctx.userId)) return noAccess(); + if (!hasTripPermission('day_edit', tripId, ctx.userId)) return permissionDenied(); + const dayErrors = this.accommodations.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 = this.places.create(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.accommodations.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 (this.auth.isDemoUser(ctx.userId)) return demoDenied(); + if (!this.accommodations.verifyTripAccess(tripId, ctx.userId)) return noAccess(); + if (!hasTripPermission('day_edit', tripId, ctx.userId)) return permissionDenied(); + const existing = this.accommodations.getAccommodation(accommodationId, tripId); + if (!existing) return { content: [{ type: 'text' as const, text: 'Accommodation not found.' }], isError: true }; + const accommodation = this.accommodations.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 (this.auth.isDemoUser(ctx.userId)) return demoDenied(); + if (!this.accommodations.verifyTripAccess(tripId, ctx.userId)) return noAccess(); + if (!hasTripPermission('day_edit', tripId, ctx.userId)) return permissionDenied(); + if (!this.accommodations.getAccommodation(accommodationId, tripId)) return { content: [{ type: 'text' as const, text: 'Accommodation not found.' }], isError: true }; + const { linkedReservationId } = this.accommodations.deleteAccommodation(accommodationId); + safeBroadcast(tripId, 'accommodation:deleted', { id: accommodationId, linkedReservationId }); + return ok({ success: true, linkedReservationId }); + } + + @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.accommodations.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.accommodations.listAccommodations(id); + return { + contents: [{ + uri: uri.href, + mimeType: 'application/json', + text: JSON.stringify(accommodations, null, 2), + }], + }; + } +} diff --git a/server/src/nest/accommodations/accommodations.module.ts b/server/src/nest/accommodations/accommodations.module.ts new file mode 100644 index 000000000..a6b9181a1 --- /dev/null +++ b/server/src/nest/accommodations/accommodations.module.ts @@ -0,0 +1,29 @@ +import { Module } from '@nestjs/common'; +import { AccommodationsController } from './accommodations.controller'; +import { AccommodationsService } from './accommodations.service'; +import { AccommodationsRpc } from './accommodations.rpc'; +import { AccommodationsMcp } from './accommodations.mcp'; +import { PlacesModule } from '../places/places.module'; +import { AuthModule } from '../auth/auth.module'; +import { DatabaseModule } from '../database/database.module'; +import { PermissionsModule } from '../permissions/permissions.module'; +import { RealtimeModule } from '../realtime/realtime.module'; +import { PluginGuardsModule } from '../plugins/host/plugin-guards.module'; + +/** + * Accommodations. One fachlichkeit, one module: the routes used to sit in + * reservations/ while the SQL sat on DaysService, so neither module owned the + * thing outright and every call crossed a domain boundary to reach its own data. + * + * It imports neither days nor reservations. The stay rows reference days by id, + * which is a foreign key, not a module edge. PlacesModule is here only because + * create_place_accommodation writes a place and a stay in one transaction -- + * the same reason DaysMcp could drop it. + */ +@Module({ + imports: [PermissionsModule, RealtimeModule, PluginGuardsModule, DatabaseModule, PlacesModule, AuthModule], + controllers: [AccommodationsController], + providers: [AccommodationsService, AccommodationsRpc, AccommodationsMcp], + exports: [AccommodationsService], +}) +export class AccommodationsModule {} diff --git a/server/src/nest/days/accommodations.rpc.ts b/server/src/nest/accommodations/accommodations.rpc.ts similarity index 94% rename from server/src/nest/days/accommodations.rpc.ts rename to server/src/nest/accommodations/accommodations.rpc.ts index bed21291e..9ba99691e 100644 --- a/server/src/nest/days/accommodations.rpc.ts +++ b/server/src/nest/accommodations/accommodations.rpc.ts @@ -5,11 +5,11 @@ import { BadParams, ForbiddenResource } from '../plugins/host/rpc-errors'; import { num, schemaMessage } from '../plugins/host/rpc-params'; import type { PluginRpcContext } from '../plugins/host/rpc-kit/types'; import { RealtimeService } from '../realtime/realtime.service'; -import { DaysService } from './days.service'; +import { AccommodationsService } from './accommodations.service'; /** * Accommodations hold db:write:accommodations but are gated on 'day_edit', NOT on - * reservation_edit. The blocks live in the day service and the REST path guards them + * reservation_edit. The blocks live in the accommodation service and the REST path guards them * the same way; the decorator makes the mismatch visible instead of burying it. */ const ACCOMMODATION_EDIT_ACTION = 'day_edit'; @@ -28,14 +28,14 @@ type AccommodationInput = { /** * The lodging-block surface a plugin may reach (#plugins). * - * Everything delegates to DaysService, so the auto-created partner hotel reservation, + * Everything delegates to AccommodationsService, so the auto-created partner hotel reservation, * the metadata sync on update and the delete cascade behave exactly like the * accommodations REST controller, cascade broadcasts included. */ @PluginController() export class AccommodationsRpc { constructor( - private readonly days: DaysService, + private readonly days: AccommodationsService, private readonly realtime: RealtimeService, private readonly guards: PluginGuards, ) {} diff --git a/server/src/nest/accommodations/accommodations.service.ts b/server/src/nest/accommodations/accommodations.service.ts new file mode 100644 index 000000000..c04b8cd30 --- /dev/null +++ b/server/src/nest/accommodations/accommodations.service.ts @@ -0,0 +1,233 @@ +import { Injectable } from '@nestjs/common'; +import type { TrekWsPayload, TrekWsTripEventName } from '@trek/shared'; +import { RealtimeService } from '../realtime/realtime.service'; +import { DatabaseService, type TripAccess } from '../database/database.service'; +import { PermissionsService } from '../permissions/permissions.service'; +import type { User } from '../../types'; + +type Trip = TripAccess; + +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; +} + +/** + * Accommodations: the stay rows in day_accommodations plus the hotel reservation + * and budget item that hang off them. + * + * The SQL used to live on DaysService while this class owned the routes and + * delegated every call into it — one fachlichkeit split across two modules. + * The statements moved here unchanged. What deliberately stayed in days/ is the + * reorder side: assertNoInvertedAccommodation and resyncAccommodationDays are + * about re-dating day rows, and the accommodation is the thing being carried, + * not the thing doing the carrying. + * + * Still gated by 'day_edit', the same permission as days. + */ +@Injectable() +export class AccommodationsService { + constructor( + private readonly dbs: DatabaseService, + private readonly permissions: PermissionsService, + private readonly realtime: RealtimeService, + ) {} + + private get db() { + return this.dbs; + } + + /** Owner or member, returning the trip. Takes a number too: the MCP tools pass + * the parsed id, the REST path the raw param. */ + verifyTripAccess(tripId: string | number, userId: number) { + return this.dbs.canAccessTrip(Number(tripId), userId); + } + + canEdit(trip: Trip, user: User): boolean { + return this.permissions.checkPermission('day_edit', user.role, trip.user_id, user.id, trip.user_id !== user.id); + } + + broadcast(tripId: string, event: E, payload: TrekWsPayload, socketId: string | undefined): void { + this.realtime.broadcast(tripId, event, payload, socketId); + } + + // ------------------------------------------------------------------------- + // The route-facing names the controller has always used. + // ------------------------------------------------------------------------- + + list(tripId: string | number) { + return this.listAccommodations(tripId); + } + + validateRefs(tripId: string | number, placeId?: number, startDayId?: number, endDayId?: number) { + return this.validateAccommodationRefs(tripId, placeId, startDayId, endDayId); + } + + get(id: string | number, tripId: string | number) { + return this.getAccommodation(id, tripId); + } + + create(tripId: string | number, data: CreateAccommodationData) { + return this.createAccommodation(tripId, data); + } + + update(id: string | number, existing: DayAccommodation, fields: Parameters[2]) { + return this.updateAccommodation(id, existing, fields); + } + + remove(id: string | number) { + return this.deleteAccommodation(id); + } + + // ------------------------------------------------------------------------- + // 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; + + // The stay and its partner hotel reservation are one logical write — + // atomic, so a failed reservation insert can't leave an orphan stay. + const accommodationId = this.db.transaction(() => { + 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 newId = 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, newId, + Object.keys(meta).length > 0 ? JSON.stringify(meta) : null + ); + + return newId; + }); + + 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), atomically. */ + deleteAccommodation(id: string | number): { linkedReservationId: number | null; deletedBudgetItemId: number | null } { + return this.db.transaction(() => { + 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/app.module.ts b/server/src/nest/app.module.ts index db12f8d87..1d1558374 100644 --- a/server/src/nest/app.module.ts +++ b/server/src/nest/app.module.ts @@ -22,6 +22,7 @@ import { BudgetModule } from './budget/budget.module'; import { ReservationsModule } from './reservations/reservations.module'; import { DaysModule } from './days/days.module'; import { DayNotesModule } from './day-notes/day-notes.module'; +import { AccommodationsModule } from './accommodations/accommodations.module'; import { AssignmentsModule } from './assignments/assignments.module'; import { PlacesModule } from './places/places.module'; import { TripsModule } from './trips/trips.module'; @@ -61,7 +62,7 @@ import { IdempotencyInterceptor } from './common/idempotency.interceptor'; * migrated. */ @Module({ - imports: [AppConfigModule, DatabaseModule, RealtimeModule, McpModule.forRoot({ accessPolicy: trekMcpAccessPolicy, validateAccess: trekMcpValidateAccess }), HealthModule, WeatherModule, HelpModule, AirportsModule, ConfigModule, SystemNoticesModule, MapsModule, PlaceEnrichmentModule, CategoriesModule, TagsModule, NotificationsModule, AtlasModule, VacayModule, PackingModule, TodoModule, BudgetModule, ReservationsModule, DaysModule, DayNotesModule, AssignmentsModule, PlacesModule, TripsModule, CollabModule, FilesModule, PhotosModule, MemoriesModule, AirtrailModule, JourneyModule, CollectionsModule, ShareModule, TripInviteModule, TransitModule, FeedsModule, SettingsModule, BackupModule, AuthModule, OidcModule, OauthModule, AdminModule, AddonsModule, AuditModule, PermissionsModule, PluginsModule, BookingImportModule, LlmParseModule], + imports: [AppConfigModule, DatabaseModule, RealtimeModule, McpModule.forRoot({ accessPolicy: trekMcpAccessPolicy, validateAccess: trekMcpValidateAccess }), HealthModule, WeatherModule, HelpModule, AirportsModule, ConfigModule, SystemNoticesModule, MapsModule, PlaceEnrichmentModule, CategoriesModule, TagsModule, NotificationsModule, AtlasModule, VacayModule, PackingModule, TodoModule, BudgetModule, ReservationsModule, DaysModule, DayNotesModule, AccommodationsModule, AssignmentsModule, PlacesModule, TripsModule, CollabModule, FilesModule, PhotosModule, MemoriesModule, AirtrailModule, JourneyModule, CollectionsModule, ShareModule, TripInviteModule, TransitModule, FeedsModule, SettingsModule, BackupModule, AuthModule, OidcModule, OauthModule, AdminModule, AddonsModule, AuditModule, PermissionsModule, PluginsModule, BookingImportModule, LlmParseModule], providers: [ // Global error-envelope normaliser (DI-registered so it also catches // framework-level exceptions like the not-found handler). diff --git a/server/src/nest/days/days.mcp.ts b/server/src/nest/days/days.mcp.ts index a6a6745ee..d3f78ce01 100644 --- a/server/src/nest/days/days.mcp.ts +++ b/server/src/nest/days/days.mcp.ts @@ -5,9 +5,7 @@ import { } from '@trek/nest-mcp'; import { z } from 'zod'; import { AuthService } from '../auth/auth.service'; -import { PlacesService } from '../places/places.service'; 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 { @@ -17,8 +15,7 @@ function parseId(value: string | string[]): number | 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 + * src/mcp/tools/days.ts and the trek://trips/{tripId}/days resource 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 @@ -32,8 +29,6 @@ function parseId(value: string | string[]): number | null { export class DaysMcp { constructor( private readonly days: DaysService, - private readonly db: DatabaseService, - private readonly places: PlacesService, private readonly auth: AuthService, ) {} @@ -107,154 +102,6 @@ export class DaysMcp { 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 (this.auth.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 (this.auth.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 = this.places.create(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 (this.auth.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 (this.auth.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', @@ -282,32 +129,4 @@ export class DaysMcp { }], }; } - - @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 c3ed54524..ab25c1984 100644 --- a/server/src/nest/days/days.module.ts +++ b/server/src/nest/days/days.module.ts @@ -3,7 +3,6 @@ import { DaysController } from './days.controller'; import { DaysService } from './days.service'; import { DaysMcp } from './days.mcp'; import { DaysRpc } from './days.rpc'; -import { AccommodationsRpc } from './accommodations.rpc'; import { PluginGuardsModule } from '../plugins/host/plugin-guards.module'; import { RealtimeModule } from '../realtime/realtime.module'; import { PermissionsModule } from '../permissions/permissions.module'; @@ -23,7 +22,7 @@ import { AuthModule } from '../auth/auth.module'; @Module({ imports: [PermissionsModule, QueryHelpersModule, PlacesModule, AuthModule, RealtimeModule, PluginGuardsModule], controllers: [DaysController], - providers: [DaysService, DaysMcp, DaysRpc, AccommodationsRpc], + providers: [DaysService, DaysMcp, DaysRpc], exports: [DaysService], }) export class DaysModule {} diff --git a/server/src/nest/days/days.service.ts b/server/src/nest/days/days.service.ts index 9e8c5e05f..daf2e54df 100644 --- a/server/src/nest/days/days.service.ts +++ b/server/src/nest/days/days.service.ts @@ -42,30 +42,6 @@ function withDatePart(timestamp: string, date: string): string { /** 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 @@ -505,135 +481,4 @@ export class DaysService { 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; - - // The stay and its partner hotel reservation are one logical write — - // atomic, so a failed reservation insert can't leave an orphan stay. - const accommodationId = this.db.transaction(() => { - 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 newId = 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, newId, - Object.keys(meta).length > 0 ? JSON.stringify(meta) : null - ); - - return newId; - }); - - 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), atomically. */ - deleteAccommodation(id: string | number): { linkedReservationId: number | null; deletedBudgetItemId: number | null } { - return this.db.transaction(() => { - 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/plugins-runtime.module.ts b/server/src/nest/plugins/plugins-runtime.module.ts index 94c6c0da3..ea3706c9f 100644 --- a/server/src/nest/plugins/plugins-runtime.module.ts +++ b/server/src/nest/plugins/plugins-runtime.module.ts @@ -21,6 +21,7 @@ import { TodoModule } from '../todo/todo.module'; import { PackingModule } from '../packing/packing.module'; import { DaysModule } from '../days/days.module'; import { DayNotesModule } from '../day-notes/day-notes.module'; +import { AccommodationsModule } from '../accommodations/accommodations.module'; import { AssignmentsModule } from '../assignments/assignments.module'; import { LlmParseModule } from '../llm-parse/llm-parse.module'; import { FilesModule } from '../files/files.module'; @@ -62,7 +63,7 @@ import { JourneyDomainModule } from '../journey/journey-domain.module'; PluginGuardsModule, PluginOAuthModule, WeatherModule, TagsModule, CategoriesModule, BudgetModule, ReservationsModule, - TodoModule, PackingModule, DaysModule, DayNotesModule, AssignmentsModule, LlmParseModule, + TodoModule, PackingModule, DaysModule, DayNotesModule, AccommodationsModule, AssignmentsModule, LlmParseModule, FilesModule, CollabModule, VacayModule, TripsModule, PlacesModule, PermissionsModule, AuditModule, AddonsModule, CollectionsModule, AtlasModule, NotificationsModule, TripMembershipModule, JourneyDomainModule, diff --git a/server/src/nest/reservations/accommodations.service.ts b/server/src/nest/reservations/accommodations.service.ts deleted file mode 100644 index 46b2958a7..000000000 --- a/server/src/nest/reservations/accommodations.service.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import type { TrekWsPayload, TrekWsTripEventName } from '@trek/shared'; -import { RealtimeService } from '../realtime/realtime.service'; -import { DatabaseService, type TripAccess } from '../database/database.service'; -import { PermissionsService } from '../permissions/permissions.service'; -import type { User } from '../../types'; -import { DaysService } from '../days/days.service'; - -type Trip = TripAccess; - -/** - * 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) live on the - * injected DaysService (the DI-native home of the legacy dayService). - */ -@Injectable() -export class AccommodationsService { - constructor( - private readonly dbs: DatabaseService, - private readonly days: DaysService, - private readonly permissions: PermissionsService, - private readonly realtime: RealtimeService, - ) {} - - /** Owner or member, returning the trip. */ - verifyTripAccess(tripId: string, userId: number) { - return this.dbs.canAccessTrip(Number(tripId), userId); - } - - canEdit(trip: Trip, user: User): boolean { - return this.permissions.checkPermission('day_edit', user.role, trip.user_id, user.id, trip.user_id !== user.id); - } - - broadcast(tripId: string, event: E, payload: TrekWsPayload, socketId: string | undefined): void { - this.realtime.broadcast(tripId, event, payload, socketId); - } - - list(tripId: string) { - return this.days.listAccommodations(tripId); - } - - validateRefs(tripId: string, placeId?: number, startDayId?: number, endDayId?: number) { - return this.days.validateAccommodationRefs(tripId, placeId, startDayId, endDayId); - } - - get(id: string, tripId: string) { - return this.days.getAccommodation(id, tripId); - } - - create(tripId: string, data: Parameters[1]) { - return this.days.createAccommodation(tripId, data); - } - - update(id: string, existing: Parameters[1], fields: Parameters[2]) { - return this.days.updateAccommodation(id, existing, fields); - } - - remove(id: string) { - return this.days.deleteAccommodation(id); - } -} diff --git a/server/src/nest/reservations/reservations.dto.ts b/server/src/nest/reservations/reservations.dto.ts index f4e718efd..3f2cdea8b 100644 --- a/server/src/nest/reservations/reservations.dto.ts +++ b/server/src/nest/reservations/reservations.dto.ts @@ -1,7 +1,5 @@ import { createZodDto } from 'nestjs-zod'; import { - accommodationCreateBodySchema, - accommodationUpdateRequestSchema, reservationCreateRequestSchema, reservationUpdateRequestSchema, reservationPositionsRequestSchema, @@ -21,5 +19,3 @@ export class ReservationCreateDto extends createZodDto(reservationCreateRequestS export class ReservationUpdateDto extends createZodDto(reservationUpdateRequestSchema) {} export class ReservationPositionsDto extends createZodDto(reservationPositionsRequestSchema) {} export class ReservationTravelersDto extends createZodDto(reservationTravelersRequestSchema) {} -export class AccommodationCreateDto extends createZodDto(accommodationCreateBodySchema) {} -export class AccommodationUpdateDto extends createZodDto(accommodationUpdateRequestSchema) {} diff --git a/server/src/nest/reservations/reservations.module.ts b/server/src/nest/reservations/reservations.module.ts index e9002387f..251a7845e 100644 --- a/server/src/nest/reservations/reservations.module.ts +++ b/server/src/nest/reservations/reservations.module.ts @@ -8,8 +8,6 @@ import { ReservationsRpc } from './reservations.rpc'; import { PluginGuardsModule } from '../plugins/host/plugin-guards.module'; import { RealtimeModule } from '../realtime/realtime.module'; import { ReservationsMcp } from './reservations.mcp'; -import { AccommodationsController } from './accommodations.controller'; -import { AccommodationsService } from './accommodations.service'; import { UpcomingReservationsController } from './upcoming-reservations.controller'; import { AuthModule } from '../auth/auth.module'; @@ -20,11 +18,11 @@ import { AuthModule } from '../auth/auth.module'; * decorator-registered MCP tools + resource for the domain. */ @Module({ - // DaysModule: AccommodationsService + ReservationsMcp inject DaysService. + // DaysModule: ReservationsMcp injects DaysService for its nine getDay calls. // BudgetModule: ReservationsService + ReservationsMcp inject BudgetService (budget-sync seam). imports: [DaysModule, PermissionsModule, BudgetModule, AuthModule, RealtimeModule, PluginGuardsModule], - controllers: [ReservationsController, AccommodationsController, UpcomingReservationsController], - providers: [ReservationsService, AccommodationsService, ReservationsMcp, ReservationsRpc], + controllers: [ReservationsController, UpcomingReservationsController], + providers: [ReservationsService, ReservationsMcp, ReservationsRpc], // For in-container consumers (ReservationsRpc, TripsService, BookingImportService). exports: [ReservationsService], }) diff --git a/server/src/nest/trips/trips.bridge.ts b/server/src/nest/trips/trips.bridge.ts index 18dfba6f3..b6c03186a 100644 --- a/server/src/nest/trips/trips.bridge.ts +++ b/server/src/nest/trips/trips.bridge.ts @@ -21,6 +21,7 @@ import { VacayService } from '../vacay/vacay.service'; import { PlacesService } from '../places/places.service'; import { MapsService } from '../maps/maps.service'; import { UserCleanupService } from '../auth/user-cleanup.service'; +import { AccommodationsService } from '../accommodations/accommodations.service'; /** * Non-Nest entry point for the trip domain — for the two consumers that cannot @@ -58,6 +59,7 @@ const trips = new TripsService( new PlacesService(dbs(), new PermissionsService(dbs()), new RealtimeService(), new MapsService(dbs(), photoCache), new QueryHelpersService(dbs()), new UnsplashService(dbs(), new RuntimeEnvService()), photoCache, journeyDomain), new UnsplashService(dbs(), new RuntimeEnvService()), new UserCleanupService(dbs()), + new AccommodationsService(dbs(), new PermissionsService(dbs()), new RealtimeService()), ); export function getTripOwner(tripId: string | number) { diff --git a/server/src/nest/trips/trips.module.ts b/server/src/nest/trips/trips.module.ts index 32799c053..f105ac3a8 100644 --- a/server/src/nest/trips/trips.module.ts +++ b/server/src/nest/trips/trips.module.ts @@ -21,11 +21,12 @@ import { UnsplashModule } from '../unsplash/unsplash.module'; import { TripsMcp } from './trips.mcp'; import { AuthModule } from '../auth/auth.module'; import { CalendarModule } from '../calendar/calendar.module'; +import { AccommodationsModule } from '../accommodations/accommodations.module'; /** Trips aggregate root (C1 — Phase 3). Uses exact strangler prefixes so it does * not capture the nested sub-domain mounts (collab, files, ...). */ @Module({ - imports: [TodoModule, PackingModule, FilesModule, ReservationsModule, DaysModule, PermissionsModule, AuditModule, BudgetModule, CollabModule, VacayModule, PlacesModule, AuthModule, AppConfigModule, UnsplashModule, RealtimeModule, PluginGuardsModule, TripMembershipModule, CalendarModule], + imports: [TodoModule, PackingModule, FilesModule, ReservationsModule, DaysModule, PermissionsModule, AuditModule, BudgetModule, CollabModule, VacayModule, PlacesModule, AuthModule, AppConfigModule, UnsplashModule, RealtimeModule, PluginGuardsModule, TripMembershipModule, CalendarModule, AccommodationsModule], controllers: [TripsController], providers: [TripsService, TripsMcp, TripsRpc], // Exported for FeedsModule (ICS feeds) and PluginsModule (RPC host injection). diff --git a/server/src/nest/trips/trips.rpc.ts b/server/src/nest/trips/trips.rpc.ts index f1c959786..2e95e6674 100644 --- a/server/src/nest/trips/trips.rpc.ts +++ b/server/src/nest/trips/trips.rpc.ts @@ -8,6 +8,7 @@ import { RealtimeService } from '../realtime/realtime.service'; import { DatabaseService } from '../database/database.service'; import { ReservationsService } from '../reservations/reservations.service'; import { DaysService } from '../days/days.service'; +import { AccommodationsService } from '../accommodations/accommodations.service'; import { TripMembershipService } from '../trip-membership/trip-membership.service'; import { TripsService, NotFoundError, ValidationError } from './trips.service'; @@ -37,6 +38,8 @@ export class TripsRpc { private readonly db: DatabaseService, private readonly realtime: RealtimeService, private readonly guards: PluginGuards, + // Appended: the hand-wired plugin-host harnesses build this positionally. + private readonly accommodations: AccommodationsService, ) {} @PluginMethod('trips.getById', { permission: 'db:read:trips' }) @@ -76,7 +79,7 @@ export class TripsRpc { @PluginMethod('trips.getAccommodations', { permission: 'db:read:trips' }) getAccommodations(params: Record, ctx: PluginRpcContext): unknown { - return this.guards.tripRead(params, ctx, () => this.days.listAccommodations(num(params.tripId, 'tripId')) as unknown[]); + return this.guards.tripRead(params, ctx, () => this.accommodations.list(num(params.tripId, 'tripId')) as unknown[]); } @PluginMethod('trips.listMine', { permission: 'db:read:trips' }) diff --git a/server/src/nest/trips/trips.service.ts b/server/src/nest/trips/trips.service.ts index 726e961e6..1cf3a7bcf 100644 --- a/server/src/nest/trips/trips.service.ts +++ b/server/src/nest/trips/trips.service.ts @@ -12,6 +12,7 @@ import { UserCleanupService } from '../auth/user-cleanup.service'; import { emitUserDeleted } from '../../plugin-user-lifecycle'; import { PlacesService } from '../places/places.service'; import { DaysService } from '../days/days.service'; +import { AccommodationsService } from '../accommodations/accommodations.service'; import { PackingService } from '../packing/packing.service'; import { TodoService } from '../todo/todo.service'; import { BudgetService } from '../budget/budget.service'; @@ -139,6 +140,8 @@ export class TripsService { private readonly places: PlacesService, private readonly unsplash: UnsplashService, private readonly userCleanup: UserCleanupService, + // Appended: the hand-wired construction sites stay positional. + private readonly accommodations: AccommodationsService, ) {} private get db() { @@ -867,7 +870,7 @@ export class TripsService { const { days: rawDays } = this.days.list(tripId); const days = rawDays.map(({ notes_items, ...day }) => ({ ...day, notes: notes_items })); - const accommodations = this.days.listAccommodations(tripId); + const accommodations = this.accommodations.list(tripId); const budgetItems = this.budget.listBudgetItems(tripId); const budget = { @@ -918,7 +921,7 @@ export class TripsService { budgetItems: this.budget.listBudgetItems(tripId), reservations: this.reservations.list(tripId), files: this.files.listFiles(tripId, false), - accommodations: this.days.listAccommodations(tripId), + accommodations: this.accommodations.list(tripId), members: [owner, ...(members || [])].filter(Boolean), }; } diff --git a/server/tests/e2e/reservations.e2e.test.ts b/server/tests/e2e/reservations.e2e.test.ts index 4c11661a2..2b8d97a73 100644 --- a/server/tests/e2e/reservations.e2e.test.ts +++ b/server/tests/e2e/reservations.e2e.test.ts @@ -9,6 +9,9 @@ import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter'; import { ZodValidationPipe } from '../../src/nest/common/zod-validation.pipe'; import { ReservationsModule } from '../../src/nest/reservations/reservations.module'; +// Accommodations left reservations/ for a domain of their own; this container has +// to assemble both or the /accommodations cases below 404 while production serves them. +import { AccommodationsModule } from '../../src/nest/accommodations/accommodations.module'; import { sessionCookie } from './harness'; import { DatabaseModule } from '../../src/nest/database/database.module'; import { RealtimeModule } from '../../src/nest/realtime/realtime.module'; @@ -58,7 +61,7 @@ describe('Reservations + accommodations e2e (real auth guard + temp SQLite, real let tripId: number; async function build() { - const moduleRef = await Test.createTestingModule({ imports: [DatabaseModule, RealtimeModule, ReservationsModule] }).compile(); + const moduleRef = await Test.createTestingModule({ imports: [DatabaseModule, RealtimeModule, ReservationsModule, AccommodationsModule] }).compile(); const nest = moduleRef.createNestApplication(); nest.use(cookieParser()); nest.useGlobalFilters(new TrekExceptionFilter()); diff --git a/server/tests/helpers/mcp-test-controllers.ts b/server/tests/helpers/mcp-test-controllers.ts index 4210d521a..6caac2857 100644 --- a/server/tests/helpers/mcp-test-controllers.ts +++ b/server/tests/helpers/mcp-test-controllers.ts @@ -58,6 +58,8 @@ import { TrekPhotosRepository } from '../../src/nest/photos/trek-photos.reposito import { UnsplashService } from '../../src/nest/unsplash/unsplash.service'; import { UserCleanupService } from '../../src/nest/auth/user-cleanup.service'; import { CalendarService } from '../../src/nest/calendar/calendar.service'; +import { AccommodationsService } from '../../src/nest/accommodations/accommodations.service'; +import { AccommodationsMcp } from '../../src/nest/accommodations/accommodations.mcp'; import { PlacePhotoCacheService } from '../../src/nest/place-photos/place-photo-cache.service'; import { RuntimeEnvService } from '../../src/nest/app-config/runtime-env.service'; import { makeNotificationsService, makeNotificationPreferencesService } from './notifications'; @@ -94,6 +96,7 @@ export function createMcpTestRegistry(): McpRegistry { journeyDomain, ); const reservationsService = new ReservationsService(dbService, permissionsService, budgetService, realtimeService); + const accommodationsService = new AccommodationsService(dbService, permissionsService, realtimeService); const tripsService = new TripsService( dbService, todoService, @@ -112,6 +115,7 @@ export function createMcpTestRegistry(): McpRegistry { // has include:['src'], so tsc never saw it. new UnsplashService(dbService, new RuntimeEnvService()), new UserCleanupService(dbService), + accommodationsService, ); const calendarService = new CalendarService(dbService, reservationsService); return createTestRegistry( @@ -127,7 +131,8 @@ export function createMcpTestRegistry(): McpRegistry { new BudgetMcp(budgetService, exchangeRatesService, dbService, authService), new ReservationsMcp(reservationsService, daysService, budgetService, authService), new DayNotesMcp(new DayNotesService(dbService, permissionsService, realtimeService), authService), - new DaysMcp(daysService, dbService, placesService, authService), + new DaysMcp(daysService, authService), + new AccommodationsMcp(accommodationsService, dbService, placesService, authService), new AssignmentsMcp(new AssignmentsService(dbService, permissionsService, realtimeService, queryHelpersService, journeyDomain), daysService, authService), new CollabMcp(collabService, authService), new VacayMcp(new VacayService(dbService, realtimeService), authService), diff --git a/server/tests/helpers/plugin-host.ts b/server/tests/helpers/plugin-host.ts index 52f7e6d27..2cd694971 100644 --- a/server/tests/helpers/plugin-host.ts +++ b/server/tests/helpers/plugin-host.ts @@ -49,7 +49,8 @@ import { PackingRpc } from '../../src/nest/packing/packing.rpc'; import { FilesRpc } from '../../src/nest/files/files.rpc'; import { PlacesRpc } from '../../src/nest/places/places.rpc'; import { DaysRpc } from '../../src/nest/days/days.rpc'; -import { AccommodationsRpc } from '../../src/nest/days/accommodations.rpc'; +import { AccommodationsRpc } from '../../src/nest/accommodations/accommodations.rpc'; +import { AccommodationsService } from '../../src/nest/accommodations/accommodations.service'; import { ItineraryRpc } from '../../src/nest/assignments/itinerary.rpc'; import { TripsRpc } from '../../src/nest/trips/trips.rpc'; import { CostsRpc } from '../../src/nest/budget/costs.rpc'; @@ -95,7 +96,10 @@ export function createPluginRpcHostFactory(dbs: DatabaseService): PluginRpcHostF const notifications = makeNotificationsService(dbs, realtime); const llmConfig = new LlmConfigResolver(new SettingsService(dbs), dbs, addons); const oauth = new PluginOAuthService(dbs); - const trips = new TripsService(dbs, todos, packing, files, reservations, days, permissions, budget, collab, vacay, realtime, places); + const accommodations = new AccommodationsService(dbs, permissions, realtime); + // Was 12 arguments for a 14-parameter constructor; unsplash and userCleanup were + // undefined here and tsconfig only typechecks src, so nothing said so. + const trips = new TripsService(dbs, todos, packing, files, reservations, days, permissions, budget, collab, vacay, realtime, places, undefined as never, undefined as never, accommodations); const guards = new PluginGuards(dbs, permissions, addons); const registry = createTestPluginRegistry([ @@ -109,9 +113,9 @@ export function createPluginRpcHostFactory(dbs: DatabaseService): PluginRpcHostF new FilesRpc(files, realtime, dbs, guards), new PlacesRpc(places, journey, realtime, guards), new DaysRpc(days, realtime, guards), - new AccommodationsRpc(days, realtime, guards), + new AccommodationsRpc(accommodations, realtime, guards), new ItineraryRpc(assignments, realtime, guards), - new TripsRpc(trips, reservations, days, membership, dbs, realtime, guards), + new TripsRpc(trips, reservations, days, membership, dbs, realtime, guards, accommodations), new CostsRpc(budget, dbs, realtime, guards), new ReservationsRpc(reservations, realtime, guards), new CollabRpc(collab, realtime, guards), diff --git a/server/tests/helpers/rpc-host-deps.ts b/server/tests/helpers/rpc-host-deps.ts index 6eb109451..6ad6fe354 100644 --- a/server/tests/helpers/rpc-host-deps.ts +++ b/server/tests/helpers/rpc-host-deps.ts @@ -14,7 +14,7 @@ import { ItineraryRpc } from '../../src/nest/assignments/itinerary.rpc'; import { TripsRpc } from '../../src/nest/trips/trips.rpc'; import { CostsRpc } from '../../src/nest/budget/costs.rpc'; import { ReservationsRpc } from '../../src/nest/reservations/reservations.rpc'; -import { AccommodationsRpc } from '../../src/nest/days/accommodations.rpc'; +import { AccommodationsRpc } from '../../src/nest/accommodations/accommodations.rpc'; import { CollabRpc } from '../../src/nest/collab/collab.rpc'; import { AtlasRpc } from '../../src/nest/atlas/atlas.rpc'; import { VacayRpc } from '../../src/nest/vacay/vacay.rpc'; @@ -71,7 +71,7 @@ export function allRpcControllers(): object[] { new PlacesRpc(anyService(), anyService(), anyService(), anyService()), new DaysRpc(anyService(), anyService(), anyService()), new ItineraryRpc(anyService(), anyService(), anyService()), - new TripsRpc(anyService(), anyService(), anyService(), anyService(), anyService(), anyService(), anyService()), + new TripsRpc(anyService(), anyService(), anyService(), anyService(), anyService(), anyService(), anyService(), anyService()), new CostsRpc(anyService(), anyService(), anyService(), anyService()), new ReservationsRpc(anyService(), anyService(), anyService()), new AccommodationsRpc(anyService(), anyService(), anyService()), diff --git a/server/tests/unit/nest/accommodations.controller.test.ts b/server/tests/unit/nest/accommodations.controller.test.ts index a8c174d02..d42400419 100644 --- a/server/tests/unit/nest/accommodations.controller.test.ts +++ b/server/tests/unit/nest/accommodations.controller.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, vi } from 'vitest'; import { HttpException } from '@nestjs/common'; -import { AccommodationsController } from '../../../src/nest/reservations/accommodations.controller'; -import type { AccommodationsService } from '../../../src/nest/reservations/accommodations.service'; +import { AccommodationsController } from '../../../src/nest/accommodations/accommodations.controller'; +import type { AccommodationsService } from '../../../src/nest/accommodations/accommodations.service'; import type { User } from '../../../src/types'; const user = { id: 1, role: 'user', email: 'u@example.test' } as User; diff --git a/server/tests/unit/nest/accommodations.service.test.ts b/server/tests/unit/nest/accommodations.service.test.ts new file mode 100644 index 000000000..44ae29ff2 --- /dev/null +++ b/server/tests/unit/nest/accommodations.service.test.ts @@ -0,0 +1,279 @@ +/** + * Unit tests for AccommodationsService. DAY-SVC-016..026, 029 and 035 moved here + * 1:1 with the SQL they cover when accommodations became their own domain; the + * case ids are unchanged so the diff reads as a move. Real in-memory SQLite. + */ +import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest'; + +const { testDb, dbMock } = vi.hoisted(() => { + const Database = require('better-sqlite3'); + const db = new Database(':memory:'); + db.exec('PRAGMA journal_mode = WAL'); + db.exec('PRAGMA foreign_keys = ON'); + db.exec('PRAGMA busy_timeout = 5000'); + return { + testDb: db, + dbMock: { + db, + closeDb: () => {}, + reinitialize: () => {}, + getPlaceWithTags: () => null, + canAccessTrip: (tripId: any, userId: number) => + db.prepare(` + SELECT t.id, t.user_id FROM trips t + LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? + WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL) + `).get(userId, tripId, userId), + isOwner: (tripId: any, userId: number) => + !!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId), + }, + }; +}); + +vi.mock('../../../src/db/database', () => dbMock); +const { broadcast } = vi.hoisted(() => ({ broadcast: vi.fn() })); +vi.mock('../../../src/websocket', () => ({ broadcast })); + +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 { DatabaseService } from '../../../src/nest/database/database.service'; +import { PermissionsService } from '../../../src/nest/permissions/permissions.service'; +import { RealtimeService } from '../../../src/nest/realtime/realtime.service'; +import { AccommodationsService } from '../../../src/nest/accommodations/accommodations.service'; +import { AccommodationsModule } from '../../../src/nest/accommodations/accommodations.module'; +import { AccommodationsController } from '../../../src/nest/accommodations/accommodations.controller'; +import { expectRegisteredProvider, expectRegisteredController } from '../../helpers/module-providers'; + +// Named `svc` so the moved cases read exactly as they did on DaysService. +const svc = new AccommodationsService( + new DatabaseService(testDb), + new PermissionsService(new DatabaseService(testDb)), + new RealtimeService(), +); + +beforeAll(() => { + createTables(testDb); + runMigrations(testDb); +}); + +beforeEach(() => { + resetTestDb(testDb); +}); + +afterAll(() => { + testDb.close(); +}); + +describe('validateAccommodationRefs', () => { + it('DAY-SVC-016 — returns no errors when all refs are valid', () => { + const { user } = createUser(testDb); + 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 = svc.validateAccommodationRefs(trip.id, place.id, day.id, day.id); + expect(errors).toHaveLength(0); + }); + + it('DAY-SVC-017 — returns error when place does not exist in trip', () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const day = createDay(testDb, trip.id) as any; + const errors = svc.validateAccommodationRefs(trip.id, 99999, day.id, day.id); + expect(errors.some((e: any) => e.field === 'place_id')).toBe(true); + }); + + it('DAY-SVC-018 — returns error when start_day_id is invalid', () => { + const { user } = createUser(testDb); + 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 = svc.validateAccommodationRefs(trip.id, place.id, 99999, day.id); + expect(errors.some((e: any) => e.field === 'start_day_id')).toBe(true); + }); +}); + +describe('createAccommodation', () => { + it('DAY-SVC-019 — creates accommodation and returns it with place info', () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const day = createDay(testDb, trip.id) as any; + const place = createPlace(testDb, trip.id, { name: 'Grand Hotel' }) as any; + + const accom = svc.createAccommodation(trip.id, { + place_id: place.id, + start_day_id: day.id, + end_day_id: day.id, + check_in: '15:00', + check_out: '11:00', + }) as any; + + expect(accom).toBeDefined(); + expect(accom.place_name).toBe('Grand Hotel'); + }); + + it('DAY-SVC-020 — auto-creates a linked reservation', () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const day = createDay(testDb, trip.id) as any; + const place = createPlace(testDb, trip.id, { name: 'City Hotel' }) as any; + + 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 * FROM reservations WHERE accommodation_id = ?').get(accom.id) as any; + expect(reservation).toBeDefined(); + expect(reservation.type).toBe('hotel'); + expect(reservation.status).toBe('confirmed'); + }); +}); + +describe('getAccommodation', () => { + it('DAY-SVC-021 — returns accommodation for valid id and trip', () => { + const { user } = createUser(testDb); + 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 = createDayAccommodation(testDb, trip.id, place.id, day.id, day.id) as any; + const found = svc.getAccommodation(accom.id, trip.id) as any; + expect(found).toBeDefined(); + expect(found.id).toBe(accom.id); + }); + + it('DAY-SVC-022 — returns undefined for non-existent accommodation', () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + expect(svc.getAccommodation(99999, trip.id)).toBeUndefined(); + }); +}); + +describe('updateAccommodation', () => { + it('DAY-SVC-023 — updates check-in and check-out times', () => { + const { user } = createUser(testDb); + 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 = svc.createAccommodation(trip.id, { + place_id: place.id, start_day_id: day.id, end_day_id: day.id, + }) 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 + const reservation = testDb.prepare('SELECT * FROM reservations WHERE accommodation_id = ?').get(accom.id) as any; + expect(reservation).toBeDefined(); + const meta = JSON.parse(reservation.metadata || '{}'); + expect(meta.check_in_time).toBe('16:00'); + expect(meta.check_out_time).toBe('12:00'); + }); + + it('DAY-SVC-024 — preserves existing fields when not updated', () => { + const { user } = createUser(testDb); + 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 = svc.createAccommodation(trip.id, { + place_id: place.id, start_day_id: day.id, end_day_id: day.id, + confirmation: 'ABC123', + }) as any; + + const existing = svc.getAccommodation(accom.id, trip.id)!; + svc.updateAccommodation(accom.id, existing as any, { check_in: '14:00' }); + + const row = svc.getAccommodation(accom.id, trip.id) as any; + expect(row.confirmation).toBe('ABC123'); + }); +}); + +describe('deleteAccommodation', () => { + it('DAY-SVC-025 — deletes accommodation and its linked reservation', () => { + const { user } = createUser(testDb); + 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 = 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 = svc.deleteAccommodation(accom.id); + expect(result.linkedReservationId).toBe(reservation.id); + + // Accommodation is gone + expect(svc.getAccommodation(accom.id, trip.id)).toBeUndefined(); + + // Reservation is gone + const deletedRes = testDb.prepare('SELECT id FROM reservations WHERE id = ?').get(reservation.id); + expect(deletedRes).toBeUndefined(); + }); + + it('DAY-SVC-026 — returns null linkedReservationId when no reservation was linked', () => { + const { user } = createUser(testDb); + 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 = createDayAccommodation(testDb, trip.id, place.id, day.id, day.id) as any; + + // Remove the auto-created reservation so there's no linked one + testDb.prepare('DELETE FROM reservations WHERE accommodation_id = ?').run(accom.id); + + const result = svc.deleteAccommodation(accom.id); + expect(result.linkedReservationId).toBeNull(); + }); +}); + +describe('listAccommodations', () => { + it('DAY-SVC-029 — listAccommodations returns the hydrated stays', () => { + 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 = svc.listAccommodations(trip.id) as { place_name: string }[]; + expect(rows).toHaveLength(1); + expect(rows[0].place_name).toBe('Ryokan'); + }); +}); + +describe('quirk fixes', () => { + it('DAY-SVC-035 — deleteAccommodation is atomic: a failed stay delete keeps the linked reservation', () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const day = createDay(testDb, trip.id); + const place = createPlace(testDb, trip.id, { name: 'Hotel' }); + const accom = svc.createAccommodation(trip.id, { + place_id: place.id, start_day_id: day.id, end_day_id: day.id, + }) as { id: number }; + testDb.exec("CREATE TRIGGER boom BEFORE DELETE ON day_accommodations BEGIN SELECT RAISE(ABORT, 'boom'); END"); + try { + expect(() => svc.deleteAccommodation(accom.id)).toThrow(); + // The earlier reservation delete inside the transaction rolled back. + expect(testDb.prepare('SELECT COUNT(*) as n FROM reservations WHERE accommodation_id = ?').get(accom.id)).toMatchObject({ n: 1 }); + } finally { + testDb.exec('DROP TRIGGER boom'); + } + }); +}); + +describe('AccommodationsService wiring', () => { + it('ACC-001: the module carries the controller, the service and the RPC surface', () => { + expectRegisteredController(AccommodationsModule, AccommodationsController); + expectRegisteredProvider(AccommodationsModule, AccommodationsService); + const exports = Reflect.getMetadata('exports', AccommodationsModule) as unknown[]; + expect(Array.isArray(exports)).toBe(true); + expect(exports).toEqual(expect.arrayContaining([AccommodationsService])); + }); + + it('ACC-002: it does not import the days or reservations modules', () => { + const imports = (Reflect.getMetadata('imports', AccommodationsModule) as { name?: string }[]) ?? []; + expect(Array.isArray(imports)).toBe(true); + const names = imports.map((m) => m?.name); + expect(names).not.toContain('DaysModule'); + expect(names).not.toContain('ReservationsModule'); + }); +}); diff --git a/server/tests/unit/nest/budget.service.db.test.ts b/server/tests/unit/nest/budget.service.db.test.ts index df75f5073..8c03a129b 100644 --- a/server/tests/unit/nest/budget.service.db.test.ts +++ b/server/tests/unit/nest/budget.service.db.test.ts @@ -99,6 +99,7 @@ const tripsSvc = new TripsService( undefined as never, // places — not exercised here undefined as never, // unsplash — not exercised here new UserCleanupService(dbs()), + undefined as never, // accommodations — not exercised here ); const createGuest = tripsSvc.createGuest.bind(tripsSvc); const deleteGuest = tripsSvc.deleteGuest.bind(tripsSvc); diff --git a/server/tests/unit/nest/days.service.test.ts b/server/tests/unit/nest/days.service.test.ts index f32432771..0e88a8d2d 100644 --- a/server/tests/unit/nest/days.service.test.ts +++ b/server/tests/unit/nest/days.service.test.ts @@ -231,175 +231,14 @@ describe('remove', () => { // ── validateAccommodationRefs ───────────────────────────────────────────────── -describe('validateAccommodationRefs', () => { - it('DAY-SVC-016 — returns no errors when all refs are valid', () => { - const { user } = createUser(testDb); - 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 = svc.validateAccommodationRefs(trip.id, place.id, day.id, day.id); - expect(errors).toHaveLength(0); - }); - - it('DAY-SVC-017 — returns error when place does not exist in trip', () => { - const { user } = createUser(testDb); - const trip = createTrip(testDb, user.id); - const day = createDay(testDb, trip.id) as any; - const errors = svc.validateAccommodationRefs(trip.id, 99999, day.id, day.id); - expect(errors.some((e: any) => e.field === 'place_id')).toBe(true); - }); - - it('DAY-SVC-018 — returns error when start_day_id is invalid', () => { - const { user } = createUser(testDb); - 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 = svc.validateAccommodationRefs(trip.id, place.id, 99999, day.id); - expect(errors.some((e: any) => e.field === 'start_day_id')).toBe(true); - }); -}); - // ── createAccommodation ─────────────────────────────────────────────────────── -describe('createAccommodation', () => { - it('DAY-SVC-019 — creates accommodation and returns it with place info', () => { - const { user } = createUser(testDb); - const trip = createTrip(testDb, user.id); - const day = createDay(testDb, trip.id) as any; - const place = createPlace(testDb, trip.id, { name: 'Grand Hotel' }) as any; - - const accom = svc.createAccommodation(trip.id, { - place_id: place.id, - start_day_id: day.id, - end_day_id: day.id, - check_in: '15:00', - check_out: '11:00', - }) as any; - - expect(accom).toBeDefined(); - expect(accom.place_name).toBe('Grand Hotel'); - }); - - it('DAY-SVC-020 — auto-creates a linked reservation', () => { - const { user } = createUser(testDb); - const trip = createTrip(testDb, user.id); - const day = createDay(testDb, trip.id) as any; - const place = createPlace(testDb, trip.id, { name: 'City Hotel' }) as any; - - 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 * FROM reservations WHERE accommodation_id = ?').get(accom.id) as any; - expect(reservation).toBeDefined(); - expect(reservation.type).toBe('hotel'); - expect(reservation.status).toBe('confirmed'); - }); -}); - // ── getAccommodation ────────────────────────────────────────────────────────── -describe('getAccommodation', () => { - it('DAY-SVC-021 — returns accommodation for valid id and trip', () => { - const { user } = createUser(testDb); - 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 = createDayAccommodation(testDb, trip.id, place.id, day.id, day.id) as any; - const found = svc.getAccommodation(accom.id, trip.id) as any; - expect(found).toBeDefined(); - expect(found.id).toBe(accom.id); - }); - - it('DAY-SVC-022 — returns undefined for non-existent accommodation', () => { - const { user } = createUser(testDb); - const trip = createTrip(testDb, user.id); - expect(svc.getAccommodation(99999, trip.id)).toBeUndefined(); - }); -}); - // ── updateAccommodation ─────────────────────────────────────────────────────── -describe('updateAccommodation', () => { - it('DAY-SVC-023 — updates check-in and check-out times', () => { - const { user } = createUser(testDb); - 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 = svc.createAccommodation(trip.id, { - place_id: place.id, start_day_id: day.id, end_day_id: day.id, - }) 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 - const reservation = testDb.prepare('SELECT * FROM reservations WHERE accommodation_id = ?').get(accom.id) as any; - expect(reservation).toBeDefined(); - const meta = JSON.parse(reservation.metadata || '{}'); - expect(meta.check_in_time).toBe('16:00'); - expect(meta.check_out_time).toBe('12:00'); - }); - - it('DAY-SVC-024 — preserves existing fields when not updated', () => { - const { user } = createUser(testDb); - 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 = svc.createAccommodation(trip.id, { - place_id: place.id, start_day_id: day.id, end_day_id: day.id, - confirmation: 'ABC123', - }) as any; - - const existing = svc.getAccommodation(accom.id, trip.id)!; - svc.updateAccommodation(accom.id, existing as any, { check_in: '14:00' }); - - const row = svc.getAccommodation(accom.id, trip.id) as any; - expect(row.confirmation).toBe('ABC123'); - }); -}); - // ── deleteAccommodation ─────────────────────────────────────────────────────── -describe('deleteAccommodation', () => { - it('DAY-SVC-025 — deletes accommodation and its linked reservation', () => { - const { user } = createUser(testDb); - 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 = 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 = svc.deleteAccommodation(accom.id); - expect(result.linkedReservationId).toBe(reservation.id); - - // Accommodation is gone - expect(svc.getAccommodation(accom.id, trip.id)).toBeUndefined(); - - // Reservation is gone - const deletedRes = testDb.prepare('SELECT id FROM reservations WHERE id = ?').get(reservation.id); - expect(deletedRes).toBeUndefined(); - }); - - it('DAY-SVC-026 — returns null linkedReservationId when no reservation was linked', () => { - const { user } = createUser(testDb); - 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 = createDayAccommodation(testDb, trip.id, place.id, day.id, day.id) as any; - - // Remove the auto-created reservation so there's no linked one - testDb.prepare('DELETE FROM reservations WHERE accommodation_id = ?').run(accom.id); - - const result = svc.deleteAccommodation(accom.id); - expect(result.linkedReservationId).toBeNull(); - }); -}); - // ── days.bridge delegation (out-of-container consumers) ─────────────────────── // The listAccommodations / restampReservationDates / resyncAccommodationDays / // addDays bridge exports were pruned when their last outside-container @@ -424,17 +263,6 @@ describe('days.bridge', () => { expect(Array.isArray(result.days[0].assignments)).toBe(true); }); - it('DAY-SVC-029 — listAccommodations returns the hydrated stays', () => { - 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 = svc.listAccommodations(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'); @@ -516,24 +344,6 @@ describe('quirk fixes', () => { } }); - it('DAY-SVC-035 — deleteAccommodation is atomic: a failed stay delete keeps the linked reservation', () => { - const { user } = createUser(testDb); - const trip = createTrip(testDb, user.id); - const day = createDay(testDb, trip.id); - const place = createPlace(testDb, trip.id, { name: 'Hotel' }); - const accom = svc.createAccommodation(trip.id, { - place_id: place.id, start_day_id: day.id, end_day_id: day.id, - }) as { id: number }; - testDb.exec("CREATE TRIGGER boom BEFORE DELETE ON day_accommodations BEGIN SELECT RAISE(ABORT, 'boom'); END"); - try { - expect(() => svc.deleteAccommodation(accom.id)).toThrow(); - // The earlier reservation delete inside the transaction rolled back. - expect(testDb.prepare('SELECT COUNT(*) as n FROM reservations WHERE accommodation_id = ?').get(accom.id)).toMatchObject({ n: 1 }); - } finally { - testDb.exec('DROP TRIGGER boom'); - } - }); - it('DAY-SVC-036 — getAssignmentsForDay returns full tag rows from the batched load', () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); diff --git a/server/tests/unit/nest/trips.service.test.ts b/server/tests/unit/nest/trips.service.test.ts index 43342bdcb..481975f52 100644 --- a/server/tests/unit/nest/trips.service.test.ts +++ b/server/tests/unit/nest/trips.service.test.ts @@ -65,6 +65,7 @@ import { VacayService } from '../../../src/nest/vacay/vacay.service'; import { TripsService } from '../../../src/nest/trips/trips.service'; import { PlacesService } from '../../../src/nest/places/places.service'; import { UserCleanupService } from '../../../src/nest/auth/user-cleanup.service'; +import { AccommodationsService } from '../../../src/nest/accommodations/accommodations.service'; import { MapsService } from '../../../src/nest/maps/maps.service'; import { getTripOwner, listMembers as bridgeListMembers } from '../../../src/nest/trips/trips.bridge'; import { QueryHelpersService } from '../../../src/nest/query-helpers/query-helpers.service'; @@ -76,7 +77,8 @@ const dbs = () => new DatabaseService(testDb); const budgetSvc = new BudgetService(dbs(), new PermissionsService(dbs()), new ExchangeRatesService(), new RealtimeService()); const daysSvc = new DaysService(dbs(), new PermissionsService(dbs()), new RealtimeService(), new QueryHelpersService(dbs())); const placesSvc = new PlacesService(dbs(), new PermissionsService(dbs()), new RealtimeService(), new MapsService(dbs()), new QueryHelpersService(dbs())); -const createAccommodation = daysSvc.createAccommodation.bind(daysSvc); +const accommodationsSvc = new AccommodationsService(dbs(), new PermissionsService(dbs()), new RealtimeService()); +const createAccommodation = accommodationsSvc.createAccommodation.bind(accommodationsSvc); const svc = new TripsService( dbs(), @@ -93,6 +95,7 @@ const svc = new TripsService( placesSvc, undefined as never, // unsplash — not exercised here new UserCleanupService(dbs()), + accommodationsSvc, ); beforeAll(() => { @@ -912,6 +915,7 @@ describe('quirk fixes', () => { placesSvc, undefined as never, // unsplash — not exercised here new UserCleanupService(dbs()), + new AccommodationsService(fdbs, new PermissionsService(dbs()), new RealtimeService()), ); } diff --git a/server/tests/unit/plugins/rpc-fallbacks.test.ts b/server/tests/unit/plugins/rpc-fallbacks.test.ts index 59954674d..209086fb8 100644 --- a/server/tests/unit/plugins/rpc-fallbacks.test.ts +++ b/server/tests/unit/plugins/rpc-fallbacks.test.ts @@ -18,7 +18,7 @@ import { PlacesRpc } from '../../../src/nest/places/places.rpc'; import { PackingRpc } from '../../../src/nest/packing/packing.rpc'; import { FilesRpc } from '../../../src/nest/files/files.rpc'; import { TripsRpc } from '../../../src/nest/trips/trips.rpc'; -import { AccommodationsRpc } from '../../../src/nest/days/accommodations.rpc'; +import { AccommodationsRpc } from '../../../src/nest/accommodations/accommodations.rpc'; import { ReservationsRpc } from '../../../src/nest/reservations/reservations.rpc'; import { DaysRpc } from '../../../src/nest/days/days.rpc'; import { VacayRpc } from '../../../src/nest/vacay/vacay.rpc'; @@ -124,13 +124,13 @@ describe('a service that says no becomes a refusal, not a crash', () => { it('FALLBACK-008 an accommodation whose refs do not validate is refused with the validator message', async () => { const { guards } = guardsFor(); - const days = { + const accommodations = { validateAccommodationRefs: vi.fn(() => [{ message: 'day 9 is not on this trip' }]), getAccommodation: vi.fn(() => ({ id: 11 })), createAccommodation: vi.fn(), updateAccommodation: vi.fn(), } as never; - const host = new PluginRpcHost('p', ALL, makeDeps(), createTestPluginRegistry([new AccommodationsRpc(days, realtime(), guards)])); + const host = new PluginRpcHost('p', ALL, makeDeps(), createTestPluginRegistry([new AccommodationsRpc(accommodations, realtime(), guards)])); expect(err(await host.dispatch(req('accommodations.update', { tripId: 1, accommodationId: 11, input: { start_day_id: 9 } }), 42)).message) .toBe('day 9 is not on this trip'); }); @@ -145,7 +145,7 @@ describe('service errors are translated into the RPC taxonomy', () => { create: vi.fn(() => { if (createThrows) throw createThrows; return { trip: { id: 99 } }; }), removeMember: vi.fn(), } as never; - const rpc = new TripsRpc(trips, {} as never, {} as never, {} as never, db, realtime(), guards); + const rpc = new TripsRpc(trips, {} as never, {} as never, {} as never, db, realtime(), guards, {} as never); return new PluginRpcHost('p', ALL, makeDeps(), createTestPluginRegistry([rpc])); } @@ -246,7 +246,7 @@ describe('every schema-validated method rejects a payload its schema refuses', ( new PlacesRpc(anything, anything, realtime(), guards), new DaysRpc(anything, realtime(), guards), new PackingRpc(anything, realtime(), guards), - new TripsRpc(anything, anything, anything, anything, db, realtime(), guards), + new TripsRpc(anything, anything, anything, anything, db, realtime(), guards, anything), new AccommodationsRpc(anything, realtime(), guards), new ReservationsRpc(anything, realtime(), guards), ]); diff --git a/server/tests/unit/plugins/rpc-host-inv-strip.test.ts b/server/tests/unit/plugins/rpc-host-inv-strip.test.ts index e6893d832..fbaf61fd1 100644 --- a/server/tests/unit/plugins/rpc-host-inv-strip.test.ts +++ b/server/tests/unit/plugins/rpc-host-inv-strip.test.ts @@ -32,7 +32,7 @@ const tripsRegistry = () => { } as unknown as DatabaseService; const guards = new PluginGuards(db, {} as never, {} as never); return createTestPluginRegistry([ - new TripsRpc({} as never, {} as never, {} as never, {} as never, db, {} as never, guards), + new TripsRpc({} as never, {} as never, {} as never, {} as never, db, {} as never, guards, {} as never), ]); }; diff --git a/server/tests/unit/reservations/bookings.rpc.test.ts b/server/tests/unit/reservations/bookings.rpc.test.ts index 56e015923..81a432680 100644 --- a/server/tests/unit/reservations/bookings.rpc.test.ts +++ b/server/tests/unit/reservations/bookings.rpc.test.ts @@ -15,8 +15,8 @@ import { createTestPluginRegistry } from '../../../src/nest/plugins/host/rpc-kit import { PluginGuards } from '../../../src/nest/plugins/host/plugin-guards.service'; import { ReservationsRpc } from '../../../src/nest/reservations/reservations.rpc'; import { ReservationsModule } from '../../../src/nest/reservations/reservations.module'; -import { AccommodationsRpc } from '../../../src/nest/days/accommodations.rpc'; -import { DaysModule } from '../../../src/nest/days/days.module'; +import { AccommodationsRpc } from '../../../src/nest/accommodations/accommodations.rpc'; +import { AccommodationsModule } from '../../../src/nest/accommodations/accommodations.module'; import type { ReservationsService } from '../../../src/nest/reservations/reservations.service'; import type { DaysService } from '../../../src/nest/days/days.service'; import type { RealtimeService } from '../../../src/nest/realtime/realtime.service'; @@ -243,6 +243,6 @@ describe('AccommodationsRpc', () => { }); it('BOOK-RPC-016 the class is listed in its module providers', () => { - expectRegisteredProvider(DaysModule, AccommodationsRpc); + expectRegisteredProvider(AccommodationsModule, AccommodationsRpc); }); }); diff --git a/server/tests/unit/trips/trips.rpc.test.ts b/server/tests/unit/trips/trips.rpc.test.ts index c3ebb0160..207f51ab7 100644 --- a/server/tests/unit/trips/trips.rpc.test.ts +++ b/server/tests/unit/trips/trips.rpc.test.ts @@ -18,6 +18,7 @@ import { NotFoundError, ValidationError } from '../../../src/nest/trips/trips.se import type { TripsService } from '../../../src/nest/trips/trips.service'; import type { ReservationsService } from '../../../src/nest/reservations/reservations.service'; import type { DaysService } from '../../../src/nest/days/days.service'; +import type { AccommodationsService } from '../../../src/nest/accommodations/accommodations.service'; import type { TripMembershipService } from '../../../src/nest/trip-membership/trip-membership.service'; import type { RealtimeService } from '../../../src/nest/realtime/realtime.service'; import type { DatabaseService } from '../../../src/nest/database/database.service'; @@ -44,8 +45,11 @@ export function build(opts: { allow?: (action: string) => boolean; updateThrows? const reservations = { list: vi.fn(() => [{ id: 5 }]) } as unknown as ReservationsService & Record>; const days = { list: vi.fn(() => ({ days: [{ id: 3 }] })), - listAccommodations: vi.fn(() => [{ id: 11 }]), } as unknown as DaysService & Record>; + // The stays left the day service when accommodations became their own domain. + const accommodations = { + list: vi.fn(() => [{ id: 11 }]), + } as unknown as AccommodationsService & Record>; const membership = { joinTripAsMember: vi.fn(() => ({ joined: true })) } as unknown as TripMembershipService & Record>; const db = { canAccessTrip: vi.fn((tripId: number, userId: number) => (tripId === 1 && userId === 42 ? { id: 1, user_id: 42 } : undefined)), @@ -61,10 +65,10 @@ export function build(opts: { allow?: (action: string) => boolean; updateThrows? const permissions = { checkPermission: vi.fn((a: string) => (opts.allow ? opts.allow(a) : true)) } as unknown as PermissionsService; const realtime = { broadcast: vi.fn() } as unknown as RealtimeService & { broadcast: ReturnType }; const guards = new PluginGuards(db, permissions, { isAddonEnabled: vi.fn(() => true) } as unknown as AddonsService); - const rpc = new TripsRpc(trips, reservations, days, membership, db, realtime, guards); + const rpc = new TripsRpc(trips, reservations, days, membership, db, realtime, guards, accommodations); const host = (...grants: string[]) => new PluginRpcHost('p', new Set(grants.length ? grants : ALL_TRIP_GRANTS), makeDeps(), createTestPluginRegistry([rpc])); - return { trips, reservations, days, membership, realtime, permissions, host }; + return { trips, reservations, days, accommodations, membership, realtime, permissions, host }; } describe('TripsRpc reads', () => { diff --git a/server/vitest.config.ts b/server/vitest.config.ts index 133d5eef0..7d5dc0c5c 100644 --- a/server/vitest.config.ts +++ b/server/vitest.config.ts @@ -61,6 +61,7 @@ export default defineConfig({ // A file matched by a glob below is EXCLUDED from the catch-all, so the last entry // only covers what sits directly under src/nest (app.module.ts and the like). thresholds: { + 'src/nest/accommodations/**/*.ts': { statements: 92, branches: 82, functions: 98, lines: 97 }, 'src/nest/addons/**/*.ts': { statements: 99, branches: 87, functions: 99, lines: 99 }, 'src/nest/admin/**/*.ts': { statements: 86, branches: 70, functions: 89, lines: 88 }, 'src/nest/airports/**/*.ts': { statements: 68, branches: 60, functions: 93, lines: 73 },