mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 13:36:48 +00:00
chore: move day backend to proper DI
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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,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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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),
|
||||
}],
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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,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),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user