Give accommodations one home instead of two halves

The routes lived in reservations/ while the SQL lived on DaysService, so the
controller delegated every call across a domain boundary to reach its own
data, and neither module owned the thing outright. The seven statements move
into AccommodationsService unchanged and the domain gets its own directory,
module, DTO, RPC and MCP surface.

What stays in days/ is the reorder side: assertNoInvertedAccommodation and
resyncAccommodationDays are about re-dating day rows, and the stay is the
thing being carried, not the thing doing the carrying.

Two edges fall out of this. AccommodationsService drops its DaysService
dependency, and DaysMcp drops DatabaseService and PlacesService, because
create_place_accommodation was their only user - which lets DaysModule stop
importing PlacesModule and closes one leg of the latent
Days -> Places -> Assignments -> Days cycle.

DAY-SVC-016..026, 029 and 035 move to accommodations.service.test.ts with
their case ids intact. The reservations e2e container assembles both modules
now: it builds a single domain module on purpose, which is exactly what
catches a move like this, and it caught it.
This commit is contained in:
Maurice
2026-08-10 09:57:50 +02:00
committed by Maurice
parent ad312cdd43
commit 3d522be0e5
32 changed files with 830 additions and 634 deletions
@@ -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';
@@ -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) {}
@@ -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),
}],
};
}
}
@@ -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 {}
@@ -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,
) {}
@@ -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<E extends TrekWsTripEventName>(tripId: string, event: E, payload: TrekWsPayload<E>, 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<AccommodationsService['updateAccommodation']>[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<string, string> = {};
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<DayAccommodation>('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 };
});
}
}
+2 -1
View File
@@ -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).
+1 -182
View File
@@ -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),
}],
};
}
}
+1 -2
View File
@@ -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 {}
-155
View File
@@ -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<string, string> = {};
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<DayAccommodation>('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 };
});
}
}
@@ -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,
@@ -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<E extends TrekWsTripEventName>(tripId: string, event: E, payload: TrekWsPayload<E>, 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<DaysService['createAccommodation']>[1]) {
return this.days.createAccommodation(tripId, data);
}
update(id: string, existing: Parameters<DaysService['updateAccommodation']>[1], fields: Parameters<DaysService['updateAccommodation']>[2]) {
return this.days.updateAccommodation(id, existing, fields);
}
remove(id: string) {
return this.days.deleteAccommodation(id);
}
}
@@ -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) {}
@@ -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],
})
+2
View File
@@ -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) {
+2 -1
View File
@@ -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).
+4 -1
View File
@@ -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<string, unknown>, 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' })
+5 -2
View File
@@ -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),
};
}