mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
chore: move reservations backend to proper DI
Folds the 626-line services/reservationService.ts into the DI-native ReservationsService — byte-identical SQL, statuses, bodies, error strings and broadcasts; the legacy file is deleted. - reservations.mcp.ts: the 5 legacy tools (create/update/delete_reservation, reorder_reservations, link_hotel_accommodation) + the trip-reservations resource move to the decorator registry (identical names, schemas, annotations, error strings, broadcasts); the canWrite/canRead registration gates become declarative access markers. Legacy registrar and resource entries removed. - reservations.bridge.ts: 9 legacy-named exports + 3 type re-exports for the outside-container consumers (tripService, airtrail import/sync, the still-legacy transit + transports registrars, transitItineraryService's type-only import). - notifyBookingChange folds to the legacy (tripId, actorId) signature; the controller passes user.id and the plugin-host factory drops its last plain-function reservation import for a private method over the injected service (constructor unchanged). - TripsService and BookingImportService inject ReservationsService instead of importing legacy functions. - Tests: reservations.service.test.ts rewritten as a real-SQL suite (RESV-SVC-001..030, RES-TRAV-001..005 moved verbatim, RESV-BRIDGE-001..009 pinning the bridge); both e2e suites converted to the DI-native pattern; path mocks repointed to the bridge or constructor stubs; tools-reservations kept and extended with the moved resource cases. Quirks preserved on purpose (parity first — see the follow-up fix commit): the raw-accommodation_id metadata-sync gate, the un-transactioned multi-statement writes, the truthy updatePositions dayId check, the empty-string COALESCE keeps, and the TEXT accommodation_id normalization.
This commit is contained in:
@@ -4,7 +4,6 @@ import { listTrips, getTrip, getTripOwner, listMembers } from '../services/tripS
|
||||
import { listDays, listAccommodations } from '../services/dayService';
|
||||
import { listPlaces } from '../services/placeService';
|
||||
import { listBudgetItems, getPerPersonSummary, calculateSettlement } from '../services/budgetService';
|
||||
import { listReservations } from '../services/reservationService';
|
||||
import { listBucketList, listVisitedCountries, getStats as getAtlasStats, listManuallyVisitedRegions } from '../services/atlasService';
|
||||
import { getNotifications } from '../services/inAppNotifications';
|
||||
import { isAddonEnabled } from '../services/adminService';
|
||||
@@ -116,18 +115,8 @@ export function registerResources(server: McpServer, userId: number, scopes: str
|
||||
// The trip-packing resource moved to the DI-discovered
|
||||
// src/nest/packing/packing.mcp.ts (@ResourceTemplate).
|
||||
|
||||
// Reservations (flights, hotels, restaurants)
|
||||
if (canRead(scopes, 'reservations')) server.registerResource(
|
||||
'trip-reservations',
|
||||
new ResourceTemplate('trek://trips/{tripId}/reservations', { list: undefined }),
|
||||
{ description: 'Reservations (flights, hotels, restaurants) for a trip', mimeType: 'application/json' },
|
||||
async (uri, { tripId }) => {
|
||||
const id = parseId(tripId);
|
||||
if (id === null || !canAccessTrip(id, userId)) return accessDenied(uri.href);
|
||||
const reservations = listReservations(id);
|
||||
return jsonContent(uri.href, reservations);
|
||||
}
|
||||
);
|
||||
// The trip-reservations resource moved to the DI-discovered
|
||||
// src/nest/reservations/reservations.mcp.ts (@ResourceTemplate).
|
||||
|
||||
// The day-notes resource moved to the DI-discovered
|
||||
// src/nest/days/day-notes.mcp.ts (@ResourceTemplate).
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp';
|
||||
import { registerJourneyTools } from './tools/journey';
|
||||
import { registerReservationTools } from './tools/reservations';
|
||||
import { registerMapsWeatherTools } from './tools/mapsWeather';
|
||||
import { registerNotificationTools } from './tools/notifications';
|
||||
import { registerAtlasTools } from './tools/atlas';
|
||||
@@ -26,7 +25,8 @@ export function registerTools(server: McpServer, userId: number, scopes: string[
|
||||
// The packing tools moved to the DI-discovered src/nest/packing/packing.mcp.ts
|
||||
// (@McpController, attached via the nest-mcp registry below).
|
||||
|
||||
registerReservationTools(server, userId, scopes);
|
||||
// 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);
|
||||
|
||||
|
||||
@@ -1,227 +0,0 @@
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp';
|
||||
import { z } from 'zod';
|
||||
import { canAccessTrip } from '../../db/database';
|
||||
import { isDemoUser } from '../../services/authService';
|
||||
import {
|
||||
createReservation, getReservation, updateReservation, deleteReservation,
|
||||
updatePositions as updateReservationPositions,
|
||||
} from '../../services/reservationService';
|
||||
import { linkBudgetItemToReservation } from '../../services/budgetService';
|
||||
import { getDay } from '../../services/dayService';
|
||||
import { placeExists, getAssignmentForTrip } from '../../nest/assignments/assignments.bridge';
|
||||
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 registerReservationTools(server: McpServer, userId: number, scopes: string[] | null): void {
|
||||
if (!canWrite(scopes, 'reservations')) return;
|
||||
|
||||
|
||||
server.registerTool(
|
||||
'create_reservation',
|
||||
{
|
||||
description: 'Recommend a reservation for a trip. Created as pending — the user must confirm it. For flights, trains, cars, and cruises, use create_transport instead. Linking: hotel → use place_id + start_day_id + end_day_id (all three required to create the accommodation link); restaurant/event/tour/activity/parking/other → use assignment_id. Set price to record the cost; it will appear on the booking and in the Budget tab.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
title: z.string().min(1).max(200),
|
||||
type: z.enum(['hotel', 'restaurant', 'event', 'tour', 'activity', 'parking', 'other']).describe('Reservation type: "hotel", "restaurant", "event", "tour", "activity", "parking", or "other"'),
|
||||
reservation_time: z.string().optional().describe('ISO 8601 datetime or time string'),
|
||||
location: z.string().max(500).optional(),
|
||||
confirmation_number: z.string().max(100).optional(),
|
||||
notes: z.string().max(1000).optional(),
|
||||
day_id: z.number().int().positive().optional(),
|
||||
place_id: z.number().int().positive().optional().describe('Hotel place to link (hotel type only)'),
|
||||
start_day_id: z.number().int().positive().optional().describe('Check-in day (hotel type only; requires place_id and end_day_id)'),
|
||||
end_day_id: z.number().int().positive().optional().describe('Check-out day (hotel type only; requires place_id and start_day_id)'),
|
||||
check_in: z.string().max(10).optional().describe('Check-in time (e.g. "15:00", hotel type only)'),
|
||||
check_out: z.string().max(10).optional().describe('Check-out time (e.g. "11:00", hotel type only)'),
|
||||
assignment_id: z.number().int().positive().optional().describe('Link to a day assignment (restaurant, train, car, cruise, event, tour, activity, other)'),
|
||||
price: z.number().nonnegative().optional().describe('Reservation cost — shown on the booking and linked in the Budget tab'),
|
||||
budget_category: z.string().max(100).optional().describe('Budget category for the price entry (defaults to reservation type)'),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||
},
|
||||
async ({ tripId, title, type, reservation_time, location, confirmation_number, notes, day_id, place_id, start_day_id, end_day_id, check_in, check_out, assignment_id, price, budget_category }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
if (!hasTripPermission('reservation_edit', tripId, userId)) return permissionDenied();
|
||||
|
||||
// Validate that all referenced IDs belong to this trip
|
||||
if (day_id && !getDay(day_id, tripId))
|
||||
return { content: [{ type: 'text' as const, text: 'day_id does not belong to this trip.' }], isError: true };
|
||||
if (place_id && !placeExists(place_id, tripId))
|
||||
return { content: [{ type: 'text' as const, text: 'place_id does not belong to this trip.' }], isError: true };
|
||||
if (start_day_id && !getDay(start_day_id, tripId))
|
||||
return { content: [{ type: 'text' as const, text: 'start_day_id does not belong to this trip.' }], isError: true };
|
||||
if (end_day_id && !getDay(end_day_id, tripId))
|
||||
return { content: [{ type: 'text' as const, text: 'end_day_id does not belong to this trip.' }], isError: true };
|
||||
if (assignment_id && !getAssignmentForTrip(assignment_id, tripId))
|
||||
return { content: [{ type: 'text' as const, text: 'assignment_id does not belong to this trip.' }], isError: true };
|
||||
|
||||
const createAccommodation = (type === 'hotel' && place_id && start_day_id && end_day_id)
|
||||
? { place_id, start_day_id, end_day_id, check_in: check_in || undefined, check_out: check_out || undefined, confirmation: confirmation_number || undefined }
|
||||
: undefined;
|
||||
|
||||
const metadata = price != null ? { price: String(price) } : undefined;
|
||||
|
||||
const { reservation, accommodationCreated } = createReservation(tripId, {
|
||||
title, type, reservation_time, location, confirmation_number,
|
||||
notes, day_id, place_id, assignment_id,
|
||||
create_accommodation: createAccommodation,
|
||||
metadata,
|
||||
});
|
||||
|
||||
if (accommodationCreated) {
|
||||
safeBroadcast(tripId, 'accommodation:created', {});
|
||||
}
|
||||
|
||||
if (price != null && price > 0) {
|
||||
const item = linkBudgetItemToReservation(tripId, reservation.id, {
|
||||
name: title,
|
||||
category: budget_category || type,
|
||||
total_price: price,
|
||||
});
|
||||
safeBroadcast(tripId, 'budget:created', { item });
|
||||
}
|
||||
|
||||
safeBroadcast(tripId, 'reservation:created', { reservation });
|
||||
return ok({ reservation });
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'update_reservation',
|
||||
{
|
||||
description: 'Update an existing reservation in a trip. Use status "confirmed" to confirm a pending recommendation, or "pending" to revert it. For flights, trains, cars, and cruises, use update_transport instead. Linking: hotel → use place_id to link to an accommodation place; restaurant/event/tour/activity/parking/other → use assignment_id to link to a day assignment.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
reservationId: z.number().int().positive(),
|
||||
title: z.string().min(1).max(200).optional(),
|
||||
type: z.enum(['hotel', 'restaurant', 'event', 'tour', 'activity', 'parking', 'other']).optional().describe('Reservation type: "hotel", "restaurant", "event", "tour", "activity", "parking", or "other"'),
|
||||
reservation_time: z.string().optional().describe('ISO 8601 datetime or time string'),
|
||||
location: z.string().max(500).optional(),
|
||||
confirmation_number: z.string().max(100).optional(),
|
||||
notes: z.string().max(1000).optional(),
|
||||
status: z.enum(['pending', 'confirmed', 'cancelled']).optional().describe('Reservation status: "pending", "confirmed", or "cancelled"'),
|
||||
place_id: z.number().int().positive().nullable().optional().describe('Link to a place (use for hotel type), or null to unlink'),
|
||||
assignment_id: z.number().int().positive().nullable().optional().describe('Link to a day assignment (use for restaurant, train, car, cruise, event, tour, activity, other), or null to unlink'),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_WRITE,
|
||||
},
|
||||
async ({ tripId, reservationId, title, type, reservation_time, location, confirmation_number, notes, status, place_id, assignment_id }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
if (!hasTripPermission('reservation_edit', tripId, userId)) return permissionDenied();
|
||||
const existing = getReservation(reservationId, tripId);
|
||||
if (!existing) return { content: [{ type: 'text' as const, text: 'Reservation not found.' }], isError: true };
|
||||
|
||||
if (place_id != null && !placeExists(place_id, tripId))
|
||||
return { content: [{ type: 'text' as const, text: 'place_id does not belong to this trip.' }], isError: true };
|
||||
if (assignment_id != null && !getAssignmentForTrip(assignment_id, tripId))
|
||||
return { content: [{ type: 'text' as const, text: 'assignment_id does not belong to this trip.' }], isError: true };
|
||||
|
||||
const { reservation } = updateReservation(reservationId, tripId, {
|
||||
title, type, reservation_time, location, confirmation_number, notes, status,
|
||||
place_id: place_id !== undefined ? place_id ?? undefined : undefined,
|
||||
assignment_id: assignment_id !== undefined ? assignment_id ?? undefined : undefined,
|
||||
}, existing);
|
||||
safeBroadcast(tripId, 'reservation:updated', { reservation });
|
||||
return ok({ reservation });
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'delete_reservation',
|
||||
{
|
||||
description: 'Delete a reservation from a trip.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
reservationId: z.number().int().positive(),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_DELETE,
|
||||
},
|
||||
async ({ tripId, reservationId }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
if (!hasTripPermission('reservation_edit', tripId, userId)) return permissionDenied();
|
||||
const { deleted, accommodationDeleted } = deleteReservation(reservationId, tripId);
|
||||
if (!deleted) return { content: [{ type: 'text' as const, text: 'Reservation not found.' }], isError: true };
|
||||
if (accommodationDeleted) {
|
||||
safeBroadcast(tripId, 'accommodation:deleted', { accommodationId: deleted.accommodation_id });
|
||||
}
|
||||
safeBroadcast(tripId, 'reservation:deleted', { reservationId });
|
||||
return ok({ success: true });
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'reorder_reservations',
|
||||
{
|
||||
description: 'Update the display order of reservations within a day.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
positions: z.array(z.object({
|
||||
id: z.number().int().positive(),
|
||||
day_plan_position: z.number().int().min(0),
|
||||
})).describe('Array of { id, day_plan_position } pairs'),
|
||||
dayId: z.number().int().positive().optional().describe('Optionally scope the update to a specific day'),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_WRITE,
|
||||
},
|
||||
async ({ tripId, positions, dayId }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
if (!hasTripPermission('reservation_edit', tripId, userId)) return permissionDenied();
|
||||
updateReservationPositions(tripId, positions, dayId);
|
||||
safeBroadcast(tripId, 'reservation:positions', { positions, dayId });
|
||||
return ok({ success: true });
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'link_hotel_accommodation',
|
||||
{
|
||||
description: 'Set or update the check-in/check-out day links for a hotel reservation. Creates or updates the accommodation record that ties the reservation to a place and a date range. Use the day IDs from get_trip_summary.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
reservationId: z.number().int().positive(),
|
||||
place_id: z.number().int().positive().describe('The hotel place to link'),
|
||||
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_out: z.string().max(10).optional().describe('Check-out time (e.g. "11:00")'),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_WRITE,
|
||||
},
|
||||
async ({ tripId, reservationId, place_id, start_day_id, end_day_id, check_in, check_out }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
if (!hasTripPermission('reservation_edit', tripId, userId)) return permissionDenied();
|
||||
const current = getReservation(reservationId, tripId);
|
||||
if (!current) return { content: [{ type: 'text' as const, text: 'Reservation not found.' }], isError: true };
|
||||
if (current.type !== 'hotel') return { content: [{ type: 'text' as const, text: 'Reservation is not of type hotel.' }], isError: true };
|
||||
|
||||
if (!placeExists(place_id, tripId))
|
||||
return { content: [{ type: 'text' as const, text: 'place_id does not belong to this trip.' }], isError: true };
|
||||
if (!getDay(start_day_id, tripId))
|
||||
return { content: [{ type: 'text' as const, text: 'start_day_id does not belong to this trip.' }], isError: true };
|
||||
if (!getDay(end_day_id, tripId))
|
||||
return { content: [{ type: 'text' as const, text: 'end_day_id does not belong to this trip.' }], isError: true };
|
||||
|
||||
const isNewAccommodation = !current.accommodation_id;
|
||||
const { reservation } = updateReservation(reservationId, tripId, {
|
||||
place_id,
|
||||
type: current.type,
|
||||
status: current.status as string,
|
||||
create_accommodation: { place_id, start_day_id, end_day_id, check_in: check_in || undefined, check_out: check_out || undefined },
|
||||
}, current);
|
||||
|
||||
safeBroadcast(tripId, isNewAccommodation ? 'accommodation:created' : 'accommodation:updated', {});
|
||||
safeBroadcast(tripId, 'reservation:updated', { reservation });
|
||||
return ok({ reservation, accommodation_id: (reservation as any)?.accommodation_id ?? null });
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,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 { createReservation, notifyBookingChange } from '../../services/reservationService';
|
||||
import { createReservation, notifyBookingChange } from '../../nest/reservations/reservations.bridge';
|
||||
import {
|
||||
buildTransitReservationParts,
|
||||
cleanTransitItineraryNames,
|
||||
|
||||
@@ -5,7 +5,7 @@ import { isDemoUser } from '../../services/authService';
|
||||
import {
|
||||
createReservation, deleteReservation, getReservation, updateReservation,
|
||||
type EndpointInput,
|
||||
} from '../../services/reservationService';
|
||||
} from '../../nest/reservations/reservations.bridge';
|
||||
import { linkBudgetItemToReservation } from '../../services/budgetService';
|
||||
import { getDay } from '../../services/dayService';
|
||||
import { findByIata } from '../../services/airportService';
|
||||
|
||||
@@ -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 — see the migration recipe below.
|
||||
collab, vacay, reservations — see the migration recipe below.
|
||||
|
||||
## Cross-cutting Foundation pieces
|
||||
|
||||
@@ -134,10 +134,21 @@ vacay followed (the largest MCP port yet — a 26-tool + 3-resource
|
||||
`vacay.mcp.ts`, including the first fixed-URI `@Resource` behind a `when`
|
||||
addon gate — plus the plugin-host swap and a 1-export `vacay.bridge.ts` for
|
||||
`tripService`'s trip-window entry shift; the DTO ratchet for its 13
|
||||
grandfathered body contracts landed as a sibling commit).
|
||||
grandfathered body contracts landed as a sibling commit); reservations
|
||||
followed (the residue fold: the wrapper `ReservationsService` was already
|
||||
DI-native at the edge, so the 626-line legacy module folded into it — a 5-tool
|
||||
+ 1-resource `reservations.mcp.ts`, the plugin host's last plain-function
|
||||
reservation import (`notifyBookingChange`) swapped for the injected service,
|
||||
`TripsService` and `BookingImportService` converted from function imports to
|
||||
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).
|
||||
Repeat these steps per
|
||||
service (next up: reservationService, the residue fold). This is a **pure
|
||||
relocation** — byte-identical
|
||||
service (next up: dayService — the dependency-honest order in
|
||||
`migration-graph.md` promotes it now that reservations is folded). 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
|
||||
injects domain services via `PluginHostDepsFactory`, so a migrated domain adds
|
||||
|
||||
@@ -5,9 +5,10 @@ import { ImportJobsService } from './import-jobs.service';
|
||||
import { KitineraryExtractorService } from './kitinerary-extractor.service';
|
||||
import { FeaturesController } from './features.controller';
|
||||
import { LlmParseModule } from '../llm-parse/llm-parse.module';
|
||||
import { ReservationsModule } from '../reservations/reservations.module';
|
||||
|
||||
@Module({
|
||||
imports: [LlmParseModule],
|
||||
imports: [LlmParseModule, ReservationsModule],
|
||||
controllers: [BookingImportController, FeaturesController],
|
||||
providers: [BookingImportService, KitineraryExtractorService, ImportJobsService],
|
||||
})
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Injectable, HttpException } from '@nestjs/common';
|
||||
import { broadcast } from '../../websocket';
|
||||
import { checkPermission } from '../../services/permissions';
|
||||
import { verifyTripAccess } from '../../services/tripAccess';
|
||||
import { createReservation } from '../../services/reservationService';
|
||||
import { ReservationsService } from '../reservations/reservations.service';
|
||||
import { createPlace } from '../../services/placeService';
|
||||
import { createBudgetItem, freezeForeignRate } from '../../services/budgetService';
|
||||
import { isAddonEnabled } from '../../services/adminService';
|
||||
@@ -23,6 +23,7 @@ export class BookingImportService {
|
||||
private readonly extractor: KitineraryExtractorService,
|
||||
private readonly llmParse: LlmParseService,
|
||||
private readonly dbs: DatabaseService,
|
||||
private readonly reservations: ReservationsService,
|
||||
) {}
|
||||
|
||||
private get db() {
|
||||
@@ -219,7 +220,7 @@ export class BookingImportService {
|
||||
};
|
||||
}
|
||||
|
||||
const { reservation, accommodationCreated } = createReservation(tripId, {
|
||||
const { reservation, accommodationCreated } = this.reservations.create(tripId, {
|
||||
...reservationData,
|
||||
place_id: placeId,
|
||||
create_accommodation: createAccommodation,
|
||||
|
||||
@@ -230,7 +230,10 @@ none of it is throwaway.
|
||||
stubs (see §2).
|
||||
2. **Per domain, as the service migrations proceed** (`packingService`,
|
||||
`dayNoteService` and `assignmentService` done 2026-07; `tripInviteService`
|
||||
done 2026-07 with no factory impact — it was never imported here; next per
|
||||
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
|
||||
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
|
||||
|
||||
@@ -37,17 +37,10 @@ import { DatabaseService } from '../../database/database.service';
|
||||
import { FilesService } from '../../files/files.service';
|
||||
import { CollabService } from '../../collab/collab.service';
|
||||
import { VacayService } from '../../vacay/vacay.service';
|
||||
import { notifyBookingChange } from '../../../services/reservationService';
|
||||
import { PluginRpcHost, ForbiddenResource, BadParams } from './rpc-host';
|
||||
import { appendAudit } from './plugin-audit';
|
||||
import { getPluginDataDb, budgetFor } from './plugin-host-state';
|
||||
|
||||
// The booking notification the REST controller sends after a create/update/delete
|
||||
// is fire-and-forget, so it never blocks the plugin write.
|
||||
function notifyBooking(actingUserId: number, tripId: number, booking: string, type: string): void {
|
||||
notifyBookingChange(tripId, actingUserId, booking, type);
|
||||
}
|
||||
|
||||
// A subsystem read is refused when its addon is off — parity with the app, where a
|
||||
// disabled addon means there is simply nothing to read (same shape as the Costs gate).
|
||||
function requireAddon(addonId: string, noun: string): void {
|
||||
@@ -177,6 +170,12 @@ export class PluginHostDepsFactory {
|
||||
return checkPermission(action, u.role ?? 'user', trip.user_id, userId, trip.user_id !== userId);
|
||||
}
|
||||
|
||||
// The booking notification the REST controller sends after a create/update/delete
|
||||
// is fire-and-forget, so it never blocks the plugin write.
|
||||
private notifyBooking(actingUserId: number, tripId: number, booking: string, type: string): void {
|
||||
this.reservations.notifyBookingChange(tripId, actingUserId, booking, type);
|
||||
}
|
||||
|
||||
create(id: string, granted: ReadonlySet<string>, router: PluginCallRouter): PluginRpcHost {
|
||||
return new PluginRpcHost(id, granted, {
|
||||
// Resolve the data handle lazily on every access rather than capturing it once.
|
||||
@@ -738,7 +737,7 @@ export class PluginHostDepsFactory {
|
||||
const i = input as { title?: string; type?: string; create_budget_entry?: unknown };
|
||||
this.reservations.syncBudgetOnCreate(String(tripId), reservation.id, i.title ?? '', i.type, i.create_budget_entry as never, undefined);
|
||||
broadcast(tripId, 'reservation:created', { reservation }, undefined);
|
||||
notifyBooking(actingUserId, tripId, i.title ?? '', i.type ?? '');
|
||||
this.notifyBooking(actingUserId, tripId, i.title ?? '', i.type ?? '');
|
||||
return reservation;
|
||||
},
|
||||
updateReservation: (tripId, reservationId, input, actingUserId) => {
|
||||
@@ -750,7 +749,7 @@ export class PluginHostDepsFactory {
|
||||
const i = input as { title?: string; type?: string; create_budget_entry?: unknown };
|
||||
this.reservations.syncBudgetOnUpdate(String(tripId), String(reservationId), i.title ?? '', i.type, cur.title, cur.type, i.create_budget_entry as never, undefined);
|
||||
broadcast(tripId, 'reservation:updated', { reservation }, undefined);
|
||||
notifyBooking(actingUserId, tripId, i.title || cur.title, i.type || cur.type || '');
|
||||
this.notifyBooking(actingUserId, tripId, i.title || cur.title, i.type || cur.type || '');
|
||||
return reservation;
|
||||
},
|
||||
deleteReservation: (tripId, reservationId, actingUserId) => {
|
||||
@@ -759,7 +758,7 @@ export class PluginHostDepsFactory {
|
||||
if (accommodationDeleted) broadcast(tripId, 'accommodation:deleted', { accommodationId: deleted.accommodation_id }, undefined);
|
||||
if (deletedBudgetItemId) broadcast(tripId, 'budget:deleted', { itemId: deletedBudgetItemId }, undefined);
|
||||
broadcast(tripId, 'reservation:deleted', { reservationId: Number(reservationId) }, undefined);
|
||||
notifyBooking(actingUserId, tripId, deleted.title, deleted.type || '');
|
||||
this.notifyBooking(actingUserId, tripId, deleted.title, deleted.type || '');
|
||||
return { deleted: true };
|
||||
},
|
||||
// --- Packing (packing_edit). Reuses PackingService + replicates the #858
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { db } from '../../db/database';
|
||||
import { DatabaseService } from '../database/database.service';
|
||||
import { ReservationsService } from './reservations.service';
|
||||
|
||||
export type { EndpointInput, ReservationEndpoint, ReservationTraveler } from './reservations.service';
|
||||
|
||||
/**
|
||||
* Non-Nest entry point for the reservations domain — for code running OUTSIDE
|
||||
* the Nest container (the legacy tripService, the airtrail import/sync
|
||||
* services, and the still-legacy transit + transports MCP registrars; the
|
||||
* reservation MCP tools and resource moved to the DI-discovered
|
||||
* reservations.mcp.ts, and the plugin RPC host injects ReservationsService via
|
||||
* PluginHostDepsFactory). Exports only the legacy services/reservationService
|
||||
* names still consumed outside the container, 1:1, so repointing a consumer is
|
||||
* an import-path-only diff. Inside the container, inject ReservationsService
|
||||
* instead. Delete exports here as their consumers migrate.
|
||||
*
|
||||
* Module-level construction is safe: `db` is the reinitialize-proof Proxy onto
|
||||
* the shared better-sqlite3 singleton.
|
||||
*/
|
||||
const reservations = new ReservationsService(new DatabaseService(db));
|
||||
|
||||
export function listReservations(tripId: string | number) {
|
||||
return reservations.list(tripId);
|
||||
}
|
||||
|
||||
export function loadEndpointsByTrip(tripId: string | number) {
|
||||
return reservations.loadEndpointsByTrip(tripId);
|
||||
}
|
||||
|
||||
export function resyncReservationDays(tripId: string | number): void {
|
||||
reservations.resyncReservationDays(tripId);
|
||||
}
|
||||
|
||||
export function createReservation(tripId: string | number, data: Parameters<ReservationsService['create']>[1]) {
|
||||
return reservations.create(tripId, data);
|
||||
}
|
||||
|
||||
export function getReservation(id: string | number, tripId: string | number) {
|
||||
return reservations.getReservation(id, tripId);
|
||||
}
|
||||
|
||||
export function getReservationWithJoins(id: string | number) {
|
||||
return reservations.getReservationWithJoins(id);
|
||||
}
|
||||
|
||||
export function updateReservation(
|
||||
id: string | number,
|
||||
tripId: string | number,
|
||||
data: Parameters<ReservationsService['update']>[2],
|
||||
current: Parameters<ReservationsService['update']>[3]
|
||||
) {
|
||||
return reservations.update(id, tripId, data, current);
|
||||
}
|
||||
|
||||
export function deleteReservation(id: string | number, tripId: string | number) {
|
||||
return reservations.remove(id, tripId);
|
||||
}
|
||||
|
||||
export function notifyBookingChange(tripId: string | number, actorId: number, booking: string, type: string): void {
|
||||
reservations.notifyBookingChange(tripId, actorId, booking, type);
|
||||
}
|
||||
@@ -80,7 +80,7 @@ export class ReservationsController {
|
||||
}
|
||||
this.reservations.syncBudgetOnCreate(tripId, reservation.id, body.title, body.type, body.create_budget_entry, socketId);
|
||||
this.reservations.broadcast(tripId, 'reservation:created', { reservation }, socketId);
|
||||
this.reservations.notifyBookingChange(tripId, user, body.title, body.type ?? '');
|
||||
this.reservations.notifyBookingChange(tripId, user.id, body.title, body.type ?? '');
|
||||
return { reservation };
|
||||
}
|
||||
|
||||
@@ -127,7 +127,7 @@ export class ReservationsController {
|
||||
if ((reservation as any)?.external_source === 'airtrail' && (reservation as any)?.sync_enabled) {
|
||||
void pushReservationToAirtrail(Number((reservation as any).id), Number(tripId)).catch(() => {});
|
||||
}
|
||||
this.reservations.notifyBookingChange(tripId, user, body.title || cur.title, body.type || cur.type || '');
|
||||
this.reservations.notifyBookingChange(tripId, user.id, body.title || cur.title, body.type || cur.type || '');
|
||||
return { reservation };
|
||||
}
|
||||
|
||||
@@ -169,7 +169,7 @@ export class ReservationsController {
|
||||
this.reservations.broadcast(tripId, 'budget:deleted', { itemId: deletedBudgetItemId }, socketId);
|
||||
}
|
||||
this.reservations.broadcast(tripId, 'reservation:deleted', { reservationId: Number(id) }, socketId);
|
||||
this.reservations.notifyBookingChange(tripId, user, deleted.title, deleted.type || '');
|
||||
this.reservations.notifyBookingChange(tripId, user.id, deleted.title, deleted.type || '');
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
import {
|
||||
McpController, Tool, ResourceTemplate, type McpContext,
|
||||
TOOL_ANNOTATIONS_WRITE, TOOL_ANNOTATIONS_DELETE, TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||
demoDenied, errorResult, ok,
|
||||
} from '@trek/nest-mcp';
|
||||
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';
|
||||
|
||||
function parseId(value: string | string[]): number | null {
|
||||
const n = Number(Array.isArray(value) ? value[0] : value);
|
||||
return Number.isInteger(n) && n > 0 ? n : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reservations MCP surface — ported 1:1 from the legacy registrars: the five
|
||||
* tools from src/mcp/tools/reservations.ts and the
|
||||
* trek://trips/{tripId}/reservations resource from src/mcp/resources.ts
|
||||
* (identical names, descriptions, schemas, annotations, error/payload shapes
|
||||
* and broadcasts). The registration-time gates map to the declarative
|
||||
* reservations read/write access markers (the legacy `if (canWrite)` /
|
||||
* `if (canRead)` checks, resolved by trekMcpAccessPolicy); there is no addon
|
||||
* gate, so no `when`.
|
||||
*/
|
||||
@McpController()
|
||||
export class ReservationsMcp {
|
||||
constructor(private readonly reservations: ReservationsService) {}
|
||||
|
||||
@Tool({
|
||||
name: 'create_reservation',
|
||||
description: 'Recommend a reservation for a trip. Created as pending — the user must confirm it. For flights, trains, cars, and cruises, use create_transport instead. Linking: hotel → use place_id + start_day_id + end_day_id (all three required to create the accommodation link); restaurant/event/tour/activity/parking/other → use assignment_id. Set price to record the cost; it will appear on the booking and in the Budget tab.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
title: z.string().min(1).max(200),
|
||||
type: z.enum(['hotel', 'restaurant', 'event', 'tour', 'activity', 'parking', 'other']).describe('Reservation type: "hotel", "restaurant", "event", "tour", "activity", "parking", or "other"'),
|
||||
reservation_time: z.string().optional().describe('ISO 8601 datetime or time string'),
|
||||
location: z.string().max(500).optional(),
|
||||
confirmation_number: z.string().max(100).optional(),
|
||||
notes: z.string().max(1000).optional(),
|
||||
day_id: z.number().int().positive().optional(),
|
||||
place_id: z.number().int().positive().optional().describe('Hotel place to link (hotel type only)'),
|
||||
start_day_id: z.number().int().positive().optional().describe('Check-in day (hotel type only; requires place_id and end_day_id)'),
|
||||
end_day_id: z.number().int().positive().optional().describe('Check-out day (hotel type only; requires place_id and start_day_id)'),
|
||||
check_in: z.string().max(10).optional().describe('Check-in time (e.g. "15:00", hotel type only)'),
|
||||
check_out: z.string().max(10).optional().describe('Check-out time (e.g. "11:00", hotel type only)'),
|
||||
assignment_id: z.number().int().positive().optional().describe('Link to a day assignment (restaurant, train, car, cruise, event, tour, activity, other)'),
|
||||
price: z.number().nonnegative().optional().describe('Reservation cost — shown on the booking and linked in the Budget tab'),
|
||||
budget_category: z.string().max(100).optional().describe('Budget category for the price entry (defaults to reservation type)'),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||
access: { group: 'reservations', mode: 'write' },
|
||||
})
|
||||
async createReservation(
|
||||
{ tripId, title, type, reservation_time, location, confirmation_number, notes, day_id, place_id, start_day_id, end_day_id, check_in, check_out, assignment_id, price, budget_category }: {
|
||||
tripId: number; title: string; type: 'hotel' | 'restaurant' | 'event' | 'tour' | 'activity' | 'parking' | 'other';
|
||||
reservation_time?: string; location?: string; confirmation_number?: string; notes?: string;
|
||||
day_id?: number; place_id?: number; start_day_id?: number; end_day_id?: number;
|
||||
check_in?: string; check_out?: string; assignment_id?: number; price?: number; budget_category?: string;
|
||||
},
|
||||
ctx: McpContext,
|
||||
) {
|
||||
if (isDemoUser(ctx.userId)) return demoDenied();
|
||||
if (!this.reservations.verifyTripAccess(tripId, ctx.userId)) return noAccess();
|
||||
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))
|
||||
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))
|
||||
return errorResult('start_day_id does not belong to this trip.');
|
||||
if (end_day_id && !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.');
|
||||
|
||||
const createAccommodation = (type === 'hotel' && place_id && start_day_id && end_day_id)
|
||||
? { place_id, start_day_id, end_day_id, check_in: check_in || undefined, check_out: check_out || undefined, confirmation: confirmation_number || undefined }
|
||||
: undefined;
|
||||
|
||||
const metadata = price != null ? { price: String(price) } : undefined;
|
||||
|
||||
const { reservation, accommodationCreated } = this.reservations.create(tripId, {
|
||||
title, type, reservation_time, location, confirmation_number,
|
||||
notes, day_id, place_id, assignment_id,
|
||||
create_accommodation: createAccommodation,
|
||||
metadata,
|
||||
});
|
||||
|
||||
if (accommodationCreated) {
|
||||
safeBroadcast(tripId, 'accommodation:created', {});
|
||||
}
|
||||
|
||||
if (price != null && price > 0) {
|
||||
const item = linkBudgetItemToReservation(tripId, reservation.id, {
|
||||
name: title,
|
||||
category: budget_category || type,
|
||||
total_price: price,
|
||||
});
|
||||
safeBroadcast(tripId, 'budget:created', { item });
|
||||
}
|
||||
|
||||
safeBroadcast(tripId, 'reservation:created', { reservation });
|
||||
return ok({ reservation });
|
||||
}
|
||||
|
||||
@Tool({
|
||||
name: 'update_reservation',
|
||||
description: 'Update an existing reservation in a trip. Use status "confirmed" to confirm a pending recommendation, or "pending" to revert it. For flights, trains, cars, and cruises, use update_transport instead. Linking: hotel → use place_id to link to an accommodation place; restaurant/event/tour/activity/parking/other → use assignment_id to link to a day assignment.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
reservationId: z.number().int().positive(),
|
||||
title: z.string().min(1).max(200).optional(),
|
||||
type: z.enum(['hotel', 'restaurant', 'event', 'tour', 'activity', 'parking', 'other']).optional().describe('Reservation type: "hotel", "restaurant", "event", "tour", "activity", "parking", or "other"'),
|
||||
reservation_time: z.string().optional().describe('ISO 8601 datetime or time string'),
|
||||
location: z.string().max(500).optional(),
|
||||
confirmation_number: z.string().max(100).optional(),
|
||||
notes: z.string().max(1000).optional(),
|
||||
status: z.enum(['pending', 'confirmed', 'cancelled']).optional().describe('Reservation status: "pending", "confirmed", or "cancelled"'),
|
||||
place_id: z.number().int().positive().nullable().optional().describe('Link to a place (use for hotel type), or null to unlink'),
|
||||
assignment_id: z.number().int().positive().nullable().optional().describe('Link to a day assignment (use for restaurant, train, car, cruise, event, tour, activity, other), or null to unlink'),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_WRITE,
|
||||
access: { group: 'reservations', mode: 'write' },
|
||||
})
|
||||
async updateReservation(
|
||||
{ tripId, reservationId, title, type, reservation_time, location, confirmation_number, notes, status, place_id, assignment_id }: {
|
||||
tripId: number; reservationId: number; title?: string;
|
||||
type?: 'hotel' | 'restaurant' | 'event' | 'tour' | 'activity' | 'parking' | 'other';
|
||||
reservation_time?: string; location?: string; confirmation_number?: string; notes?: string;
|
||||
status?: 'pending' | 'confirmed' | 'cancelled'; place_id?: number | null; assignment_id?: number | null;
|
||||
},
|
||||
ctx: McpContext,
|
||||
) {
|
||||
if (isDemoUser(ctx.userId)) return demoDenied();
|
||||
if (!this.reservations.verifyTripAccess(tripId, ctx.userId)) return noAccess();
|
||||
if (!hasTripPermission('reservation_edit', tripId, ctx.userId)) return permissionDenied();
|
||||
const existing = this.reservations.getReservation(reservationId, tripId);
|
||||
if (!existing) return errorResult('Reservation not found.');
|
||||
|
||||
if (place_id != null && !placeExists(place_id, tripId))
|
||||
return errorResult('place_id does not belong to this trip.');
|
||||
if (assignment_id != null && !getAssignmentForTrip(assignment_id, tripId))
|
||||
return errorResult('assignment_id does not belong to this trip.');
|
||||
|
||||
const { reservation } = this.reservations.update(reservationId, tripId, {
|
||||
title, type, reservation_time, location, confirmation_number, notes, status,
|
||||
place_id: place_id !== undefined ? place_id ?? undefined : undefined,
|
||||
assignment_id: assignment_id !== undefined ? assignment_id ?? undefined : undefined,
|
||||
}, existing);
|
||||
safeBroadcast(tripId, 'reservation:updated', { reservation });
|
||||
return ok({ reservation });
|
||||
}
|
||||
|
||||
@Tool({
|
||||
name: 'delete_reservation',
|
||||
description: 'Delete a reservation from a trip.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
reservationId: z.number().int().positive(),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_DELETE,
|
||||
access: { group: 'reservations', mode: 'write' },
|
||||
})
|
||||
async deleteReservation({ tripId, reservationId }: { tripId: number; reservationId: number }, ctx: McpContext) {
|
||||
if (isDemoUser(ctx.userId)) return demoDenied();
|
||||
if (!this.reservations.verifyTripAccess(tripId, ctx.userId)) return noAccess();
|
||||
if (!hasTripPermission('reservation_edit', tripId, ctx.userId)) return permissionDenied();
|
||||
const { deleted, accommodationDeleted } = this.reservations.remove(reservationId, tripId);
|
||||
if (!deleted) return errorResult('Reservation not found.');
|
||||
if (accommodationDeleted) {
|
||||
safeBroadcast(tripId, 'accommodation:deleted', { accommodationId: deleted.accommodation_id });
|
||||
}
|
||||
safeBroadcast(tripId, 'reservation:deleted', { reservationId });
|
||||
return ok({ success: true });
|
||||
}
|
||||
|
||||
@Tool({
|
||||
name: 'reorder_reservations',
|
||||
description: 'Update the display order of reservations within a day.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
positions: z.array(z.object({
|
||||
id: z.number().int().positive(),
|
||||
day_plan_position: z.number().int().min(0),
|
||||
})).describe('Array of { id, day_plan_position } pairs'),
|
||||
dayId: z.number().int().positive().optional().describe('Optionally scope the update to a specific day'),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_WRITE,
|
||||
access: { group: 'reservations', mode: 'write' },
|
||||
})
|
||||
async reorderReservations(
|
||||
{ tripId, positions, dayId }: { tripId: number; positions: { id: number; day_plan_position: number }[]; dayId?: number },
|
||||
ctx: McpContext,
|
||||
) {
|
||||
if (isDemoUser(ctx.userId)) return demoDenied();
|
||||
if (!this.reservations.verifyTripAccess(tripId, ctx.userId)) return noAccess();
|
||||
if (!hasTripPermission('reservation_edit', tripId, ctx.userId)) return permissionDenied();
|
||||
this.reservations.updatePositions(tripId, positions, dayId);
|
||||
safeBroadcast(tripId, 'reservation:positions', { positions, dayId });
|
||||
return ok({ success: true });
|
||||
}
|
||||
|
||||
@Tool({
|
||||
name: 'link_hotel_accommodation',
|
||||
description: 'Set or update the check-in/check-out day links for a hotel reservation. Creates or updates the accommodation record that ties the reservation to a place and a date range. Use the day IDs from get_trip_summary.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
reservationId: z.number().int().positive(),
|
||||
place_id: z.number().int().positive().describe('The hotel place to link'),
|
||||
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_out: z.string().max(10).optional().describe('Check-out time (e.g. "11:00")'),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_WRITE,
|
||||
access: { group: 'reservations', mode: 'write' },
|
||||
})
|
||||
async linkHotelAccommodation(
|
||||
{ tripId, reservationId, place_id, start_day_id, end_day_id, check_in, check_out }: {
|
||||
tripId: number; reservationId: number; place_id: number; start_day_id: number; end_day_id: number;
|
||||
check_in?: string; check_out?: string;
|
||||
},
|
||||
ctx: McpContext,
|
||||
) {
|
||||
if (isDemoUser(ctx.userId)) return demoDenied();
|
||||
if (!this.reservations.verifyTripAccess(tripId, ctx.userId)) return noAccess();
|
||||
if (!hasTripPermission('reservation_edit', tripId, ctx.userId)) return permissionDenied();
|
||||
const current = this.reservations.getReservation(reservationId, tripId);
|
||||
if (!current) return errorResult('Reservation not found.');
|
||||
if (current.type !== 'hotel') return errorResult('Reservation is not of type hotel.');
|
||||
|
||||
if (!placeExists(place_id, tripId))
|
||||
return errorResult('place_id does not belong to this trip.');
|
||||
if (!getDay(start_day_id, tripId))
|
||||
return errorResult('start_day_id does not belong to this trip.');
|
||||
if (!getDay(end_day_id, tripId))
|
||||
return errorResult('end_day_id does not belong to this trip.');
|
||||
|
||||
const isNewAccommodation = !current.accommodation_id;
|
||||
const { reservation } = this.reservations.update(reservationId, tripId, {
|
||||
place_id,
|
||||
type: current.type,
|
||||
status: current.status as string,
|
||||
create_accommodation: { place_id, start_day_id, end_day_id, check_in: check_in || undefined, check_out: check_out || undefined },
|
||||
}, current);
|
||||
|
||||
safeBroadcast(tripId, isNewAccommodation ? 'accommodation:created' : 'accommodation:updated', {});
|
||||
safeBroadcast(tripId, 'reservation:updated', { reservation });
|
||||
return ok({ reservation, accommodation_id: reservation?.accommodation_id ?? null });
|
||||
}
|
||||
|
||||
@ResourceTemplate({
|
||||
name: 'trip-reservations',
|
||||
uriTemplate: 'trek://trips/{tripId}/reservations',
|
||||
description: 'Reservations (flights, hotels, restaurants) for a trip',
|
||||
mimeType: 'application/json',
|
||||
access: { group: 'reservations', mode: 'read' },
|
||||
})
|
||||
async tripReservationsResource(uri: URL, { tripId }: { tripId: string | string[] }, ctx: McpContext) {
|
||||
const id = parseId(tripId);
|
||||
if (id === null || !this.reservations.verifyTripAccess(id, ctx.userId)) {
|
||||
return {
|
||||
contents: [{
|
||||
uri: uri.href,
|
||||
mimeType: 'application/json',
|
||||
text: JSON.stringify({ error: 'Trip not found or access denied' }),
|
||||
}],
|
||||
};
|
||||
}
|
||||
const reservations = this.reservations.list(id);
|
||||
return {
|
||||
contents: [{
|
||||
uri: uri.href,
|
||||
mimeType: 'application/json',
|
||||
text: JSON.stringify(reservations, null, 2),
|
||||
}],
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ReservationsController } from './reservations.controller';
|
||||
import { ReservationsService } from './reservations.service';
|
||||
import { ReservationsMcp } from './reservations.mcp';
|
||||
import { AccommodationsController } from './accommodations.controller';
|
||||
import { AccommodationsService } from './accommodations.service';
|
||||
import { UpcomingReservationsController } from './upcoming-reservations.controller';
|
||||
@@ -8,12 +9,13 @@ import { UpcomingReservationsController } from './upcoming-reservations.controll
|
||||
/**
|
||||
* Reservations + accommodations domain (S5 — Phase 2 trip sub-domain).
|
||||
* Mounts: /api/trips/:tripId/reservations, /accommodations, and the cross-trip
|
||||
* /api/reservations/upcoming dashboard feed.
|
||||
* /api/reservations/upcoming dashboard feed. ReservationsMcp carries the
|
||||
* decorator-registered MCP tools + resource for the domain.
|
||||
*/
|
||||
@Module({
|
||||
controllers: [ReservationsController, AccommodationsController, UpcomingReservationsController],
|
||||
providers: [ReservationsService, AccommodationsService],
|
||||
// For in-container consumers (PluginHostDepsFactory).
|
||||
providers: [ReservationsService, AccommodationsService, ReservationsMcp],
|
||||
// For in-container consumers (PluginHostDepsFactory, TripsService, BookingImportService).
|
||||
exports: [ReservationsService],
|
||||
})
|
||||
export class ReservationsModule {}
|
||||
|
||||
@@ -2,31 +2,134 @@ import { Injectable } from '@nestjs/common';
|
||||
import { DatabaseService } from '../database/database.service';
|
||||
import { broadcast } from '../../websocket';
|
||||
import { checkPermission } from '../../services/permissions';
|
||||
import type { User } from '../../types';
|
||||
import * as svc from '../../services/reservationService';
|
||||
import { verifyTripAccess } from '../../services/tripAccess';
|
||||
import { avatarUrl } from '../../services/avatarUrl';
|
||||
import type { Reservation, User } from '../../types';
|
||||
import { createBudgetItem, updateBudgetItem, deleteBudgetItem, linkBudgetItemToReservation } from '../../services/budgetService';
|
||||
import { typeToCostCategory } from '@trek/shared';
|
||||
|
||||
type Trip = NonNullable<ReturnType<typeof svc.verifyTripAccess>>;
|
||||
type Trip = NonNullable<ReturnType<typeof verifyTripAccess>>;
|
||||
type BudgetEntry = { total_price?: number; category?: string } | undefined;
|
||||
|
||||
export interface ReservationEndpoint {
|
||||
id?: number;
|
||||
reservation_id?: number;
|
||||
role: 'from' | 'to' | 'stop';
|
||||
sequence: number;
|
||||
name: string;
|
||||
code: string | null;
|
||||
lat: number;
|
||||
lng: number;
|
||||
timezone: string | null;
|
||||
local_time: string | null;
|
||||
local_date: string | null;
|
||||
}
|
||||
|
||||
export type EndpointInput = Omit<ReservationEndpoint, 'id' | 'reservation_id' | 'sequence'> & { sequence?: number };
|
||||
|
||||
// --- Travelers (#1517): trip members / named guests assigned to a booking -----
|
||||
|
||||
export interface ReservationTraveler {
|
||||
user_id: number;
|
||||
username: string;
|
||||
avatar: string | null;
|
||||
avatar_url?: string | null;
|
||||
is_guest?: number | null;
|
||||
}
|
||||
|
||||
/** A reservation row as the joined list/get queries return it (open record —
|
||||
* the legacy queries selected `r.*` and callers pass the row through; the
|
||||
* named fields are the columns consumers read in a typed position). */
|
||||
export type ReservationRow = Record<string, unknown> & {
|
||||
id: number;
|
||||
trip_id: number;
|
||||
title: string;
|
||||
status: string;
|
||||
type: string;
|
||||
metadata?: string | null;
|
||||
accommodation_id?: number | string | null;
|
||||
day_positions?: Record<number, number> | null;
|
||||
endpoints?: ReservationEndpoint[];
|
||||
travelers?: ReservationTraveler[];
|
||||
};
|
||||
|
||||
interface CreateAccommodation {
|
||||
place_id?: number;
|
||||
start_day_id?: number;
|
||||
end_day_id?: number;
|
||||
check_in?: string;
|
||||
check_out?: string;
|
||||
confirmation?: string;
|
||||
}
|
||||
|
||||
export interface CreateReservationData {
|
||||
title: string;
|
||||
reservation_time?: string;
|
||||
reservation_end_time?: string;
|
||||
location?: string;
|
||||
confirmation_number?: string;
|
||||
notes?: string;
|
||||
url?: string;
|
||||
day_id?: number;
|
||||
end_day_id?: number;
|
||||
place_id?: number;
|
||||
assignment_id?: number;
|
||||
status?: string;
|
||||
type?: string;
|
||||
accommodation_id?: number;
|
||||
metadata?: unknown;
|
||||
create_accommodation?: CreateAccommodation;
|
||||
endpoints?: EndpointInput[];
|
||||
needs_review?: boolean;
|
||||
}
|
||||
|
||||
export interface UpdateReservationData {
|
||||
title?: string;
|
||||
reservation_time?: string;
|
||||
reservation_end_time?: string;
|
||||
location?: string;
|
||||
confirmation_number?: string;
|
||||
notes?: string;
|
||||
url?: string;
|
||||
day_id?: number;
|
||||
end_day_id?: number | null;
|
||||
place_id?: number;
|
||||
assignment_id?: number;
|
||||
status?: string;
|
||||
type?: string;
|
||||
accommodation_id?: number;
|
||||
metadata?: unknown;
|
||||
create_accommodation?: CreateAccommodation;
|
||||
endpoints?: EndpointInput[];
|
||||
needs_review?: boolean;
|
||||
}
|
||||
|
||||
type AccommodationTimesMeta = {
|
||||
check_in_time?: string | null;
|
||||
check_in_end_time?: string | null;
|
||||
check_out_time?: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Thin Nest wrapper around the existing reservation service. Trip-access, the
|
||||
* 'reservation_edit' permission, the SQL and the WebSocket broadcasts reuse the
|
||||
* legacy code unchanged. The legacy route's budget side effects (auto-create /
|
||||
* Reservations domain service — owns the reservation SQL (moved 1:1 from the
|
||||
* legacy services/reservationService.ts: identical statements, the `||`
|
||||
* falsy-coercion defaults, the COALESCE update semantics, the post-write
|
||||
* re-selects and the un-transactioned multi-statement writes). Trip access,
|
||||
* the 'reservation_edit' permission and the WebSocket broadcast keep their
|
||||
* legacy call paths. The legacy route's budget side effects (auto-create /
|
||||
* update / delete a linked budget item) and the booking notification are
|
||||
* encapsulated here so the controller stays thin — behaviour is 1:1.
|
||||
* Non-Nest consumers (legacy tripService, airtrail import/sync, the transit +
|
||||
* transports MCP registrars) go through reservations.bridge.ts instead of
|
||||
* importing this class directly; the plugin RPC host injects it via
|
||||
* PluginHostDepsFactory.
|
||||
*/
|
||||
@Injectable()
|
||||
export class ReservationsService {
|
||||
constructor(private readonly dbs: DatabaseService) {}
|
||||
constructor(private readonly db: DatabaseService) {}
|
||||
|
||||
private get db() {
|
||||
return this.dbs.connection;
|
||||
}
|
||||
|
||||
verifyTripAccess(tripId: string, userId: number) {
|
||||
return svc.verifyTripAccess(tripId, userId);
|
||||
verifyTripAccess(tripId: string | number, userId: number) {
|
||||
return verifyTripAccess(tripId, userId);
|
||||
}
|
||||
|
||||
canEdit(trip: Trip, user: User): boolean {
|
||||
@@ -37,41 +140,570 @@ export class ReservationsService {
|
||||
broadcast(tripId, event, payload, socketId);
|
||||
}
|
||||
|
||||
list(tripId: string) {
|
||||
return svc.listReservations(tripId);
|
||||
/** Fire-and-forget booking-change notification, mirroring the legacy dynamic import. */
|
||||
notifyBookingChange(tripId: string | number, actorId: number, booking: string, type: string): void {
|
||||
import('../../services/notificationService')
|
||||
.then(({ send }) => {
|
||||
try {
|
||||
const actor = this.db.get<{ email: string }>('SELECT email FROM users WHERE id = ?', actorId);
|
||||
if (!actor) return;
|
||||
const trip = this.db.get<{ title: string }>('SELECT title FROM trips WHERE id = ?', tripId);
|
||||
send({
|
||||
event: 'booking_change',
|
||||
actorId,
|
||||
scope: 'trip',
|
||||
targetId: Number(tripId),
|
||||
params: {
|
||||
trip: trip?.title || 'Untitled',
|
||||
actor: actor.email,
|
||||
booking,
|
||||
type: type || 'booking',
|
||||
tripId: String(tripId),
|
||||
},
|
||||
}).catch(() => {});
|
||||
} catch {
|
||||
// Notifications must never make the booking write fail.
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
// Cross-trip "upcoming reservations" feed (dashboard widget). Reuses the legacy
|
||||
// query unchanged; the default limit (6) matches the legacy inline handler.
|
||||
listUpcoming(userId: number) {
|
||||
return svc.getUpcomingReservations(userId);
|
||||
loadEndpointsByTrip(tripId: string | number): Map<number, ReservationEndpoint[]> {
|
||||
const rows = this.db.all<ReservationEndpoint>(`
|
||||
SELECT e.* FROM reservation_endpoints e
|
||||
JOIN reservations r ON e.reservation_id = r.id
|
||||
WHERE r.trip_id = ?
|
||||
ORDER BY e.reservation_id, e.sequence
|
||||
`, tripId);
|
||||
const map = new Map<number, ReservationEndpoint[]>();
|
||||
for (const r of rows) {
|
||||
const list = map.get(r.reservation_id!) ?? [];
|
||||
list.push(r);
|
||||
map.set(r.reservation_id!, list);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
create(tripId: string, data: Parameters<typeof svc.createReservation>[1]) {
|
||||
return svc.createReservation(tripId, data);
|
||||
private loadEndpoints(reservationId: number): ReservationEndpoint[] {
|
||||
return this.db.all<ReservationEndpoint>(
|
||||
'SELECT * FROM reservation_endpoints WHERE reservation_id = ? ORDER BY sequence',
|
||||
reservationId
|
||||
);
|
||||
}
|
||||
|
||||
updatePositions(tripId: string, positions: Parameters<typeof svc.updatePositions>[1], dayId: unknown): void {
|
||||
svc.updatePositions(tripId, positions, dayId as Parameters<typeof svc.updatePositions>[2]);
|
||||
private toTraveler(r: { user_id: number; username: string; avatar: string | null; is_guest?: number | null }): ReservationTraveler {
|
||||
return { user_id: r.user_id, username: r.username, avatar: r.avatar, is_guest: r.is_guest ?? null, avatar_url: avatarUrl(r) };
|
||||
}
|
||||
|
||||
getReservation(id: string, tripId: string) {
|
||||
return svc.getReservation(id, tripId);
|
||||
/** Users assignable on a trip: its members (guests included) plus the owner. */
|
||||
private assignableUserIds(tripId: string | number): Set<number> {
|
||||
const members = this.db.all<{ user_id: number }>('SELECT user_id FROM trip_members WHERE trip_id = ?', tripId);
|
||||
const ids = new Set(members.map(m => m.user_id));
|
||||
const owner = this.db.get<{ user_id: number }>('SELECT user_id FROM trips WHERE id = ?', tripId);
|
||||
if (owner) ids.add(owner.user_id);
|
||||
return ids;
|
||||
}
|
||||
|
||||
update(id: string, tripId: string, data: Parameters<typeof svc.updateReservation>[2], current: Parameters<typeof svc.updateReservation>[3]) {
|
||||
return svc.updateReservation(id, tripId, data, current);
|
||||
loadTravelersByTrip(tripId: string | number): Map<number, ReservationTraveler[]> {
|
||||
const rows = this.db.all<ReservationTraveler & { reservation_id: number }>(`
|
||||
SELECT rt.reservation_id, rt.user_id, COALESCE(u.display_name, u.username) AS username, u.avatar, u.is_guest
|
||||
FROM reservation_travelers rt
|
||||
JOIN reservations r ON rt.reservation_id = r.id
|
||||
JOIN users u ON rt.user_id = u.id
|
||||
WHERE r.trip_id = ?
|
||||
ORDER BY rt.reservation_id
|
||||
`, tripId);
|
||||
const map = new Map<number, ReservationTraveler[]>();
|
||||
for (const row of rows) {
|
||||
const list = map.get(row.reservation_id) ?? [];
|
||||
list.push(this.toTraveler(row));
|
||||
map.set(row.reservation_id, list);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
remove(id: string, tripId: string) {
|
||||
return svc.deleteReservation(id, tripId);
|
||||
loadTravelers(reservationId: number | string): ReservationTraveler[] {
|
||||
const rows = this.db.all<ReservationTraveler>(`
|
||||
SELECT rt.user_id, COALESCE(u.display_name, u.username) AS username, u.avatar, u.is_guest
|
||||
FROM reservation_travelers rt
|
||||
JOIN users u ON rt.user_id = u.id
|
||||
WHERE rt.reservation_id = ?
|
||||
`, reservationId);
|
||||
return rows.map(r => this.toTraveler(r));
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace a reservation's assigned travelers. Only real trip members/guests are
|
||||
* accepted — ids that aren't on the trip are silently dropped so a stale client
|
||||
* can't leak a cross-trip user. #1517.
|
||||
*/
|
||||
setReservationTravelers(reservationId: number | string, tripId: string | number, userIds: number[]): void {
|
||||
const allowed = this.assignableUserIds(tripId);
|
||||
const ids = [...new Set(userIds)].filter(uid => allowed.has(uid));
|
||||
this.db.run('DELETE FROM reservation_travelers WHERE reservation_id = ?', reservationId);
|
||||
if (ids.length > 0) {
|
||||
const insert = this.db.prepare('INSERT OR IGNORE INTO reservation_travelers (reservation_id, user_id) VALUES (?, ?)');
|
||||
for (const uid of ids) insert.run(reservationId, uid);
|
||||
}
|
||||
}
|
||||
|
||||
/** Assign trip members / named guests to a reservation (#1517). Null when off-trip. */
|
||||
setTravelers(id: string, tripId: string, userIds: number[]) {
|
||||
if (!svc.getReservation(id, tripId)) return null;
|
||||
svc.setReservationTravelers(id, tripId, userIds);
|
||||
return { travelers: svc.loadTravelers(id), reservation: svc.getReservationWithJoins(Number(id)) };
|
||||
if (!this.getReservation(id, tripId)) return null;
|
||||
this.setReservationTravelers(id, tripId, userIds);
|
||||
return { travelers: this.loadTravelers(id), reservation: this.getReservationWithJoins(Number(id)) };
|
||||
}
|
||||
|
||||
// Resolve the day row whose date matches the date portion of an ISO-ish
|
||||
// timestamp. Used to keep `day_id` / `end_day_id` in sync with
|
||||
// `reservation_time` / `reservation_end_time` so non-transport bookings
|
||||
// (tours, restaurants, events, ...) end up on the right day in the UI,
|
||||
// which now filters by day_id instead of reservation_time.
|
||||
private resolveDayIdFromTime(
|
||||
tripId: string | number,
|
||||
time: string | null | undefined,
|
||||
clampToNearest = true,
|
||||
): number | null {
|
||||
if (!time) return null;
|
||||
const datePart = time.slice(0, 10);
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(datePart)) return null;
|
||||
const exact = this.db.get<{ id: number }>('SELECT id FROM days WHERE trip_id = ? AND date = ? LIMIT 1', tripId, datePart);
|
||||
if (exact) return exact.id;
|
||||
// Fallback: clamp to the nearest day in the trip so an imported booking whose
|
||||
// exact date has no day row (or sits just outside the span) still lands on a day.
|
||||
// Skipped by callers (e.g. resyncReservationDays) that must leave a booking whose
|
||||
// date now falls outside the range untouched instead of snapping it to an edge day.
|
||||
if (!clampToNearest) return null;
|
||||
const nearest = this.db.get<{ id: number }>(
|
||||
'SELECT id FROM days WHERE trip_id = ? ORDER BY ABS(JULIANDAY(date) - JULIANDAY(?)) ASC, date ASC LIMIT 1',
|
||||
tripId, datePart
|
||||
);
|
||||
return nearest?.id ?? null;
|
||||
}
|
||||
|
||||
// After a trip's date range changes, generateDays positionally re-dates the day rows
|
||||
// (keeping their ids), so a dated booking's day_id stays glued to a now-re-dated day and
|
||||
// the booking visually shifts by the offset (#1288). Re-anchor non-hotel bookings to the
|
||||
// day matching their absolute reservation_time — the same derivation create/update
|
||||
// use. Only updates when a matching day exists, so a booking whose date now falls outside
|
||||
// the new range is left untouched. Hotels linked to a day_accommodation are excluded here —
|
||||
// resyncAccommodationDays re-anchors the accommodation span and its linked reservation;
|
||||
// unlinked dated hotels (e.g. imported ones) re-anchor like any other booking.
|
||||
resyncReservationDays(tripId: string | number): void {
|
||||
const rows = this.db.all<{
|
||||
id: number; reservation_time: string | null; reservation_end_time: string | null;
|
||||
day_id: number | null; end_day_id: number | null;
|
||||
}>(
|
||||
`SELECT id, reservation_time, reservation_end_time, day_id, end_day_id
|
||||
FROM reservations
|
||||
WHERE trip_id = ? AND (type != 'hotel' OR accommodation_id IS NULL) AND reservation_time IS NOT NULL`,
|
||||
tripId
|
||||
);
|
||||
const update = this.db.prepare('UPDATE reservations SET day_id = ?, end_day_id = ? WHERE id = ?');
|
||||
for (const r of rows) {
|
||||
const newDayId = this.resolveDayIdFromTime(tripId, r.reservation_time, false);
|
||||
if (newDayId == null) continue;
|
||||
const newEndDayId = r.reservation_end_time
|
||||
? (this.resolveDayIdFromTime(tripId, r.reservation_end_time, false) ?? r.end_day_id)
|
||||
: r.end_day_id;
|
||||
if (newDayId !== r.day_id || newEndDayId !== r.end_day_id) {
|
||||
update.run(newDayId, newEndDayId, r.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private saveEndpoints(reservationId: number, endpoints: EndpointInput[]): void {
|
||||
// Run the transaction through DatabaseService (which re-derives the
|
||||
// statement from the injected connection on each call). The bridge
|
||||
// instance is built over the reinitialize-proof `db` Proxy, so a
|
||||
// demo-reset / restore-from-backup that closes and reinitialises the
|
||||
// connection can't leave this holding a dead handle — the legacy module
|
||||
// bound its transaction lazily per call for the same reason ("The
|
||||
// database connection is not open").
|
||||
this.db.transaction(() => {
|
||||
this.db.run('DELETE FROM reservation_endpoints WHERE reservation_id = ?', reservationId);
|
||||
const insert = this.db.prepare(`
|
||||
INSERT INTO reservation_endpoints (reservation_id, role, sequence, name, code, lat, lng, timezone, local_time, local_date)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
// lat/lng are NOT NULL: an imported transport whose pick-up/return (or station/
|
||||
// stop) couldn't be geocoded reaches here with null coords. Skip those rows rather
|
||||
// than let the INSERT throw and fail the entire booking save — the dates still live
|
||||
// on reservation_time/reservation_end_time, so the booking lands on its day either way.
|
||||
endpoints
|
||||
.filter((e) => e.lat != null && e.lng != null)
|
||||
.forEach((e, i) => {
|
||||
insert.run(reservationId, e.role, e.sequence ?? i, e.name, e.code ?? null, e.lat, e.lng, e.timezone ?? null, e.local_time ?? null, e.local_date ?? null);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
list(tripId: string | number) {
|
||||
const reservations = this.db.all<ReservationRow>(`
|
||||
SELECT r.*, d.day_number, p.name as place_name, r.assignment_id,
|
||||
ap.place_id as accommodation_place_id, acc_p.name as accommodation_name,
|
||||
ap.start_day_id as accommodation_start_day_id, ap.end_day_id as accommodation_end_day_id
|
||||
FROM reservations r
|
||||
LEFT JOIN days d ON r.day_id = d.id
|
||||
LEFT JOIN places p ON r.place_id = p.id
|
||||
LEFT JOIN day_accommodations ap ON r.accommodation_id = ap.id
|
||||
LEFT JOIN places acc_p ON ap.place_id = acc_p.id
|
||||
WHERE r.trip_id = ?
|
||||
ORDER BY r.reservation_time ASC, r.created_at ASC
|
||||
`, tripId);
|
||||
|
||||
const dayPositions = this.db.all<{ reservation_id: number; day_id: number; position: number }>(`
|
||||
SELECT rdp.reservation_id, rdp.day_id, rdp.position
|
||||
FROM reservation_day_positions rdp
|
||||
JOIN reservations r ON rdp.reservation_id = r.id
|
||||
WHERE r.trip_id = ?
|
||||
`, tripId);
|
||||
|
||||
const posMap = new Map<number, Record<number, number>>();
|
||||
for (const dp of dayPositions) {
|
||||
if (!posMap.has(dp.reservation_id)) posMap.set(dp.reservation_id, {});
|
||||
posMap.get(dp.reservation_id)![dp.day_id] = dp.position;
|
||||
}
|
||||
|
||||
const endpointsMap = this.loadEndpointsByTrip(tripId);
|
||||
const travelersMap = this.loadTravelersByTrip(tripId);
|
||||
|
||||
for (const r of reservations) {
|
||||
r.day_positions = posMap.get(r.id) || null;
|
||||
r.endpoints = endpointsMap.get(r.id) || [];
|
||||
r.travelers = travelersMap.get(r.id) || [];
|
||||
// accommodation_id is a TEXT column; the integer FK reads back as a numeric
|
||||
// string (e.g. "14.0"). Normalize to an int so clients can parse it.
|
||||
r.accommodation_id = r.accommodation_id == null ? null : Math.trunc(Number(r.accommodation_id));
|
||||
}
|
||||
|
||||
return reservations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Upcoming reservations across all of a user's active trips, soonest first.
|
||||
* Used by the dashboard's "Upcoming reservations" widget. A reservation counts
|
||||
* as upcoming when its own time is in the future, or — for timeless entries —
|
||||
* when its day falls on or after today. Cancelled bookings are skipped.
|
||||
* The default limit (6) matches the legacy inline handler.
|
||||
*/
|
||||
listUpcoming(userId: number, limit = 6) {
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const now = new Date().toISOString();
|
||||
|
||||
const reservations = this.db.all<Record<string, unknown>>(`
|
||||
SELECT r.id, r.trip_id, r.title, r.type, r.status, r.location,
|
||||
r.reservation_time, r.confirmation_number,
|
||||
t.title as trip_title, t.cover_image as trip_cover,
|
||||
d.date as day_date, p.name as place_name, p.image_url as place_image
|
||||
FROM reservations r
|
||||
JOIN trips t ON t.id = r.trip_id
|
||||
LEFT JOIN trip_members tm ON tm.trip_id = t.id AND tm.user_id = ?
|
||||
LEFT JOIN days d ON r.day_id = d.id
|
||||
LEFT JOIN places p ON r.place_id = p.id
|
||||
WHERE (t.user_id = ? OR tm.user_id IS NOT NULL)
|
||||
AND t.is_archived = 0
|
||||
AND r.status != 'cancelled'
|
||||
AND (
|
||||
(r.reservation_time IS NOT NULL AND r.reservation_time >= ?)
|
||||
OR (r.reservation_time IS NULL AND d.date IS NOT NULL AND d.date >= ?)
|
||||
)
|
||||
ORDER BY COALESCE(r.reservation_time, d.date) ASC
|
||||
LIMIT ?
|
||||
`, userId, userId, now, today, limit);
|
||||
|
||||
return reservations;
|
||||
}
|
||||
|
||||
getReservationWithJoins(id: string | number) {
|
||||
const row = this.db.get<ReservationRow>(`
|
||||
SELECT r.*, d.day_number, p.name as place_name, r.assignment_id,
|
||||
ap.place_id as accommodation_place_id, acc_p.name as accommodation_name,
|
||||
ap.start_day_id as accommodation_start_day_id, ap.end_day_id as accommodation_end_day_id
|
||||
FROM reservations r
|
||||
LEFT JOIN days d ON r.day_id = d.id
|
||||
LEFT JOIN places p ON r.place_id = p.id
|
||||
LEFT JOIN day_accommodations ap ON r.accommodation_id = ap.id
|
||||
LEFT JOIN places acc_p ON ap.place_id = acc_p.id
|
||||
WHERE r.id = ?
|
||||
`, id);
|
||||
if (!row) return undefined;
|
||||
row.endpoints = this.loadEndpoints(row.id);
|
||||
row.travelers = this.loadTravelers(row.id);
|
||||
// accommodation_id is a TEXT column; the integer FK reads back as a numeric
|
||||
// string (e.g. "14.0"). Normalize to an int so clients can parse it.
|
||||
row.accommodation_id = row.accommodation_id == null ? null : Math.trunc(Number(row.accommodation_id));
|
||||
return row;
|
||||
}
|
||||
|
||||
create(tripId: string | number, data: CreateReservationData): { reservation: ReservationRow; accommodationCreated: boolean } {
|
||||
const {
|
||||
title, reservation_time, reservation_end_time, location,
|
||||
confirmation_number, notes, url, day_id, end_day_id, place_id, assignment_id,
|
||||
status, type, accommodation_id, metadata, create_accommodation,
|
||||
endpoints, needs_review
|
||||
} = data;
|
||||
|
||||
let accommodationCreated = false;
|
||||
|
||||
// Auto-create accommodation for hotel reservations
|
||||
let resolvedAccommodationId: number | null = accommodation_id || null;
|
||||
if (type === 'hotel' && !resolvedAccommodationId && create_accommodation) {
|
||||
const { place_id: accPlaceId, start_day_id, end_day_id, check_in, check_out, confirmation: accConf } = create_accommodation;
|
||||
if (start_day_id && end_day_id) {
|
||||
const accResult = this.db.run(
|
||||
'INSERT INTO day_accommodations (trip_id, place_id, start_day_id, end_day_id, check_in, check_out, confirmation) VALUES (?, ?, ?, ?, ?, ?, ?)',
|
||||
tripId, accPlaceId || null, start_day_id, end_day_id, check_in || null, check_out || null, accConf || confirmation_number || null
|
||||
);
|
||||
resolvedAccommodationId = Number(accResult.lastInsertRowid);
|
||||
accommodationCreated = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Derive day_id / end_day_id from reservation_time when the client
|
||||
// didn't explicitly set them (non-hotel bookings only — hotels store
|
||||
// their date range on the linked day_accommodation).
|
||||
const resolvedType = type || 'other';
|
||||
let resolvedDayId: number | null = day_id ?? null;
|
||||
if (resolvedDayId == null && resolvedType !== 'hotel' && reservation_time) {
|
||||
resolvedDayId = this.resolveDayIdFromTime(tripId, reservation_time);
|
||||
}
|
||||
let resolvedEndDayId: number | null = end_day_id ?? null;
|
||||
if (resolvedEndDayId == null && resolvedType !== 'hotel' && reservation_end_time) {
|
||||
resolvedEndDayId = this.resolveDayIdFromTime(tripId, reservation_end_time);
|
||||
}
|
||||
|
||||
const result = this.db.run(`
|
||||
INSERT INTO reservations (trip_id, day_id, end_day_id, place_id, assignment_id, title, reservation_time, reservation_end_time, location, confirmation_number, notes, url, status, type, accommodation_id, metadata, needs_review)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
tripId,
|
||||
resolvedDayId,
|
||||
resolvedEndDayId,
|
||||
place_id || null,
|
||||
assignment_id || null,
|
||||
title,
|
||||
reservation_time || null,
|
||||
reservation_end_time || null,
|
||||
location || null,
|
||||
confirmation_number || null,
|
||||
notes || null,
|
||||
url || null,
|
||||
status || 'pending',
|
||||
resolvedType,
|
||||
resolvedAccommodationId,
|
||||
metadata ? JSON.stringify(metadata) : null,
|
||||
needs_review ? 1 : 0
|
||||
);
|
||||
|
||||
if (endpoints && endpoints.length > 0) {
|
||||
this.saveEndpoints(Number(result.lastInsertRowid), endpoints);
|
||||
}
|
||||
|
||||
// Sync check-in/out to accommodation if linked
|
||||
if (accommodation_id && metadata) {
|
||||
const meta = (typeof metadata === 'string' ? JSON.parse(metadata) : metadata) as AccommodationTimesMeta;
|
||||
if (meta.check_in_time || meta.check_in_end_time || meta.check_out_time) {
|
||||
this.db.run(
|
||||
'UPDATE day_accommodations SET check_in = COALESCE(?, check_in), check_in_end = COALESCE(?, check_in_end), check_out = COALESCE(?, check_out) WHERE id = ?',
|
||||
meta.check_in_time || null, meta.check_in_end_time || null, meta.check_out_time || null, accommodation_id
|
||||
);
|
||||
}
|
||||
if (confirmation_number) {
|
||||
this.db.run(
|
||||
'UPDATE day_accommodations SET confirmation = COALESCE(?, confirmation) WHERE id = ?',
|
||||
confirmation_number, accommodation_id
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// The row was just inserted, so the re-select can't miss (legacy typed this any).
|
||||
const reservation = this.getReservationWithJoins(Number(result.lastInsertRowid))!;
|
||||
return { reservation, accommodationCreated };
|
||||
}
|
||||
|
||||
updatePositions(tripId: string | number, positions: { id: number; day_plan_position: number }[], dayId?: number | string | null) {
|
||||
if (dayId) {
|
||||
// Per-day positions for multi-day reservations
|
||||
const stmt = this.db.prepare('INSERT OR REPLACE INTO reservation_day_positions (reservation_id, day_id, position) VALUES (?, ?, ?)');
|
||||
this.db.transaction(() => {
|
||||
for (const item of positions) {
|
||||
stmt.run(item.id, dayId, item.day_plan_position);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// Legacy: update global position
|
||||
const stmt = this.db.prepare('UPDATE reservations SET day_plan_position = ? WHERE id = ? AND trip_id = ?');
|
||||
this.db.transaction(() => {
|
||||
for (const item of positions) {
|
||||
stmt.run(item.day_plan_position, item.id, tripId);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
getReservation(id: string | number, tripId: string | number) {
|
||||
return this.db.get<Reservation>('SELECT * FROM reservations WHERE id = ? AND trip_id = ?', id, tripId);
|
||||
}
|
||||
|
||||
update(id: string | number, tripId: string | number, data: UpdateReservationData, current: Reservation): { reservation: ReservationRow; accommodationChanged: boolean } {
|
||||
const {
|
||||
title, reservation_time, reservation_end_time, location,
|
||||
confirmation_number, notes, url, day_id, end_day_id, place_id, assignment_id,
|
||||
status, type, accommodation_id, metadata, create_accommodation,
|
||||
endpoints, needs_review
|
||||
} = data;
|
||||
|
||||
let accommodationChanged = false;
|
||||
|
||||
// Update or create accommodation for hotel reservations
|
||||
let resolvedAccId: number | null = accommodation_id !== undefined ? (accommodation_id || null) : (current.accommodation_id ?? null);
|
||||
if (resolvedAccId) {
|
||||
const accExists = this.db.get('SELECT id FROM day_accommodations WHERE id = ?', resolvedAccId);
|
||||
if (!accExists) resolvedAccId = null;
|
||||
}
|
||||
if (type === 'hotel' && create_accommodation) {
|
||||
const { place_id: accPlaceId, start_day_id, end_day_id, check_in, check_out, confirmation: accConf } = create_accommodation;
|
||||
if (start_day_id && end_day_id) {
|
||||
if (resolvedAccId) {
|
||||
this.db.run(
|
||||
'UPDATE day_accommodations SET place_id = ?, start_day_id = ?, end_day_id = ?, check_in = ?, check_out = ?, confirmation = ? WHERE id = ?',
|
||||
accPlaceId || null, start_day_id, end_day_id, check_in || null, check_out || null, accConf || confirmation_number || null, resolvedAccId
|
||||
);
|
||||
} else if (accPlaceId) {
|
||||
const accResult = this.db.run(
|
||||
'INSERT INTO day_accommodations (trip_id, place_id, start_day_id, end_day_id, check_in, check_out, confirmation) VALUES (?, ?, ?, ?, ?, ?, ?)',
|
||||
tripId, accPlaceId, start_day_id, end_day_id, check_in || null, check_out || null, accConf || confirmation_number || null
|
||||
);
|
||||
resolvedAccId = Number(accResult.lastInsertRowid);
|
||||
}
|
||||
accommodationChanged = true;
|
||||
}
|
||||
}
|
||||
|
||||
const resolvedType = (type ?? current.type) || 'other';
|
||||
const nextReservationTime = resolvedType === 'hotel'
|
||||
? null
|
||||
: (reservation_time !== undefined ? (reservation_time || null) : current.reservation_time);
|
||||
const nextReservationEndTime = resolvedType === 'hotel'
|
||||
? null
|
||||
: (reservation_end_time !== undefined ? (reservation_end_time || null) : current.reservation_end_time);
|
||||
|
||||
// day_id / end_day_id: honour an explicit value from the client,
|
||||
// otherwise derive from the (possibly updated) reservation_time so the
|
||||
// planner renders the booking on the correct day.
|
||||
let nextDayId: number | null;
|
||||
if (day_id != null) {
|
||||
// Explicit day from the client (e.g. moved on the planner).
|
||||
nextDayId = day_id;
|
||||
} else if (resolvedType !== 'hotel' && nextReservationTime) {
|
||||
// No day set but we have a date — pin it to the matching day so the booking
|
||||
// still shows in the Plan (covers bookings saved without a selected day, and
|
||||
// the case where an earlier edit cleared day_id).
|
||||
nextDayId = this.resolveDayIdFromTime(tripId, nextReservationTime);
|
||||
} else if (day_id === undefined) {
|
||||
// Field absent and nothing to derive from — keep whatever it had.
|
||||
nextDayId = current.day_id ?? null;
|
||||
} else {
|
||||
nextDayId = null;
|
||||
}
|
||||
|
||||
let nextEndDayId: number | null;
|
||||
if (end_day_id !== undefined) {
|
||||
nextEndDayId = end_day_id ?? null;
|
||||
} else if (reservation_end_time !== undefined && resolvedType !== 'hotel') {
|
||||
nextEndDayId = this.resolveDayIdFromTime(tripId, nextReservationEndTime);
|
||||
} else {
|
||||
nextEndDayId = current.end_day_id ?? null;
|
||||
}
|
||||
|
||||
this.db.run(`
|
||||
UPDATE reservations SET
|
||||
title = COALESCE(?, title),
|
||||
reservation_time = ?,
|
||||
reservation_end_time = ?,
|
||||
location = ?,
|
||||
confirmation_number = ?,
|
||||
notes = ?,
|
||||
url = ?,
|
||||
day_id = ?,
|
||||
end_day_id = ?,
|
||||
place_id = ?,
|
||||
assignment_id = ?,
|
||||
status = COALESCE(?, status),
|
||||
type = COALESCE(?, type),
|
||||
accommodation_id = ?,
|
||||
metadata = ?,
|
||||
needs_review = COALESCE(?, needs_review)
|
||||
WHERE id = ?
|
||||
`,
|
||||
title || null,
|
||||
nextReservationTime,
|
||||
nextReservationEndTime,
|
||||
location !== undefined ? (location || null) : current.location,
|
||||
confirmation_number !== undefined ? (confirmation_number || null) : current.confirmation_number,
|
||||
notes !== undefined ? (notes || null) : current.notes,
|
||||
url !== undefined ? (url || null) : (current as Reservation & { url?: string | null }).url,
|
||||
nextDayId,
|
||||
nextEndDayId,
|
||||
place_id !== undefined ? (place_id || null) : current.place_id,
|
||||
assignment_id !== undefined ? (assignment_id || null) : current.assignment_id,
|
||||
status || null,
|
||||
type || null,
|
||||
resolvedAccId,
|
||||
metadata !== undefined ? (metadata ? JSON.stringify(metadata) : null) : current.metadata,
|
||||
needs_review === undefined ? null : (needs_review ? 1 : 0),
|
||||
id
|
||||
);
|
||||
|
||||
if (endpoints !== undefined) {
|
||||
this.saveEndpoints(Number(id), endpoints);
|
||||
}
|
||||
|
||||
// Sync check-in/out to accommodation if linked
|
||||
const resolvedMeta = metadata !== undefined ? metadata : (current.metadata ? JSON.parse(current.metadata as string) : null);
|
||||
if (resolvedAccId && resolvedMeta) {
|
||||
const meta = (typeof resolvedMeta === 'string' ? JSON.parse(resolvedMeta) : resolvedMeta) as AccommodationTimesMeta;
|
||||
if (meta.check_in_time || meta.check_in_end_time || meta.check_out_time) {
|
||||
this.db.run(
|
||||
'UPDATE day_accommodations SET check_in = COALESCE(?, check_in), check_in_end = COALESCE(?, check_in_end), check_out = COALESCE(?, check_out) WHERE id = ?',
|
||||
meta.check_in_time || null, meta.check_in_end_time || null, meta.check_out_time || null, resolvedAccId
|
||||
);
|
||||
}
|
||||
const resolvedConf = confirmation_number !== undefined ? confirmation_number : current.confirmation_number;
|
||||
if (resolvedConf) {
|
||||
this.db.run(
|
||||
'UPDATE day_accommodations SET confirmation = COALESCE(?, confirmation) WHERE id = ?',
|
||||
resolvedConf, resolvedAccId
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// The caller passed the pre-checked `current` row, so the re-select can't
|
||||
// miss (legacy typed this any).
|
||||
const reservation = this.getReservationWithJoins(id)!;
|
||||
return { reservation, accommodationChanged };
|
||||
}
|
||||
|
||||
remove(id: string | number, tripId: string | number): { deleted: { id: number; title: string; type: string; accommodation_id: number | null } | undefined; accommodationDeleted: boolean; deletedBudgetItemId: number | null } {
|
||||
const reservation = this.db.get<{ id: number; title: string; type: string; accommodation_id: number | null }>(
|
||||
'SELECT id, title, type, accommodation_id FROM reservations WHERE id = ? AND trip_id = ?', id, tripId
|
||||
);
|
||||
if (!reservation) return { deleted: undefined, accommodationDeleted: false, deletedBudgetItemId: null };
|
||||
|
||||
let accommodationDeleted = false;
|
||||
if (reservation.accommodation_id) {
|
||||
this.db.run('DELETE FROM day_accommodations WHERE id = ?', reservation.accommodation_id);
|
||||
accommodationDeleted = true;
|
||||
}
|
||||
|
||||
const linkedBudget = this.db.get<{ id: number }>('SELECT id FROM budget_items WHERE trip_id = ? AND reservation_id = ?', tripId, id);
|
||||
if (linkedBudget) {
|
||||
this.db.run('DELETE FROM budget_items WHERE id = ?', linkedBudget.id);
|
||||
}
|
||||
|
||||
this.db.run('DELETE FROM reservations WHERE id = ?', id);
|
||||
return { deleted: reservation, accommodationDeleted, deletedBudgetItemId: linkedBudget ? linkedBudget.id : null };
|
||||
}
|
||||
|
||||
/** POST side effect: auto-create a linked budget item when a price is provided. */
|
||||
@@ -95,7 +727,7 @@ export class ReservationsService {
|
||||
// but only if it still carries the auto-derived category (so a manual pick in
|
||||
// the Costs editor is preserved). Runs regardless of create_budget_entry.
|
||||
if (type && currentType && type !== currentType) {
|
||||
const linked = this.db.prepare('SELECT id, category FROM budget_items WHERE trip_id = ? AND reservation_id = ?').get(tripId, id) as { id: number; category: string } | undefined;
|
||||
const linked = this.db.get<{ id: number; category: string }>('SELECT id, category FROM budget_items WHERE trip_id = ? AND reservation_id = ?', tripId, id);
|
||||
if (linked) {
|
||||
const oldCat = typeToCostCategory(currentType);
|
||||
const newCat = typeToCostCategory(type);
|
||||
@@ -113,7 +745,7 @@ export class ReservationsService {
|
||||
|
||||
if (!(Number(entry.total_price) > 0)) {
|
||||
// Explicit clear (total_price 0/empty) — drop the linked item.
|
||||
const linked = this.db.prepare('SELECT id FROM budget_items WHERE trip_id = ? AND reservation_id = ?').get(tripId, id) as { id: number } | undefined;
|
||||
const linked = this.db.get<{ id: number }>('SELECT id FROM budget_items WHERE trip_id = ? AND reservation_id = ?', tripId, id);
|
||||
if (linked) {
|
||||
deleteBudgetItem(linked.id, tripId);
|
||||
broadcast(tripId, 'budget:deleted', { itemId: linked.id }, socketId);
|
||||
@@ -124,13 +756,13 @@ export class ReservationsService {
|
||||
try {
|
||||
const itemName = title || currentTitle;
|
||||
const category = entry.category || type || currentType || 'Other';
|
||||
const existing = this.db.prepare('SELECT id FROM budget_items WHERE trip_id = ? AND reservation_id = ?').get(tripId, id) as { id: number } | undefined;
|
||||
const existing = this.db.get<{ id: number }>('SELECT id FROM budget_items WHERE trip_id = ? AND reservation_id = ?', tripId, id);
|
||||
if (existing) {
|
||||
const updated = updateBudgetItem(existing.id, tripId, { name: itemName, category, total_price: entry.total_price });
|
||||
broadcast(tripId, 'budget:updated', { item: updated }, socketId);
|
||||
} else {
|
||||
const item = createBudgetItem(tripId, { name: itemName, category, total_price: entry.total_price });
|
||||
this.db.prepare('UPDATE budget_items SET reservation_id = ? WHERE id = ?').run(id, item.id);
|
||||
this.db.run('UPDATE budget_items SET reservation_id = ? WHERE id = ?', id, item.id);
|
||||
item.reservation_id = Number(id);
|
||||
broadcast(tripId, 'budget:created', { item }, socketId);
|
||||
}
|
||||
@@ -138,9 +770,4 @@ export class ReservationsService {
|
||||
console.error('[reservations] Failed to create/update budget entry:', err);
|
||||
}
|
||||
}
|
||||
|
||||
/** Fire-and-forget booking-change notification, mirroring the legacy dynamic import. */
|
||||
notifyBookingChange(tripId: string, actor: User, booking: string, type: string): void {
|
||||
svc.notifyBookingChange(tripId, actor.id, booking, type);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { FilesModule } from '../files/files.module';
|
||||
import { PackingModule } from '../packing/packing.module';
|
||||
import { ReservationsModule } from '../reservations/reservations.module';
|
||||
import { TodoModule } from '../todo/todo.module';
|
||||
import { TripsController } from './trips.controller';
|
||||
import { TripsService } from './trips.service';
|
||||
@@ -8,7 +9,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],
|
||||
imports: [TodoModule, PackingModule, FilesModule, ReservationsModule],
|
||||
controllers: [TripsController],
|
||||
providers: [TripsService],
|
||||
})
|
||||
|
||||
@@ -9,7 +9,7 @@ import { listPlaces } from '../../services/placeService';
|
||||
import { PackingService } from '../packing/packing.service';
|
||||
import { TodoService } from '../todo/todo.service';
|
||||
import { listBudgetItems, rebaseTripCurrency } from '../../services/budgetService';
|
||||
import { listReservations } from '../../services/reservationService';
|
||||
import { ReservationsService } from '../reservations/reservations.service';
|
||||
import { FilesService } from '../files/files.service';
|
||||
import { searchUnsplashPhotos, getUnsplashKey } from '../../services/unsplashService';
|
||||
|
||||
@@ -27,6 +27,7 @@ export class TripsService {
|
||||
private readonly todo: TodoService,
|
||||
private readonly packing: PackingService,
|
||||
private readonly files: FilesService,
|
||||
private readonly reservations: ReservationsService,
|
||||
) {}
|
||||
|
||||
private get db() {
|
||||
@@ -143,7 +144,7 @@ export class TripsService {
|
||||
packingItems: this.packing.listItems(tripId, viewerId),
|
||||
todoItems: this.todo.listItems(tripId),
|
||||
budgetItems: listBudgetItems(tripId),
|
||||
reservations: listReservations(tripId),
|
||||
reservations: this.reservations.list(tripId),
|
||||
files: this.files.listFiles(tripId, false),
|
||||
accommodations: listAccommodations(tripId),
|
||||
members: [owner, ...(members || [])].filter(Boolean),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { AirtrailImportResult } from '@trek/shared';
|
||||
import { db } from '../../db/database';
|
||||
import { broadcast } from '../../websocket';
|
||||
import { createReservation } from '../reservationService';
|
||||
import { createReservation } from '../../nest/reservations/reservations.bridge';
|
||||
import { getAirtrailCredentials } from './airtrailService';
|
||||
import { AirtrailRequestError, listFlights } from './airtrailClient';
|
||||
import type { AirtrailFlightRaw } from './airtrailClient';
|
||||
|
||||
@@ -3,7 +3,7 @@ import { db } from '../../db/database';
|
||||
import { broadcast } from '../../websocket';
|
||||
import { isAddonEnabled } from '../adminService';
|
||||
import { logError, logInfo } from '../auditLog';
|
||||
import { getReservation, getReservationWithJoins, updateReservation } from '../reservationService';
|
||||
import { getReservation, getReservationWithJoins, updateReservation } from '../../nest/reservations/reservations.bridge';
|
||||
import {
|
||||
AirtrailAuthError,
|
||||
AirtrailCreds,
|
||||
|
||||
@@ -1,626 +0,0 @@
|
||||
import { db } from '../db/database';
|
||||
import { Reservation } from '../types';
|
||||
import { avatarUrl } from './avatarUrl';
|
||||
|
||||
export { verifyTripAccess } from './tripAccess';
|
||||
|
||||
export interface ReservationEndpoint {
|
||||
id?: number;
|
||||
reservation_id?: number;
|
||||
role: 'from' | 'to' | 'stop';
|
||||
sequence: number;
|
||||
name: string;
|
||||
code: string | null;
|
||||
lat: number;
|
||||
lng: number;
|
||||
timezone: string | null;
|
||||
local_time: string | null;
|
||||
local_date: string | null;
|
||||
}
|
||||
|
||||
export type EndpointInput = Omit<ReservationEndpoint, 'id' | 'reservation_id' | 'sequence'> & { sequence?: number };
|
||||
|
||||
export function notifyBookingChange(
|
||||
tripId: string | number,
|
||||
actorId: number,
|
||||
booking: string,
|
||||
type: string,
|
||||
): void {
|
||||
import('./notificationService')
|
||||
.then(({ send }) => {
|
||||
try {
|
||||
const actor = db.prepare('SELECT email FROM users WHERE id = ?').get(actorId) as { email: string } | undefined;
|
||||
if (!actor) return;
|
||||
const trip = db.prepare('SELECT title FROM trips WHERE id = ?').get(tripId) as { title: string } | undefined;
|
||||
send({
|
||||
event: 'booking_change',
|
||||
actorId,
|
||||
scope: 'trip',
|
||||
targetId: Number(tripId),
|
||||
params: {
|
||||
trip: trip?.title || 'Untitled',
|
||||
actor: actor.email,
|
||||
booking,
|
||||
type: type || 'booking',
|
||||
tripId: String(tripId),
|
||||
},
|
||||
}).catch(() => {});
|
||||
} catch {
|
||||
// Notifications must never make the booking write fail.
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
export function loadEndpointsByTrip(tripId: string | number): Map<number, ReservationEndpoint[]> {
|
||||
const rows = db.prepare(`
|
||||
SELECT e.* FROM reservation_endpoints e
|
||||
JOIN reservations r ON e.reservation_id = r.id
|
||||
WHERE r.trip_id = ?
|
||||
ORDER BY e.reservation_id, e.sequence
|
||||
`).all(tripId) as ReservationEndpoint[];
|
||||
const map = new Map<number, ReservationEndpoint[]>();
|
||||
for (const r of rows) {
|
||||
const list = map.get(r.reservation_id!) ?? [];
|
||||
list.push(r);
|
||||
map.set(r.reservation_id!, list);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
function loadEndpoints(reservationId: number): ReservationEndpoint[] {
|
||||
return db.prepare(
|
||||
'SELECT * FROM reservation_endpoints WHERE reservation_id = ? ORDER BY sequence'
|
||||
).all(reservationId) as ReservationEndpoint[];
|
||||
}
|
||||
|
||||
// --- Travelers (#1517): trip members / named guests assigned to a booking -----
|
||||
|
||||
export interface ReservationTraveler {
|
||||
user_id: number;
|
||||
username: string;
|
||||
avatar: string | null;
|
||||
avatar_url?: string | null;
|
||||
is_guest?: number | null;
|
||||
}
|
||||
|
||||
const toTraveler = (r: { user_id: number; username: string; avatar: string | null; is_guest?: number | null }): ReservationTraveler =>
|
||||
({ user_id: r.user_id, username: r.username, avatar: r.avatar, is_guest: r.is_guest ?? null, avatar_url: avatarUrl(r) });
|
||||
|
||||
/** Users assignable on a trip: its members (guests included) plus the owner. */
|
||||
function assignableUserIds(tripId: string | number): Set<number> {
|
||||
const members = db.prepare('SELECT user_id FROM trip_members WHERE trip_id = ?').all(tripId) as { user_id: number }[];
|
||||
const ids = new Set(members.map(m => m.user_id));
|
||||
const owner = db.prepare('SELECT user_id FROM trips WHERE id = ?').get(tripId) as { user_id: number } | undefined;
|
||||
if (owner) ids.add(owner.user_id);
|
||||
return ids;
|
||||
}
|
||||
|
||||
export function loadTravelersByTrip(tripId: string | number): Map<number, ReservationTraveler[]> {
|
||||
const rows = db.prepare(`
|
||||
SELECT rt.reservation_id, rt.user_id, COALESCE(u.display_name, u.username) AS username, u.avatar, u.is_guest
|
||||
FROM reservation_travelers rt
|
||||
JOIN reservations r ON rt.reservation_id = r.id
|
||||
JOIN users u ON rt.user_id = u.id
|
||||
WHERE r.trip_id = ?
|
||||
ORDER BY rt.reservation_id
|
||||
`).all(tripId) as (ReservationTraveler & { reservation_id: number })[];
|
||||
const map = new Map<number, ReservationTraveler[]>();
|
||||
for (const row of rows) {
|
||||
const list = map.get(row.reservation_id) ?? [];
|
||||
list.push(toTraveler(row));
|
||||
map.set(row.reservation_id, list);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
export function loadTravelers(reservationId: number | string): ReservationTraveler[] {
|
||||
const rows = db.prepare(`
|
||||
SELECT rt.user_id, COALESCE(u.display_name, u.username) AS username, u.avatar, u.is_guest
|
||||
FROM reservation_travelers rt
|
||||
JOIN users u ON rt.user_id = u.id
|
||||
WHERE rt.reservation_id = ?
|
||||
`).all(reservationId) as ReservationTraveler[];
|
||||
return rows.map(toTraveler);
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace a reservation's assigned travelers. Only real trip members/guests are
|
||||
* accepted — ids that aren't on the trip are silently dropped so a stale client
|
||||
* can't leak a cross-trip user. #1517.
|
||||
*/
|
||||
export function setReservationTravelers(reservationId: number | string, tripId: string | number, userIds: number[]): void {
|
||||
const allowed = assignableUserIds(tripId);
|
||||
const ids = [...new Set(userIds)].filter(uid => allowed.has(uid));
|
||||
db.prepare('DELETE FROM reservation_travelers WHERE reservation_id = ?').run(reservationId);
|
||||
if (ids.length > 0) {
|
||||
const insert = db.prepare('INSERT OR IGNORE INTO reservation_travelers (reservation_id, user_id) VALUES (?, ?)');
|
||||
for (const uid of ids) insert.run(reservationId, uid);
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve the day row whose date matches the date portion of an ISO-ish
|
||||
// timestamp. Used to keep `day_id` / `end_day_id` in sync with
|
||||
// `reservation_time` / `reservation_end_time` so non-transport bookings
|
||||
// (tours, restaurants, events, ...) end up on the right day in the UI,
|
||||
// which now filters by day_id instead of reservation_time.
|
||||
function resolveDayIdFromTime(
|
||||
tripId: string | number,
|
||||
time: string | null | undefined,
|
||||
clampToNearest = true,
|
||||
): number | null {
|
||||
if (!time) return null;
|
||||
const datePart = time.slice(0, 10);
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(datePart)) return null;
|
||||
const exact = db
|
||||
.prepare('SELECT id FROM days WHERE trip_id = ? AND date = ? LIMIT 1')
|
||||
.get(tripId, datePart) as { id: number } | undefined;
|
||||
if (exact) return exact.id;
|
||||
// Fallback: clamp to the nearest day in the trip so an imported booking whose
|
||||
// exact date has no day row (or sits just outside the span) still lands on a day.
|
||||
// Skipped by callers (e.g. resyncReservationDays) that must leave a booking whose
|
||||
// date now falls outside the range untouched instead of snapping it to an edge day.
|
||||
if (!clampToNearest) return null;
|
||||
const nearest = db
|
||||
.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY ABS(JULIANDAY(date) - JULIANDAY(?)) ASC, date ASC LIMIT 1')
|
||||
.get(tripId, datePart) as { id: number } | undefined;
|
||||
return nearest?.id ?? null;
|
||||
}
|
||||
|
||||
// After a trip's date range changes, generateDays positionally re-dates the day rows
|
||||
// (keeping their ids), so a dated booking's day_id stays glued to a now-re-dated day and
|
||||
// the booking visually shifts by the offset (#1288). Re-anchor non-hotel bookings to the
|
||||
// day matching their absolute reservation_time — the same derivation create/updateReservation
|
||||
// use. Only updates when a matching day exists, so a booking whose date now falls outside
|
||||
// the new range is left untouched. Hotels linked to a day_accommodation are excluded here —
|
||||
// resyncAccommodationDays re-anchors the accommodation span and its linked reservation;
|
||||
// unlinked dated hotels (e.g. imported ones) re-anchor like any other booking.
|
||||
export function resyncReservationDays(tripId: string | number): void {
|
||||
const rows = db.prepare(
|
||||
`SELECT id, reservation_time, reservation_end_time, day_id, end_day_id
|
||||
FROM reservations
|
||||
WHERE trip_id = ? AND (type != 'hotel' OR accommodation_id IS NULL) AND reservation_time IS NOT NULL`,
|
||||
).all(tripId) as {
|
||||
id: number; reservation_time: string | null; reservation_end_time: string | null;
|
||||
day_id: number | null; end_day_id: number | null;
|
||||
}[];
|
||||
const update = db.prepare('UPDATE reservations SET day_id = ?, end_day_id = ? WHERE id = ?');
|
||||
for (const r of rows) {
|
||||
const newDayId = resolveDayIdFromTime(tripId, r.reservation_time, false);
|
||||
if (newDayId == null) continue;
|
||||
const newEndDayId = r.reservation_end_time
|
||||
? (resolveDayIdFromTime(tripId, r.reservation_end_time, false) ?? r.end_day_id)
|
||||
: r.end_day_id;
|
||||
if (newDayId !== r.day_id || newEndDayId !== r.end_day_id) {
|
||||
update.run(newDayId, newEndDayId, r.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function saveEndpoints(reservationId: number, endpoints: EndpointInput[]): void {
|
||||
// Bind the transaction lazily on each call. Binding at module load time
|
||||
// captures the DB connection that was open then, which becomes invalid
|
||||
// after demo-reset / restore-from-backup closes and reinitialises the
|
||||
// connection — every later endpoint save would throw
|
||||
// "The database connection is not open".
|
||||
const tx = db.transaction((rid: number, eps: EndpointInput[]) => {
|
||||
db.prepare('DELETE FROM reservation_endpoints WHERE reservation_id = ?').run(rid);
|
||||
const insert = db.prepare(`
|
||||
INSERT INTO reservation_endpoints (reservation_id, role, sequence, name, code, lat, lng, timezone, local_time, local_date)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
// lat/lng are NOT NULL: an imported transport whose pick-up/return (or station/
|
||||
// stop) couldn't be geocoded reaches here with null coords. Skip those rows rather
|
||||
// than let the INSERT throw and fail the entire booking save — the dates still live
|
||||
// on reservation_time/reservation_end_time, so the booking lands on its day either way.
|
||||
eps
|
||||
.filter((e) => e.lat != null && e.lng != null)
|
||||
.forEach((e, i) => {
|
||||
insert.run(rid, e.role, e.sequence ?? i, e.name, e.code ?? null, e.lat, e.lng, e.timezone ?? null, e.local_time ?? null, e.local_date ?? null);
|
||||
});
|
||||
});
|
||||
tx(reservationId, endpoints);
|
||||
}
|
||||
|
||||
export function listReservations(tripId: string | number) {
|
||||
const reservations = db.prepare(`
|
||||
SELECT r.*, d.day_number, p.name as place_name, r.assignment_id,
|
||||
ap.place_id as accommodation_place_id, acc_p.name as accommodation_name,
|
||||
ap.start_day_id as accommodation_start_day_id, ap.end_day_id as accommodation_end_day_id
|
||||
FROM reservations r
|
||||
LEFT JOIN days d ON r.day_id = d.id
|
||||
LEFT JOIN places p ON r.place_id = p.id
|
||||
LEFT JOIN day_accommodations ap ON r.accommodation_id = ap.id
|
||||
LEFT JOIN places acc_p ON ap.place_id = acc_p.id
|
||||
WHERE r.trip_id = ?
|
||||
ORDER BY r.reservation_time ASC, r.created_at ASC
|
||||
`).all(tripId) as any[];
|
||||
|
||||
const dayPositions = db.prepare(`
|
||||
SELECT rdp.reservation_id, rdp.day_id, rdp.position
|
||||
FROM reservation_day_positions rdp
|
||||
JOIN reservations r ON rdp.reservation_id = r.id
|
||||
WHERE r.trip_id = ?
|
||||
`).all(tripId) as { reservation_id: number; day_id: number; position: number }[];
|
||||
|
||||
const posMap = new Map<number, Record<number, number>>();
|
||||
for (const dp of dayPositions) {
|
||||
if (!posMap.has(dp.reservation_id)) posMap.set(dp.reservation_id, {});
|
||||
posMap.get(dp.reservation_id)![dp.day_id] = dp.position;
|
||||
}
|
||||
|
||||
const endpointsMap = loadEndpointsByTrip(tripId);
|
||||
const travelersMap = loadTravelersByTrip(tripId);
|
||||
|
||||
for (const r of reservations) {
|
||||
r.day_positions = posMap.get(r.id) || null;
|
||||
r.endpoints = endpointsMap.get(r.id) || [];
|
||||
r.travelers = travelersMap.get(r.id) || [];
|
||||
// accommodation_id is a TEXT column; the integer FK reads back as a numeric
|
||||
// string (e.g. "14.0"). Normalize to an int so clients can parse it.
|
||||
r.accommodation_id = r.accommodation_id == null ? null : Math.trunc(Number(r.accommodation_id));
|
||||
}
|
||||
|
||||
return reservations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Upcoming reservations across all of a user's active trips, soonest first.
|
||||
* Used by the dashboard's "Upcoming reservations" widget. A reservation counts
|
||||
* as upcoming when its own time is in the future, or — for timeless entries —
|
||||
* when its day falls on or after today. Cancelled bookings are skipped.
|
||||
*/
|
||||
export function getUpcomingReservations(userId: number, limit = 6) {
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const now = new Date().toISOString();
|
||||
|
||||
const reservations = db.prepare(`
|
||||
SELECT r.id, r.trip_id, r.title, r.type, r.status, r.location,
|
||||
r.reservation_time, r.confirmation_number,
|
||||
t.title as trip_title, t.cover_image as trip_cover,
|
||||
d.date as day_date, p.name as place_name, p.image_url as place_image
|
||||
FROM reservations r
|
||||
JOIN trips t ON t.id = r.trip_id
|
||||
LEFT JOIN trip_members tm ON tm.trip_id = t.id AND tm.user_id = ?
|
||||
LEFT JOIN days d ON r.day_id = d.id
|
||||
LEFT JOIN places p ON r.place_id = p.id
|
||||
WHERE (t.user_id = ? OR tm.user_id IS NOT NULL)
|
||||
AND t.is_archived = 0
|
||||
AND r.status != 'cancelled'
|
||||
AND (
|
||||
(r.reservation_time IS NOT NULL AND r.reservation_time >= ?)
|
||||
OR (r.reservation_time IS NULL AND d.date IS NOT NULL AND d.date >= ?)
|
||||
)
|
||||
ORDER BY COALESCE(r.reservation_time, d.date) ASC
|
||||
LIMIT ?
|
||||
`).all(userId, userId, now, today, limit) as any[];
|
||||
|
||||
return reservations;
|
||||
}
|
||||
|
||||
export function getReservationWithJoins(id: string | number) {
|
||||
const row = db.prepare(`
|
||||
SELECT r.*, d.day_number, p.name as place_name, r.assignment_id,
|
||||
ap.place_id as accommodation_place_id, acc_p.name as accommodation_name,
|
||||
ap.start_day_id as accommodation_start_day_id, ap.end_day_id as accommodation_end_day_id
|
||||
FROM reservations r
|
||||
LEFT JOIN days d ON r.day_id = d.id
|
||||
LEFT JOIN places p ON r.place_id = p.id
|
||||
LEFT JOIN day_accommodations ap ON r.accommodation_id = ap.id
|
||||
LEFT JOIN places acc_p ON ap.place_id = acc_p.id
|
||||
WHERE r.id = ?
|
||||
`).get(id) as any;
|
||||
if (!row) return undefined;
|
||||
row.endpoints = loadEndpoints(row.id);
|
||||
row.travelers = loadTravelers(row.id);
|
||||
// accommodation_id is a TEXT column; the integer FK reads back as a numeric
|
||||
// string (e.g. "14.0"). Normalize to an int so clients can parse it.
|
||||
row.accommodation_id = row.accommodation_id == null ? null : Math.trunc(Number(row.accommodation_id));
|
||||
return row;
|
||||
}
|
||||
|
||||
interface CreateAccommodation {
|
||||
place_id?: number;
|
||||
start_day_id?: number;
|
||||
end_day_id?: number;
|
||||
check_in?: string;
|
||||
check_out?: string;
|
||||
confirmation?: string;
|
||||
}
|
||||
|
||||
interface CreateReservationData {
|
||||
title: string;
|
||||
reservation_time?: string;
|
||||
reservation_end_time?: string;
|
||||
location?: string;
|
||||
confirmation_number?: string;
|
||||
notes?: string;
|
||||
url?: string;
|
||||
day_id?: number;
|
||||
end_day_id?: number;
|
||||
place_id?: number;
|
||||
assignment_id?: number;
|
||||
status?: string;
|
||||
type?: string;
|
||||
accommodation_id?: number;
|
||||
metadata?: any;
|
||||
create_accommodation?: CreateAccommodation;
|
||||
endpoints?: EndpointInput[];
|
||||
needs_review?: boolean;
|
||||
}
|
||||
|
||||
export function createReservation(tripId: string | number, data: CreateReservationData): { reservation: any; accommodationCreated: boolean } {
|
||||
const {
|
||||
title, reservation_time, reservation_end_time, location,
|
||||
confirmation_number, notes, url, day_id, end_day_id, place_id, assignment_id,
|
||||
status, type, accommodation_id, metadata, create_accommodation,
|
||||
endpoints, needs_review
|
||||
} = data;
|
||||
|
||||
let accommodationCreated = false;
|
||||
|
||||
// Auto-create accommodation for hotel reservations
|
||||
let resolvedAccommodationId: number | null = accommodation_id || null;
|
||||
if (type === 'hotel' && !resolvedAccommodationId && create_accommodation) {
|
||||
const { place_id: accPlaceId, start_day_id, end_day_id, check_in, check_out, confirmation: accConf } = create_accommodation;
|
||||
if (start_day_id && end_day_id) {
|
||||
const accResult = db.prepare(
|
||||
'INSERT INTO day_accommodations (trip_id, place_id, start_day_id, end_day_id, check_in, check_out, confirmation) VALUES (?, ?, ?, ?, ?, ?, ?)'
|
||||
).run(tripId, accPlaceId || null, start_day_id, end_day_id, check_in || null, check_out || null, accConf || confirmation_number || null);
|
||||
resolvedAccommodationId = Number(accResult.lastInsertRowid);
|
||||
accommodationCreated = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Derive day_id / end_day_id from reservation_time when the client
|
||||
// didn't explicitly set them (non-hotel bookings only — hotels store
|
||||
// their date range on the linked day_accommodation).
|
||||
const resolvedType = type || 'other';
|
||||
let resolvedDayId: number | null = day_id ?? null;
|
||||
if (resolvedDayId == null && resolvedType !== 'hotel' && reservation_time) {
|
||||
resolvedDayId = resolveDayIdFromTime(tripId, reservation_time);
|
||||
}
|
||||
let resolvedEndDayId: number | null = end_day_id ?? null;
|
||||
if (resolvedEndDayId == null && resolvedType !== 'hotel' && reservation_end_time) {
|
||||
resolvedEndDayId = resolveDayIdFromTime(tripId, reservation_end_time);
|
||||
}
|
||||
|
||||
const result = db.prepare(`
|
||||
INSERT INTO reservations (trip_id, day_id, end_day_id, place_id, assignment_id, title, reservation_time, reservation_end_time, location, confirmation_number, notes, url, status, type, accommodation_id, metadata, needs_review)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
tripId,
|
||||
resolvedDayId,
|
||||
resolvedEndDayId,
|
||||
place_id || null,
|
||||
assignment_id || null,
|
||||
title,
|
||||
reservation_time || null,
|
||||
reservation_end_time || null,
|
||||
location || null,
|
||||
confirmation_number || null,
|
||||
notes || null,
|
||||
url || null,
|
||||
status || 'pending',
|
||||
resolvedType,
|
||||
resolvedAccommodationId,
|
||||
metadata ? JSON.stringify(metadata) : null,
|
||||
needs_review ? 1 : 0
|
||||
);
|
||||
|
||||
if (endpoints && endpoints.length > 0) {
|
||||
saveEndpoints(Number(result.lastInsertRowid), endpoints);
|
||||
}
|
||||
|
||||
// Sync check-in/out to accommodation if linked
|
||||
if (accommodation_id && metadata) {
|
||||
const meta = typeof metadata === 'string' ? JSON.parse(metadata) : metadata;
|
||||
if (meta.check_in_time || meta.check_in_end_time || meta.check_out_time) {
|
||||
db.prepare('UPDATE day_accommodations SET check_in = COALESCE(?, check_in), check_in_end = COALESCE(?, check_in_end), check_out = COALESCE(?, check_out) WHERE id = ?')
|
||||
.run(meta.check_in_time || null, meta.check_in_end_time || null, meta.check_out_time || null, accommodation_id);
|
||||
}
|
||||
if (confirmation_number) {
|
||||
db.prepare('UPDATE day_accommodations SET confirmation = COALESCE(?, confirmation) WHERE id = ?')
|
||||
.run(confirmation_number, accommodation_id);
|
||||
}
|
||||
}
|
||||
|
||||
const reservation = getReservationWithJoins(Number(result.lastInsertRowid));
|
||||
return { reservation, accommodationCreated };
|
||||
}
|
||||
|
||||
export function updatePositions(tripId: string | number, positions: { id: number; day_plan_position: number }[], dayId?: number | string) {
|
||||
if (dayId) {
|
||||
// Per-day positions for multi-day reservations
|
||||
const stmt = db.prepare('INSERT OR REPLACE INTO reservation_day_positions (reservation_id, day_id, position) VALUES (?, ?, ?)');
|
||||
const updateMany = db.transaction((items: { id: number; day_plan_position: number }[]) => {
|
||||
for (const item of items) {
|
||||
stmt.run(item.id, dayId, item.day_plan_position);
|
||||
}
|
||||
});
|
||||
updateMany(positions);
|
||||
} else {
|
||||
// Legacy: update global position
|
||||
const stmt = db.prepare('UPDATE reservations SET day_plan_position = ? WHERE id = ? AND trip_id = ?');
|
||||
const updateMany = db.transaction((items: { id: number; day_plan_position: number }[]) => {
|
||||
for (const item of items) {
|
||||
stmt.run(item.day_plan_position, item.id, tripId);
|
||||
}
|
||||
});
|
||||
updateMany(positions);
|
||||
}
|
||||
}
|
||||
|
||||
export function getReservation(id: string | number, tripId: string | number) {
|
||||
return db.prepare('SELECT * FROM reservations WHERE id = ? AND trip_id = ?').get(id, tripId) as Reservation | undefined;
|
||||
}
|
||||
|
||||
interface UpdateReservationData {
|
||||
title?: string;
|
||||
reservation_time?: string;
|
||||
reservation_end_time?: string;
|
||||
location?: string;
|
||||
confirmation_number?: string;
|
||||
notes?: string;
|
||||
url?: string;
|
||||
day_id?: number;
|
||||
end_day_id?: number | null;
|
||||
place_id?: number;
|
||||
assignment_id?: number;
|
||||
status?: string;
|
||||
type?: string;
|
||||
accommodation_id?: number;
|
||||
metadata?: any;
|
||||
create_accommodation?: CreateAccommodation;
|
||||
endpoints?: EndpointInput[];
|
||||
needs_review?: boolean;
|
||||
}
|
||||
|
||||
export function updateReservation(id: string | number, tripId: string | number, data: UpdateReservationData, current: Reservation): { reservation: any; accommodationChanged: boolean } {
|
||||
const {
|
||||
title, reservation_time, reservation_end_time, location,
|
||||
confirmation_number, notes, url, day_id, end_day_id, place_id, assignment_id,
|
||||
status, type, accommodation_id, metadata, create_accommodation,
|
||||
endpoints, needs_review
|
||||
} = data;
|
||||
|
||||
let accommodationChanged = false;
|
||||
|
||||
// Update or create accommodation for hotel reservations
|
||||
let resolvedAccId: number | null = accommodation_id !== undefined ? (accommodation_id || null) : (current.accommodation_id ?? null);
|
||||
if (resolvedAccId) {
|
||||
const accExists = db.prepare('SELECT id FROM day_accommodations WHERE id = ?').get(resolvedAccId);
|
||||
if (!accExists) resolvedAccId = null;
|
||||
}
|
||||
if (type === 'hotel' && create_accommodation) {
|
||||
const { place_id: accPlaceId, start_day_id, end_day_id, check_in, check_out, confirmation: accConf } = create_accommodation;
|
||||
if (start_day_id && end_day_id) {
|
||||
if (resolvedAccId) {
|
||||
db.prepare('UPDATE day_accommodations SET place_id = ?, start_day_id = ?, end_day_id = ?, check_in = ?, check_out = ?, confirmation = ? WHERE id = ?')
|
||||
.run(accPlaceId || null, start_day_id, end_day_id, check_in || null, check_out || null, accConf || confirmation_number || null, resolvedAccId);
|
||||
} else if (accPlaceId) {
|
||||
const accResult = db.prepare(
|
||||
'INSERT INTO day_accommodations (trip_id, place_id, start_day_id, end_day_id, check_in, check_out, confirmation) VALUES (?, ?, ?, ?, ?, ?, ?)'
|
||||
).run(tripId, accPlaceId, start_day_id, end_day_id, check_in || null, check_out || null, accConf || confirmation_number || null);
|
||||
resolvedAccId = Number(accResult.lastInsertRowid);
|
||||
}
|
||||
accommodationChanged = true;
|
||||
}
|
||||
}
|
||||
|
||||
const resolvedType = (type ?? current.type) || 'other';
|
||||
const nextReservationTime = resolvedType === 'hotel'
|
||||
? null
|
||||
: (reservation_time !== undefined ? (reservation_time || null) : current.reservation_time);
|
||||
const nextReservationEndTime = resolvedType === 'hotel'
|
||||
? null
|
||||
: (reservation_end_time !== undefined ? (reservation_end_time || null) : current.reservation_end_time);
|
||||
|
||||
// day_id / end_day_id: honour an explicit value from the client,
|
||||
// otherwise derive from the (possibly updated) reservation_time so the
|
||||
// planner renders the booking on the correct day.
|
||||
let nextDayId: number | null;
|
||||
if (day_id != null) {
|
||||
// Explicit day from the client (e.g. moved on the planner).
|
||||
nextDayId = day_id;
|
||||
} else if (resolvedType !== 'hotel' && nextReservationTime) {
|
||||
// No day set but we have a date — pin it to the matching day so the booking
|
||||
// still shows in the Plan (covers bookings saved without a selected day, and
|
||||
// the case where an earlier edit cleared day_id).
|
||||
nextDayId = resolveDayIdFromTime(tripId, nextReservationTime);
|
||||
} else if (day_id === undefined) {
|
||||
// Field absent and nothing to derive from — keep whatever it had.
|
||||
nextDayId = current.day_id ?? null;
|
||||
} else {
|
||||
nextDayId = null;
|
||||
}
|
||||
|
||||
let nextEndDayId: number | null;
|
||||
if (end_day_id !== undefined) {
|
||||
nextEndDayId = end_day_id ?? null;
|
||||
} else if (reservation_end_time !== undefined && resolvedType !== 'hotel') {
|
||||
nextEndDayId = resolveDayIdFromTime(tripId, nextReservationEndTime);
|
||||
} else {
|
||||
nextEndDayId = (current as any).end_day_id ?? null;
|
||||
}
|
||||
|
||||
db.prepare(`
|
||||
UPDATE reservations SET
|
||||
title = COALESCE(?, title),
|
||||
reservation_time = ?,
|
||||
reservation_end_time = ?,
|
||||
location = ?,
|
||||
confirmation_number = ?,
|
||||
notes = ?,
|
||||
url = ?,
|
||||
day_id = ?,
|
||||
end_day_id = ?,
|
||||
place_id = ?,
|
||||
assignment_id = ?,
|
||||
status = COALESCE(?, status),
|
||||
type = COALESCE(?, type),
|
||||
accommodation_id = ?,
|
||||
metadata = ?,
|
||||
needs_review = COALESCE(?, needs_review)
|
||||
WHERE id = ?
|
||||
`).run(
|
||||
title || null,
|
||||
nextReservationTime,
|
||||
nextReservationEndTime,
|
||||
location !== undefined ? (location || null) : current.location,
|
||||
confirmation_number !== undefined ? (confirmation_number || null) : current.confirmation_number,
|
||||
notes !== undefined ? (notes || null) : current.notes,
|
||||
url !== undefined ? (url || null) : (current as any).url,
|
||||
nextDayId,
|
||||
nextEndDayId,
|
||||
place_id !== undefined ? (place_id || null) : current.place_id,
|
||||
assignment_id !== undefined ? (assignment_id || null) : current.assignment_id,
|
||||
status || null,
|
||||
type || null,
|
||||
resolvedAccId,
|
||||
metadata !== undefined ? (metadata ? JSON.stringify(metadata) : null) : current.metadata,
|
||||
needs_review === undefined ? null : (needs_review ? 1 : 0),
|
||||
id
|
||||
);
|
||||
|
||||
if (endpoints !== undefined) {
|
||||
saveEndpoints(Number(id), endpoints);
|
||||
}
|
||||
|
||||
// Sync check-in/out to accommodation if linked
|
||||
const resolvedMeta = metadata !== undefined ? metadata : (current.metadata ? JSON.parse(current.metadata as string) : null);
|
||||
if (resolvedAccId && resolvedMeta) {
|
||||
const meta = typeof resolvedMeta === 'string' ? JSON.parse(resolvedMeta) : resolvedMeta;
|
||||
if (meta.check_in_time || meta.check_in_end_time || meta.check_out_time) {
|
||||
db.prepare('UPDATE day_accommodations SET check_in = COALESCE(?, check_in), check_in_end = COALESCE(?, check_in_end), check_out = COALESCE(?, check_out) WHERE id = ?')
|
||||
.run(meta.check_in_time || null, meta.check_in_end_time || null, meta.check_out_time || null, resolvedAccId);
|
||||
}
|
||||
const resolvedConf = confirmation_number !== undefined ? confirmation_number : current.confirmation_number;
|
||||
if (resolvedConf) {
|
||||
db.prepare('UPDATE day_accommodations SET confirmation = COALESCE(?, confirmation) WHERE id = ?')
|
||||
.run(resolvedConf, resolvedAccId);
|
||||
}
|
||||
}
|
||||
|
||||
const reservation = getReservationWithJoins(id);
|
||||
return { reservation, accommodationChanged };
|
||||
}
|
||||
|
||||
export function deleteReservation(id: string | number, tripId: string | number): { deleted: { id: number; title: string; type: string; accommodation_id: number | null } | undefined; accommodationDeleted: boolean; deletedBudgetItemId: number | null } {
|
||||
const reservation = db.prepare('SELECT id, title, type, accommodation_id FROM reservations WHERE id = ? AND trip_id = ?').get(id, tripId) as { id: number; title: string; type: string; accommodation_id: number | null } | undefined;
|
||||
if (!reservation) return { deleted: undefined, accommodationDeleted: false, deletedBudgetItemId: null };
|
||||
|
||||
let accommodationDeleted = false;
|
||||
if (reservation.accommodation_id) {
|
||||
db.prepare('DELETE FROM day_accommodations WHERE id = ?').run(reservation.accommodation_id);
|
||||
accommodationDeleted = true;
|
||||
}
|
||||
|
||||
const linkedBudget = db.prepare('SELECT id FROM budget_items WHERE trip_id = ? AND reservation_id = ?').get(tripId, id) as { id: number } | undefined;
|
||||
if (linkedBudget) {
|
||||
db.prepare('DELETE FROM budget_items WHERE id = ?').run(linkedBudget.id);
|
||||
}
|
||||
|
||||
db.prepare('DELETE FROM reservations WHERE id = ?').run(id);
|
||||
return { deleted: reservation, accommodationDeleted, deletedBudgetItemId: linkedBudget ? linkedBudget.id : null };
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { haversineKm } from './distanceService';
|
||||
import type { EndpointInput } from './reservationService';
|
||||
import type { EndpointInput } from '../nest/reservations/reservations.bridge';
|
||||
import { localParts, resolveTimeZone } from './timezoneService';
|
||||
import { deriveTransitStats, type TransitItinerary, type TransitLeg } from './transitService';
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import { Trip, User } from '../types';
|
||||
import { listDays, listAccommodations, addDays, resyncAccommodationDays, restampReservationDates } from './dayService';
|
||||
import { listBudgetItems, removeUserFromBudgetItems } from './budgetService';
|
||||
import { listItems as listPackingItems } from '../nest/packing/packing.bridge';
|
||||
import { listReservations, loadEndpointsByTrip, resyncReservationDays } from './reservationService';
|
||||
import { listReservations, loadEndpointsByTrip, resyncReservationDays } from '../nest/reservations/reservations.bridge';
|
||||
import { listNotes as listCollabNotes } from '../nest/collab/collab.bridge';
|
||||
import { shiftOwnerEntriesForTripWindow } from '../nest/vacay/vacay.bridge';
|
||||
import { resolveTimeZone } from './timezoneService';
|
||||
|
||||
@@ -19,7 +19,7 @@ const { db } = vi.hoisted(() => {
|
||||
tmp.exec('PRAGMA journal_mode = WAL');
|
||||
tmp.exec(`CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL,
|
||||
email TEXT NOT NULL UNIQUE, role TEXT NOT NULL DEFAULT 'user', password_version INTEGER NOT NULL DEFAULT 0,
|
||||
avatar TEXT);`);
|
||||
avatar TEXT, display_name TEXT, is_guest INTEGER NOT NULL DEFAULT 0);`);
|
||||
tmp.exec('CREATE TABLE trips (id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT);');
|
||||
// bundle()'s todoItems now runs TodoService's real SQL (DI-injected, no mock).
|
||||
tmp.exec(`CREATE TABLE todo_items (id INTEGER PRIMARY KEY AUTOINCREMENT, trip_id INTEGER NOT NULL,
|
||||
@@ -44,7 +44,27 @@ const { db } = vi.hoisted(() => {
|
||||
tmp.exec(`CREATE TABLE file_links (id INTEGER PRIMARY KEY AUTOINCREMENT, file_id INTEGER NOT NULL,
|
||||
reservation_id INTEGER, assignment_id INTEGER, place_id INTEGER,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP);`);
|
||||
tmp.exec('CREATE TABLE IF NOT EXISTS reservations (id INTEGER PRIMARY KEY AUTOINCREMENT, trip_id INTEGER, title TEXT);');
|
||||
// bundle()'s reservations now runs ReservationsService's real SQL
|
||||
// (DI-injected, no mock) — the joined list query needs the full reservation
|
||||
// table set (trimmed from src/db/schema.ts; accommodation_id is TEXT there).
|
||||
tmp.exec(`CREATE TABLE reservations (id INTEGER PRIMARY KEY AUTOINCREMENT, trip_id INTEGER, title TEXT,
|
||||
day_id INTEGER, end_day_id INTEGER, place_id INTEGER, assignment_id INTEGER, type TEXT,
|
||||
status TEXT DEFAULT 'pending', reservation_time TEXT, reservation_end_time TEXT, location TEXT,
|
||||
confirmation_number TEXT, notes TEXT, url TEXT, accommodation_id TEXT, metadata TEXT,
|
||||
needs_review INTEGER DEFAULT 0, day_plan_position REAL, external_source TEXT, sync_enabled INTEGER,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP);`);
|
||||
tmp.exec('CREATE TABLE days (id INTEGER PRIMARY KEY AUTOINCREMENT, trip_id INTEGER NOT NULL, day_number INTEGER, date TEXT);');
|
||||
tmp.exec('CREATE TABLE places (id INTEGER PRIMARY KEY AUTOINCREMENT, trip_id INTEGER NOT NULL, name TEXT, image_url TEXT);');
|
||||
tmp.exec(`CREATE TABLE day_accommodations (id INTEGER PRIMARY KEY AUTOINCREMENT, trip_id INTEGER NOT NULL,
|
||||
place_id INTEGER, start_day_id INTEGER, end_day_id INTEGER, check_in TEXT, check_in_end TEXT,
|
||||
check_out TEXT, confirmation TEXT);`);
|
||||
tmp.exec(`CREATE TABLE reservation_day_positions (reservation_id INTEGER NOT NULL, day_id INTEGER NOT NULL,
|
||||
position REAL, PRIMARY KEY (reservation_id, day_id));`);
|
||||
tmp.exec(`CREATE TABLE reservation_endpoints (id INTEGER PRIMARY KEY AUTOINCREMENT, reservation_id INTEGER NOT NULL,
|
||||
role TEXT, sequence INTEGER, name TEXT, code TEXT, lat REAL NOT NULL, lng REAL NOT NULL,
|
||||
timezone TEXT, local_time TEXT, local_date TEXT);`);
|
||||
tmp.exec(`CREATE TABLE reservation_travelers (reservation_id INTEGER NOT NULL, user_id INTEGER NOT NULL,
|
||||
PRIMARY KEY (reservation_id, user_id));`);
|
||||
return { db: tmp };
|
||||
});
|
||||
|
||||
@@ -54,7 +74,7 @@ vi.mock('../../src/db/database', () => ({
|
||||
}));
|
||||
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn() }));
|
||||
vi.mock('../../src/services/notificationService', () => ({ send: vi.fn().mockResolvedValue(undefined) }));
|
||||
vi.mock('../../src/services/auditLog', () => ({ writeAudit: vi.fn(), getClientIp: vi.fn(() => '1.2.3.4'), logInfo: vi.fn() }));
|
||||
vi.mock('../../src/services/auditLog', () => ({ writeAudit: vi.fn(), getClientIp: vi.fn(() => '1.2.3.4'), logInfo: vi.fn(), logError: vi.fn() }));
|
||||
vi.mock('../../src/services/demo', () => ({ isDemoEmail: vi.fn(() => false) }));
|
||||
|
||||
const { checkPermission } = vi.hoisted(() => ({ checkPermission: vi.fn() }));
|
||||
@@ -72,7 +92,6 @@ vi.mock('../../src/services/tripService', () => tripSvc);
|
||||
vi.mock('../../src/services/dayService', () => ({ listDays: () => ({ days: [] }), listAccommodations: () => [] }));
|
||||
vi.mock('../../src/services/placeService', () => ({ listPlaces: () => [] }));
|
||||
vi.mock('../../src/services/budgetService', () => ({ listBudgetItems: () => [] }));
|
||||
vi.mock('../../src/services/reservationService', () => ({ listReservations: () => [] }));
|
||||
|
||||
import { TripsModule } from '../../src/nest/trips/trips.module';
|
||||
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
|
||||
|
||||
@@ -12,6 +12,8 @@ import { DayNotesMcp } from '../../src/nest/days/day-notes.mcp';
|
||||
import { DayNotesService } from '../../src/nest/days/day-notes.service';
|
||||
import { PackingMcp } from '../../src/nest/packing/packing.mcp';
|
||||
import { PackingService } from '../../src/nest/packing/packing.service';
|
||||
import { ReservationsMcp } from '../../src/nest/reservations/reservations.mcp';
|
||||
import { ReservationsService } from '../../src/nest/reservations/reservations.service';
|
||||
import { TagsMcp } from '../../src/nest/tags/tags.mcp';
|
||||
import { TagsService } from '../../src/nest/tags/tags.service';
|
||||
import { TodoMcp } from '../../src/nest/todo/todo.mcp';
|
||||
@@ -34,6 +36,7 @@ export function createMcpTestRegistry(): McpRegistry {
|
||||
new CategoriesMcp(new CategoriesService(dbService)),
|
||||
new TodoMcp(new TodoService(dbService)),
|
||||
new PackingMcp(new PackingService(dbService)),
|
||||
new ReservationsMcp(new ReservationsService(dbService)),
|
||||
new DayNotesMcp(new DayNotesService(dbService)),
|
||||
new AssignmentsMcp(new AssignmentsService(dbService)),
|
||||
new CollabMcp(new CollabService(dbService)),
|
||||
|
||||
@@ -244,32 +244,8 @@ describe('Resource: trek://trips/{tripId}/budget', () => {
|
||||
// The trek://trips/{tripId}/packing and .../packing/bags resources moved to the
|
||||
// DI-discovered PackingMcp — see tools-packing.test.ts.
|
||||
|
||||
describe('Resource: trek://trips/{tripId}/reservations', () => {
|
||||
it('returns reservations for a trip', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
createReservation(testDb, trip.id, { title: 'Flight to Paris', type: 'flight' });
|
||||
|
||||
await withHarness(user.id, async (harness) => {
|
||||
const result = await harness.client.readResource({ uri: `trek://trips/${trip.id}/reservations` });
|
||||
const items = parseResourceResult(result) as any[];
|
||||
expect(items).toHaveLength(1);
|
||||
expect(items[0].title).toBe('Flight to Paris');
|
||||
});
|
||||
});
|
||||
|
||||
it('returns access denied for unauthorized trip', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const { user: other } = createUser(testDb);
|
||||
const trip = createTrip(testDb, other.id);
|
||||
|
||||
await withHarness(user.id, async (harness) => {
|
||||
const result = await harness.client.readResource({ uri: `trek://trips/${trip.id}/reservations` });
|
||||
const data = parseResourceResult(result) as any;
|
||||
expect(data.error).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
// The trek://trips/{tripId}/reservations resource moved to the DI-discovered
|
||||
// ReservationsMcp — see tools-reservations.test.ts.
|
||||
|
||||
// The trek://trips/{tripId}/days/{dayId}/notes resource moved to the
|
||||
// DI-discovered DayNotesMcp — see tools-notes.test.ts.
|
||||
|
||||
@@ -37,7 +37,7 @@ import { createTables } from '../../../src/db/schema';
|
||||
import { runMigrations } from '../../../src/db/migrations';
|
||||
import { resetTestDb } from '../../helpers/test-db';
|
||||
import { createUser, createTrip, createDay, createPlace, createReservation, createDayAssignment } from '../../helpers/factories';
|
||||
import { createMcpHarness, parseToolResult, type McpHarness } from '../../helpers/mcp-harness';
|
||||
import { createMcpHarness, parseToolResult, parseResourceResult, type McpHarness } from '../../helpers/mcp-harness';
|
||||
|
||||
beforeAll(() => {
|
||||
createTables(testDb);
|
||||
@@ -450,3 +450,35 @@ describe('Tool: link_hotel_accommodation', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// trek://trips/{tripId}/reservations resource (moved from the legacy
|
||||
// registerResources into the DI-discovered ReservationsMcp)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Resource: trek://trips/{tripId}/reservations', () => {
|
||||
it('returns reservations for a trip', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
createReservation(testDb, trip.id, { title: 'Flight to Paris', type: 'flight' });
|
||||
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.readResource({ uri: `trek://trips/${trip.id}/reservations` });
|
||||
const items = parseResourceResult(result) as { title: string }[];
|
||||
expect(items).toHaveLength(1);
|
||||
expect(items[0].title).toBe('Flight to Paris');
|
||||
});
|
||||
});
|
||||
|
||||
it('returns access denied for unauthorized trip', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const { user: other } = createUser(testDb);
|
||||
const trip = createTrip(testDb, other.id);
|
||||
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.readResource({ uri: `trek://trips/${trip.id}/reservations` });
|
||||
const data = parseResourceResult(result) as { error?: string };
|
||||
expect(data.error).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -42,8 +42,8 @@ vi.mock('../../../src/services/transitService', async (importOriginal) => ({
|
||||
plan: planMock,
|
||||
}));
|
||||
vi.mock('../../../src/websocket', () => ({ broadcast: broadcastMock }));
|
||||
vi.mock('../../../src/services/reservationService', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('../../../src/services/reservationService')>()),
|
||||
vi.mock('../../../src/nest/reservations/reservations.bridge', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('../../../src/nest/reservations/reservations.bridge')>()),
|
||||
notifyBookingChange: notifyBookingChangeMock,
|
||||
}));
|
||||
vi.mock('../../../src/config', () => ({
|
||||
|
||||
@@ -9,7 +9,6 @@ import { DatabaseService } from '../../../../src/nest/database/database.service'
|
||||
vi.mock('../../../../src/websocket', () => ({ broadcast: vi.fn() }));
|
||||
vi.mock('../../../../src/services/permissions', () => ({ checkPermission: vi.fn(() => true) }));
|
||||
vi.mock('../../../../src/services/tripAccess', () => ({ verifyTripAccess: vi.fn() }));
|
||||
vi.mock('../../../../src/services/reservationService', () => ({ createReservation: vi.fn() }));
|
||||
vi.mock('../../../../src/services/placeService', () => ({ createPlace: vi.fn() }));
|
||||
vi.mock('../../../../src/services/mapsService', () => ({ searchNominatim: vi.fn() }));
|
||||
|
||||
@@ -21,7 +20,8 @@ const file = (name = 'a.pdf') => ({ buffer: Buffer.from('x'), originalname: name
|
||||
function make(opts: { kit?: boolean; ai?: boolean; extract?: any; parse?: any }) {
|
||||
const extractor = { isAvailable: () => opts.kit ?? false, extract: vi.fn(opts.extract ?? (async () => [])) };
|
||||
const llmParse = { isAvailable: () => opts.ai ?? false, parse: vi.fn(opts.parse ?? (async () => ({ kiItems: [], warnings: [] }))) };
|
||||
return { svc: new BookingImportService(extractor as any, llmParse as any, new DatabaseService(dbConn)), extractor, llmParse };
|
||||
const reservations = { create: vi.fn() };
|
||||
return { svc: new BookingImportService(extractor as any, llmParse as any, new DatabaseService(dbConn), reservations as never), extractor, llmParse, reservations };
|
||||
}
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
@@ -57,7 +57,7 @@ describe('ReservationsController (parity with the legacy /api/trips/:tripId/rese
|
||||
expect(broadcast).toHaveBeenCalledWith('5', 'accommodation:created', {}, 'sock');
|
||||
expect(syncBudgetOnCreate).toHaveBeenCalledWith('5', 9, 'Hotel', 'lodging', { total_price: 200 }, 'sock');
|
||||
expect(broadcast).toHaveBeenCalledWith('5', 'reservation:created', { reservation: { id: 9 } }, 'sock');
|
||||
expect(notifyBookingChange).toHaveBeenCalledWith('5', user, 'Hotel', 'lodging');
|
||||
expect(notifyBookingChange).toHaveBeenCalledWith('5', user.id, 'Hotel', 'lodging');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -89,7 +89,7 @@ describe('ReservationsController (parity with the legacy /api/trips/:tripId/rese
|
||||
new ReservationsController(svc).update(user, '5', '9', { create_budget_entry: { total_price: 50 } }, 'sock');
|
||||
expect(broadcast).toHaveBeenCalledWith('5', 'accommodation:updated', {}, 'sock');
|
||||
expect(syncBudgetOnUpdate).toHaveBeenCalledWith('5', '9', '', undefined, 'Old', 'lodging', { total_price: 50 }, 'sock');
|
||||
expect(notifyBookingChange).toHaveBeenCalledWith('5', user, 'Old', 'lodging');
|
||||
expect(notifyBookingChange).toHaveBeenCalledWith('5', user.id, 'Old', 'lodging');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,13 +1,35 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
/**
|
||||
* Unit tests for the DI-native ReservationsService — RESV-SVC-001+ (real-SQL
|
||||
* cases over the folded legacy services/reservationService.ts statements),
|
||||
* RES-TRAV-001..005 (moved 1:1 from tests/unit/services/reservationTravelers.test.ts)
|
||||
* and RESV-BRIDGE-001..009 (one delegation case per reservations.bridge export).
|
||||
* Uses a real in-memory SQLite DB so the SQL logic — falsy-coercion defaults,
|
||||
* COALESCE update semantics, day derivation, the accommodation/budget cascades
|
||||
* and the TEXT accommodation_id normalization — is exercised faithfully.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
|
||||
// Mock the data + side-effect dependencies the service reaches into directly.
|
||||
const { dbMock } = vi.hoisted(() => {
|
||||
const stmt = { get: vi.fn(), all: vi.fn(() => []), run: vi.fn() };
|
||||
return { dbMock: { prepare: vi.fn(() => stmt), _stmt: stmt } };
|
||||
const { testDb, dbMock } = vi.hoisted(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const Database = require('better-sqlite3');
|
||||
const db = new Database(':memory:');
|
||||
db.exec('PRAGMA journal_mode = WAL');
|
||||
db.exec('PRAGMA foreign_keys = ON');
|
||||
db.exec('PRAGMA busy_timeout = 5000');
|
||||
const mock = {
|
||||
db, closeDb: () => {}, reinitialize: () => {}, getPlaceWithTags: () => null,
|
||||
canAccessTrip: (tripId: unknown, userId: number) => db.prepare(`
|
||||
SELECT t.* FROM trips t
|
||||
LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ?
|
||||
WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)
|
||||
`).get(userId, tripId, userId),
|
||||
isOwner: (tripId: unknown, userId: number) =>
|
||||
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
|
||||
};
|
||||
return { testDb: db, dbMock: mock };
|
||||
});
|
||||
vi.mock('../../../src/db/database', () => ({ db: dbMock, closeDb: () => {}, reinitialize: () => {} }));
|
||||
import { db as dbConn } from '../../../src/db/database';
|
||||
import { DatabaseService } from '../../../src/nest/database/database.service';
|
||||
|
||||
vi.mock('../../../src/db/database', () => dbMock);
|
||||
|
||||
const { broadcast } = vi.hoisted(() => ({ broadcast: vi.fn() }));
|
||||
vi.mock('../../../src/websocket', () => ({ broadcast }));
|
||||
@@ -20,124 +42,695 @@ const { budget } = vi.hoisted(() => ({
|
||||
}));
|
||||
vi.mock('../../../src/services/budgetService', () => budget);
|
||||
|
||||
const { resv } = vi.hoisted(() => ({
|
||||
resv: {
|
||||
verifyTripAccess: vi.fn(), listReservations: vi.fn(), createReservation: vi.fn(), updatePositions: vi.fn(),
|
||||
getReservation: vi.fn(), updateReservation: vi.fn(), deleteReservation: vi.fn(),
|
||||
notifyBookingChange: vi.fn(),
|
||||
setReservationTravelers: vi.fn(), loadTravelers: vi.fn(), getReservationWithJoins: vi.fn(),
|
||||
},
|
||||
}));
|
||||
vi.mock('../../../src/services/reservationService', () => resv);
|
||||
const { notif } = vi.hoisted(() => ({ notif: { send: vi.fn().mockResolvedValue(undefined) } }));
|
||||
vi.mock('../../../src/services/notificationService', () => notif);
|
||||
|
||||
import { createTables } from '../../../src/db/schema';
|
||||
import { runMigrations } from '../../../src/db/migrations';
|
||||
import { resetTestDb } from '../../helpers/test-db';
|
||||
import { createUser, createTrip, createReservation, createBudgetItem, createPlace, createDay, createDayAccommodation, addTripMember } from '../../helpers/factories';
|
||||
import { DatabaseService } from '../../../src/nest/database/database.service';
|
||||
import { ReservationsService } from '../../../src/nest/reservations/reservations.service';
|
||||
import * as bridge from '../../../src/nest/reservations/reservations.bridge';
|
||||
|
||||
function svc() {
|
||||
return new ReservationsService(new DatabaseService(dbConn));
|
||||
}
|
||||
const svc = new ReservationsService(new DatabaseService(testDb));
|
||||
|
||||
beforeAll(() => { createTables(testDb); runMigrations(testDb); });
|
||||
beforeEach(() => {
|
||||
resetTestDb(testDb);
|
||||
vi.clearAllMocks();
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
});
|
||||
afterAll(() => testDb.close());
|
||||
|
||||
describe('ReservationsService', () => {
|
||||
it('canEdit delegates to checkPermission with reservation_edit', () => {
|
||||
svc().canEdit({ user_id: 2 } as never, { id: 1, role: 'user' } as never);
|
||||
/** Owner + trip helper for the common single-user cases. */
|
||||
function ownerTrip(overrides: Parameters<typeof createTrip>[2] = {}) {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id, overrides);
|
||||
return { user, trip };
|
||||
}
|
||||
|
||||
describe('ReservationsService (DI-native, real SQL)', () => {
|
||||
it('RESV-SVC-001: canEdit delegates to checkPermission with reservation_edit', () => {
|
||||
svc.canEdit({ user_id: 2 } as never, { id: 1, role: 'user' } as never);
|
||||
expect(checkPermission).toHaveBeenCalledWith('reservation_edit', 'user', 2, 1, true);
|
||||
});
|
||||
|
||||
it('list/create/getReservation/remove delegate to the legacy service', () => {
|
||||
resv.listReservations.mockReturnValue([{ id: 1 }]);
|
||||
expect(svc().list('5')).toEqual([{ id: 1 }]);
|
||||
svc().create('5', { title: 'X' } as never);
|
||||
expect(resv.createReservation).toHaveBeenCalledWith('5', { title: 'X' });
|
||||
svc().getReservation('9', '5');
|
||||
expect(resv.getReservation).toHaveBeenCalledWith('9', '5');
|
||||
svc().remove('9', '5');
|
||||
expect(resv.deleteReservation).toHaveBeenCalledWith('9', '5');
|
||||
describe('create', () => {
|
||||
it('RESV-SVC-002: applies the legacy falsy-coercion defaults and re-selects the joined row', () => {
|
||||
const { trip } = ownerTrip();
|
||||
const { reservation, accommodationCreated } = svc.create(String(trip.id), { title: 'Dinner', location: '' } as never);
|
||||
expect(accommodationCreated).toBe(false);
|
||||
expect(reservation).toMatchObject({
|
||||
title: 'Dinner',
|
||||
type: 'other', // type || 'other'
|
||||
status: 'pending', // status || 'pending'
|
||||
location: null, // '' || null
|
||||
accommodation_id: null,
|
||||
endpoints: [],
|
||||
travelers: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('RESV-SVC-003: derives day_id from reservation_time for non-hotel bookings', () => {
|
||||
const { trip } = ownerTrip({ start_date: '2030-05-01', end_date: '2030-05-03' });
|
||||
const day2 = testDb.prepare('SELECT id FROM days WHERE trip_id = ? AND date = ?').get(trip.id, '2030-05-02') as { id: number };
|
||||
const { reservation } = svc.create(String(trip.id), { title: 'Tour', type: 'tour', reservation_time: '2030-05-02T10:00:00' });
|
||||
expect(reservation.day_id).toBe(day2.id);
|
||||
// Out-of-range time clamps to the nearest day (create path clamps).
|
||||
const day3 = testDb.prepare('SELECT id FROM days WHERE trip_id = ? AND date = ?').get(trip.id, '2030-05-03') as { id: number };
|
||||
const { reservation: clamped } = svc.create(String(trip.id), { title: 'Late', type: 'tour', reservation_time: '2030-06-20T10:00:00' });
|
||||
expect(clamped.day_id).toBe(day3.id);
|
||||
});
|
||||
|
||||
it('RESV-SVC-004: auto-creates the accommodation for a hotel with create_accommodation', () => {
|
||||
const { trip } = ownerTrip({ start_date: '2030-05-01', end_date: '2030-05-03' });
|
||||
const place = createPlace(testDb, trip.id);
|
||||
const days = testDb.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY day_number').all(trip.id) as { id: number }[];
|
||||
const { reservation, accommodationCreated } = svc.create(String(trip.id), {
|
||||
title: 'Hotel', type: 'hotel',
|
||||
create_accommodation: { place_id: place.id, start_day_id: days[0].id, end_day_id: days[2].id, check_in: '15:00' },
|
||||
confirmation_number: 'ABC123',
|
||||
});
|
||||
expect(accommodationCreated).toBe(true);
|
||||
const acc = testDb.prepare('SELECT * FROM day_accommodations WHERE trip_id = ?').get(trip.id) as Record<string, unknown>;
|
||||
expect(acc).toMatchObject({ place_id: place.id, check_in: '15:00', confirmation: 'ABC123' });
|
||||
// TEXT accommodation_id column reads back normalized to an int.
|
||||
expect(reservation.accommodation_id).toBe(Number(acc.id));
|
||||
expect(reservation.accommodation_name).toBe(place.name);
|
||||
});
|
||||
|
||||
it('RESV-SVC-005: saves endpoints, skipping null-coordinate rows; sequence defaults to the post-filter index', () => {
|
||||
const { trip } = ownerTrip();
|
||||
const endpoints = [
|
||||
{ role: 'from', name: 'A', code: 'AAA', lat: 1, lng: 2, timezone: null, local_time: null, local_date: null },
|
||||
{ role: 'to', name: 'NoGeo', code: null, lat: null, lng: null, timezone: null, local_time: null, local_date: null },
|
||||
{ role: 'to', name: 'B', code: 'BBB', lat: 3, lng: 4, timezone: null, local_time: null, local_date: null },
|
||||
];
|
||||
const { reservation } = svc.create(String(trip.id), { title: 'Bus', type: 'other', endpoints } as never);
|
||||
const rows = testDb.prepare('SELECT name, sequence FROM reservation_endpoints WHERE reservation_id = ? ORDER BY sequence').all(reservation.id) as { name: string; sequence: number }[];
|
||||
// The index fallback runs AFTER the null-coord filter, so 'B' (third on
|
||||
// the wire, second surviving row) gets sequence 1.
|
||||
expect(rows).toEqual([{ name: 'A', sequence: 0 }, { name: 'B', sequence: 1 }]);
|
||||
});
|
||||
|
||||
it('RESV-SVC-006 (quirk preserved): metadata check-in sync is gated on the raw accommodation_id, so an auto-created accommodation is not updated', () => {
|
||||
const { trip } = ownerTrip({ start_date: '2030-05-01', end_date: '2030-05-02' });
|
||||
const place = createPlace(testDb, trip.id);
|
||||
const days = testDb.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY day_number').all(trip.id) as { id: number }[];
|
||||
svc.create(String(trip.id), {
|
||||
title: 'Hotel', type: 'hotel',
|
||||
create_accommodation: { place_id: place.id, start_day_id: days[0].id, end_day_id: days[1].id },
|
||||
metadata: { check_in_time: '16:00' },
|
||||
});
|
||||
const acc = testDb.prepare('SELECT check_in FROM day_accommodations WHERE trip_id = ?').get(trip.id) as { check_in: string | null };
|
||||
expect(acc.check_in).toBeNull(); // the L416 gate reads accommodation_id, not resolvedAccommodationId
|
||||
});
|
||||
});
|
||||
|
||||
describe('setTravelers', () => {
|
||||
describe('update', () => {
|
||||
it('RESV-SVC-007: an empty-string title silently keeps the old value (COALESCE + `|| null`)', () => {
|
||||
const { trip } = ownerTrip();
|
||||
const res = createReservation(testDb, trip.id, { title: 'Old title' });
|
||||
const current = svc.getReservation(String(res.id), String(trip.id))!;
|
||||
const { reservation } = svc.update(String(res.id), String(trip.id), { title: '', notes: 'updated' }, current);
|
||||
expect(reservation.title).toBe('Old title');
|
||||
expect(reservation.notes).toBe('updated');
|
||||
});
|
||||
|
||||
it('RESV-SVC-008: switching to hotel forces reservation_time/reservation_end_time to null', () => {
|
||||
const { trip } = ownerTrip();
|
||||
const res = createReservation(testDb, trip.id, { title: 'X', type: 'tour' });
|
||||
testDb.prepare('UPDATE reservations SET reservation_time = ? WHERE id = ?').run('2030-05-01T10:00:00', res.id);
|
||||
const current = svc.getReservation(String(res.id), String(trip.id))!;
|
||||
const { reservation } = svc.update(String(res.id), String(trip.id), { type: 'hotel' }, current);
|
||||
expect(reservation.reservation_time).toBeNull();
|
||||
expect(reservation.reservation_end_time).toBeNull();
|
||||
});
|
||||
|
||||
it('RESV-SVC-009: a stale accommodation_id pointing at a deleted row is nulled out', () => {
|
||||
const { trip } = ownerTrip();
|
||||
const res = createReservation(testDb, trip.id, { title: 'Hotel', type: 'hotel' });
|
||||
testDb.prepare('UPDATE reservations SET accommodation_id = ? WHERE id = ?').run('999', res.id);
|
||||
const current = svc.getReservation(String(res.id), String(trip.id))!;
|
||||
const { reservation } = svc.update(String(res.id), String(trip.id), { notes: 'n' }, current);
|
||||
expect(reservation.accommodation_id).toBeNull();
|
||||
});
|
||||
|
||||
it('RESV-SVC-010: endpoints [] wipes stored endpoints; an absent field leaves them alone', () => {
|
||||
const { trip } = ownerTrip();
|
||||
const res = createReservation(testDb, trip.id, { title: 'Bus' });
|
||||
testDb.prepare('INSERT INTO reservation_endpoints (reservation_id, role, sequence, name, lat, lng) VALUES (?, ?, 0, ?, 1, 2)').run(res.id, 'from', 'A');
|
||||
let current = svc.getReservation(String(res.id), String(trip.id))!;
|
||||
svc.update(String(res.id), String(trip.id), { notes: 'no endpoints field' }, current);
|
||||
expect(testDb.prepare('SELECT COUNT(*) as c FROM reservation_endpoints WHERE reservation_id = ?').get(res.id)).toEqual({ c: 1 });
|
||||
current = svc.getReservation(String(res.id), String(trip.id))!;
|
||||
svc.update(String(res.id), String(trip.id), { endpoints: [] }, current);
|
||||
expect(testDb.prepare('SELECT COUNT(*) as c FROM reservation_endpoints WHERE reservation_id = ?').get(res.id)).toEqual({ c: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('remove', () => {
|
||||
it('RESV-SVC-011: cascades to the linked accommodation and budget item, reporting both', () => {
|
||||
const { trip } = ownerTrip({ start_date: '2030-05-01', end_date: '2030-05-02' });
|
||||
const place = createPlace(testDb, trip.id);
|
||||
const days = testDb.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY day_number').all(trip.id) as { id: number }[];
|
||||
const acc = createDayAccommodation(testDb, trip.id, place.id, days[0].id, days[1].id);
|
||||
const res = createReservation(testDb, trip.id, { title: 'Hotel', type: 'hotel' });
|
||||
testDb.prepare('UPDATE reservations SET accommodation_id = ? WHERE id = ?').run(String(acc.id), res.id);
|
||||
const item = createBudgetItem(testDb, trip.id);
|
||||
testDb.prepare('UPDATE budget_items SET reservation_id = ? WHERE id = ?').run(res.id, item.id);
|
||||
|
||||
const result = svc.remove(String(res.id), String(trip.id));
|
||||
expect(result.deleted).toMatchObject({ id: res.id, title: 'Hotel', type: 'hotel' });
|
||||
expect(result.accommodationDeleted).toBe(true);
|
||||
expect(result.deletedBudgetItemId).toBe(item.id);
|
||||
expect(testDb.prepare('SELECT COUNT(*) as c FROM reservations WHERE id = ?').get(res.id)).toEqual({ c: 0 });
|
||||
expect(testDb.prepare('SELECT COUNT(*) as c FROM day_accommodations WHERE id = ?').get(acc.id)).toEqual({ c: 0 });
|
||||
expect(testDb.prepare('SELECT COUNT(*) as c FROM budget_items WHERE id = ?').get(item.id)).toEqual({ c: 0 });
|
||||
});
|
||||
|
||||
it('RESV-SVC-012: returns the empty shape when the reservation is missing', () => {
|
||||
const { trip } = ownerTrip();
|
||||
expect(svc.remove('999', String(trip.id))).toEqual({ deleted: undefined, accommodationDeleted: false, deletedBudgetItemId: null });
|
||||
});
|
||||
});
|
||||
|
||||
describe('updatePositions', () => {
|
||||
it('RESV-SVC-013: without dayId updates the global day_plan_position (legacy branch)', () => {
|
||||
const { trip } = ownerTrip();
|
||||
const r1 = createReservation(testDb, trip.id, { title: 'A' });
|
||||
const r2 = createReservation(testDb, trip.id, { title: 'B' });
|
||||
svc.updatePositions(String(trip.id), [{ id: r2.id, day_plan_position: 0 }, { id: r1.id, day_plan_position: 1 }]);
|
||||
expect(testDb.prepare('SELECT day_plan_position FROM reservations WHERE id = ?').get(r2.id)).toEqual({ day_plan_position: 0 });
|
||||
expect(testDb.prepare('SELECT day_plan_position FROM reservations WHERE id = ?').get(r1.id)).toEqual({ day_plan_position: 1 });
|
||||
});
|
||||
|
||||
it('RESV-SVC-014: with dayId upserts per-day positions into reservation_day_positions', () => {
|
||||
const { trip } = ownerTrip();
|
||||
const day = createDay(testDb, trip.id, { date: '2030-05-01' });
|
||||
const r1 = createReservation(testDb, trip.id, { title: 'A' });
|
||||
svc.updatePositions(String(trip.id), [{ id: r1.id, day_plan_position: 3 }], day.id);
|
||||
svc.updatePositions(String(trip.id), [{ id: r1.id, day_plan_position: 5 }], day.id); // OR REPLACE
|
||||
expect(testDb.prepare('SELECT position FROM reservation_day_positions WHERE reservation_id = ? AND day_id = ?').get(r1.id, day.id)).toEqual({ position: 5 });
|
||||
expect(testDb.prepare('SELECT day_plan_position FROM reservations WHERE id = ?').get(r1.id)).toEqual({ day_plan_position: null });
|
||||
});
|
||||
});
|
||||
|
||||
describe('list / getReservationWithJoins', () => {
|
||||
it('RESV-SVC-015: attaches day_positions, endpoints, travelers and normalizes the TEXT accommodation_id', () => {
|
||||
const { user, trip } = ownerTrip({ start_date: '2030-05-01', end_date: '2030-05-02' });
|
||||
const place = createPlace(testDb, trip.id);
|
||||
const days = testDb.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY day_number').all(trip.id) as { id: number }[];
|
||||
const acc = createDayAccommodation(testDb, trip.id, place.id, days[0].id, days[1].id);
|
||||
const res = createReservation(testDb, trip.id, { title: 'Hotel', type: 'hotel' });
|
||||
// The TEXT column quirk: the FK reads back as a numeric string like "14.0".
|
||||
testDb.prepare('UPDATE reservations SET accommodation_id = ? WHERE id = ?').run(`${acc.id}.0`, res.id);
|
||||
testDb.prepare('INSERT INTO reservation_day_positions (reservation_id, day_id, position) VALUES (?, ?, ?)').run(res.id, days[0].id, 2);
|
||||
svc.setReservationTravelers(res.id, trip.id, [user.id]);
|
||||
|
||||
const [row] = svc.list(String(trip.id));
|
||||
expect(row.accommodation_id).toBe(acc.id);
|
||||
expect(row.accommodation_name).toBe(place.name);
|
||||
expect(row.day_positions).toEqual({ [days[0].id]: 2 });
|
||||
expect(row.travelers).toHaveLength(1);
|
||||
|
||||
const single = svc.getReservationWithJoins(res.id)!;
|
||||
expect(single.accommodation_id).toBe(acc.id);
|
||||
expect(single.travelers).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('RESV-SVC-016: getReservationWithJoins returns undefined for a missing id', () => {
|
||||
expect(svc.getReservationWithJoins(999)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('listUpcoming', () => {
|
||||
it('RESV-SVC-017: returns future reservations across owned + member trips, skipping cancelled', () => {
|
||||
const { user, trip } = ownerTrip();
|
||||
const future = createReservation(testDb, trip.id, { title: 'Future' });
|
||||
testDb.prepare('UPDATE reservations SET reservation_time = ? WHERE id = ?').run('2999-01-01T10:00:00', future.id);
|
||||
const past = createReservation(testDb, trip.id, { title: 'Past' });
|
||||
testDb.prepare('UPDATE reservations SET reservation_time = ? WHERE id = ?').run('2000-01-01T10:00:00', past.id);
|
||||
const cancelled = createReservation(testDb, trip.id, { title: 'Cancelled' });
|
||||
testDb.prepare("UPDATE reservations SET reservation_time = ?, status = 'cancelled' WHERE id = ?").run('2999-01-02T10:00:00', cancelled.id);
|
||||
|
||||
const rows = svc.listUpcoming(user.id) as { title: string }[];
|
||||
expect(rows.map(r => r.title)).toEqual(['Future']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resyncReservationDays', () => {
|
||||
it('RESV-SVC-018: re-anchors a dated booking to the day matching its time; out-of-range stays untouched', () => {
|
||||
const { trip } = ownerTrip({ start_date: '2030-05-01', end_date: '2030-05-03' });
|
||||
const days = testDb.prepare('SELECT id, date FROM days WHERE trip_id = ? ORDER BY day_number').all(trip.id) as { id: number; date: string }[];
|
||||
const res = createReservation(testDb, trip.id, { title: 'Tour', type: 'tour', day_id: days[0].id });
|
||||
testDb.prepare('UPDATE reservations SET reservation_time = ? WHERE id = ?').run('2030-05-03T09:00:00', res.id);
|
||||
const outside = createReservation(testDb, trip.id, { title: 'Outside', type: 'tour', day_id: days[0].id });
|
||||
testDb.prepare('UPDATE reservations SET reservation_time = ? WHERE id = ?').run('2031-01-01T09:00:00', outside.id);
|
||||
|
||||
svc.resyncReservationDays(String(trip.id));
|
||||
expect(testDb.prepare('SELECT day_id FROM reservations WHERE id = ?').get(res.id)).toEqual({ day_id: days[2].id });
|
||||
expect(testDb.prepare('SELECT day_id FROM reservations WHERE id = ?').get(outside.id)).toEqual({ day_id: days[0].id });
|
||||
});
|
||||
});
|
||||
|
||||
describe('setTravelers (controller-facing composition)', () => {
|
||||
it('returns null when the reservation is not on the trip (off-trip guard)', () => {
|
||||
resv.getReservation.mockReturnValue(undefined);
|
||||
expect(svc().setTravelers('9', '5', [2])).toBeNull();
|
||||
expect(resv.setReservationTravelers).not.toHaveBeenCalled();
|
||||
const { trip } = ownerTrip();
|
||||
expect(svc.setTravelers('999', String(trip.id), [2])).toBeNull();
|
||||
});
|
||||
|
||||
it('assigns travelers and returns the refreshed travelers + reservation', () => {
|
||||
resv.getReservation.mockReturnValue({ id: 9 });
|
||||
const travelers = [{ user_id: 2, username: 'Sam', avatar: null, is_guest: 0 }];
|
||||
resv.loadTravelers.mockReturnValue(travelers);
|
||||
resv.getReservationWithJoins.mockReturnValue({ id: 9, travelers });
|
||||
expect(svc().setTravelers('9', '5', [2])).toEqual({ travelers, reservation: { id: 9, travelers } });
|
||||
expect(resv.setReservationTravelers).toHaveBeenCalledWith('9', '5', [2]);
|
||||
expect(resv.getReservationWithJoins).toHaveBeenCalledWith(9);
|
||||
const { user, trip } = ownerTrip();
|
||||
const res = createReservation(testDb, trip.id);
|
||||
const result = svc.setTravelers(String(res.id), String(trip.id), [user.id])!;
|
||||
expect(result.travelers).toHaveLength(1);
|
||||
expect(result.travelers[0]).toMatchObject({ user_id: user.id });
|
||||
expect(result.reservation).toMatchObject({ id: res.id });
|
||||
});
|
||||
});
|
||||
|
||||
describe('syncBudgetOnCreate', () => {
|
||||
it('does nothing without a positive price', () => {
|
||||
svc().syncBudgetOnCreate('5', 9, 'Hotel', 'lodging', undefined, 'sock');
|
||||
svc().syncBudgetOnCreate('5', 9, 'Hotel', 'lodging', { total_price: 0 }, 'sock');
|
||||
svc.syncBudgetOnCreate('5', 9, 'Hotel', 'lodging', undefined, 'sock');
|
||||
svc.syncBudgetOnCreate('5', 9, 'Hotel', 'lodging', { total_price: 0 }, 'sock');
|
||||
expect(budget.linkBudgetItemToReservation).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('links a budget item and broadcasts budget:created', () => {
|
||||
budget.linkBudgetItemToReservation.mockReturnValue({ id: 7 });
|
||||
svc().syncBudgetOnCreate('5', 9, 'Hotel', 'lodging', { total_price: 200, category: 'Lodging' }, 'sock');
|
||||
svc.syncBudgetOnCreate('5', 9, 'Hotel', 'lodging', { total_price: 200, category: 'Lodging' }, 'sock');
|
||||
expect(budget.linkBudgetItemToReservation).toHaveBeenCalledWith('5', 9, { name: 'Hotel', category: 'Lodging', total_price: 200 });
|
||||
expect(broadcast).toHaveBeenCalledWith('5', 'budget:created', { item: { id: 7 } }, 'sock');
|
||||
});
|
||||
|
||||
it('falls back to type then "Other" for the category and swallows errors', () => {
|
||||
budget.linkBudgetItemToReservation.mockImplementation(() => { throw new Error('boom'); });
|
||||
expect(() => svc().syncBudgetOnCreate('5', 9, 'Hotel', undefined, { total_price: 50 }, 'sock')).not.toThrow();
|
||||
expect(() => svc.syncBudgetOnCreate('5', 9, 'Hotel', undefined, { total_price: 50 }, 'sock')).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('syncBudgetOnUpdate', () => {
|
||||
function linkedItem(overrides: Parameters<typeof createBudgetItem>[2] = {}) {
|
||||
const { trip } = ownerTrip();
|
||||
const res = createReservation(testDb, trip.id);
|
||||
const item = createBudgetItem(testDb, trip.id, overrides);
|
||||
testDb.prepare('UPDATE budget_items SET reservation_id = ? WHERE id = ?').run(res.id, item.id);
|
||||
return { trip, res, item };
|
||||
}
|
||||
|
||||
it('deletes the linked item when the price is explicitly cleared (total_price 0)', () => {
|
||||
dbMock._stmt.get.mockReturnValueOnce({ id: 7 });
|
||||
svc().syncBudgetOnUpdate('5', '9', 'Hotel', 'lodging', 'Hotel', 'lodging', { total_price: 0 }, 'sock');
|
||||
expect(budget.deleteBudgetItem).toHaveBeenCalledWith(7, '5');
|
||||
expect(broadcast).toHaveBeenCalledWith('5', 'budget:deleted', { itemId: 7 }, 'sock');
|
||||
const { trip, res, item } = linkedItem();
|
||||
svc.syncBudgetOnUpdate(String(trip.id), String(res.id), 'Hotel', 'lodging', 'Hotel', 'lodging', { total_price: 0 }, 'sock');
|
||||
expect(budget.deleteBudgetItem).toHaveBeenCalledWith(item.id, String(trip.id));
|
||||
expect(broadcast).toHaveBeenCalledWith(String(trip.id), 'budget:deleted', { itemId: item.id }, 'sock');
|
||||
});
|
||||
|
||||
it('leaves the linked item alone when no budget entry is on the payload (no wipe)', () => {
|
||||
svc().syncBudgetOnUpdate('5', '9', 'Hotel', 'lodging', 'Hotel', 'lodging', undefined, 'sock');
|
||||
const { trip, res } = linkedItem();
|
||||
svc.syncBudgetOnUpdate(String(trip.id), String(res.id), 'Hotel', 'lodging', 'Hotel', 'lodging', undefined, 'sock');
|
||||
expect(budget.deleteBudgetItem).not.toHaveBeenCalled();
|
||||
expect(budget.updateBudgetItem).not.toHaveBeenCalled();
|
||||
expect(budget.createBudgetItem).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('syncs the linked expense category when the booking type changes', () => {
|
||||
dbMock._stmt.get.mockReturnValueOnce({ id: 7, category: 'other' });
|
||||
budget.updateBudgetItem.mockReturnValue({ id: 7, category: 'flights' });
|
||||
svc().syncBudgetOnUpdate('5', '9', 'X', 'flight', 'X', 'other', undefined, 'sock');
|
||||
expect(budget.updateBudgetItem).toHaveBeenCalledWith(7, '5', { category: 'flights' });
|
||||
expect(broadcast).toHaveBeenCalledWith('5', 'budget:updated', { item: { id: 7, category: 'flights' } }, 'sock');
|
||||
const { trip, res, item } = linkedItem({ category: 'other' });
|
||||
budget.updateBudgetItem.mockReturnValue({ id: item.id, category: 'flights' });
|
||||
svc.syncBudgetOnUpdate(String(trip.id), String(res.id), 'X', 'flight', 'X', 'other', undefined, 'sock');
|
||||
expect(budget.updateBudgetItem).toHaveBeenCalledWith(item.id, String(trip.id), { category: 'flights' });
|
||||
expect(broadcast).toHaveBeenCalledWith(String(trip.id), 'budget:updated', { item: { id: item.id, category: 'flights' } }, 'sock');
|
||||
});
|
||||
|
||||
it('updates an existing linked item when a price is provided', () => {
|
||||
dbMock._stmt.get.mockReturnValueOnce({ id: 7 }); // existing lookup
|
||||
budget.updateBudgetItem.mockReturnValue({ id: 7 });
|
||||
svc().syncBudgetOnUpdate('5', '9', 'New', 'lodging', 'Old', 'lodging', { total_price: 80 }, 'sock');
|
||||
expect(budget.updateBudgetItem).toHaveBeenCalledWith(7, '5', { name: 'New', category: 'lodging', total_price: 80 });
|
||||
expect(broadcast).toHaveBeenCalledWith('5', 'budget:updated', { item: { id: 7 } }, 'sock');
|
||||
const { trip, res, item } = linkedItem();
|
||||
budget.updateBudgetItem.mockReturnValue({ id: item.id });
|
||||
svc.syncBudgetOnUpdate(String(trip.id), String(res.id), 'New', 'lodging', 'Old', 'lodging', { total_price: 80 }, 'sock');
|
||||
expect(budget.updateBudgetItem).toHaveBeenCalledWith(item.id, String(trip.id), { name: 'New', category: 'lodging', total_price: 80 });
|
||||
expect(broadcast).toHaveBeenCalledWith(String(trip.id), 'budget:updated', { item: { id: item.id } }, 'sock');
|
||||
});
|
||||
|
||||
it('creates + links a new item when none exists, using the current title fallback', () => {
|
||||
dbMock._stmt.get.mockReturnValue(undefined); // no existing
|
||||
budget.createBudgetItem.mockReturnValue({ id: 9 });
|
||||
svc().syncBudgetOnUpdate('5', '9', '', undefined, 'Old title', 'flight', { total_price: 120 }, 'sock');
|
||||
expect(budget.createBudgetItem).toHaveBeenCalledWith('5', { name: 'Old title', category: 'flight', total_price: 120 });
|
||||
expect(dbMock._stmt.run).toHaveBeenCalled(); // UPDATE budget_items SET reservation_id
|
||||
expect(broadcast).toHaveBeenCalledWith('5', 'budget:created', { item: { id: 9, reservation_id: 9 } }, 'sock');
|
||||
const { trip } = ownerTrip();
|
||||
const res = createReservation(testDb, trip.id);
|
||||
const created = createBudgetItem(testDb, trip.id); // the row the mocked create "returns"
|
||||
budget.createBudgetItem.mockReturnValue({ id: created.id });
|
||||
svc.syncBudgetOnUpdate(String(trip.id), String(res.id), '', undefined, 'Old title', 'flight', { total_price: 120 }, 'sock');
|
||||
expect(budget.createBudgetItem).toHaveBeenCalledWith(String(trip.id), { name: 'Old title', category: 'flight', total_price: 120 });
|
||||
// The service back-links the created item to the reservation itself.
|
||||
expect(testDb.prepare('SELECT reservation_id FROM budget_items WHERE id = ?').get(created.id)).toEqual({ reservation_id: res.id });
|
||||
expect(broadcast).toHaveBeenCalledWith(String(trip.id), 'budget:created', { item: { id: created.id, reservation_id: res.id } }, 'sock');
|
||||
});
|
||||
});
|
||||
|
||||
it('notifyBookingChange resolves without throwing (fire-and-forget)', () => {
|
||||
expect(() => svc().notifyBookingChange('5', { id: 1, email: 'a@b.c' } as never, 'Hotel', 'lodging')).not.toThrow();
|
||||
describe('notifyBookingChange', () => {
|
||||
it('sends the booking_change notification with the legacy payload (fire-and-forget)', async () => {
|
||||
const { user, trip } = ownerTrip();
|
||||
expect(() => svc.notifyBookingChange(String(trip.id), user.id, 'Hotel', 'lodging')).not.toThrow();
|
||||
await vi.waitFor(() => expect(notif.send).toHaveBeenCalled());
|
||||
expect(notif.send).toHaveBeenCalledWith({
|
||||
event: 'booking_change',
|
||||
actorId: user.id,
|
||||
scope: 'trip',
|
||||
targetId: trip.id,
|
||||
params: { trip: trip.title, actor: user.email, booking: 'Hotel', type: 'lodging', tripId: String(trip.id) },
|
||||
});
|
||||
});
|
||||
|
||||
it('is a silent no-op when the actor does not exist', async () => {
|
||||
const { trip } = ownerTrip();
|
||||
svc.notifyBookingChange(String(trip.id), 999, 'Hotel', '');
|
||||
await new Promise(r => setTimeout(r, 10));
|
||||
expect(notif.send).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('setReservationTravelers / loadTravelers (#1517)', () => {
|
||||
/** Create a named guest user (no credentials) and add it to the trip. */
|
||||
function addGuest(tripId: number, displayName: string) {
|
||||
const { user } = createUser(testDb);
|
||||
testDb.prepare('UPDATE users SET is_guest = 1, display_name = ? WHERE id = ?').run(displayName, user.id);
|
||||
addTripMember(testDb, tripId, user.id);
|
||||
return user;
|
||||
}
|
||||
|
||||
it('RES-TRAV-001: assigns trip members and returns them via loadTravelers', () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: member } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id);
|
||||
addTripMember(testDb, trip.id, member.id);
|
||||
const res = createReservation(testDb, trip.id);
|
||||
|
||||
svc.setReservationTravelers(res.id, trip.id, [member.id]);
|
||||
|
||||
const travelers = svc.loadTravelers(res.id);
|
||||
expect(travelers).toHaveLength(1);
|
||||
expect(travelers[0]).toMatchObject({ user_id: member.id, username: member.username, is_guest: 0 });
|
||||
});
|
||||
|
||||
it('RES-TRAV-002: silently drops a user id that is not on the trip (cross-trip guard)', () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: member } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id);
|
||||
addTripMember(testDb, trip.id, member.id);
|
||||
const res = createReservation(testDb, trip.id);
|
||||
|
||||
// An outsider who belongs to a different trip only.
|
||||
const { user: outsider } = createUser(testDb);
|
||||
const otherTrip = createTrip(testDb, outsider.id);
|
||||
addTripMember(testDb, otherTrip.id, outsider.id);
|
||||
|
||||
svc.setReservationTravelers(res.id, trip.id, [member.id, outsider.id]);
|
||||
|
||||
const ids = svc.loadTravelers(res.id).map(t => t.user_id);
|
||||
expect(ids).toEqual([member.id]);
|
||||
expect(ids).not.toContain(outsider.id);
|
||||
});
|
||||
|
||||
it('RES-TRAV-003: assigns a named guest (users.is_guest = 1) joined via trip_members', () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id);
|
||||
const guest = addGuest(trip.id, 'Grandma');
|
||||
const res = createReservation(testDb, trip.id);
|
||||
|
||||
svc.setReservationTravelers(res.id, trip.id, [guest.id]);
|
||||
|
||||
const travelers = svc.loadTravelers(res.id);
|
||||
expect(travelers).toHaveLength(1);
|
||||
expect(travelers[0]).toMatchObject({ user_id: guest.id, username: 'Grandma', is_guest: 1 });
|
||||
});
|
||||
|
||||
it('RES-TRAV-004: re-setting replaces the previous set', () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: a } = createUser(testDb);
|
||||
const { user: b } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id);
|
||||
addTripMember(testDb, trip.id, a.id);
|
||||
addTripMember(testDb, trip.id, b.id);
|
||||
const res = createReservation(testDb, trip.id);
|
||||
|
||||
svc.setReservationTravelers(res.id, trip.id, [a.id]);
|
||||
expect(svc.loadTravelers(res.id).map(t => t.user_id)).toEqual([a.id]);
|
||||
|
||||
svc.setReservationTravelers(res.id, trip.id, [b.id]);
|
||||
const ids = svc.loadTravelers(res.id).map(t => t.user_id);
|
||||
expect(ids).toEqual([b.id]);
|
||||
expect(ids).not.toContain(a.id);
|
||||
|
||||
// Clearing removes everyone.
|
||||
svc.setReservationTravelers(res.id, trip.id, []);
|
||||
expect(svc.loadTravelers(res.id)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('RES-TRAV-005: list attaches travelers[] per reservation', () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: member } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id);
|
||||
addTripMember(testDb, trip.id, member.id);
|
||||
const withTravelers = createReservation(testDb, trip.id, { title: 'Flight' });
|
||||
const withoutTravelers = createReservation(testDb, trip.id, { title: 'Hotel' });
|
||||
|
||||
svc.setReservationTravelers(withTravelers.id, trip.id, [member.id]);
|
||||
|
||||
const list = svc.list(trip.id);
|
||||
const assigned = list.find(r => r.id === withTravelers.id)!;
|
||||
const empty = list.find(r => r.id === withoutTravelers.id)!;
|
||||
|
||||
expect(assigned.travelers).toHaveLength(1);
|
||||
expect(assigned.travelers![0]).toMatchObject({ user_id: member.id, username: member.username });
|
||||
expect(empty.travelers).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('reservations.bridge (legacy-named delegation for outside-container consumers)', () => {
|
||||
it('RESV-BRIDGE-001: listReservations delegates to ReservationsService.list', () => {
|
||||
const { trip } = ownerTrip();
|
||||
createReservation(testDb, trip.id, { title: 'A' });
|
||||
expect(bridge.listReservations(trip.id).map(r => r.title)).toEqual(['A']);
|
||||
});
|
||||
|
||||
it('RESV-BRIDGE-002: loadEndpointsByTrip groups endpoints per reservation', () => {
|
||||
const { trip } = ownerTrip();
|
||||
const res = createReservation(testDb, trip.id);
|
||||
testDb.prepare('INSERT INTO reservation_endpoints (reservation_id, role, sequence, name, lat, lng) VALUES (?, ?, 0, ?, 1, 2)').run(res.id, 'from', 'A');
|
||||
const map = bridge.loadEndpointsByTrip(trip.id);
|
||||
expect(map.get(res.id)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('RESV-BRIDGE-003: resyncReservationDays re-anchors through the bridge', () => {
|
||||
const { trip } = ownerTrip({ start_date: '2030-05-01', end_date: '2030-05-02' });
|
||||
const days = testDb.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY day_number').all(trip.id) as { id: number }[];
|
||||
const res = createReservation(testDb, trip.id, { title: 'T', type: 'tour', day_id: days[0].id });
|
||||
testDb.prepare('UPDATE reservations SET reservation_time = ? WHERE id = ?').run('2030-05-02T09:00:00', res.id);
|
||||
bridge.resyncReservationDays(trip.id);
|
||||
expect(testDb.prepare('SELECT day_id FROM reservations WHERE id = ?').get(res.id)).toEqual({ day_id: days[1].id });
|
||||
});
|
||||
|
||||
it('RESV-BRIDGE-004: createReservation creates through the bridge', () => {
|
||||
const { trip } = ownerTrip();
|
||||
const { reservation } = bridge.createReservation(trip.id, { title: 'Bridge' });
|
||||
expect(reservation).toMatchObject({ title: 'Bridge', type: 'other' });
|
||||
});
|
||||
|
||||
it('RESV-BRIDGE-005: getReservation is trip-scoped', () => {
|
||||
const { trip } = ownerTrip();
|
||||
const res = createReservation(testDb, trip.id);
|
||||
expect(bridge.getReservation(res.id, trip.id)).toMatchObject({ id: res.id });
|
||||
expect(bridge.getReservation(res.id, trip.id + 1)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('RESV-BRIDGE-006: getReservationWithJoins attaches endpoints + travelers', () => {
|
||||
const { trip } = ownerTrip();
|
||||
const res = createReservation(testDb, trip.id);
|
||||
expect(bridge.getReservationWithJoins(res.id)).toMatchObject({ id: res.id, endpoints: [], travelers: [] });
|
||||
});
|
||||
|
||||
it('RESV-BRIDGE-007: updateReservation updates through the bridge', () => {
|
||||
const { trip } = ownerTrip();
|
||||
const res = createReservation(testDb, trip.id, { title: 'Old' });
|
||||
const current = bridge.getReservation(res.id, trip.id)!;
|
||||
const { reservation } = bridge.updateReservation(res.id, trip.id, { title: 'New' }, current);
|
||||
expect(reservation.title).toBe('New');
|
||||
});
|
||||
|
||||
it('RESV-BRIDGE-008: deleteReservation deletes through the bridge', () => {
|
||||
const { trip } = ownerTrip();
|
||||
const res = createReservation(testDb, trip.id);
|
||||
const { deleted } = bridge.deleteReservation(res.id, trip.id);
|
||||
expect(deleted).toMatchObject({ id: res.id });
|
||||
expect(bridge.getReservation(res.id, trip.id)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('RESV-BRIDGE-009: notifyBookingChange fires the notification through the bridge', async () => {
|
||||
const { user, trip } = ownerTrip();
|
||||
bridge.notifyBookingChange(trip.id, user.id, 'Bridge booking', 'other');
|
||||
await vi.waitFor(() => expect(notif.send).toHaveBeenCalled());
|
||||
});
|
||||
});
|
||||
|
||||
describe('ReservationsService — legacy branch parity (coverage of the folded conditionals)', () => {
|
||||
it('RESV-SVC-019: create with a linked accommodation_id syncs metadata times + confirmation onto it', () => {
|
||||
const { trip } = ownerTrip({ start_date: '2030-05-01', end_date: '2030-05-02' });
|
||||
const place = createPlace(testDb, trip.id);
|
||||
const days = testDb.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY day_number').all(trip.id) as { id: number }[];
|
||||
const acc = createDayAccommodation(testDb, trip.id, place.id, days[0].id, days[1].id);
|
||||
svc.create(String(trip.id), {
|
||||
title: 'Hotel', type: 'hotel', accommodation_id: acc.id,
|
||||
metadata: { check_in_time: '15:00', check_out_time: '11:00' },
|
||||
confirmation_number: 'CN-1',
|
||||
});
|
||||
const row = testDb.prepare('SELECT check_in, check_out, confirmation FROM day_accommodations WHERE id = ?').get(acc.id);
|
||||
expect(row).toEqual({ check_in: '15:00', check_out: '11:00', confirmation: 'CN-1' });
|
||||
});
|
||||
|
||||
it('RESV-SVC-020: create with a malformed reservation_time derives no day', () => {
|
||||
const { trip } = ownerTrip({ start_date: '2030-05-01', end_date: '2030-05-02' });
|
||||
const { reservation } = svc.create(String(trip.id), { title: 'X', type: 'tour', reservation_time: 'not-a-date' });
|
||||
expect(reservation.day_id).toBeNull();
|
||||
});
|
||||
|
||||
it('RESV-SVC-021: hotel update with create_accommodation updates the linked accommodation in place', () => {
|
||||
const { trip } = ownerTrip({ start_date: '2030-05-01', end_date: '2030-05-03' });
|
||||
const place = createPlace(testDb, trip.id);
|
||||
const place2 = createPlace(testDb, trip.id, { name: 'Other Hotel' });
|
||||
const days = testDb.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY day_number').all(trip.id) as { id: number }[];
|
||||
const acc = createDayAccommodation(testDb, trip.id, place.id, days[0].id, days[1].id);
|
||||
const res = createReservation(testDb, trip.id, { title: 'Hotel', type: 'hotel' });
|
||||
testDb.prepare('UPDATE reservations SET accommodation_id = ? WHERE id = ?').run(String(acc.id), res.id);
|
||||
const current = svc.getReservation(String(res.id), String(trip.id))!;
|
||||
const { accommodationChanged } = svc.update(String(res.id), String(trip.id), {
|
||||
type: 'hotel',
|
||||
create_accommodation: { place_id: place2.id, start_day_id: days[1].id, end_day_id: days[2].id, check_in: '14:00' },
|
||||
confirmation_number: 'CN-2',
|
||||
}, current);
|
||||
expect(accommodationChanged).toBe(true);
|
||||
const row = testDb.prepare('SELECT place_id, start_day_id, end_day_id, check_in, confirmation FROM day_accommodations WHERE id = ?').get(acc.id);
|
||||
expect(row).toEqual({ place_id: place2.id, start_day_id: days[1].id, end_day_id: days[2].id, check_in: '14:00', confirmation: 'CN-2' });
|
||||
});
|
||||
|
||||
it('RESV-SVC-022: hotel update inserts a new accommodation when none is linked and a place is given', () => {
|
||||
const { trip } = ownerTrip({ start_date: '2030-05-01', end_date: '2030-05-02' });
|
||||
const place = createPlace(testDb, trip.id);
|
||||
const days = testDb.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY day_number').all(trip.id) as { id: number }[];
|
||||
const res = createReservation(testDb, trip.id, { title: 'Hotel', type: 'hotel' });
|
||||
const current = svc.getReservation(String(res.id), String(trip.id))!;
|
||||
const { reservation, accommodationChanged } = svc.update(String(res.id), String(trip.id), {
|
||||
type: 'hotel',
|
||||
create_accommodation: { place_id: place.id, start_day_id: days[0].id, end_day_id: days[1].id },
|
||||
}, current);
|
||||
expect(accommodationChanged).toBe(true);
|
||||
expect(reservation.accommodation_id).not.toBeNull();
|
||||
expect(testDb.prepare('SELECT COUNT(*) as c FROM day_accommodations WHERE trip_id = ?').get(trip.id)).toEqual({ c: 1 });
|
||||
});
|
||||
|
||||
it('RESV-SVC-023: explicit update fields bind their values; empty strings null; absent fields keep current', () => {
|
||||
const { trip } = ownerTrip();
|
||||
const place = createPlace(testDb, trip.id);
|
||||
const res = createReservation(testDb, trip.id, { title: 'T', type: 'tour' });
|
||||
testDb.prepare("UPDATE reservations SET location = 'Loc', notes = 'N', url = 'U', confirmation_number = 'C' WHERE id = ?").run(res.id);
|
||||
let current = svc.getReservation(String(res.id), String(trip.id))!;
|
||||
let { reservation } = svc.update(String(res.id), String(trip.id), {
|
||||
location: 'NewLoc', notes: '', url: 'https://x', confirmation_number: 'NEW',
|
||||
status: 'confirmed', needs_review: true, metadata: { a: 1 }, place_id: place.id,
|
||||
}, current);
|
||||
expect(reservation).toMatchObject({
|
||||
location: 'NewLoc', notes: null, url: 'https://x', confirmation_number: 'NEW',
|
||||
status: 'confirmed', needs_review: 1, metadata: JSON.stringify({ a: 1 }), place_id: place.id,
|
||||
});
|
||||
current = svc.getReservation(String(res.id), String(trip.id))!;
|
||||
({ reservation } = svc.update(String(res.id), String(trip.id), { title: 'T2' }, current));
|
||||
expect(reservation).toMatchObject({ title: 'T2', location: 'NewLoc', url: 'https://x', place_id: place.id });
|
||||
});
|
||||
|
||||
it('RESV-SVC-024: explicit day_id / end_day_id updates win over derivation; explicit null end clears', () => {
|
||||
const { trip } = ownerTrip({ start_date: '2030-05-01', end_date: '2030-05-03' });
|
||||
const days = testDb.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY day_number').all(trip.id) as { id: number }[];
|
||||
const res = createReservation(testDb, trip.id, { title: 'T', type: 'tour' });
|
||||
let current = svc.getReservation(String(res.id), String(trip.id))!;
|
||||
let { reservation } = svc.update(String(res.id), String(trip.id), { day_id: days[1].id, end_day_id: days[2].id }, current);
|
||||
expect(reservation).toMatchObject({ day_id: days[1].id, end_day_id: days[2].id });
|
||||
current = svc.getReservation(String(res.id), String(trip.id))!;
|
||||
({ reservation } = svc.update(String(res.id), String(trip.id), { end_day_id: null, reservation_end_time: '2030-05-02T18:00:00' }, current));
|
||||
// explicit end_day_id null wins over the end-time derivation
|
||||
expect(reservation.end_day_id).toBeNull();
|
||||
current = svc.getReservation(String(res.id), String(trip.id))!;
|
||||
({ reservation } = svc.update(String(res.id), String(trip.id), { reservation_end_time: '2030-05-02T18:00:00' }, current));
|
||||
// no explicit end day + an end time -> derived
|
||||
expect(reservation.end_day_id).toBe(days[1].id);
|
||||
});
|
||||
|
||||
it('RESV-SVC-025: with metadata absent, the stored metadata JSON drives the accommodation sync (confirmation falls back to current)', () => {
|
||||
const { trip } = ownerTrip({ start_date: '2030-05-01', end_date: '2030-05-02' });
|
||||
const place = createPlace(testDb, trip.id);
|
||||
const days = testDb.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY day_number').all(trip.id) as { id: number }[];
|
||||
const acc = createDayAccommodation(testDb, trip.id, place.id, days[0].id, days[1].id);
|
||||
const res = createReservation(testDb, trip.id, { title: 'Hotel', type: 'hotel' });
|
||||
testDb.prepare("UPDATE reservations SET accommodation_id = ?, metadata = ?, confirmation_number = 'KEEP' WHERE id = ?")
|
||||
.run(String(acc.id), JSON.stringify({ check_in_time: '16:00' }), res.id);
|
||||
const current = svc.getReservation(String(res.id), String(trip.id))!;
|
||||
svc.update(String(res.id), String(trip.id), { notes: 'touch' }, current);
|
||||
const row = testDb.prepare('SELECT check_in, confirmation FROM day_accommodations WHERE id = ?').get(acc.id);
|
||||
expect(row).toEqual({ check_in: '16:00', confirmation: 'KEEP' });
|
||||
});
|
||||
|
||||
it('RESV-SVC-026: resync derives the end day too, keeping the stored one when the end time is out of range', () => {
|
||||
const { trip } = ownerTrip({ start_date: '2030-05-01', end_date: '2030-05-03' });
|
||||
const days = testDb.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY day_number').all(trip.id) as { id: number }[];
|
||||
const res = createReservation(testDb, trip.id, { title: 'T', type: 'tour', day_id: days[0].id });
|
||||
testDb.prepare('UPDATE reservations SET reservation_time = ?, reservation_end_time = ?, end_day_id = ? WHERE id = ?')
|
||||
.run('2030-05-02T09:00:00', '2031-01-01T09:00:00', days[0].id, res.id);
|
||||
svc.resyncReservationDays(String(trip.id));
|
||||
// start re-anchored to day 2; out-of-range end time keeps the stored end day
|
||||
expect(testDb.prepare('SELECT day_id, end_day_id FROM reservations WHERE id = ?').get(res.id))
|
||||
.toEqual({ day_id: days[1].id, end_day_id: days[0].id });
|
||||
// A booking already on the right day is left untouched (no-change skip).
|
||||
svc.resyncReservationDays(String(trip.id));
|
||||
expect(testDb.prepare('SELECT day_id FROM reservations WHERE id = ?').get(res.id)).toEqual({ day_id: days[1].id });
|
||||
});
|
||||
|
||||
it('RESV-SVC-027: listUpcoming includes a timeless reservation whose day is in the future', () => {
|
||||
const { user, trip } = ownerTrip();
|
||||
const day = createDay(testDb, trip.id, { date: '2999-06-01' });
|
||||
createReservation(testDb, trip.id, { title: 'Timeless', day_id: day.id });
|
||||
const rows = svc.listUpcoming(user.id) as { title: string }[];
|
||||
expect(rows.map(r => r.title)).toContain('Timeless');
|
||||
});
|
||||
|
||||
it('RESV-SVC-028: list merges multiple per-day positions for one reservation', () => {
|
||||
const { trip } = ownerTrip();
|
||||
const d1 = createDay(testDb, trip.id, { date: '2030-05-01' });
|
||||
const d2 = createDay(testDb, trip.id, { date: '2030-05-02' });
|
||||
const res = createReservation(testDb, trip.id);
|
||||
testDb.prepare('INSERT INTO reservation_day_positions (reservation_id, day_id, position) VALUES (?, ?, ?)').run(res.id, d1.id, 0);
|
||||
testDb.prepare('INSERT INTO reservation_day_positions (reservation_id, day_id, position) VALUES (?, ?, ?)').run(res.id, d2.id, 3);
|
||||
const [row] = svc.list(String(trip.id));
|
||||
expect(row.day_positions).toEqual({ [d1.id]: 0, [d2.id]: 3 });
|
||||
});
|
||||
|
||||
it('RESV-SVC-029: notifyBookingChange falls back to "Untitled" and type "booking"', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
svc.notifyBookingChange('424242', user.id, 'B', '');
|
||||
await vi.waitFor(() => expect(notif.send).toHaveBeenCalled());
|
||||
expect(notif.send).toHaveBeenCalledWith(expect.objectContaining({
|
||||
params: expect.objectContaining({ trip: 'Untitled', type: 'booking' }),
|
||||
}));
|
||||
});
|
||||
|
||||
it('RESV-SVC-030: syncBudgetOnUpdate type-change sync is a no-op without a linked item or when the category was hand-picked', () => {
|
||||
const { trip } = ownerTrip();
|
||||
const res = createReservation(testDb, trip.id);
|
||||
// no linked item at all
|
||||
svc.syncBudgetOnUpdate(String(trip.id), String(res.id), 'X', 'flight', 'X', 'other', undefined, undefined);
|
||||
expect(budget.updateBudgetItem).not.toHaveBeenCalled();
|
||||
// linked item whose category no longer matches the auto-derived one
|
||||
const item = createBudgetItem(testDb, trip.id, { category: 'Hand picked' });
|
||||
testDb.prepare('UPDATE budget_items SET reservation_id = ? WHERE id = ?').run(res.id, item.id);
|
||||
svc.syncBudgetOnUpdate(String(trip.id), String(res.id), 'X', 'flight', 'X', 'other', undefined, undefined);
|
||||
expect(budget.updateBudgetItem).not.toHaveBeenCalled();
|
||||
// explicit clear with no linked item deletes nothing
|
||||
svc.syncBudgetOnUpdate(String(trip.id), '999999', 'X', 'flight', 'X', 'flight', { total_price: 0 }, undefined);
|
||||
expect(budget.deleteBudgetItem).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -31,19 +31,19 @@ vi.mock('../../../src/services/budgetService', () => ({
|
||||
listBudgetItems: () => [],
|
||||
rebaseTripCurrency: vi.fn(async () => {}),
|
||||
}));
|
||||
vi.mock('../../../src/services/reservationService', () => ({ listReservations: () => [] }));
|
||||
|
||||
import { TripsService } from '../../../src/nest/trips/trips.service';
|
||||
import type { TodoService } from '../../../src/nest/todo/todo.service';
|
||||
import type { PackingService } from '../../../src/nest/packing/packing.service';
|
||||
import type { FilesService } from '../../../src/nest/files/files.service';
|
||||
import type { ReservationsService } from '../../../src/nest/reservations/reservations.service';
|
||||
import { rebaseTripCurrency } from '../../../src/services/budgetService';
|
||||
|
||||
const todoStub = { listItems: () => [] } as unknown as TodoService;
|
||||
const packingListItems = vi.fn(() => []);
|
||||
const packingStub = { listItems: packingListItems } as unknown as PackingService;
|
||||
const filesStub = { listFiles: () => [] } as unknown as FilesService;
|
||||
function svc() { return new TripsService(new DatabaseService(dbConn), todoStub, packingStub, filesStub); }
|
||||
const reservationsStub = { list: () => [] } as unknown as ReservationsService;
|
||||
function svc() { return new TripsService(new DatabaseService(dbConn), todoStub, packingStub, filesStub, reservationsStub); }
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
describe('TripsService (wrapper delegation + bundle/copy/notify helpers)', () => {
|
||||
|
||||
@@ -245,6 +245,7 @@ const reservationsStub = {
|
||||
list(tid: string) { return [{ id: 1, trip_id: Number(tid), title: 'Flight' }]; },
|
||||
syncBudgetOnCreate() {},
|
||||
syncBudgetOnUpdate() {},
|
||||
notifyBookingChange() {},
|
||||
} as unknown as ReservationsService;
|
||||
vi.mock('../../../src/services/journeyService', () => ({
|
||||
// Consumed by the real assignments.service.ts module (loaded un-mocked as a
|
||||
|
||||
@@ -10,7 +10,7 @@ vi.mock('../../../src/db/database', () => ({ db: { prepare: vi.fn() } }));
|
||||
vi.mock('../../../src/services/adminService', () => ({ isAddonEnabled: vi.fn(() => true) }));
|
||||
vi.mock('../../../src/services/auditLog', () => ({ logError: vi.fn(), logInfo: vi.fn() }));
|
||||
vi.mock('../../../src/websocket', () => ({ broadcast: vi.fn() }));
|
||||
vi.mock('../../../src/services/reservationService', () => ({
|
||||
vi.mock('../../../src/nest/reservations/reservations.bridge', () => ({
|
||||
getReservation: vi.fn(),
|
||||
getReservationWithJoins: vi.fn(),
|
||||
updateReservation: vi.fn(),
|
||||
@@ -33,7 +33,7 @@ vi.mock('../../../src/services/airtrail/airtrailService', () => ({
|
||||
|
||||
import { pushReservationToAirtrail, runAirtrailSyncForUser } from '../../../src/services/airtrail/airtrailSync';
|
||||
import { db } from '../../../src/db/database';
|
||||
import { getReservation, getReservationWithJoins, updateReservation } from '../../../src/services/reservationService';
|
||||
import { getReservation, getReservationWithJoins, updateReservation } from '../../../src/nest/reservations/reservations.bridge';
|
||||
import { getFlight, listFlights, saveFlight } from '../../../src/services/airtrail/airtrailClient';
|
||||
import { isAirtrailWriteEnabled, getAirtrailCredentials } from '../../../src/services/airtrail/airtrailService';
|
||||
|
||||
|
||||
@@ -1,121 +0,0 @@
|
||||
/**
|
||||
* Unit tests for reservation traveler assignment — RES-TRAV-001..005.
|
||||
* The #1517 backend: assign trip members / named guests to a booking, with a
|
||||
* cross-trip guard so a stale client can't leak a user from another trip.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, beforeEach, afterAll, vi } from 'vitest';
|
||||
|
||||
const { testDb, dbMock } = vi.hoisted(() => {
|
||||
const Database = require('better-sqlite3');
|
||||
const db = new Database(':memory:');
|
||||
db.exec('PRAGMA foreign_keys = ON');
|
||||
return { testDb: db, dbMock: { db } };
|
||||
});
|
||||
vi.mock('../../../src/db/database', () => dbMock);
|
||||
|
||||
import { createTables } from '../../../src/db/schema';
|
||||
import { runMigrations } from '../../../src/db/migrations';
|
||||
import { resetTestDb } from '../../helpers/test-db';
|
||||
import { createUser, createTrip, createReservation, addTripMember } from '../../helpers/factories';
|
||||
import { setReservationTravelers, loadTravelers, listReservations } from '../../../src/services/reservationService';
|
||||
|
||||
beforeAll(() => { createTables(testDb); runMigrations(testDb); });
|
||||
beforeEach(() => resetTestDb(testDb));
|
||||
afterAll(() => testDb.close());
|
||||
|
||||
/** Create a named guest user (no credentials) and add it to the trip. */
|
||||
function addGuest(tripId: number, displayName: string) {
|
||||
const { user } = createUser(testDb);
|
||||
testDb.prepare('UPDATE users SET is_guest = 1, display_name = ? WHERE id = ?').run(displayName, user.id);
|
||||
addTripMember(testDb, tripId, user.id);
|
||||
return user;
|
||||
}
|
||||
|
||||
describe('setReservationTravelers / loadTravelers (#1517)', () => {
|
||||
it('RES-TRAV-001: assigns trip members and returns them via loadTravelers', () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: member } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id);
|
||||
addTripMember(testDb, trip.id, member.id);
|
||||
const res = createReservation(testDb, trip.id);
|
||||
|
||||
setReservationTravelers(res.id, trip.id, [member.id]);
|
||||
|
||||
const travelers = loadTravelers(res.id);
|
||||
expect(travelers).toHaveLength(1);
|
||||
expect(travelers[0]).toMatchObject({ user_id: member.id, username: member.username, is_guest: 0 });
|
||||
});
|
||||
|
||||
it('RES-TRAV-002: silently drops a user id that is not on the trip (cross-trip guard)', () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: member } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id);
|
||||
addTripMember(testDb, trip.id, member.id);
|
||||
const res = createReservation(testDb, trip.id);
|
||||
|
||||
// An outsider who belongs to a different trip only.
|
||||
const { user: outsider } = createUser(testDb);
|
||||
const otherTrip = createTrip(testDb, outsider.id);
|
||||
addTripMember(testDb, otherTrip.id, outsider.id);
|
||||
|
||||
setReservationTravelers(res.id, trip.id, [member.id, outsider.id]);
|
||||
|
||||
const ids = loadTravelers(res.id).map(t => t.user_id);
|
||||
expect(ids).toEqual([member.id]);
|
||||
expect(ids).not.toContain(outsider.id);
|
||||
});
|
||||
|
||||
it('RES-TRAV-003: assigns a named guest (users.is_guest = 1) joined via trip_members', () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id);
|
||||
const guest = addGuest(trip.id, 'Grandma');
|
||||
const res = createReservation(testDb, trip.id);
|
||||
|
||||
setReservationTravelers(res.id, trip.id, [guest.id]);
|
||||
|
||||
const travelers = loadTravelers(res.id);
|
||||
expect(travelers).toHaveLength(1);
|
||||
expect(travelers[0]).toMatchObject({ user_id: guest.id, username: 'Grandma', is_guest: 1 });
|
||||
});
|
||||
|
||||
it('RES-TRAV-004: re-setting replaces the previous set', () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: a } = createUser(testDb);
|
||||
const { user: b } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id);
|
||||
addTripMember(testDb, trip.id, a.id);
|
||||
addTripMember(testDb, trip.id, b.id);
|
||||
const res = createReservation(testDb, trip.id);
|
||||
|
||||
setReservationTravelers(res.id, trip.id, [a.id]);
|
||||
expect(loadTravelers(res.id).map(t => t.user_id)).toEqual([a.id]);
|
||||
|
||||
setReservationTravelers(res.id, trip.id, [b.id]);
|
||||
const ids = loadTravelers(res.id).map(t => t.user_id);
|
||||
expect(ids).toEqual([b.id]);
|
||||
expect(ids).not.toContain(a.id);
|
||||
|
||||
// Clearing removes everyone.
|
||||
setReservationTravelers(res.id, trip.id, []);
|
||||
expect(loadTravelers(res.id)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('RES-TRAV-005: listReservations attaches travelers[] per reservation', () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: member } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id);
|
||||
addTripMember(testDb, trip.id, member.id);
|
||||
const withTravelers = createReservation(testDb, trip.id, { title: 'Flight' });
|
||||
const withoutTravelers = createReservation(testDb, trip.id, { title: 'Hotel' });
|
||||
|
||||
setReservationTravelers(withTravelers.id, trip.id, [member.id]);
|
||||
|
||||
const list = listReservations(trip.id);
|
||||
const assigned = list.find(r => r.id === withTravelers.id);
|
||||
const empty = list.find(r => r.id === withoutTravelers.id);
|
||||
|
||||
expect(assigned.travelers).toHaveLength(1);
|
||||
expect(assigned.travelers[0]).toMatchObject({ user_id: member.id, username: member.username });
|
||||
expect(empty.travelers).toEqual([]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user