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 },