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';