refactor(trips): trip copy through repositories for the roadtrip, accommodation and reservation tables

This commit is contained in:
jubnl
2026-09-23 16:19:19 +02:00
parent 5c14f228ce
commit 47d17d3820
4 changed files with 320 additions and 62 deletions
@@ -418,4 +418,24 @@ export class DayAccommodationsRepository extends TrekRepository<DayAccommodation
.where({ trip: trip_id, place: place_id })
.execute<{ id: number }[]>('all', false);
}
// ---------------------------------------------------------------------------
// Plan 3d Task 6 (`trips.service.ts::copy`) — additive, appended after
// Task 3's own methods above, per this task's file-ownership rule.
// ---------------------------------------------------------------------------
/**
* TP55 (`trips.service.ts::copy`'s day_accommodations read) — `SELECT *
* FROM day_accommodations WHERE trip_id = ?`, no `ORDER BY` (matching the
* legacy statement exactly — each row is independently remapped and
* inserted, so read order doesn't affect the copy). {@link
* DayAccommodationRow} is already this table's full-column shape (AC34's
* own `findInTrip` projection), reused here rather than declared again.
*/
async listAllForTrip(trip_id: number): Promise<DayAccommodationRow[]> {
return await this.qb('a')
.select(['a.*'])
.where({ trip: trip_id })
.execute<DayAccommodationRow[]>('all', false);
}
}
@@ -1,4 +1,5 @@
import type { ReservationEndpoints } from '../entities/ReservationEndpoints.entity';
import { Reservations } from '../entities/Reservations.entity';
import { type AssertRowKeys } from './_shared/rows';
import { TrekRepository } from './_shared/trek-repository';
@@ -20,6 +21,51 @@ export interface ReservationEndpointRow {
const _reservationEndpointRowKeys: AssertRowKeys<ReservationEndpointRow, ReservationEndpoints> = true;
/**
* TP58's `SELECT *` shape — every scalar column of `Reservations`. Declared
* here, not on `Reservations.repository.ts` (which has its own, differently
* named row type for the same shape, RS35's `ReservationAllColumnsRow`):
* Task 4 owns that file for Plan 3d Task 6's tree window, so the
* reservations-copy read/write (TP58/TP59) live on this repository instead
* — see {@link ReservationEndpointsRepository.listAllForTrip}.
*/
export interface ReservationCopySourceRow {
id: number;
trip_id: number;
day_id: number | null;
end_day_id: number | null;
place_id: number | null;
assignment_id: number | null;
title: string;
accommodation_id: string | null;
reservation_time: string | null;
reservation_end_time: string | null;
location: string | null;
confirmation_number: string | null;
notes: string | null;
status: string | null;
type: string | null;
created_at: string | null;
metadata: string | null;
day_plan_position: number | null;
needs_review: number;
external_source: string | null;
external_id: string | null;
external_owner_user_id: number | null;
external_synced_at: string | null;
sync_enabled: number | null;
external_hash: string | null;
url: string | null;
ingest_state: string;
}
const _reservationCopySourceRowKeys: AssertRowKeys<ReservationCopySourceRow, Reservations> = true;
/** Kysely typing for TP58 — this repository is templated on `ReservationEndpoints`, which has no relation to walk to `reservations`' own scalar columns, so the read goes through Kysely directly (program rule 23). */
interface ReservationsCopyKyselyDB {
reservations: ReservationCopySourceRow;
}
export class ReservationEndpointsRepository extends TrekRepository<ReservationEndpoints> {
/**
* RS3 (`ReservationsService.loadEndpointsByTrip`) — `SELECT e.* FROM
@@ -98,4 +144,93 @@ export class ReservationEndpointsRepository extends TrekRepository<ReservationEn
async setLocalDate(id: number, local_date: string): Promise<void> {
await this.nativeUpdate({ id }, { local_date });
}
// ---------------------------------------------------------------------------
// Plan 3d Task 6 (`trips.service.ts::copy`) — additive. Endpoints/travelers/
// day-positions are never copied with a reservation (R7, `copy` never
// copied them — kept); these two methods reach the PARENT `reservations`
// table only, for TP58/TP59, because Task 4 owns `Reservations
// .repository.ts` for this same tree window ("a needed method in the
// other's repository is requested via the report, not written").
// ---------------------------------------------------------------------------
/**
* TP58 (`trips.service.ts::copy`'s reservations read) — `SELECT * FROM
* reservations WHERE trip_id = ?`, no `ORDER BY` (matching the legacy
* statement; the copy loop's own id-remap doesn't depend on read order).
*/
async listAllForTrip(trip_id: number): Promise<ReservationCopySourceRow[]> {
return await this.kysely<ReservationsCopyKyselyDB>()
.selectFrom('reservations')
.selectAll()
.where('trip_id', '=', trip_id)
.execute();
}
/**
* TP59 (`trips.service.ts::copy`'s reservation INSERT) — `INSERT INTO
* reservations (trip_id, day_id, end_day_id, place_id, assignment_id,
* accommodation_id, title, reservation_time, reservation_end_time,
* location, confirmation_number, notes, url, status, type, metadata,
* day_plan_position, needs_review, ingest_state) VALUES (?×19)`. The
* `external_*`/`sync_enabled`/`created_at` columns are deliberately
* omitted (the legacy statement's own column list never named them — a
* copy must not inherit the source's external sync identity).
*
* `this.getEntityManager().insert(Reservations, …)`, not `this.insert()`:
* this repository is templated on `ReservationEndpoints`, so `this.insert`
* would write THAT table. `getEntityManager()` is `TrekRepository`'s own
* validated override (its class docstring), so this still fails closed
* outside a request context/open transaction exactly like every other
* write in this file — the escape hatch is safe, not a bypass.
*
* `accommodation_id` arrives ALREADY formatted as the legacy `'<id>.0'`
* TEXT shape (`trips.service.ts`'s own `legacyAccommodationIdText`, the
* program's Plan 3d Task 3 `"14.0"` finding): a plain JS number written
* here would be inlined as a SQL literal (rule 22) and stored as `'14'`,
* not the shape the legacy raw-bound statement produced.
*/
async insertReservationCopy(input: {
trip_id: number;
day_id: number | null;
end_day_id: number | null;
place_id: number | null;
assignment_id: number | null;
accommodation_id: string | null;
title: string;
reservation_time: string | null;
reservation_end_time: string | null;
location: string | null;
confirmation_number: string | null;
notes: string | null;
url: string | null;
status: string | null;
type: string | null;
metadata: string | null;
day_plan_position: number | null;
needs_review: number;
ingest_state: string;
}): Promise<number> {
return await this.getEntityManager().insert(Reservations, {
trip: input.trip_id,
day: input.day_id,
endDay: input.end_day_id,
place: input.place_id,
assignment: input.assignment_id,
accommodation_id: input.accommodation_id,
title: input.title,
reservation_time: input.reservation_time,
reservation_end_time: input.reservation_end_time,
location: input.location,
confirmation_number: input.confirmation_number,
notes: input.notes,
url: input.url,
status: input.status,
type: input.type,
metadata: input.metadata,
day_plan_position: input.day_plan_position,
needs_review: input.needs_review,
ingest_state: input.ingest_state,
});
}
}
+112 -49
View File
@@ -10,6 +10,12 @@ import { DayAssignments } from '../../db/entities/DayAssignments.entity';
import { AssignmentParticipants } from '../../db/entities/AssignmentParticipants.entity';
import { Tags } from '../../db/entities/Tags.entity';
import { DayNotes } from '../../db/entities/DayNotes.entity';
import { RoadtripVias } from '../../db/entities/RoadtripVias.entity';
import { RoadtripDayTracks } from '../../db/entities/RoadtripDayTracks.entity';
import { RoadtripPreferences } from '../../db/entities/RoadtripPreferences.entity';
import { RoadtripDayBoundaries } from '../../db/entities/RoadtripDayBoundaries.entity';
import { DayAccommodations } from '../../db/entities/DayAccommodations.entity';
import { ReservationEndpoints } from '../../db/entities/ReservationEndpoints.entity';
import { MAX_TRIP_DAYS, tripSpanDays, type ActiveTrip, type TrekWsPayload, type TrekWsTripEventName } from '@trek/shared';
import { RealtimeService } from '../realtime/realtime.service';
import { PermissionsService } from '../permissions/permissions.service';
@@ -52,6 +58,27 @@ export function withoutFeedToken<T>(row: T): T {
return row;
}
/**
* `copy`'s (TP59) `reservations.accommodation_id` write. Mirrors
* `AccommodationsService`'s own `legacyBoundIntegerText` (Plan 3d Task 3,
* `accommodations.service.ts` — unexported there, so not importable across
* this task's file-ownership boundary; reproduced here instead, same
* one-line shape): a plain JS number bound as a `?` parameter through
* better-sqlite3 is stored as SQLite REAL regardless of integer-ness, and
* SQLite's "numeric value inserted into a TEXT column becomes text" rule
* then renders `14` as `'14.0'`, not the plain integer text `'14'` a
* SQL-literal-inlined `em.insert()` (rule 22) would produce. The legacy raw
* `copy` statement bound the new stay's id (a plain number from `accomMap`)
* the same way — reproduced explicitly here since the write now goes
* through a typed insert (`ReservationEndpointsRepository
* .insertReservationCopy`) instead of a raw bound parameter; passing an
* already-formatted STRING sidesteps the shape question entirely (SQLite
* applies no numeric conversion to a value that already has TEXT affinity).
*/
function legacyAccommodationIdText(id: number): string {
return `${id}.0`;
}
interface CreateTripData {
title: string;
description?: string | null;
@@ -206,6 +233,39 @@ export class TripsService {
return this.em.getRepository(DayNotes);
}
// Plan 3d Task 6 (`copy`'s 11 survivors — roadtrip vias/tracks/preferences/
// boundaries, day_accommodations, reservations): same `this.em
// .getRepository(...)` pattern as the Task 8 getters above, not a new
// constructor parameter.
private get roadtripViasRepo() {
return this.em.getRepository(RoadtripVias);
}
private get roadtripDayTracksRepo() {
return this.em.getRepository(RoadtripDayTracks);
}
private get roadtripPreferencesRepo() {
return this.em.getRepository(RoadtripPreferences);
}
private get roadtripDayBoundariesRepo() {
return this.em.getRepository(RoadtripDayBoundaries);
}
private get dayAccommodationsRepo() {
return this.em.getRepository(DayAccommodations);
}
// `ReservationEndpointsRepository`, not `ReservationsRepository`: Task 4
// owns `Reservations.repository.ts` for this tree window, so TP58/TP59
// (the reservations read/insert) live on a repository this task owns
// instead — see `ReservationEndpoints.repository.ts`'s own docstring on
// `listAllForTrip`/`insertReservationCopy`.
private get reservationEndpointsRepo() {
return this.em.getRepository(ReservationEndpoints);
}
async canAccessTrip(tripId: string | number, userId: number) {
const access = await this.em.getRepository(Trips).findAccessible(tripId, userId);
return access as { user_id: number } | null | undefined;
@@ -660,30 +720,27 @@ export class TripsService {
// trip that looks complete and quietly drives somewhere else — visible
// only once somebody starts editing the copy, with nothing to recover
// from. Both tables are keyed by day, so they ride on `dayMap`.
const oldVias = this.db.prepare(`
SELECT v.* FROM roadtrip_vias v JOIN days d ON d.id = v.day_id WHERE d.trip_id = ?
`).all(sourceTripId) as any[]; // TP42 — Plan 3d
const insertVia = this.db.prepare(
'INSERT INTO roadtrip_vias (day_id, after_order_index, sequence, lat, lng) VALUES (?, ?, ?, ?, ?)',
);
const oldVias = await this.roadtripViasRepo.listForTrip(Number(sourceTripId)); // TP42
for (const v of oldVias) {
const newDayId = dayMap.get(v.day_id);
if (newDayId) insertVia.run(newDayId, v.after_order_index, v.sequence, v.lat, v.lng); // TP43 — Plan 3d
if (newDayId) {
await this.roadtripViasRepo.insertVia({ day_id: newDayId, after_order_index: v.after_order_index, sequence: v.sequence, lat: v.lat, lng: v.lng }); // TP43
}
}
const oldTracks = this.db.prepare(`
SELECT t.* FROM roadtrip_day_tracks t JOIN days d ON d.id = t.day_id WHERE d.trip_id = ?
`).all(sourceTripId) as any[]; // TP44 — Plan 3d
const insertTrack = this.db.prepare(
'INSERT INTO roadtrip_day_tracks (day_id, place_id, stray_km) VALUES (?, ?, ?)',
);
const oldTracks = await this.roadtripDayTracksRepo.listForTrip(Number(sourceTripId)); // TP44
for (const t of oldTracks) {
const newDayId = dayMap.get(t.day_id);
// The track is a place of the trip, so it has been copied too — but skip
// the row rather than point it at the original, the way the assignment
// and accommodation loops below skip an id they cannot map.
const newPlaceId = placeMap.get(t.place_id);
if (newDayId && newPlaceId) insertTrack.run(newDayId, newPlaceId, t.stray_km); // TP45 — Plan 3d
// `upsertTrack`'s ON CONFLICT branch never fires on this call path:
// `newDayId` is a day that was just inserted into the brand-new trip
// above, so no `roadtrip_day_tracks` row can already exist for it —
// a plain INSERT (the legacy statement) and this upsert produce the
// identical single row.
if (newDayId && newPlaceId) await this.roadtripDayTracksRepo.upsertTrack(newDayId, newPlaceId, t.stray_km); // TP45
}
const oldTags = await this.tagsRepo.listPlaceTagsForTrip(sourceTripId); // TP46
@@ -708,15 +765,21 @@ export class TripsService {
}
}
this.db.prepare('INSERT INTO roadtrip_preferences (trip_id, key, value) SELECT ?, key, value FROM roadtrip_preferences WHERE trip_id = ?').run(newTripId, sourceTripId); // TP50 — Plan 3d
const oldBoundaries = this.db.prepare('SELECT * FROM roadtrip_day_boundaries WHERE trip_id = ?').all(sourceTripId) as {
day_number: number; from_assignment_id: number; to_assignment_id: number | null; fraction: number;
}[]; // TP51 — Plan 3d
const insertBoundary = this.db.prepare('INSERT INTO roadtrip_day_boundaries (trip_id, day_number, from_assignment_id, to_assignment_id, fraction) VALUES (?, ?, ?, ?, ?)');
const oldPreferences = await this.roadtripPreferencesRepo.listForTrip(Number(sourceTripId)); // TP50
for (const pref of oldPreferences) {
await this.roadtripPreferencesRepo.upsertValue(newTripId, pref.key, pref.value); // TP50
}
const oldBoundaries = await this.roadtripDayBoundariesRepo.listForTrip(Number(sourceTripId)); // TP51
for (const boundary of oldBoundaries) {
const from = assignmentMap.get(boundary.from_assignment_id);
const to = boundary.to_assignment_id === null ? null : assignmentMap.get(boundary.to_assignment_id);
if (from && to !== undefined) insertBoundary.run(newTripId, boundary.day_number, from, to, boundary.fraction); // TP52 — Plan 3d
// `upsertBoundary`'s ON CONFLICT branch never fires here either — the
// new trip's `roadtrip_day_boundaries` starts empty, same reasoning
// as the via/track upserts above.
if (from && to !== undefined) {
await this.roadtripDayBoundariesRepo.upsertBoundary(newTripId, { day_number: boundary.day_number, from_assignment_id: from, to_assignment_id: to, fraction: boundary.fraction }); // TP52
}
}
const oldParticipants = await this.assignmentParticipantsRepo.listForTrip(sourceTripId); // TP53
@@ -725,19 +788,18 @@ export class TripsService {
if (newAssignmentId) await this.assignmentParticipantsRepo.insertIgnore(newAssignmentId, [ap.user_id]); // TP54
}
const oldAccom = this.db.prepare('SELECT * FROM day_accommodations WHERE trip_id = ?').all(sourceTripId) as any[]; // TP55 — Plan 3d
const accomMap = new Map<number, number | bigint>();
const insertAccom = this.db.prepare(`
INSERT INTO day_accommodations (trip_id, place_id, start_day_id, end_day_id, check_in, check_in_end, check_out, confirmation, notes)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
const oldAccom = await this.dayAccommodationsRepo.listAllForTrip(Number(sourceTripId)); // TP55
const accomMap = new Map<number, number>();
for (const a of oldAccom) {
const newPlaceId = placeMap.get(a.place_id);
const newPlaceId = a.place_id != null ? placeMap.get(a.place_id) : undefined;
const newStartDay = dayMap.get(a.start_day_id);
const newEndDay = dayMap.get(a.end_day_id);
if (newPlaceId && newStartDay && newEndDay) {
const r = insertAccom.run(newTripId, newPlaceId, newStartDay, newEndDay, a.check_in, a.check_in_end, a.check_out, a.confirmation, a.notes); // TP56 — Plan 3d
accomMap.set(a.id, r.lastInsertRowid);
const newAccomId = await this.dayAccommodationsRepo.insertStay({ // TP56
trip_id: newTripId, place_id: newPlaceId, start_day_id: newStartDay, end_day_id: newEndDay,
check_in: a.check_in, check_in_end: a.check_in_end, check_out: a.check_out, confirmation: a.confirmation, notes: a.notes,
});
accomMap.set(a.id, newAccomId);
}
}
@@ -749,36 +811,37 @@ export class TripsService {
if (!a.accommodation_id) continue;
const newAssignmentId = assignmentMap.get(a.id);
const newAccomId = accomMap.get(a.accommodation_id);
if (newAssignmentId && newAccomId) await this.dayAssignmentsRepo.setAccommodation(newAssignmentId, Number(newAccomId)); // TP57
if (newAssignmentId && newAccomId) await this.dayAssignmentsRepo.setAccommodation(newAssignmentId, newAccomId); // TP57
}
const oldReservations = this.db.prepare('SELECT * FROM reservations WHERE trip_id = ?').all(sourceTripId) as any[]; // TP58 — Plan 3d
const oldReservations = await this.reservationEndpointsRepo.listAllForTrip(Number(sourceTripId)); // TP58
// The external_* / sync_enabled columns are deliberately not copied: the
// duplicate must not inherit the source's external sync identity.
const reservationMap = new Map<number, number | bigint>();
const insertReservation = this.db.prepare(`
INSERT INTO reservations (trip_id, day_id, end_day_id, place_id, assignment_id, accommodation_id, title, reservation_time, reservation_end_time,
location, confirmation_number, notes, url, status, type, metadata, day_plan_position, needs_review, ingest_state)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
const reservationMap = new Map<number, number>();
for (const r of oldReservations) {
const rr = insertReservation.run(newTripId,
r.day_id ? (dayMap.get(r.day_id) ?? null) : null,
// accommodation_id is a TEXT column, so the SOURCE value reads back
// as a string — coerce before the number-keyed map lookup or the
// link silently nulls.
const newAccomId = r.accommodation_id != null ? (accomMap.get(Number(r.accommodation_id)) ?? null) : null;
const newReservationId = await this.reservationEndpointsRepo.insertReservationCopy({
trip_id: newTripId,
day_id: r.day_id ? (dayMap.get(r.day_id) ?? null) : null,
// end_day_id is a day reference too (multi-day transport) — remap it like
// day_id, otherwise the duplicated trip loses the reservation's end-day link.
r.end_day_id ? (dayMap.get(r.end_day_id) ?? null) : null,
r.place_id ? (placeMap.get(r.place_id) ?? null) : null,
r.assignment_id ? (assignmentMap.get(r.assignment_id) ?? null) : null,
// accommodation_id is a TEXT column, so it reads back as a string —
// coerce before the number-keyed map lookup or the link silently nulls.
r.accommodation_id != null ? (accomMap.get(Number(r.accommodation_id)) ?? null) : null,
r.title, r.reservation_time, r.reservation_end_time,
r.location, r.confirmation_number, r.notes, r.url, r.status, r.type,
end_day_id: r.end_day_id ? (dayMap.get(r.end_day_id) ?? null) : null,
place_id: r.place_id ? (placeMap.get(r.place_id) ?? null) : null,
assignment_id: r.assignment_id ? (assignmentMap.get(r.assignment_id) ?? null) : null,
// the NEW value is re-formatted to the legacy raw-bound `'<id>.0'`
// TEXT shape on the way back out — see `legacyAccommodationIdText`.
accommodation_id: newAccomId != null ? legacyAccommodationIdText(newAccomId) : null,
title: r.title, reservation_time: r.reservation_time, reservation_end_time: r.reservation_end_time,
location: r.location, confirmation_number: r.confirmation_number, notes: r.notes, url: r.url, status: r.status, type: r.type,
// ingest_state travels with the copy: a staged booking must not turn
// 'live' just because the trip was duplicated, or it lands in the
// duplicate's public feed.
r.metadata, r.day_plan_position, r.needs_review ?? 0, r.ingest_state ?? 'live'); // TP59 — Plan 3d
reservationMap.set(r.id, rr.lastInsertRowid);
metadata: r.metadata, day_plan_position: r.day_plan_position, needs_review: r.needs_review ?? 0, ingest_state: r.ingest_state ?? 'live',
}); // TP59
reservationMap.set(r.id, newReservationId);
}
const oldBudget = this.db.prepare('SELECT * FROM budget_items WHERE trip_id = ?').all(sourceTripId) as any[]; // TP60 — Plan 3e
+53 -13
View File
@@ -1332,14 +1332,22 @@ describe('copy — whole-trip parity (Task 8)', () => {
testDb.prepare('INSERT INTO roadtrip_day_boundaries (trip_id, day_number, from_assignment_id, to_assignment_id, fraction) VALUES (?, 1, ?, NULL, 0.5)').run(trip.id, assignment.id);
const accomId = Number(testDb.prepare(
"INSERT INTO day_accommodations (trip_id, place_id, start_day_id, end_day_id, check_in, check_out) VALUES (?, ?, ?, ?, '15:00', '11:00')",
"INSERT INTO day_accommodations (trip_id, place_id, start_day_id, end_day_id, check_in, check_in_end, check_out, confirmation, notes) VALUES (?, ?, ?, ?, '15:00', '15:30', '11:00', 'CONF-1', 'Late checkout ok')",
).run(trip.id, stop.id, days[0].id, days[1].id).lastInsertRowid);
testDb.prepare('UPDATE day_assignments SET accommodation_id = ? WHERE id = ?').run(accomId, assignment.id);
const resId = Number(testDb.prepare(`
INSERT INTO reservations (trip_id, day_id, end_day_id, place_id, assignment_id, accommodation_id, title, status, type)
VALUES (?, ?, ?, ?, ?, ?, 'Stay', 'confirmed', 'hotel')
INSERT INTO reservations (trip_id, day_id, end_day_id, place_id, assignment_id, accommodation_id, title, reservation_time, reservation_end_time,
location, confirmation_number, notes, url, status, type, metadata, day_plan_position, needs_review, ingest_state,
external_source, external_id, sync_enabled)
VALUES (?, ?, ?, ?, ?, ?, 'Stay', '15:00', '11:00', 'Front desk', 'CONF-99', 'Bring ID', 'https://example.com/booking',
'confirmed', 'hotel', '{"note":"seed"}', 1.5, 1, 'live', 'airtrail', 'ext-123', 0)
`).run(trip.id, days[0].id, days[1].id, stop.id, assignment.id, accomId).lastInsertRowid);
// Confirms the seed itself reproduces the legacy "14.0" TEXT shape
// (this is the SOURCE row's own accommodation_id, written the same way
// the pre-migration `copy` bound it — a plain number through
// better-sqlite3 into the TEXT column).
expect((testDb.prepare('SELECT accommodation_id FROM reservations WHERE id = ?').get(resId) as { accommodation_id: string }).accommodation_id).toBe(`${accomId}.0`);
const itemId = Number(testDb.prepare(
"INSERT INTO budget_items (trip_id, category, name, total_price, reservation_id, currency) VALUES (?, 'Accommodation', 'Hotel', 300, ?, 'EUR')",
@@ -1421,23 +1429,55 @@ describe('copy — whole-trip parity (Task 8)', () => {
};
expect(newBoundary).toEqual({ day_number: 1, from_assignment_id: newAssignment.id, to_assignment_id: null, fraction: 0.5 });
// day_accommodations, and the assignment's accommodation_id stamped at the NEW accommodation
const newAccom = testDb.prepare('SELECT id, place_id, start_day_id, end_day_id, check_in, check_out FROM day_accommodations WHERE trip_id = ?').get(newTripId) as {
id: number; place_id: number | null; start_day_id: number; end_day_id: number; check_in: string | null; check_out: string | null;
// day_accommodations (TP55/TP56, byte-diffed against the source — full
// key set modulo id/trip_id/place_id/start_day_id/end_day_id/created_at,
// every one of which is either a remapped FK or excluded on principle),
// and the assignment's accommodation_id stamped at the NEW accommodation
const newAccom = testDb.prepare('SELECT * FROM day_accommodations WHERE trip_id = ?').get(newTripId) as {
id: number; place_id: number | null; start_day_id: number; end_day_id: number;
check_in: string | null; check_in_end: string | null; check_out: string | null; confirmation: string | null; notes: string | null;
};
expect(newAccom).toMatchObject({ place_id: newStop.id, start_day_id: newDays[0].id, end_day_id: newDays[1].id, check_in: '15:00', check_out: '11:00' });
const sourceAccom = testDb.prepare('SELECT check_in, check_in_end, check_out, confirmation, notes FROM day_accommodations WHERE id = ?').get(accomId) as {
check_in: string | null; check_in_end: string | null; check_out: string | null; confirmation: string | null; notes: string | null;
};
expect({ check_in: newAccom.check_in, check_in_end: newAccom.check_in_end, check_out: newAccom.check_out, confirmation: newAccom.confirmation, notes: newAccom.notes }).toEqual(sourceAccom);
expect({ place_id: newAccom.place_id, start_day_id: newAccom.start_day_id, end_day_id: newAccom.end_day_id }).toEqual({ place_id: newStop.id, start_day_id: newDays[0].id, end_day_id: newDays[1].id });
expect(newAssignmentFull.accommodation_id).toBe(newAccom.id);
// reservations
// reservations (TP58/TP59), byte-diffed against the source — full key
// set modulo id/trip_id/day_id/end_day_id/place_id/assignment_id/
// created_at (remapped FKs) and the external_*/sync_enabled columns
// (deliberately NOT copied, asserted below).
const newRes = testDb.prepare('SELECT * FROM reservations WHERE trip_id = ?').get(newTripId) as {
id: number; day_id: number | null; end_day_id: number | null; place_id: number | null; assignment_id: number | null;
title: string; status: string | null; type: string | null; ingest_state: string; accommodation_id: string | null;
title: string; reservation_time: string | null; reservation_end_time: string | null; location: string | null;
confirmation_number: string | null; notes: string | null; url: string | null; status: string | null; type: string | null;
metadata: string | null; day_plan_position: number | null; needs_review: number; ingest_state: string; accommodation_id: string | null;
external_source: string | null; external_id: string | null; sync_enabled: number | null;
};
expect(newRes).toMatchObject({
day_id: newDays[0].id, end_day_id: newDays[1].id, place_id: newStop.id, assignment_id: newAssignment.id,
title: 'Stay', status: 'confirmed', type: 'hotel', ingest_state: 'live',
expect({
title: newRes.title, reservation_time: newRes.reservation_time, reservation_end_time: newRes.reservation_end_time,
location: newRes.location, confirmation_number: newRes.confirmation_number, notes: newRes.notes, url: newRes.url,
status: newRes.status, type: newRes.type, metadata: newRes.metadata, day_plan_position: newRes.day_plan_position,
needs_review: newRes.needs_review, ingest_state: newRes.ingest_state,
}).toEqual({
title: 'Stay', reservation_time: '15:00', reservation_end_time: '11:00', location: 'Front desk',
confirmation_number: 'CONF-99', notes: 'Bring ID', url: 'https://example.com/booking', status: 'confirmed',
type: 'hotel', metadata: '{"note":"seed"}', day_plan_position: 1.5, needs_review: 1, ingest_state: 'live',
});
expect(Number(newRes.accommodation_id)).toBe(newAccom.id);
expect({ day_id: newRes.day_id, end_day_id: newRes.end_day_id, place_id: newRes.place_id, assignment_id: newRes.assignment_id }).toEqual({
day_id: newDays[0].id, end_day_id: newDays[1].id, place_id: newStop.id, assignment_id: newAssignment.id,
});
// The TRAP proof: the copy's accommodation_id is the LEGACY raw-bound
// TEXT shape (`'<id>.0'`), not the `em.insert()`-inlined `'<id>'` shape
// rule 22 would otherwise produce.
expect(newRes.accommodation_id).toBe(`${newAccom.id}.0`);
// external_* / sync_enabled are deliberately NOT copied — the duplicate
// must not inherit the source's external sync identity (the source has
// both set, above).
expect(newRes.external_source).toBeNull();
expect(newRes.external_id).toBeNull();
expect(newRes.sync_enabled).toBe(1); // the column's own DB DEFAULT, never the source's `0`
// budget_items / members / payers / category order
const newItem = testDb.prepare('SELECT * FROM budget_items WHERE trip_id = ?').get(newTripId) as {