chore: move day backend to proper DI

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