diff --git a/server/src/db/repositories/DayAccommodations.repository.ts b/server/src/db/repositories/DayAccommodations.repository.ts index f381305c8..e3ad93a2f 100644 --- a/server/src/db/repositories/DayAccommodations.repository.ts +++ b/server/src/db/repositories/DayAccommodations.repository.ts @@ -418,4 +418,24 @@ export class DayAccommodationsRepository extends TrekRepository('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 { + return await this.qb('a') + .select(['a.*']) + .where({ trip: trip_id }) + .execute('all', false); + } } diff --git a/server/src/db/repositories/ReservationEndpoints.repository.ts b/server/src/db/repositories/ReservationEndpoints.repository.ts index ed1a2eca9..31100f8e0 100644 --- a/server/src/db/repositories/ReservationEndpoints.repository.ts +++ b/server/src/db/repositories/ReservationEndpoints.repository.ts @@ -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 = 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 = 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 { /** * RS3 (`ReservationsService.loadEndpointsByTrip`) — `SELECT e.* FROM @@ -98,4 +144,93 @@ export class ReservationEndpointsRepository extends TrekRepository { 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 { + return await this.kysely() + .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 `'.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 { + 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, + }); + } } diff --git a/server/src/nest/trips/trips.service.ts b/server/src/nest/trips/trips.service.ts index 18e008039..a7770d1e1 100644 --- a/server/src/nest/trips/trips.service.ts +++ b/server/src/nest/trips/trips.service.ts @@ -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(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(); - 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(); 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(); - 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(); 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 `'.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 diff --git a/server/tests/unit/nest/trips.service.test.ts b/server/tests/unit/nest/trips.service.test.ts index 085a3b609..ce63257f3 100644 --- a/server/tests/unit/nest/trips.service.test.ts +++ b/server/tests/unit/nest/trips.service.test.ts @@ -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 (`'.0'`), not the `em.insert()`-inlined `''` 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 {