mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-23 07:26:30 +00:00
feat(accommodations): put a booked night on its check-in day
A road-trip stop is a day assignment: the rail, the server-side plan and the map all build their stops from day_assignments and only look the stay up afterwards, to hang check-in and check-out on one. A stay written without an assignment was therefore invisible to every routing surface, so a hotel entered in the day planner never reached the road-trip view and had to be entered a second time as an ordinary place. The road-trip side has always written both halves in one go; this is the day planner catching up, in the domain that writes the stay. Booking a night now also assigns its place to the check-in day, inside the transaction that already writes the stay and its partner hotel reservation, and types the place as a hotel stop when it carries no type of its own, so it reads exactly like a night booked in the road trip. Only the check-in day gets a stop; the later nights ride on the stay row. A place already planned for that day gets no second stop and the booking claims none, so cancelling it cannot delete a stop somebody placed by hand. Moving the booking moves its own stop, deleting it takes its own stop back, and a trip re-date carries it along with the stay. All four write surfaces (REST, plugin RPC and both MCP tools) run the same write and announce the same events; the answers carry the stop too, because the broadcast skips the socket that sent the request. New nullable day_assignments.accommodation_id marks a stop as a booking's own. It stays NULL for every existing row on purpose: everything planned before this was planned by hand, and no backfill rewrites a finished trip. A stay booked earlier picks up its stop the next time somebody saves it. The booking form keeps writing stays on its own: that surface is gated on reservation_edit and the day stop is day_edit work.
This commit is contained in:
@@ -4731,6 +4731,17 @@ function runMigrations(db: Database.Database): void {
|
||||
const hasSource = db.prepare("SELECT 1 FROM pragma_table_info('places') WHERE name = 'source'").get();
|
||||
if (!hasSource) db.exec('ALTER TABLE places ADD COLUMN source TEXT');
|
||||
},
|
||||
|
||||
// Provenance for a day stop that a lodging booking put there rather than the
|
||||
// traveller: it carries the stay's id, so moving or deleting the booking can
|
||||
// move or delete exactly that stop and never one somebody placed by hand.
|
||||
// Deliberately left NULL for every row that already exists: everything planned
|
||||
// before this shipped was planned by hand, and a booking must not start
|
||||
// claiming ownership of it.
|
||||
() => {
|
||||
const hasColumn = db.prepare("SELECT 1 FROM pragma_table_info('day_assignments') WHERE name = 'accommodation_id'").get();
|
||||
if (!hasColumn) db.exec('ALTER TABLE day_assignments ADD COLUMN accommodation_id INTEGER');
|
||||
},
|
||||
];
|
||||
|
||||
if (currentVersion < migrations.length) {
|
||||
|
||||
@@ -37,6 +37,11 @@ type AccommodationBody = {
|
||||
* found) bodies, create 201 / rest 200, and the cascade broadcasts (a created
|
||||
* accommodation also emits reservation:created; a delete emits the linked
|
||||
* reservation/budget deletions) with the forwarded X-Socket-Id.
|
||||
*
|
||||
* A booking also writes the day stop that puts it on the route, so the answers
|
||||
* carry that stop alongside the existing fields: the broadcast skips the socket
|
||||
* that sent the request, and the client that booked the night has to be able to
|
||||
* draw it without waiting for a reload.
|
||||
*/
|
||||
@Controller('api/trips/:tripId/accommodations')
|
||||
// TripAccessGuard resolves :tripId and 404s a trip the user cannot reach; mutations
|
||||
@@ -70,10 +75,14 @@ export class AccommodationsController {
|
||||
if (errors.length > 0) {
|
||||
throw new HttpException({ error: errors[0].message }, 404);
|
||||
}
|
||||
const accommodation = this.accommodations.create(tripId, { place_id, start_day_id, end_day_id, check_in, check_in_end, check_out, confirmation, notes } as never);
|
||||
const { accommodation, mirror } = this.accommodations.create(tripId, { place_id, start_day_id, end_day_id, check_in, check_in_end, check_out, confirmation, notes } as never);
|
||||
this.accommodations.broadcast(tripId, 'accommodation:created', { accommodation }, socketId);
|
||||
this.accommodations.broadcast(tripId, 'reservation:created', {}, socketId);
|
||||
return { accommodation };
|
||||
this.accommodations.announceMirror(tripId, mirror, (event, payload) => this.accommodations.broadcast(tripId, event, payload, socketId), socketId);
|
||||
// The stop rides in the answer as well: the broadcast above deliberately skips
|
||||
// the socket that sent the request, so without it the very session that booked
|
||||
// the night would be the one that cannot see it until a reload.
|
||||
return { accommodation, assignment: mirror.created };
|
||||
}
|
||||
|
||||
@RequirePermission('day_edit')
|
||||
@@ -95,9 +104,10 @@ export class AccommodationsController {
|
||||
if (errors.length > 0) {
|
||||
throw new HttpException({ error: errors[0].message }, 404);
|
||||
}
|
||||
const accommodation = this.accommodations.update(id, existing as never, { place_id, start_day_id, end_day_id, check_in, check_in_end, check_out, confirmation, notes } as never);
|
||||
const { accommodation, mirror } = this.accommodations.update(id, existing as never, { place_id, start_day_id, end_day_id, check_in, check_in_end, check_out, confirmation, notes } as never);
|
||||
this.accommodations.broadcast(tripId, 'accommodation:updated', { accommodation }, socketId);
|
||||
return { accommodation };
|
||||
this.accommodations.announceMirror(tripId, mirror, (event, payload) => this.accommodations.broadcast(tripId, event, payload, socketId), socketId);
|
||||
return { accommodation, assignment: mirror.created, removedAssignments: mirror.removed };
|
||||
}
|
||||
|
||||
@RequirePermission('day_edit')
|
||||
@@ -111,7 +121,8 @@ export class AccommodationsController {
|
||||
if (!this.accommodations.get(id, tripId)) {
|
||||
throw new HttpException({ error: 'Accommodation not found' }, 404);
|
||||
}
|
||||
const { linkedReservationIds, deletedBudgetItemIds } = this.accommodations.remove(id);
|
||||
const { linkedReservationIds, deletedBudgetItemIds, mirror } = this.accommodations.remove(id);
|
||||
this.accommodations.announceMirror(tripId, mirror, (event, payload) => this.accommodations.broadcast(tripId, event, payload, socketId), socketId);
|
||||
for (const reservationId of linkedReservationIds) {
|
||||
this.accommodations.broadcast(tripId, 'reservation:deleted', { reservationId }, socketId);
|
||||
}
|
||||
@@ -119,6 +130,6 @@ export class AccommodationsController {
|
||||
this.accommodations.broadcast(tripId, 'budget:deleted', { itemId }, socketId);
|
||||
}
|
||||
this.accommodations.broadcast(tripId, 'accommodation:deleted', { accommodationId: Number(id) }, socketId);
|
||||
return { success: true };
|
||||
return { success: true, removedAssignments: mirror.removed };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import { AuthService } from '../auth/auth.service';
|
||||
import { PlacesService } from '../places/places.service';
|
||||
import { noAccess, permissionDenied } from '../../mcp/tools/_shared';
|
||||
import { DatabaseService } from '../database/database.service';
|
||||
import { AccommodationsService } from './accommodations.service';
|
||||
import { AccommodationsService, type MirrorSender } from './accommodations.service';
|
||||
|
||||
function parseId(value: string | string[]): number | null {
|
||||
const n = Number(Array.isArray(value) ? value[0] : value);
|
||||
@@ -36,9 +36,15 @@ export class AccommodationsMcp {
|
||||
private readonly guards: McpToolGuardsService,
|
||||
) {}
|
||||
|
||||
/** The tools' own broadcast, handed to announceMirror so the day stop a booking
|
||||
* writes is announced exactly as the REST route announces it. */
|
||||
private mirrorSender(tripId: number): MirrorSender {
|
||||
return (event, payload) => this.guards.safeBroadcast(tripId, event, payload as Record<string, unknown>);
|
||||
}
|
||||
|
||||
@Tool({
|
||||
name: 'create_accommodation',
|
||||
description: 'Add an accommodation (hotel, Airbnb, etc.) to a trip, linked to a place and a date range.',
|
||||
description: 'Add an accommodation (hotel, Airbnb, etc.) to a trip, linked to a place and a date range. This also puts the place on its check-in day, so the stay shows up as a stop on the route.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
place_id: z.number().int().positive().describe('The place to use as the accommodation'),
|
||||
@@ -65,14 +71,15 @@ export class AccommodationsMcp {
|
||||
if (!this.guards.hasTripPermission('day_edit', tripId, ctx.userId)) return permissionDenied();
|
||||
const errors = this.accommodations.validateAccommodationRefs(tripId, place_id, start_day_id, end_day_id);
|
||||
if (errors.length > 0) return { content: [{ type: 'text' as const, text: errors.map(e => e.message).join(', ') }], isError: true };
|
||||
const accommodation = this.accommodations.createAccommodation(tripId, { place_id, start_day_id, end_day_id, check_in, check_in_end, check_out, confirmation, notes });
|
||||
const { accommodation, mirror } = this.accommodations.createAccommodation(tripId, { place_id, start_day_id, end_day_id, check_in, check_in_end, check_out, confirmation, notes });
|
||||
this.guards.safeBroadcast(tripId, 'accommodation:created', { accommodation });
|
||||
return ok({ accommodation });
|
||||
this.accommodations.announceMirror(tripId, mirror, this.mirrorSender(tripId));
|
||||
return ok({ accommodation, assignment: mirror.created });
|
||||
}
|
||||
|
||||
@Tool({
|
||||
name: 'create_place_accommodation',
|
||||
description: 'Create a new place and immediately set it as an accommodation for a date range in one atomic operation. Use place details from search_place results. Only use when the place does not yet exist — if it already exists, use create_accommodation directly. Set price + currency to record the accommodation cost so it shows on the item.',
|
||||
description: 'Create a new place, set it as an accommodation for a date range and put it on its check-in day, in one atomic operation. Use place details from search_place results. Only use when the place does not yet exist — if it already exists, use create_accommodation directly. Set price + currency to record the accommodation cost so it shows on the item.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
name: z.string().min(1).max(200),
|
||||
@@ -118,12 +125,16 @@ export class AccommodationsMcp {
|
||||
try {
|
||||
const result = this.db.transaction(() => {
|
||||
const place = this.places.create(String(tripId), { name, description, lat, lng, address, category_id, google_place_id, google_ftid, osm_id, notes: place_notes, website, phone, price, currency });
|
||||
const accommodation = this.accommodations.createAccommodation(tripId, { place_id: place.id, start_day_id, end_day_id, check_in, check_in_end, check_out, confirmation, notes: accommodation_notes });
|
||||
return { place, accommodation };
|
||||
const { accommodation, mirror } = this.accommodations.createAccommodation(tripId, { place_id: place.id, start_day_id, end_day_id, check_in, check_in_end, check_out, confirmation, notes: accommodation_notes });
|
||||
return { place, accommodation, mirror };
|
||||
});
|
||||
this.guards.safeBroadcast(tripId, 'place:created', { place: result.place });
|
||||
// The stamped copy, not the one create() returned: booking the night types the
|
||||
// place as lodging, and place:created is the only announcement it gets here.
|
||||
const place = result.mirror.stamped ?? result.place;
|
||||
this.guards.safeBroadcast(tripId, 'place:created', { place });
|
||||
this.guards.safeBroadcast(tripId, 'accommodation:created', { accommodation: result.accommodation });
|
||||
return ok(result);
|
||||
this.accommodations.announceMirror(tripId, { ...result.mirror, stamped: null }, this.mirrorSender(tripId));
|
||||
return ok({ place, accommodation: result.accommodation, assignment: result.mirror.created });
|
||||
} catch {
|
||||
return { content: [{ type: 'text' as const, text: 'Failed to create place and accommodation.' }], isError: true };
|
||||
}
|
||||
@@ -131,7 +142,7 @@ export class AccommodationsMcp {
|
||||
|
||||
@Tool({
|
||||
name: 'update_accommodation',
|
||||
description: 'Update fields on an existing accommodation.',
|
||||
description: 'Update fields on an existing accommodation. Moving it to another check-in day or place moves the stop it put on the route with it.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
accommodationId: z.number().int().positive(),
|
||||
@@ -159,14 +170,15 @@ export class AccommodationsMcp {
|
||||
if (!this.guards.hasTripPermission('day_edit', tripId, ctx.userId)) return permissionDenied();
|
||||
const existing = this.accommodations.getAccommodation(accommodationId, tripId);
|
||||
if (!existing) return { content: [{ type: 'text' as const, text: 'Accommodation not found.' }], isError: true };
|
||||
const accommodation = this.accommodations.updateAccommodation(accommodationId, existing, { place_id, start_day_id, end_day_id, check_in, check_in_end, check_out, confirmation, notes });
|
||||
const { accommodation, mirror } = this.accommodations.updateAccommodation(accommodationId, existing, { place_id, start_day_id, end_day_id, check_in, check_in_end, check_out, confirmation, notes });
|
||||
this.guards.safeBroadcast(tripId, 'accommodation:updated', { accommodation });
|
||||
return ok({ accommodation });
|
||||
this.accommodations.announceMirror(tripId, mirror, this.mirrorSender(tripId));
|
||||
return ok({ accommodation, assignment: mirror.created });
|
||||
}
|
||||
|
||||
@Tool({
|
||||
name: 'delete_accommodation',
|
||||
description: 'Delete an accommodation from a trip.',
|
||||
description: 'Delete an accommodation from a trip. The stop this booking put on its check-in day goes with it; a stop that was already there stays.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
accommodationId: z.number().int().positive(),
|
||||
@@ -181,9 +193,10 @@ export class AccommodationsMcp {
|
||||
if (!this.accommodations.getAccommodation(accommodationId, tripId)) return { content: [{ type: 'text' as const, text: 'Accommodation not found.' }], isError: true };
|
||||
// linkedReservationId stays the first one so the tool's answer keeps its shape;
|
||||
// linkedReservationIds carries the rest for a block that had more than one booking.
|
||||
const { linkedReservationId, linkedReservationIds } = this.accommodations.deleteAccommodation(accommodationId);
|
||||
const { linkedReservationId, linkedReservationIds, mirror } = this.accommodations.deleteAccommodation(accommodationId);
|
||||
this.accommodations.announceMirror(tripId, mirror, this.mirrorSender(tripId));
|
||||
this.guards.safeBroadcast(tripId, 'accommodation:deleted', { id: accommodationId, linkedReservationId, linkedReservationIds });
|
||||
return ok({ success: true, linkedReservationId, linkedReservationIds });
|
||||
return ok({ success: true, linkedReservationId, linkedReservationIds, removedAssignments: mirror.removed });
|
||||
}
|
||||
|
||||
@ResourceTemplate({
|
||||
|
||||
@@ -4,6 +4,7 @@ import { AccommodationsService } from './accommodations.service';
|
||||
import { AccommodationsRpc } from './accommodations.rpc';
|
||||
import { AccommodationsMcp } from './accommodations.mcp';
|
||||
import { PlacesModule } from '../places/places.module';
|
||||
import { AssignmentsDomainModule } from '../assignments/assignments-domain.module';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { DatabaseModule } from '../database/database.module';
|
||||
import { PermissionsModule } from '../permissions/permissions.module';
|
||||
@@ -20,9 +21,15 @@ import { McpSharedModule } from '../mcp-shared/mcp-shared.module';
|
||||
* which is a foreign key, not a module edge. PlacesModule is here only because
|
||||
* create_place_accommodation writes a place and a stay in one transaction --
|
||||
* the same reason DaysMcp could drop it.
|
||||
*
|
||||
* AssignmentsDomainModule is the service half only, the same leaf PlacesModule
|
||||
* already imports, so the day stop a booking implies is written through
|
||||
* AssignmentsService instead of a second copy of its SQL. No edge back: that
|
||||
* module reaches permissions, query helpers, journey and realtime, none of
|
||||
* which comes near accommodations.
|
||||
*/
|
||||
@Module({
|
||||
imports: [McpSharedModule, PermissionsModule, RealtimeModule, PluginGuardsModule, DatabaseModule, PlacesModule, AuthModule],
|
||||
imports: [McpSharedModule, PermissionsModule, RealtimeModule, PluginGuardsModule, DatabaseModule, PlacesModule, AssignmentsDomainModule, AuthModule],
|
||||
controllers: [AccommodationsController],
|
||||
providers: [AccommodationsService, AccommodationsRpc, AccommodationsMcp],
|
||||
exports: [AccommodationsService],
|
||||
|
||||
@@ -30,7 +30,8 @@ type AccommodationInput = {
|
||||
*
|
||||
* Everything delegates to AccommodationsService, so the auto-created partner hotel reservation,
|
||||
* the metadata sync on update and the delete cascade behave exactly like the
|
||||
* accommodations REST controller, cascade broadcasts included.
|
||||
* accommodations REST controller, cascade broadcasts included -- the day stop a booking
|
||||
* puts on the route among them.
|
||||
*/
|
||||
@PluginController()
|
||||
export class AccommodationsRpc {
|
||||
@@ -55,7 +56,7 @@ export class AccommodationsRpc {
|
||||
// Verifies the place and both days belong to this trip.
|
||||
const errors = this.days.validateAccommodationRefs(tripId, placeId, startDayId, endDayId);
|
||||
if (errors.length > 0) throw new ForbiddenResource(errors[0].message);
|
||||
const accommodation = this.days.createAccommodation(tripId, {
|
||||
const { accommodation, mirror } = this.days.createAccommodation(tripId, {
|
||||
place_id: placeId,
|
||||
start_day_id: startDayId,
|
||||
end_day_id: endDayId,
|
||||
@@ -68,6 +69,7 @@ export class AccommodationsRpc {
|
||||
this.realtime.broadcast(tripId, 'accommodation:created', { accommodation });
|
||||
// The block creates a partner hotel reservation, so the bookings view refreshes too.
|
||||
this.realtime.broadcast(tripId, 'reservation:created', {});
|
||||
this.days.announceMirror(tripId, mirror, (event, payload) => this.realtime.broadcast(tripId, event, payload));
|
||||
return accommodation;
|
||||
}
|
||||
|
||||
@@ -84,8 +86,9 @@ export class AccommodationsRpc {
|
||||
const input = parsed.data as { place_id?: number; start_day_id?: number; end_day_id?: number; check_in?: string; check_in_end?: string; check_out?: string; confirmation?: string; notes?: string };
|
||||
const errors = this.days.validateAccommodationRefs(tripId, input.place_id, input.start_day_id, input.end_day_id);
|
||||
if (errors.length > 0) throw new ForbiddenResource(errors[0].message);
|
||||
const accommodation = this.days.updateAccommodation(accommodationId, existing, input);
|
||||
const { accommodation, mirror } = this.days.updateAccommodation(accommodationId, existing, input);
|
||||
this.realtime.broadcast(tripId, 'accommodation:updated', { accommodation });
|
||||
this.days.announceMirror(tripId, mirror, (event, payload) => this.realtime.broadcast(tripId, event, payload));
|
||||
return accommodation;
|
||||
}
|
||||
|
||||
@@ -99,7 +102,8 @@ export class AccommodationsRpc {
|
||||
throw new ForbiddenResource(`no accommodation ${accommodationId} on trip ${tripId}`);
|
||||
}
|
||||
// Deleting a block can take its partner reservation and budget item with it.
|
||||
const { linkedReservationIds, deletedBudgetItemIds } = this.days.deleteAccommodation(accommodationId);
|
||||
const { linkedReservationIds, deletedBudgetItemIds, mirror } = this.days.deleteAccommodation(accommodationId);
|
||||
this.days.announceMirror(tripId, mirror, (event, payload) => this.realtime.broadcast(tripId, event, payload));
|
||||
for (const reservationId of linkedReservationIds) this.realtime.broadcast(tripId, 'reservation:deleted', { reservationId });
|
||||
for (const itemId of deletedBudgetItemIds) this.realtime.broadcast(tripId, 'budget:deleted', { itemId });
|
||||
this.realtime.broadcast(tripId, 'accommodation:deleted', { accommodationId });
|
||||
|
||||
@@ -1,12 +1,30 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import type { TrekWsPayload, TrekWsTripEventName } from '@trek/shared';
|
||||
import { RealtimeService } from '../realtime/realtime.service';
|
||||
import { DatabaseService, type TripAccess } from '../database/database.service';
|
||||
import { DatabaseService, type PlaceWithTags, type TripAccess } from '../database/database.service';
|
||||
import { PermissionsService } from '../permissions/permissions.service';
|
||||
import { AssignmentsService } from '../assignments/assignments.service';
|
||||
import type { User } from '../../types';
|
||||
|
||||
type Trip = TripAccess;
|
||||
|
||||
type MirroredAssignment = ReturnType<AssignmentsService['createAssignment']>;
|
||||
|
||||
/** How a surface sends the mirror's events; see announceMirror. */
|
||||
export type MirrorSender = <E extends TrekWsTripEventName>(event: E, payload: TrekWsPayload<E>) => void;
|
||||
|
||||
/** What a stay write did to the day plan, on top of writing the stay itself. */
|
||||
export interface AccommodationMirror {
|
||||
/** The day stop the booking added, or null when that day already held the place. */
|
||||
created: MirroredAssignment | null;
|
||||
/** Day stops the booking took back, because it moved days or was deleted. */
|
||||
removed: { id: number; dayId: number }[];
|
||||
/** The place, when this write was the one that typed it as lodging. */
|
||||
stamped: PlaceWithTags | null;
|
||||
}
|
||||
|
||||
const noMirror = (): AccommodationMirror => ({ created: null, removed: [], stamped: null });
|
||||
|
||||
export interface DayAccommodation {
|
||||
id: number;
|
||||
trip_id: number;
|
||||
@@ -43,6 +61,16 @@ export interface CreateAccommodationData {
|
||||
* not the thing doing the carrying.
|
||||
*
|
||||
* Still gated by 'day_edit', the same permission as days.
|
||||
*
|
||||
* It also owns the day stop a booking implies. A road-trip stop IS a day
|
||||
* assignment: the rail, the server-side plan and the map all build their stops
|
||||
* from day_assignments and only look the stay up afterwards, to hang check-in
|
||||
* and check-out on one. A stay written without an assignment is therefore
|
||||
* invisible to every routing surface, which is what made people enter their
|
||||
* hotel a second time as an ordinary place. The road-trip side has always
|
||||
* written both halves in one go (the place, its day, and the stay); this is the
|
||||
* day planner catching up, in the domain that writes the stay, so the next
|
||||
* surface to book a night does not have to remember it.
|
||||
*/
|
||||
@Injectable()
|
||||
export class AccommodationsService {
|
||||
@@ -50,6 +78,7 @@ export class AccommodationsService {
|
||||
private readonly dbs: DatabaseService,
|
||||
private readonly permissions: PermissionsService,
|
||||
private readonly realtime: RealtimeService,
|
||||
private readonly assignments: AssignmentsService,
|
||||
) {}
|
||||
|
||||
private get db() {
|
||||
@@ -98,6 +127,21 @@ export class AccommodationsService {
|
||||
return this.deleteAccommodation(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send what a stay write did to the day plan, and let the journey skeletons
|
||||
* catch up the way an assignment route does.
|
||||
*
|
||||
* Takes the sender rather than broadcasting itself: REST and the plugin RPC
|
||||
* hand their socket id in, the MCP tools tag their events, and the fan-out is
|
||||
* the one part that must not exist in three copies.
|
||||
*/
|
||||
announceMirror(tripId: string | number, mirror: AccommodationMirror, send: MirrorSender, socketId?: string): void {
|
||||
for (const stop of mirror.removed) send('assignment:deleted', { assignmentId: stop.id, dayId: stop.dayId });
|
||||
if (mirror.created) send('assignment:created', { assignment: mirror.created });
|
||||
if (mirror.stamped) send('place:updated', { place: mirror.stamped });
|
||||
if (mirror.created || mirror.removed.length > 0) this.assignments.reconcile(tripId, socketId);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Accommodation CRUD
|
||||
// -------------------------------------------------------------------------
|
||||
@@ -141,12 +185,88 @@ export class AccommodationsService {
|
||||
return errors;
|
||||
}
|
||||
|
||||
/**
|
||||
* Put the booking's check-in day on the map.
|
||||
*
|
||||
* Only the check-in day gets a stop, even for a fortnight's stay: that is the
|
||||
* day you drive there, and it is exactly what the road-trip side writes for a
|
||||
* night it books itself. The later nights ride on the stay row, which is where
|
||||
* the rail reads check-out from.
|
||||
*
|
||||
* Runs inside the caller's transaction.
|
||||
*/
|
||||
private mirrorStay(accommodationId: number, placeId: number | null, dayId: number): AccommodationMirror {
|
||||
const mirror = noMirror();
|
||||
// A stay can outlive its place (place_id is ON DELETE SET NULL) and the booking
|
||||
// form writes stays that never had one. Nothing to put on the map then.
|
||||
if (!placeId) return mirror;
|
||||
|
||||
// 'hotel' is a service stop: it takes no number, stays out of the day's stop
|
||||
// count and falls under the existing "show service stops in Days" switch. Without
|
||||
// the stamp a booked night made here would look nothing like one booked in the
|
||||
// road trip. Only ever filled in when it is empty: a type the traveller picked
|
||||
// (a campsite, say) is theirs.
|
||||
const place = this.db.get<{ stop_type: string | null }>('SELECT stop_type FROM places WHERE id = ?', placeId);
|
||||
if (place && !place.stop_type) {
|
||||
this.db.run("UPDATE places SET stop_type = 'hotel' WHERE id = ?", placeId);
|
||||
mirror.stamped = this.db.getPlaceWithTags(placeId);
|
||||
}
|
||||
|
||||
// The road-trip flow assigns the place to the day and only then books the night.
|
||||
// Claiming that row would make cancelling the booking delete a stop the traveller
|
||||
// placed, so the booking rides along with it and marks nothing as its own. Same
|
||||
// answer for a place already planned for that day by hand.
|
||||
if (this.db.get('SELECT id FROM day_assignments WHERE day_id = ? AND place_id = ?', dayId, placeId)) return mirror;
|
||||
|
||||
// Through AssignmentsService, so the stop lands at the end of the day with the
|
||||
// order_index every other new assignment gets. Evening is where you arrive at a
|
||||
// hotel, and the day planner has no drive to position it against anyway.
|
||||
const created = this.assignments.createAssignment(dayId, placeId);
|
||||
if (created) {
|
||||
this.db.run('UPDATE day_assignments SET accommodation_id = ? WHERE id = ?', accommodationId, created.id);
|
||||
mirror.created = created;
|
||||
}
|
||||
return mirror;
|
||||
}
|
||||
|
||||
/** The day stops this booking, and only this booking, put on the plan. */
|
||||
private ownStops(accommodationId: number) {
|
||||
return this.db.all<{ id: number; day_id: number; place_id: number }>(
|
||||
'SELECT id, day_id, place_id FROM day_assignments WHERE accommodation_id = ?', accommodationId
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Carry the mirrored stop over to wherever the booking now is.
|
||||
*
|
||||
* A booking that owns no stop gets one, which is how a stay made before any of
|
||||
* this existed picks one up: re-save it once and it appears. The cost is that a
|
||||
* mirrored stop somebody deleted by hand comes back the next time they touch the
|
||||
* booking, which is the lesser of the two surprises.
|
||||
*
|
||||
* Runs inside the caller's transaction.
|
||||
*/
|
||||
private remirrorStay(accommodationId: number, placeId: number | null, dayId: number): AccommodationMirror {
|
||||
const own = this.ownStops(accommodationId);
|
||||
if (own.length === 1 && own[0].day_id === dayId && own[0].place_id === placeId) return noMirror();
|
||||
|
||||
const mirror = noMirror();
|
||||
for (const stop of own) {
|
||||
this.db.run('DELETE FROM day_assignments WHERE id = ?', stop.id);
|
||||
mirror.removed.push({ id: stop.id, dayId: stop.day_id });
|
||||
}
|
||||
const fresh = this.mirrorStay(accommodationId, placeId, dayId);
|
||||
mirror.created = fresh.created;
|
||||
mirror.stamped = fresh.stamped;
|
||||
return mirror;
|
||||
}
|
||||
|
||||
createAccommodation(tripId: string | number, data: CreateAccommodationData) {
|
||||
const { place_id, start_day_id, end_day_id, check_in, check_in_end, check_out, confirmation, notes } = data;
|
||||
|
||||
// The stay and its partner hotel reservation are one logical write —
|
||||
// atomic, so a failed reservation insert can't leave an orphan stay.
|
||||
const accommodationId = this.db.transaction(() => {
|
||||
// The stay, its partner hotel reservation and the day stop it implies are one
|
||||
// logical write, and an atomic one, so a failed insert halfway can't leave an orphan.
|
||||
const written = this.db.transaction(() => {
|
||||
const result = this.db.run(
|
||||
'INSERT INTO day_accommodations (trip_id, place_id, start_day_id, end_day_id, check_in, check_in_end, check_out, confirmation, notes) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
tripId, place_id, start_day_id, end_day_id, check_in || null, check_in_end || null, check_out || null, confirmation || null, notes || null
|
||||
@@ -170,10 +290,10 @@ export class AccommodationsService {
|
||||
Object.keys(meta).length > 0 ? JSON.stringify(meta) : null
|
||||
);
|
||||
|
||||
return newId;
|
||||
return { accommodationId: newId, mirror: this.mirrorStay(Number(newId), place_id ?? null, start_day_id) };
|
||||
});
|
||||
|
||||
return this.getAccommodationWithPlace(accommodationId);
|
||||
return { accommodation: this.getAccommodationWithPlace(written.accommodationId), mirror: written.mirror };
|
||||
}
|
||||
|
||||
getAccommodation(id: string | number, tripId: string | number) {
|
||||
@@ -193,10 +313,15 @@ export class AccommodationsService {
|
||||
const newConfirmation = fields.confirmation !== undefined ? fields.confirmation : existing.confirmation;
|
||||
const newNotes = fields.notes !== undefined ? fields.notes : existing.notes;
|
||||
|
||||
this.db.run(
|
||||
'UPDATE day_accommodations SET place_id = ?, start_day_id = ?, end_day_id = ?, check_in = ?, check_in_end = ?, check_out = ?, confirmation = ?, notes = ? WHERE id = ?',
|
||||
newPlaceId, newStartDayId, newEndDayId, newCheckIn, newCheckInEnd, newCheckOut, newConfirmation, newNotes, id
|
||||
);
|
||||
// The stay row and the day stop that mirrors it describe the same booking, so a
|
||||
// move that wrote only one of the two must not survive.
|
||||
const mirror = this.db.transaction(() => {
|
||||
this.db.run(
|
||||
'UPDATE day_accommodations SET place_id = ?, start_day_id = ?, end_day_id = ?, check_in = ?, check_in_end = ?, check_out = ?, confirmation = ?, notes = ? WHERE id = ?',
|
||||
newPlaceId, newStartDayId, newEndDayId, newCheckIn, newCheckInEnd, newCheckOut, newConfirmation, newNotes, id
|
||||
);
|
||||
return this.remirrorStay(Number(id), newPlaceId, newStartDayId);
|
||||
});
|
||||
|
||||
// Sync check-in/out/confirmation to every linked reservation. The booking form
|
||||
// lets more than one hotel booking point at the same block and there is no
|
||||
@@ -212,7 +337,7 @@ export class AccommodationsService {
|
||||
JSON.stringify(meta), newConfirmation || null, res.id);
|
||||
}
|
||||
|
||||
return this.getAccommodationWithPlace(Number(id));
|
||||
return { accommodation: this.getAccommodationWithPlace(Number(id)), mirror };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -230,6 +355,7 @@ export class AccommodationsService {
|
||||
deletedBudgetItemId: number | null;
|
||||
linkedReservationIds: number[];
|
||||
deletedBudgetItemIds: number[];
|
||||
mirror: AccommodationMirror;
|
||||
} {
|
||||
return this.db.transaction(() => {
|
||||
const linkedRes = this.db.all<{ id: number }>('SELECT id FROM reservations WHERE accommodation_id = ?', Number(id));
|
||||
@@ -243,6 +369,15 @@ export class AccommodationsService {
|
||||
this.db.run('DELETE FROM reservations WHERE id = ?', res.id);
|
||||
}
|
||||
|
||||
// Only the stops this booking put there itself. A stop the traveller placed
|
||||
// and then booked a night at keeps standing, which is how cancelling a night
|
||||
// in the road trip has always behaved.
|
||||
const mirror = noMirror();
|
||||
for (const stop of this.ownStops(Number(id))) {
|
||||
this.db.run('DELETE FROM day_assignments WHERE id = ?', stop.id);
|
||||
mirror.removed.push({ id: stop.id, dayId: stop.day_id });
|
||||
}
|
||||
|
||||
this.db.run('DELETE FROM day_accommodations WHERE id = ?', id);
|
||||
const linkedReservationIds = linkedRes.map(r => r.id);
|
||||
return {
|
||||
@@ -250,6 +385,7 @@ export class AccommodationsService {
|
||||
deletedBudgetItemId: deletedBudgetItemIds[0] ?? null,
|
||||
linkedReservationIds,
|
||||
deletedBudgetItemIds,
|
||||
mirror,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -365,6 +365,16 @@ export class DaysService {
|
||||
ELSE :date || SUBSTR(reservation_time, 11) END
|
||||
WHERE accommodation_id = :accId AND type = 'hotel'
|
||||
`);
|
||||
// The day stop a booking wrote moves with it, the way its linked booking does.
|
||||
// Left behind it would sit on a day the traveller no longer sleeps there, with
|
||||
// nothing on screen to say why. Re-indexed to the end of the target day, because
|
||||
// its old position belonged to a day it is leaving.
|
||||
const moveStayStop = this.db.prepare(`
|
||||
UPDATE day_assignments
|
||||
SET day_id = :dayId,
|
||||
order_index = COALESCE((SELECT MAX(order_index) FROM day_assignments WHERE day_id = :dayId), -1) + 1
|
||||
WHERE accommodation_id = :accId
|
||||
`);
|
||||
|
||||
for (const stay of stays) {
|
||||
const oldStartDate = prevDateByDayId.get(stay.start_day_id);
|
||||
@@ -375,6 +385,7 @@ export class DaysService {
|
||||
if (newStart && newEnd && newStart.day_number <= newEnd.day_number
|
||||
&& (newStart.id !== stay.start_day_id || newEnd.id !== stay.end_day_id)) {
|
||||
updateStay.run(newStart.id, newEnd.id, stay.id);
|
||||
moveStayStop.run({ dayId: newStart.id, accId: stay.id });
|
||||
stay.start_day_id = newStart.id;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -562,7 +562,14 @@ export class ReservationsService {
|
||||
|
||||
let accommodationCreated = false;
|
||||
|
||||
// Auto-create accommodation for hotel reservations
|
||||
// Auto-create accommodation for hotel reservations.
|
||||
//
|
||||
// Deliberately NOT through AccommodationsService, which would also put the
|
||||
// place on its check-in day: this surface is gated on 'reservation_edit' and
|
||||
// that stop is 'day_edit' work, and the two are configured separately. A
|
||||
// booking clerk must not rewrite the day plan as a side effect. A hotel
|
||||
// booked here therefore still reaches the route once somebody opens the stay
|
||||
// and saves it.
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user