mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-20 22:16:26 +00:00
refactor(trips): trip copy through repositories for the roadtrip, accommodation and reservation tables
This commit is contained in:
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user