mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 21:46:49 +00:00
refactor(reservations): reservations, endpoints and travelers through repositories
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { raw, type Platform, type RawQueryFragment } from '@mikro-orm/core';
|
||||
import { SqlitePlatform } from '@mikro-orm/sql';
|
||||
import type { Expression, ExpressionBuilder, ExpressionWrapper, ReferenceExpression, SqlBool, StringReference } from 'kysely';
|
||||
|
||||
/**
|
||||
* The only place a repository may spell a database function.
|
||||
@@ -500,3 +501,131 @@ export function dayDistance(platform: Platform, ref: string, isoDate: string): R
|
||||
return unsupported(platform);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Plan 3d Task 0 review (H1/L1) — the five helpers above return a MikroORM
|
||||
// `RawQueryFragment`. Handed into a Kysely statement, a `RawQueryFragment`
|
||||
// stringifies through its own `[Symbol.toPrimitive]('string')` coercion into
|
||||
// the literal text `?` (its parameter-placeholder marker, meant for the
|
||||
// MikroORM QueryBuilder's OWN parameter interpolation, not Kysely's) — Kysely
|
||||
// binds it as an actual `?` value parameter and the statement fails at
|
||||
// execution (verified directly, not assumed: `db.selectFrom(...).where(...,
|
||||
// startsWithIsoDate(platform, 'col'))` throws `SqliteError: near "?": syntax
|
||||
// error` the first time it runs). Task 2 absorbed this finding (its report,
|
||||
// H1/L1) — these are genuinely separate functions, not a wrapper over the
|
||||
// ones above, because a Kysely `Expression` and a MikroORM `RawQueryFragment`
|
||||
// are different types with no conversion between them. Each takes the
|
||||
// caller's own `ExpressionBuilder<DB, TB>` (the same "no consumer-facing
|
||||
// generic DB" shape `_shared/reservation-visibility.ts`'s `publicStayExists`
|
||||
// documents is the only one that survives `tsc` for a shared Kysely helper —
|
||||
// unlike that one, these ARE fully generic over `<DB, TB>`, because they only
|
||||
// ever call `eb.fn`/`eb.cast`/`eb(...)`, none of which hit the QueryBuilder
|
||||
// method-chaining inference limit that forced `publicStayExists` to fix its
|
||||
// shape). Platform-dispatched and fail-closed like every helper above; each
|
||||
// has an SQLF-0xx test pinning its compiled `{sql, parameters}` AND a
|
||||
// raw-statement equivalence on seeded rows (`tests/unit/db/dialect/sql-functions.test.ts`).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* The Kysely-expression twin of {@link startsWithIsoDate}: `<ref> GLOB
|
||||
* '[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]*'`, spelled through SQLite's
|
||||
* function form (`glob(X, Y)` is exactly `Y GLOB X` — the inventory's own
|
||||
* §19 finding, since Kysely's `ComparisonOperator` union has no `GLOB`
|
||||
* entry). Returns a boolean `Expression<SqlBool>`, usable in a `.where()`
|
||||
* or inside a `CASE WHEN` condition. Same digit-class pattern as the
|
||||
* MikroORM version — NOT the wider `'????-??-??*'` shorthand (a `?` in
|
||||
* SQLite GLOB matches any character, letters included).
|
||||
*/
|
||||
export function startsWithIsoDateKysely<DB, TB extends keyof DB>(
|
||||
platform: Platform,
|
||||
eb: ExpressionBuilder<DB, TB>,
|
||||
ref: ReferenceExpression<DB, TB>,
|
||||
): ExpressionWrapper<DB, TB, SqlBool> {
|
||||
if (platform instanceof SqlitePlatform) {
|
||||
return eb.fn<SqlBool>('glob', [eb.val('[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]*'), ref]);
|
||||
}
|
||||
return unsupported(platform);
|
||||
}
|
||||
|
||||
/**
|
||||
* The Kysely-expression twin of {@link substring}: `substr(<ref>,
|
||||
* <start>[, <length>])`, 1-based on both ends. `start`/`length` are
|
||||
* validated integers, the same guard `substring()` above applies, and bound
|
||||
* as genuine parameters here (`eb.val`) rather than spelled into the SQL
|
||||
* text — Kysely's `fn()` takes `ReferenceExpression`s, not a raw text
|
||||
* fragment, so there is no equivalent "spell a constant into the SQL"
|
||||
* shape to match; a bound integer literal renders identically.
|
||||
*/
|
||||
export function substringKysely<DB, TB extends keyof DB>(
|
||||
platform: Platform,
|
||||
eb: ExpressionBuilder<DB, TB>,
|
||||
ref: ReferenceExpression<DB, TB>,
|
||||
start: number,
|
||||
length?: number,
|
||||
): ExpressionWrapper<DB, TB, string> {
|
||||
if (!Number.isInteger(start) || start < 1) {
|
||||
throw new Error(`sql-functions: substringKysely needs a 1-based integer start, got ${start}`);
|
||||
}
|
||||
if (length !== undefined && (!Number.isInteger(length) || length < 0)) {
|
||||
throw new Error(`sql-functions: substringKysely needs a non-negative integer length, got ${length}`);
|
||||
}
|
||||
if (platform instanceof SqlitePlatform) {
|
||||
const args: ReferenceExpression<DB, TB>[] = length === undefined
|
||||
? [ref, eb.val(start)]
|
||||
: [ref, eb.val(start), eb.val(length)];
|
||||
return eb.fn<string>('substr', args);
|
||||
}
|
||||
return unsupported(platform);
|
||||
}
|
||||
|
||||
/** A part of a `concatKysely()` expression — a column reference, a bound value, or a nested Kysely `Expression<string>` (composes freely, unlike `concat()`'s MikroORM `RawQueryFragment` form). */
|
||||
export type KyselyConcatPart<DB, TB extends keyof DB> =
|
||||
| { column: StringReference<DB, TB> }
|
||||
| { value: string }
|
||||
| { expression: Expression<string> };
|
||||
|
||||
/**
|
||||
* The Kysely-expression twin of {@link concat}: `<part> || <part> || …`,
|
||||
* SQLite's/Postgres's string concatenation operator, built through the
|
||||
* expression builder's own binary-operator call (`eb(lhs, '||', rhs)`) so
|
||||
* it composes with {@link substringKysely}/{@link castIntegerKysely} —
|
||||
* exactly the composition `concat()`'s MikroORM form cannot do (its
|
||||
* docstring explains why).
|
||||
*/
|
||||
export function concatKysely<DB, TB extends keyof DB>(
|
||||
platform: Platform,
|
||||
eb: ExpressionBuilder<DB, TB>,
|
||||
...parts: readonly KyselyConcatPart<DB, TB>[]
|
||||
): ExpressionWrapper<DB, TB, string> {
|
||||
if (parts.length < 2) {
|
||||
throw new Error(`sql-functions: concatKysely needs at least two parts, got ${parts.length}`);
|
||||
}
|
||||
if (!(platform instanceof SqlitePlatform)) return unsupported(platform);
|
||||
const operand = (part: KyselyConcatPart<DB, TB>): Expression<string> =>
|
||||
'column' in part ? eb.ref(part.column).$castTo<string>() : 'value' in part ? eb.val(part.value) : part.expression;
|
||||
let acc: Expression<string> = operand(parts[0]!);
|
||||
for (const part of parts.slice(1)) {
|
||||
acc = eb(acc, '||', operand(part));
|
||||
}
|
||||
// The loop above always runs at least once (the `parts.length < 2` guard
|
||||
// above throws otherwise), so `acc` is always the result of the `eb(...)`
|
||||
// binary-operator call — a real `ExpressionWrapper`, not the bare
|
||||
// `Expression` interface `operand()`'s own return type declares.
|
||||
return acc as ExpressionWrapper<DB, TB, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The Kysely-expression twin of {@link castInteger}: `CAST(<ref> AS
|
||||
* INTEGER)`, for `reservations.accommodation_id` (TEXT) compared against an
|
||||
* INTEGER column inside a Kysely statement (RS20/RV2's join shape — a
|
||||
* correlated `EXISTS`/scalar-subquery context the MikroORM QueryBuilder
|
||||
* cannot express, per the inventory's own T6 ruling).
|
||||
*/
|
||||
export function castIntegerKysely<DB, TB extends keyof DB>(
|
||||
platform: Platform,
|
||||
eb: ExpressionBuilder<DB, TB>,
|
||||
ref: ReferenceExpression<DB, TB>,
|
||||
): ExpressionWrapper<DB, TB, number> {
|
||||
if (platform instanceof SqlitePlatform) return eb.cast<number>(ref, 'integer');
|
||||
return unsupported(platform);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,149 @@
|
||||
import type { DayAccommodations } from '../entities/DayAccommodations.entity';
|
||||
import { coalesceParam, columnRef } from '../dialect/sql-functions';
|
||||
import { TrekRepository } from './_shared/trek-repository';
|
||||
|
||||
export class DayAccommodationsRepository extends TrekRepository<DayAccommodations> {}
|
||||
/**
|
||||
* Plan 3d Task 2 (`ReservationsService`'s `day_accommodations` writes — RS27,
|
||||
* RS29, RS30, RS37–RS40, RS42, RS43, RS46, RS47 — plus the RS21 dispatch
|
||||
* union's `'day_accommodations'` arm). This is the reservations-owned SUBSET
|
||||
* of the repository: `days.service.ts` DY19/DY20/DY22 and `places.service.ts`
|
||||
* PL16 (Plan 3d Task 3) add methods here too — the class is shared, per the
|
||||
* inventory's own note (§7) — Task 3 appends rather than replaces.
|
||||
*
|
||||
* `day_accommodations` has no MikroORM relation to `reservations`
|
||||
* (`reservations.accommodation_id` is a bare `p.text().nullable()` column,
|
||||
* no FK — Task 0's R10 finding) — every method here that only reaches this
|
||||
* table stays on the QueryBuilder/`nativeUpdate`/`insert`, never Kysely: the
|
||||
* TEXT-vs-INTEGER join lives in `ReservationsRepository`, not here.
|
||||
*/
|
||||
export class DayAccommodationsRepository extends TrekRepository<DayAccommodations> {
|
||||
/**
|
||||
* RS27/RS40 (`ReservationsService.createInTx`/`updateInTx`'s auto-create
|
||||
* branch) — `INSERT INTO day_accommodations (trip_id, place_id,
|
||||
* start_day_id, end_day_id, check_in, check_out, confirmation) VALUES
|
||||
* (?×7)`. Seven columns only — no `check_in_end`, no `notes` (unlike
|
||||
* `AccommodationsService`'s own AC30, a 9-column insert — a DIFFERENT
|
||||
* method, D4). The caller passes already-decided values (the `|| null`
|
||||
* coercions stay in the service); `em.insert()` returns the generated PK.
|
||||
*/
|
||||
async insertBookingStay(input: {
|
||||
trip_id: number | string;
|
||||
place_id: number | null;
|
||||
start_day_id: number;
|
||||
end_day_id: number;
|
||||
check_in: string | null;
|
||||
check_out: string | null;
|
||||
confirmation: string | null;
|
||||
}): Promise<number> {
|
||||
return await this.insert({
|
||||
trip: input.trip_id,
|
||||
place: input.place_id,
|
||||
startDay: input.start_day_id,
|
||||
endDay: input.end_day_id,
|
||||
check_in: input.check_in,
|
||||
check_out: input.check_out,
|
||||
confirmation: input.confirmation,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* RS29/RS42 — `UPDATE day_accommodations SET check_in = COALESCE(?,
|
||||
* check_in), check_in_end = COALESCE(?, check_in_end), check_out =
|
||||
* COALESCE(?, check_out) WHERE id = ?`. `coalesceParam` (value-side, a
|
||||
* bound `?`) through `qb().update()` — `nativeUpdate`'s `EntityData`
|
||||
* shape doesn't accept a raw fragment value (`DayAssignmentsRepository
|
||||
* .shiftOrderFrom`'s precedent).
|
||||
*/
|
||||
async patchTimes(id: number, check_in: string | null, check_in_end: string | null, check_out: string | null): Promise<void> {
|
||||
const platform = this.getEntityManager().getPlatform();
|
||||
await this.qb()
|
||||
.update({
|
||||
check_in: coalesceParam(platform, 'check_in', check_in),
|
||||
check_in_end: coalesceParam(platform, 'check_in_end', check_in_end),
|
||||
check_out: coalesceParam(platform, 'check_out', check_out),
|
||||
})
|
||||
.where({ id })
|
||||
.execute('run');
|
||||
}
|
||||
|
||||
/** RS30/RS43 — `UPDATE day_accommodations SET confirmation = COALESCE(?, confirmation) WHERE id = ?`. */
|
||||
async patchConfirmation(id: number, confirmation: string | null): Promise<void> {
|
||||
const platform = this.getEntityManager().getPlatform();
|
||||
await this.qb()
|
||||
.update({ confirmation: coalesceParam(platform, 'confirmation', confirmation) })
|
||||
.where({ id })
|
||||
.execute('run');
|
||||
}
|
||||
|
||||
/**
|
||||
* RS37/RS46 — `SELECT id FROM day_accommodations WHERE id = ? AND trip_id
|
||||
* = ?`. `id`/`trip_id: number` (typed filter, program rule 23): both
|
||||
* callers (`updateInTx`'s re-link scoping check, `remove`'s own-stay
|
||||
* check) pass the SAME `toRowId`-parsed trip id the service method itself
|
||||
* resolved once at its gate.
|
||||
*/
|
||||
async existsInTrip(id: number, trip_id: number): Promise<boolean> {
|
||||
const row = await this.qb('a')
|
||||
.select(['a.id'])
|
||||
.where({ id, trip: trip_id })
|
||||
.execute<{ id: number } | undefined>('get', false);
|
||||
return !!row;
|
||||
}
|
||||
|
||||
/** RS38 — `SELECT check_in FROM day_accommodations WHERE id = ?`. */
|
||||
async getCheckIn(id: number): Promise<string | null | undefined> {
|
||||
const row = await this.qb('a')
|
||||
.select(['a.check_in'])
|
||||
.where({ id })
|
||||
.execute<{ check_in: string | null } | undefined>('get', false);
|
||||
return row?.check_in;
|
||||
}
|
||||
|
||||
/**
|
||||
* RS39 — `UPDATE day_accommodations SET place_id = ?, start_day_id = ?,
|
||||
* end_day_id = ?, check_in = ?, check_out = ?, confirmation = ? WHERE id =
|
||||
* ?`. Six columns (no `check_in_end`/`notes`, unlike `AccommodationsService
|
||||
* .moveStay`'s own write) — the caller's already-decided values, written
|
||||
* verbatim (the `PlacesRepository.updatePlace`/PL11 precedent: no SQL-side
|
||||
* COALESCE here, the legacy statement itself has none for these columns).
|
||||
*/
|
||||
async updateFromBooking(id: number, write: {
|
||||
place_id: number | null;
|
||||
start_day_id: number;
|
||||
end_day_id: number;
|
||||
check_in: string | null;
|
||||
check_out: string | null;
|
||||
confirmation: string | null;
|
||||
}): Promise<void> {
|
||||
await this.nativeUpdate({ id }, {
|
||||
place: write.place_id,
|
||||
startDay: write.start_day_id,
|
||||
endDay: write.end_day_id,
|
||||
check_in: write.check_in,
|
||||
check_out: write.check_out,
|
||||
confirmation: write.confirmation,
|
||||
});
|
||||
}
|
||||
|
||||
/** RS47 — `DELETE FROM day_accommodations WHERE id = ? AND trip_id = ?`, run AFTER `accommodations.dropStayStops` (ordering load-bearing, kept in the service). `id`/`trip_id: number` (typed filter, program rule 23; rule 21 — the SAME `toRowId`-parsed trip id `existsInTrip`'s gate read used). */
|
||||
async deleteInTrip(id: number, trip_id: number): Promise<void> {
|
||||
await this.nativeDelete({ id, trip: trip_id });
|
||||
}
|
||||
|
||||
/**
|
||||
* RS21's `'day_accommodations'` dispatch arm (`ReservationsService
|
||||
* .referencesOutsideTrip`'s `elsewhere('day_accommodations',
|
||||
* data.accommodation_id)` — `SELECT trip_id FROM day_accommodations WHERE
|
||||
* id = ?`). A missing row is not an offender (the caller's own `!!row &&
|
||||
* String(row.trip_id) !== String(tripId)` check, preserved in the
|
||||
* service) — this returns `undefined` on a miss, never throws.
|
||||
*/
|
||||
async getTripId(id: number): Promise<number | undefined> {
|
||||
const platform = this.getEntityManager().getPlatform();
|
||||
const row = await this.qb('a')
|
||||
.select([columnRef(platform, 'a.trip_id').as('trip_id')])
|
||||
.where({ id })
|
||||
.execute<{ trip_id: number } | undefined>('get', false);
|
||||
return row?.trip_id;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,79 @@
|
||||
import type { ReservationDayPositions } from '../entities/ReservationDayPositions.entity';
|
||||
import { columnRef } from '../dialect/sql-functions';
|
||||
import { TrekRepository } from './_shared/trek-repository';
|
||||
|
||||
export class ReservationDayPositionsRepository extends TrekRepository<ReservationDayPositions> {}
|
||||
/** `reservation_day_positions`/`reservations`/`days`'s shape for RS31's Kysely upsert. */
|
||||
interface ReservationDayPositionsKyselyDB {
|
||||
reservation_day_positions: {
|
||||
reservation_id: number;
|
||||
day_id: number;
|
||||
position: number;
|
||||
};
|
||||
reservations: {
|
||||
id: number;
|
||||
// `number | string`: the legacy statement raw-binds `tripId` exactly as
|
||||
// the caller received it (D4's T5 escape hatch) — the Kysely column type
|
||||
// here is widened to accept both without narrowing which non-canonical
|
||||
// ids match, never actually persisted (this table has no `trip_id`
|
||||
// column of its own).
|
||||
trip_id: number | string;
|
||||
};
|
||||
days: {
|
||||
id: number;
|
||||
trip_id: number | string;
|
||||
};
|
||||
}
|
||||
|
||||
export class ReservationDayPositionsRepository extends TrekRepository<ReservationDayPositions> {
|
||||
/**
|
||||
* RS19 (`ReservationsService.list`) — `SELECT rdp.reservation_id,
|
||||
* rdp.day_id, rdp.position FROM reservation_day_positions rdp JOIN
|
||||
* reservations r ON rdp.reservation_id = r.id WHERE r.trip_id = ?`. The
|
||||
* fold into `{[day_id]: position}` per reservation stays in the service.
|
||||
*/
|
||||
async listForTrip(trip_id: number): Promise<{ reservation_id: number; day_id: number; position: number }[]> {
|
||||
const platform = this.getEntityManager().getPlatform();
|
||||
// `rdp.reservation_id`/`rdp.day_id` are `persist(false)` mirrors of the
|
||||
// `reservation`/`day` relations — bare-selecting them alongside an
|
||||
// active JOIN on `rdp.reservation` (aliased `r`) is the exact trap
|
||||
// `DayAssignmentsRepository.assignmentWithPlaceSelect`'s `category_id`
|
||||
// docstring documents (the column silently aliases off the JOINED
|
||||
// entity instead); `columnRef` selects the literal physical column.
|
||||
return this.qb('rdp')
|
||||
.join('rdp.reservation', 'r')
|
||||
.select([columnRef(platform, 'rdp.reservation_id').as('reservation_id'), columnRef(platform, 'rdp.day_id').as('day_id'), 'rdp.position'])
|
||||
.where({ 'r.trip': trip_id })
|
||||
.execute<{ reservation_id: number; day_id: number; position: number }[]>('all', false);
|
||||
}
|
||||
|
||||
/**
|
||||
* RS31 (`ReservationsService.updatePositions`'s day branch) — `INSERT OR
|
||||
* REPLACE INTO reservation_day_positions (reservation_id, day_id,
|
||||
* position) SELECT r.id, d.id, ? FROM reservations r JOIN days d ON
|
||||
* d.trip_id = r.trip_id WHERE r.id = ? AND d.id = ? AND r.trip_id = ?`.
|
||||
*
|
||||
* Kysely `insertInto().orReplace().columns().expression(selectFrom…)`
|
||||
* (the MikroORM QueryBuilder's `insertFrom` has no `OR REPLACE` flag —
|
||||
* `.onConflict().merge()` is semantically different, a real UPDATE rather
|
||||
* than REPLACE's delete-then-insert). The trip scoping lives IN the
|
||||
* SELECT (a foreign `reservation_id`/`day_id` materialises zero rows to
|
||||
* insert, never a foreign-key error) — preserved exactly, not hoisted
|
||||
* into a pre-check.
|
||||
*/
|
||||
async upsertScoped(trip_id: number | string, reservation_id: number, day_id: number, position: number): Promise<void> {
|
||||
await this.kysely<ReservationDayPositionsKyselyDB>()
|
||||
.insertInto('reservation_day_positions')
|
||||
.orReplace()
|
||||
.columns(['reservation_id', 'day_id', 'position'])
|
||||
.expression((eb) =>
|
||||
eb
|
||||
.selectFrom('reservations as r')
|
||||
.innerJoin('days as d', 'd.trip_id', 'r.trip_id')
|
||||
.select(['r.id as reservation_id', 'd.id as day_id', eb.val(position).as('position')])
|
||||
.where('r.id', '=', reservation_id)
|
||||
.where('d.id', '=', day_id)
|
||||
.where('r.trip_id', '=', trip_id),
|
||||
)
|
||||
.execute();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,101 @@
|
||||
import type { ReservationEndpoints } from '../entities/ReservationEndpoints.entity';
|
||||
import { type AssertRowKeys } from './_shared/rows';
|
||||
import { TrekRepository } from './_shared/trek-repository';
|
||||
|
||||
export class ReservationEndpointsRepository extends TrekRepository<ReservationEndpoints> {}
|
||||
/** A `reservation_endpoints` row exactly as `r.*`/`SELECT *` read it (every scalar column). */
|
||||
export interface ReservationEndpointRow {
|
||||
id: number;
|
||||
reservation_id: number;
|
||||
role: string;
|
||||
sequence: number;
|
||||
name: string;
|
||||
code: string | null;
|
||||
lat: number;
|
||||
lng: number;
|
||||
timezone: string | null;
|
||||
local_time: string | null;
|
||||
local_date: string | null;
|
||||
created_at: string | null;
|
||||
}
|
||||
|
||||
const _reservationEndpointRowKeys: AssertRowKeys<ReservationEndpointRow, ReservationEndpoints> = true;
|
||||
|
||||
export class ReservationEndpointsRepository extends TrekRepository<ReservationEndpoints> {
|
||||
/**
|
||||
* RS3 (`ReservationsService.loadEndpointsByTrip`) — `SELECT e.* FROM
|
||||
* reservation_endpoints e JOIN reservations r ON e.reservation_id = r.id
|
||||
* WHERE r.trip_id = ? ORDER BY e.reservation_id, e.sequence`. The
|
||||
* per-reservation grouping into a `Map` stays in the service (JS, not SQL).
|
||||
*/
|
||||
async listForTrip(trip_id: number): Promise<ReservationEndpointRow[]> {
|
||||
return this.qb('e')
|
||||
.join('e.reservation', 'r')
|
||||
.select(['e.*'])
|
||||
.where({ 'r.trip': trip_id })
|
||||
.orderBy({ 'e.reservation': 'asc', 'e.sequence': 'asc' })
|
||||
.execute<ReservationEndpointRow[]>('all', false);
|
||||
}
|
||||
|
||||
/**
|
||||
* RR2 (`ReservationsReadService.loadEndpoints`) — `SELECT * FROM
|
||||
* reservation_endpoints WHERE reservation_id = ? ORDER BY sequence`.
|
||||
*/
|
||||
async listForReservation(reservation_id: number): Promise<ReservationEndpointRow[]> {
|
||||
return this.qb('e')
|
||||
.select(['e.*'])
|
||||
.where({ reservation: reservation_id })
|
||||
.orderBy({ sequence: 'asc' })
|
||||
.execute<ReservationEndpointRow[]>('all', false);
|
||||
}
|
||||
|
||||
/** RS16 (`ReservationsService.saveEndpoints`'s replace-all) — `DELETE FROM reservation_endpoints WHERE reservation_id = ?`. */
|
||||
async deleteForReservation(reservation_id: number): Promise<void> {
|
||||
await this.nativeDelete({ reservation: reservation_id });
|
||||
}
|
||||
|
||||
/**
|
||||
* RS17 — `INSERT INTO reservation_endpoints (reservation_id, role,
|
||||
* sequence, name, code, lat, lng, timezone, local_time, local_date) VALUES
|
||||
* (?×10)`, one row per call (the legacy `prepare` + per-row `run`). The
|
||||
* lat/lng-null skip and the `sequence ?? i` (index AFTER the filter) stay
|
||||
* in the service — this writes exactly the row it is handed.
|
||||
*/
|
||||
async insertEndpoint(input: {
|
||||
reservation_id: number;
|
||||
role: string;
|
||||
sequence: number;
|
||||
name: string;
|
||||
code: string | null;
|
||||
lat: number;
|
||||
lng: number;
|
||||
timezone: string | null;
|
||||
local_time: string | null;
|
||||
local_date: string | null;
|
||||
}): Promise<number> {
|
||||
return await this.insert({
|
||||
reservation: input.reservation_id,
|
||||
role: input.role,
|
||||
sequence: input.sequence,
|
||||
name: input.name,
|
||||
code: input.code,
|
||||
lat: input.lat,
|
||||
lng: input.lng,
|
||||
timezone: input.timezone,
|
||||
local_time: input.local_time,
|
||||
local_date: input.local_date,
|
||||
});
|
||||
}
|
||||
|
||||
/** DY17 (`DaysService.restampReservationDates`) — `SELECT id, local_date FROM reservation_endpoints WHERE reservation_id = ?`. */
|
||||
async listIdAndDate(reservation_id: number): Promise<{ id: number; local_date: string | null }[]> {
|
||||
return this.qb('e')
|
||||
.select(['e.id', 'e.local_date'])
|
||||
.where({ reservation: reservation_id })
|
||||
.execute<{ id: number; local_date: string | null }[]>('all', false);
|
||||
}
|
||||
|
||||
/** DY18 — `UPDATE reservation_endpoints SET local_date = ? WHERE id = ?`. */
|
||||
async setLocalDate(id: number, local_date: string): Promise<void> {
|
||||
await this.nativeUpdate({ id }, { local_date });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,77 @@
|
||||
import type { ReservationTravelers } from '../entities/ReservationTravelers.entity';
|
||||
import { coalesce, columnRef } from '../dialect/sql-functions';
|
||||
import { TrekRepository } from './_shared/trek-repository';
|
||||
|
||||
export class ReservationTravelersRepository extends TrekRepository<ReservationTravelers> {}
|
||||
/** A traveler row as RS6/RR3 project it (before `toTraveler()`'s `avatar_url` add). */
|
||||
export interface TravelerJoinRow {
|
||||
reservation_id: number;
|
||||
user_id: number;
|
||||
username: string;
|
||||
avatar: string | null;
|
||||
is_guest: number | null;
|
||||
}
|
||||
|
||||
export class ReservationTravelersRepository extends TrekRepository<ReservationTravelers> {
|
||||
/**
|
||||
* RS6 (`ReservationsService.loadTravelersByTrip`) — `SELECT
|
||||
* rt.reservation_id, rt.user_id, COALESCE(u.display_name, u.username) AS
|
||||
* username, u.avatar, u.is_guest FROM reservation_travelers rt JOIN
|
||||
* reservations r ON rt.reservation_id = r.id JOIN users u ON rt.user_id =
|
||||
* u.id WHERE r.trip_id = ? ORDER BY rt.reservation_id`.
|
||||
*/
|
||||
async listForTrip(trip_id: number): Promise<TravelerJoinRow[]> {
|
||||
const platform = this.getEntityManager().getPlatform();
|
||||
// `rt.reservation_id`/`rt.user_id` are `persist(false)` mirrors of the
|
||||
// `reservation`/`user` relations, both ALSO actively joined here
|
||||
// (aliased `r`/`u`) — the exact trap `DayAssignmentsRepository
|
||||
// .assignmentWithPlaceSelect`'s `category_id` docstring documents
|
||||
// (a bare select silently aliases off the joined entity instead);
|
||||
// `columnRef` selects the literal physical columns.
|
||||
return this.qb('rt')
|
||||
.join('rt.reservation', 'r')
|
||||
.join('rt.user', 'u')
|
||||
.select([columnRef(platform, 'rt.reservation_id').as('reservation_id'), columnRef(platform, 'rt.user_id').as('user_id'), coalesce(platform, 'u.display_name', 'u.username').as('username'), 'u.avatar', 'u.is_guest'])
|
||||
.where({ 'r.trip': trip_id })
|
||||
.orderBy({ 'rt.reservation': 'asc' })
|
||||
.execute<TravelerJoinRow[]>('all', false);
|
||||
}
|
||||
|
||||
/**
|
||||
* RR3 (`ReservationsReadService.loadTravelers`) — `SELECT rt.user_id,
|
||||
* COALESCE(u.display_name, u.username) AS username, u.avatar, u.is_guest
|
||||
* FROM reservation_travelers rt JOIN users u ON rt.user_id = u.id WHERE
|
||||
* rt.reservation_id = ?` — **no ORDER BY** (unlike `listForTrip`), matching
|
||||
* the legacy statement's own row order (SQLite's).
|
||||
*/
|
||||
async listForReservation(reservation_id: number): Promise<Omit<TravelerJoinRow, 'reservation_id'>[]> {
|
||||
const platform = this.getEntityManager().getPlatform();
|
||||
return this.qb('rt')
|
||||
.join('rt.user', 'u')
|
||||
.select([columnRef(platform, 'rt.user_id').as('user_id'), coalesce(platform, 'u.display_name', 'u.username').as('username'), 'u.avatar', 'u.is_guest'])
|
||||
.where({ reservation: reservation_id })
|
||||
.execute<Omit<TravelerJoinRow, 'reservation_id'>[]>('all', false);
|
||||
}
|
||||
|
||||
/** RS8 (`ReservationsService.setReservationTravelers`'s replace-all) — `DELETE FROM reservation_travelers WHERE reservation_id = ?`. */
|
||||
async deleteForReservation(reservation_id: number): Promise<void> {
|
||||
await this.nativeDelete({ reservation: reservation_id });
|
||||
}
|
||||
|
||||
/**
|
||||
* RS9 — `INSERT OR IGNORE INTO reservation_travelers (reservation_id,
|
||||
* user_id) VALUES (?, ?)`, run once per id. `upsertMany` with
|
||||
* `onConflictAction: 'ignore'` on the table's real unique key
|
||||
* (`UNIQUE(reservation_id, user_id)`, `ReservationTravelers.entity.ts`'s
|
||||
* `uniques`), named by entity property (`reservation`/`user`, the
|
||||
* `AssignmentParticipantsRepository.insertIgnore` precedent). The caller
|
||||
* roster-scopes and dedupes the ids before calling; the empty-array guard
|
||||
* here is defensive, matching the service's own guard.
|
||||
*/
|
||||
async insertIgnore(reservation_id: number, user_ids: number[]): Promise<void> {
|
||||
if (user_ids.length === 0) return;
|
||||
await this.upsertMany(
|
||||
user_ids.map((user_id) => ({ reservation: reservation_id, user: user_id })),
|
||||
{ onConflictFields: ['reservation', 'user'], onConflictAction: 'ignore' },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,458 @@
|
||||
import type { Reservations } from '../entities/Reservations.entity';
|
||||
import { DayAssignments } from '../entities/DayAssignments.entity';
|
||||
import { Days } from '../entities/Days.entity';
|
||||
import { columnRef, concatKysely, dayDistance, substringKysely } from '../dialect/sql-functions';
|
||||
import { TrekRepository } from './_shared/trek-repository';
|
||||
|
||||
export class ReservationsRepository extends TrekRepository<Reservations> {}
|
||||
/**
|
||||
* RS18/RR1's shared joined projection — `SELECT r.*, d.day_number, p.name as
|
||||
* place_name, r.assignment_id, ap.place_id as accommodation_place_id,
|
||||
* acc_p.name as accommodation_name, ap.start_day_id as
|
||||
* accommodation_start_day_id, ap.end_day_id as accommodation_end_day_id FROM
|
||||
* reservations r LEFT JOIN days d ON r.day_id = d.id LEFT JOIN places p ON
|
||||
* r.place_id = p.id LEFT JOIN day_accommodations ap ON r.accommodation_id =
|
||||
* ap.id LEFT JOIN places acc_p ON ap.place_id = acc_p.id`. The service
|
||||
* folds `accommodation_id` (`Math.trunc(Number(x))`), `day_positions`,
|
||||
* `endpoints` and `travelers` on afterward (unchanged from the legacy split).
|
||||
*/
|
||||
export interface ReservationJoinRow {
|
||||
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;
|
||||
day_number: number | null;
|
||||
place_name: string | null;
|
||||
accommodation_place_id: number | null;
|
||||
accommodation_name: string | null;
|
||||
accommodation_start_day_id: number | null;
|
||||
accommodation_end_day_id: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Kysely typing for the RS18/RR1 join (`joinedQuery` below) — `reservations
|
||||
* .accommodation_id` is a bare `p.text().nullable()` column with no FK to
|
||||
* `day_accommodations` (§18.1 of the inventory), so the QueryBuilder's
|
||||
* relation-path `.join()` cannot express `LEFT JOIN day_accommodations ap ON
|
||||
* r.accommodation_id = ap.id` at all — the same escape hatch
|
||||
* `DayAssignmentsRepository.effectiveStart`/`TripsRepository.tripSelectQuery`
|
||||
* use. `id`/`trip_id` stay `number | string` (raw-bind, D4's T5 seam): a
|
||||
* Kysely `.where(col, '=', value)` call is a TYPED condition regardless of
|
||||
* how loosely its value is typed (program rule 23 bans a raw SQL-text
|
||||
* condition string, not a loosely-typed bound value), so this join keeps the
|
||||
* legacy statement's exact raw-bind flexibility.
|
||||
*/
|
||||
interface ReservationJoinKyselyDB {
|
||||
reservations: {
|
||||
id: number | string;
|
||||
trip_id: number | string;
|
||||
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;
|
||||
};
|
||||
days: { id: number; day_number: number };
|
||||
places: { id: number; name: string };
|
||||
day_accommodations: { id: number; place_id: number | null; start_day_id: number; end_day_id: number };
|
||||
}
|
||||
|
||||
/**
|
||||
* Kysely typing for `restampLinkedReservation` (DY23). `accommodation_id:
|
||||
* string | number | null` — R2's ruling: the legacy statement binds `accId`
|
||||
* (a plain JS `number`, `stay.id`) straight into the `:accId` named param
|
||||
* compared against the TEXT column, so this method's caller passes the SAME
|
||||
* number, unconverted, and the interface is widened only so that bind
|
||||
* type-checks (SQLite applies its own TEXT-affinity conversion at compare
|
||||
* time regardless of which JS type bound the value — see the method's own
|
||||
* docstring).
|
||||
*/
|
||||
interface ReservationRestampKyselyDB {
|
||||
reservations: {
|
||||
id: number;
|
||||
accommodation_id: string | number | null;
|
||||
type: string | null;
|
||||
day_id: number | null;
|
||||
reservation_time: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
export class ReservationsRepository extends TrekRepository<Reservations> {
|
||||
private joinedQuery() {
|
||||
return this.kysely<ReservationJoinKyselyDB>()
|
||||
.selectFrom('reservations as r')
|
||||
.leftJoin('days as d', 'd.id', 'r.day_id')
|
||||
.leftJoin('places as p', 'p.id', 'r.place_id')
|
||||
.leftJoin('day_accommodations as ap', 'ap.id', 'r.accommodation_id')
|
||||
.leftJoin('places as acc_p', 'acc_p.id', 'ap.place_id')
|
||||
.selectAll('r')
|
||||
.select([
|
||||
'd.day_number as day_number',
|
||||
'p.name as place_name',
|
||||
'ap.place_id as accommodation_place_id',
|
||||
'acc_p.name as accommodation_name',
|
||||
'ap.start_day_id as accommodation_start_day_id',
|
||||
'ap.end_day_id as accommodation_end_day_id',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* RS18 (`ReservationsService.list`) — the joined projection for a whole
|
||||
* trip, `ORDER BY r.reservation_time ASC, r.created_at ASC`.
|
||||
*/
|
||||
async listForTrip(trip_id: number | string): Promise<ReservationJoinRow[]> {
|
||||
const rows = await this.joinedQuery()
|
||||
.where('r.trip_id', '=', trip_id)
|
||||
.orderBy('r.reservation_time', 'asc')
|
||||
.orderBy('r.created_at', 'asc')
|
||||
.execute();
|
||||
return rows as ReservationJoinRow[];
|
||||
}
|
||||
|
||||
/**
|
||||
* RR1 (`ReservationsReadService.getReservationWithJoins`) — the SAME
|
||||
* projection for one reservation, no `ORDER BY` (a single row).
|
||||
*/
|
||||
async findWithJoins(id: number | string): Promise<ReservationJoinRow | undefined> {
|
||||
const row = await this.joinedQuery().where('r.id', '=', id).executeTakeFirst();
|
||||
return row as ReservationJoinRow | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* RS35 (`ReservationsService.getReservation`) — `SELECT * FROM
|
||||
* reservations WHERE id = ? AND trip_id = ?`, the trip-scoping guard every
|
||||
* write path re-reads through. `id`/`trip_id: number` (rule 21 — the
|
||||
* SERVICE parses both ONCE via `toRowId` before calling this, and every
|
||||
* later write in the same method reuses that same parsed value, never a
|
||||
* second, independent conversion).
|
||||
*/
|
||||
async findInTrip(id: number, trip_id: number): Promise<ReservationAllColumnsRow | undefined> {
|
||||
return this.qb('r')
|
||||
.select(['r.*'])
|
||||
.where({ id, trip: trip_id })
|
||||
.execute<ReservationAllColumnsRow | undefined>('get', false);
|
||||
}
|
||||
|
||||
/** RS45 (`ReservationsService.remove`'s pre-image read) — `SELECT id, title, type, accommodation_id FROM reservations WHERE id = ? AND trip_id = ?`. */
|
||||
async findHeaderInTrip(id: number, trip_id: number): Promise<{ id: number; title: string; type: string | null; accommodation_id: string | null } | undefined> {
|
||||
return this.qb('r')
|
||||
.select(['r.id', 'r.title', 'r.type', 'r.accommodation_id'])
|
||||
.where({ id, trip: trip_id })
|
||||
.execute<{ id: number; title: string; type: string | null; accommodation_id: string | null } | undefined>('get', false);
|
||||
}
|
||||
|
||||
/**
|
||||
* RS12 (`ReservationsService.resyncReservationDays`) — `SELECT id,
|
||||
* reservation_time, reservation_end_time, day_id, end_day_id FROM
|
||||
* reservations WHERE trip_id = ? AND (type != 'hotel' OR accommodation_id
|
||||
* IS NULL) AND reservation_time IS NOT NULL`.
|
||||
*/
|
||||
async listResyncCandidates(trip_id: number): Promise<{
|
||||
id: number; reservation_time: string | null; reservation_end_time: string | null;
|
||||
day_id: number | null; end_day_id: number | null;
|
||||
}[]> {
|
||||
const platform = this.getEntityManager().getPlatform();
|
||||
return this.qb('r')
|
||||
.select(['r.id', 'r.reservation_time', 'r.reservation_end_time', columnRef(platform, 'r.day_id').as('day_id'), columnRef(platform, 'r.end_day_id').as('end_day_id')])
|
||||
.where({
|
||||
trip: trip_id,
|
||||
$or: [{ type: { $ne: 'hotel' } }, { accommodation_id: null }],
|
||||
reservation_time: { $ne: null },
|
||||
})
|
||||
.execute<{ id: number; reservation_time: string | null; reservation_end_time: string | null; day_id: number | null; end_day_id: number | null }[]>('all', false);
|
||||
}
|
||||
|
||||
/** RS13 (`resyncReservationDays`'s per-row write) — `UPDATE reservations SET day_id = ?, end_day_id = ? WHERE id = ?`. */
|
||||
async setDays(id: number, day_id: number | null, end_day_id: number | null): Promise<void> {
|
||||
await this.nativeUpdate({ id }, { day: day_id, endDay: end_day_id });
|
||||
}
|
||||
|
||||
/**
|
||||
* RS28 (`ReservationsService.createInTx`) — `INSERT INTO reservations
|
||||
* (trip_id, day_id, end_day_id, place_id, assignment_id, title,
|
||||
* reservation_time, reservation_end_time, location, confirmation_number,
|
||||
* notes, url, status, type, accommodation_id, metadata, needs_review)
|
||||
* VALUES (?×17)`. `sync_enabled`/`ingest_state`/`day_plan_position`
|
||||
* deliberately NOT named, so the column's own DB-level `DEFAULT` applies
|
||||
* (`sync_enabled INTEGER DEFAULT 1`, `Migration20200101021800_…`;
|
||||
* `ingest_state` `.default('live')`) — the caller has already resolved
|
||||
* every value (the `|| null` coercions, `resolvedOrNull`, the numeric
|
||||
* `accommodation_id` stringified for the TEXT column, R2).
|
||||
*/
|
||||
async insertReservation(input: {
|
||||
trip_id: number;
|
||||
day_id: number | null;
|
||||
end_day_id: number | null;
|
||||
place_id: number | null;
|
||||
assignment_id: number | 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;
|
||||
type: string;
|
||||
accommodation_id: string | null;
|
||||
metadata: string | null;
|
||||
needs_review: number;
|
||||
}): Promise<number> {
|
||||
return await this.insert({
|
||||
trip: input.trip_id,
|
||||
day: input.day_id,
|
||||
endDay: input.end_day_id,
|
||||
place: input.place_id,
|
||||
assignment: input.assignment_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,
|
||||
accommodation_id: input.accommodation_id,
|
||||
metadata: input.metadata,
|
||||
needs_review: input.needs_review,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* RS41 (`ReservationsService.updateInTx`) — the 16-column `UPDATE
|
||||
* reservations SET … WHERE id = ?`. The `PlacesRepository.updatePlace`/PL11
|
||||
* precedent: the four legacy `COALESCE(?, col)` keep-if-null columns
|
||||
* (`title`, `status`, `type`, `needs_review`) are resolved to their FINAL
|
||||
* value by the SERVICE first (it already holds the pre-image via the
|
||||
* `current` parameter every caller passes), so this writes exactly what it
|
||||
* is handed — no SQL-side COALESCE. `id: number` (rule 21 — the SAME
|
||||
* `toRowId`-parsed value `findInTrip`'s gate read used).
|
||||
*/
|
||||
async updateReservation(id: number, write: {
|
||||
title: string;
|
||||
reservation_time: string | null;
|
||||
reservation_end_time: string | null;
|
||||
location: string | null;
|
||||
confirmation_number: string | null;
|
||||
notes: string | null;
|
||||
url: string | null;
|
||||
day_id: number | null;
|
||||
end_day_id: number | null;
|
||||
place_id: number | null;
|
||||
assignment_id: number | null;
|
||||
status: string;
|
||||
type: string;
|
||||
accommodation_id: string | null;
|
||||
metadata: string | null;
|
||||
needs_review: number;
|
||||
}): Promise<void> {
|
||||
await this.nativeUpdate({ id }, {
|
||||
title: write.title,
|
||||
reservation_time: write.reservation_time,
|
||||
reservation_end_time: write.reservation_end_time,
|
||||
location: write.location,
|
||||
confirmation_number: write.confirmation_number,
|
||||
notes: write.notes,
|
||||
url: write.url,
|
||||
day: write.day_id,
|
||||
endDay: write.end_day_id,
|
||||
place: write.place_id,
|
||||
assignment: write.assignment_id,
|
||||
status: write.status,
|
||||
type: write.type,
|
||||
accommodation_id: write.accommodation_id,
|
||||
metadata: write.metadata,
|
||||
needs_review: write.needs_review,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* RS33 (`updatePositions`'s legacy/global branch) — `UPDATE reservations
|
||||
* SET day_plan_position = ? WHERE id = ? AND trip_id = ?`. `position:
|
||||
* number | null`, never `undefined` (R8 — the SERVICE maps an absent
|
||||
* `day_plan_position` to `null` explicitly before calling, so
|
||||
* `nativeUpdate` always WRITES the column instead of silently skipping it).
|
||||
*/
|
||||
async setDayPlanPosition(id: number, trip_id: number, position: number | null): Promise<void> {
|
||||
await this.nativeUpdate({ id, trip: trip_id }, { day_plan_position: position });
|
||||
}
|
||||
|
||||
/** RS50 (`remove`) — `DELETE FROM reservations WHERE id = ?`, unscoped (the caller already proved trip access via `findHeaderInTrip`). */
|
||||
async deleteById(id: number): Promise<void> {
|
||||
await this.nativeDelete({ id });
|
||||
}
|
||||
|
||||
/** DY14 (`DaysService.restampReservationDates`) — `SELECT id, day_id, end_day_id, reservation_time, reservation_end_time FROM reservations WHERE trip_id = ?`. */
|
||||
async listForRestamp(trip_id: number): Promise<{
|
||||
id: number; day_id: number | null; end_day_id: number | null;
|
||||
reservation_time: string | null; reservation_end_time: string | null;
|
||||
}[]> {
|
||||
const platform = this.getEntityManager().getPlatform();
|
||||
return this.qb('r')
|
||||
.select(['r.id', columnRef(platform, 'r.day_id').as('day_id'), columnRef(platform, 'r.end_day_id').as('end_day_id'), 'r.reservation_time', 'r.reservation_end_time'])
|
||||
.where({ trip: trip_id })
|
||||
.execute<{ id: number; day_id: number | null; end_day_id: number | null; reservation_time: string | null; reservation_end_time: string | null }[]>('all', false);
|
||||
}
|
||||
|
||||
/** DY15 — `UPDATE reservations SET reservation_time = ? WHERE id = ?`. */
|
||||
async setReservationTime(id: number, reservation_time: string): Promise<void> {
|
||||
await this.nativeUpdate({ id }, { reservation_time });
|
||||
}
|
||||
|
||||
/** DY16 — `UPDATE reservations SET reservation_end_time = ? WHERE id = ?`. */
|
||||
async setReservationEndTime(id: number, reservation_end_time: string): Promise<void> {
|
||||
await this.nativeUpdate({ id }, { reservation_end_time });
|
||||
}
|
||||
|
||||
/**
|
||||
* DY23 (`DaysService.resyncAccommodationDays`) — the named-param `UPDATE
|
||||
* reservations SET day_id = :dayId, reservation_time = CASE WHEN
|
||||
* reservation_time IS NULL THEN :date ELSE :date || SUBSTR(reservation_time,
|
||||
* 11) END WHERE accommodation_id = :accId AND type = 'hotel'`. Kysely (the
|
||||
* `CASE WHEN … ELSE … || SUBSTR(...)` shape has no typed-filter
|
||||
* equivalent — the T6 tier per the inventory's own ruling), through
|
||||
* `concatKysely`/`substringKysely` (`sql-functions.ts`'s Kysely-expression
|
||||
* twins of `concat`/`substring` — the MikroORM `RawQueryFragment` forms
|
||||
* render as a literal `?` token inside a Kysely statement and throw at
|
||||
* execution, Task 0 review H1). `accommodation_id` (TEXT) compared against
|
||||
* `accId` (a bound NUMBER, exactly as the legacy statement bound it — R2,
|
||||
* never stringified here, so the SAME SQLite TEXT-affinity conversion the
|
||||
* legacy statement triggered still applies, including its own miss on a
|
||||
* `"14.0"`-shaped row — parity, not a fix).
|
||||
*/
|
||||
async restampLinkedReservation(accommodation_id: number, day_id: number, date: string): Promise<void> {
|
||||
const platform = this.getEntityManager().getPlatform();
|
||||
await this.kysely<ReservationRestampKyselyDB>()
|
||||
.updateTable('reservations')
|
||||
.set((eb) => ({
|
||||
day_id,
|
||||
reservation_time: eb
|
||||
.case()
|
||||
.when('reservation_time', 'is', null)
|
||||
.then(eb.val(date))
|
||||
.else(concatKysely(platform, eb, { value: date }, { expression: substringKysely(platform, eb, 'reservation_time', 11) }))
|
||||
.end(),
|
||||
}))
|
||||
.where('accommodation_id', '=', accommodation_id)
|
||||
.where('type', '=', 'hotel')
|
||||
.execute();
|
||||
}
|
||||
|
||||
/**
|
||||
* RS22's dispatch (`ReservationsService.referencesOutsideTrip`'s
|
||||
* `assignment_id` branch) — `SELECT d.trip_id FROM day_assignments da JOIN
|
||||
* days d ON da.day_id = d.id WHERE da.id = ?`. Reached through
|
||||
* `DayAssignmentsRepository`'s own relation join (`da.day` — the same
|
||||
* cross-repository-read shape `DaysRepository.isEmptyDay`/
|
||||
* `TripMembersRepository.rosterUserIds` already use for a table this
|
||||
* repository does not own), never a new method on that file
|
||||
* (`DayAssignmentsRepository`/`DaysRepository` are Task 1's
|
||||
* additive-method territory per the file-ownership split).
|
||||
*/
|
||||
async getAssignmentTripId(assignment_id: number): Promise<number | undefined> {
|
||||
const platform = this.getEntityManager().getPlatform();
|
||||
const dayAssignments = this.getEntityManager().getRepository(DayAssignments);
|
||||
const row = await dayAssignments
|
||||
.qb('da')
|
||||
.join('da.day', 'd')
|
||||
.select([columnRef(platform, 'd.trip_id').as('trip_id')])
|
||||
.where({ id: assignment_id })
|
||||
.execute<{ trip_id: number } | undefined>('get', false);
|
||||
return row?.trip_id;
|
||||
}
|
||||
|
||||
/**
|
||||
* RS11 (`resolveDayIdFromTime`'s nearest-day fallback) — `SELECT id FROM
|
||||
* days WHERE trip_id = ? ORDER BY ABS(JULIANDAY(date) - JULIANDAY(?)) ASC,
|
||||
* date ASC LIMIT 1`. `dayDistance`, through `DaysRepository`'s own QB (the
|
||||
* same cross-repository-read shape as `getAssignmentTripId` above —
|
||||
* `DaysRepository` stays Task 1's additive-method territory; no new
|
||||
* method is added there).
|
||||
*/
|
||||
async findNearestDayId(trip_id: number, iso_date: string): Promise<number | undefined> {
|
||||
const platform = this.getEntityManager().getPlatform();
|
||||
const days = this.getEntityManager().getRepository(Days);
|
||||
const row = await days
|
||||
.qb('d')
|
||||
.select(['d.id'])
|
||||
.where({ trip: trip_id })
|
||||
.orderBy([{ [dayDistance(platform, 'd.date', iso_date)]: 'asc' }, { date: 'asc' }])
|
||||
.limit(1)
|
||||
.execute<{ id: number } | undefined>('get', false);
|
||||
return row?.id;
|
||||
}
|
||||
}
|
||||
|
||||
/** `SELECT *` — every scalar column of `Reservations`, RS35's shape. */
|
||||
export interface ReservationAllColumnsRow {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -15,6 +15,8 @@ import { Days } from '../../db/entities/Days.entity';
|
||||
import { DayAssignments } from '../../db/entities/DayAssignments.entity';
|
||||
import { DayNotes } from '../../db/entities/DayNotes.entity';
|
||||
import { Trips } from '../../db/entities/Trips.entity';
|
||||
import { Reservations } from '../../db/entities/Reservations.entity';
|
||||
import { ReservationEndpoints } from '../../db/entities/ReservationEndpoints.entity';
|
||||
|
||||
/**
|
||||
* Days (S6 — Phase 2 trip sub-domain), mounted at /api/trips/:tripId/days.
|
||||
@@ -25,12 +27,15 @@ import { Trips } from '../../db/entities/Trips.entity';
|
||||
* Day notes used to live here too, with their own full file set; they are their
|
||||
* own domain now (day-notes/).
|
||||
*
|
||||
* `MikroOrmModule.forFeature([Days, DayAssignments, DayNotes, Trips])`
|
||||
* registers `DaysRepository`/`DayAssignmentsRepository`/`DayNotesRepository`/
|
||||
* `TripsRepository` for `DaysService`'s `@InjectRepository` constructor
|
||||
* params (Plan 3c Task 2) — the same forFeature + `@InjectRepository`
|
||||
* wiring every converted domain copies (`trip-membership.module.ts`'s
|
||||
* precedent for pulling in a repository this module doesn't otherwise own).
|
||||
* `MikroOrmModule.forFeature([Days, DayAssignments, DayNotes, Trips,
|
||||
* Reservations, ReservationEndpoints])` registers `DaysRepository`/
|
||||
* `DayAssignmentsRepository`/`DayNotesRepository`/`TripsRepository`/
|
||||
* `ReservationsRepository`/`ReservationEndpointsRepository` for
|
||||
* `DaysService`'s `@InjectRepository` constructor params (Plan 3c Task 2;
|
||||
* the last two added by Plan 3d Task 2 for DY14–DY18/DY23) — the same
|
||||
* forFeature + `@InjectRepository` wiring every converted domain copies
|
||||
* (`trip-membership.module.ts`'s precedent for pulling in a repository this
|
||||
* module doesn't otherwise own).
|
||||
*/
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -41,7 +46,7 @@ import { Trips } from '../../db/entities/Trips.entity';
|
||||
AuthModule,
|
||||
RealtimeModule,
|
||||
PluginGuardsModule,
|
||||
MikroOrmModule.forFeature([Days, DayAssignments, DayNotes, Trips]),
|
||||
MikroOrmModule.forFeature([Days, DayAssignments, DayNotes, Trips, Reservations, ReservationEndpoints]),
|
||||
],
|
||||
controllers: [DaysController],
|
||||
providers: [DaysService, DaysMcp, DaysRpc],
|
||||
|
||||
@@ -17,6 +17,10 @@ import { DayNotes } from '../../db/entities/DayNotes.entity';
|
||||
import type { DayNotesRepository } from '../../db/repositories/DayNotes.repository';
|
||||
import { Trips } from '../../db/entities/Trips.entity';
|
||||
import type { TripsRepository } from '../../db/repositories/Trips.repository';
|
||||
import { Reservations } from '../../db/entities/Reservations.entity';
|
||||
import type { ReservationsRepository } from '../../db/repositories/Reservations.repository';
|
||||
import { ReservationEndpoints } from '../../db/entities/ReservationEndpoints.entity';
|
||||
import type { ReservationEndpointsRepository } from '../../db/repositories/ReservationEndpoints.repository';
|
||||
|
||||
type Trip = TripAccess;
|
||||
|
||||
@@ -114,6 +118,12 @@ export class DaysService {
|
||||
@InjectRepository(DayAssignments) private readonly dayAssignmentsRepo: DayAssignmentsRepository,
|
||||
@InjectRepository(DayNotes) private readonly dayNotesRepo: DayNotesRepository,
|
||||
@InjectRepository(Trips) private readonly tripsRepo: TripsRepository,
|
||||
// Plan 3d Task 2 (DY14–DY18, DY23): `restampReservationDates`/
|
||||
// `resyncAccommodationDays`'s `reservations`/`reservation_endpoints`
|
||||
// statements convert onto these two — the only new constructor params
|
||||
// this task adds (`day_accommodations` stays raw here, Task 3's own).
|
||||
@InjectRepository(Reservations) private readonly reservationsRepo: ReservationsRepository,
|
||||
@InjectRepository(ReservationEndpoints) private readonly reservationEndpointsRepo: ReservationEndpointsRepository,
|
||||
) {}
|
||||
|
||||
async verifyTripAccess(tripId: string | number, userId: number) {
|
||||
@@ -336,9 +346,11 @@ export class DaysService {
|
||||
* date (time-of-day preserved). Transport endpoints (flight legs) shift by the
|
||||
* same per-booking day delta so multi-leg timing stays internally consistent.
|
||||
*
|
||||
* DY14–DY18 — Plan 3d (`reservations`/`reservation_endpoints`): stays raw on
|
||||
* `DatabaseService` inside the caller's `uow.transactional` block, per the
|
||||
* Plan 3b `UserCleanupService` precedent for cross-domain writes.
|
||||
* DY14–DY18 — Plan 3d Task 2: converted onto `ReservationsRepository
|
||||
* .listForRestamp`/`setReservationTime`/`setReservationEndTime` and
|
||||
* `ReservationEndpointsRepository.listIdAndDate`/`setLocalDate`, inside the
|
||||
* caller's `uow.transactional` block (unchanged — repository methods never
|
||||
* open their own transaction, program brief item 7).
|
||||
*
|
||||
* `tripId: number` (Task 9 fix wave, H2): every caller now passes the ONE
|
||||
* `toRowId`-parsed value its own entry point already computed, never the
|
||||
@@ -353,34 +365,22 @@ export class DaysService {
|
||||
newDateById: Map<number, string | null>,
|
||||
): Promise<void> {
|
||||
// DY14 — Plan 3d
|
||||
const reservations = this.db.all<{
|
||||
id: number; day_id: number | null; end_day_id: number | null;
|
||||
reservation_time: string | null; reservation_end_time: string | null;
|
||||
}>(
|
||||
'SELECT id, day_id, end_day_id, reservation_time, reservation_end_time FROM reservations WHERE trip_id = ?',
|
||||
tripId
|
||||
);
|
||||
|
||||
// DY15 — Plan 3d
|
||||
const setTime = this.db.prepare('UPDATE reservations SET reservation_time = ? WHERE id = ?');
|
||||
// DY16 — Plan 3d
|
||||
const setEndTime = this.db.prepare('UPDATE reservations SET reservation_end_time = ? WHERE id = ?');
|
||||
// DY17 — Plan 3d
|
||||
const endpoints = this.db.prepare('SELECT id, local_date FROM reservation_endpoints WHERE reservation_id = ?');
|
||||
// DY18 — Plan 3d
|
||||
const setEndpointDate = this.db.prepare('UPDATE reservation_endpoints SET local_date = ? WHERE id = ?');
|
||||
const reservations = await this.reservationsRepo.listForRestamp(tripId);
|
||||
|
||||
for (const r of reservations) {
|
||||
if (r.day_id != null && r.reservation_time) {
|
||||
const oldDate = oldDateById.get(r.day_id);
|
||||
const newDate = newDateById.get(r.day_id);
|
||||
if (oldDate && newDate && oldDate !== newDate) {
|
||||
setTime.run(withDatePart(r.reservation_time, newDate), r.id);
|
||||
// DY15 — Plan 3d
|
||||
await this.reservationsRepo.setReservationTime(r.id, withDatePart(r.reservation_time, newDate));
|
||||
// Shift each transport leg's local_date by the same number of days.
|
||||
const delta = dayDelta(oldDate, newDate);
|
||||
if (delta !== 0) {
|
||||
for (const ep of endpoints.all(r.id) as { id: number; local_date: string | null }[]) {
|
||||
if (ep.local_date) setEndpointDate.run(addDays(ep.local_date, delta), ep.id);
|
||||
// DY17 — Plan 3d
|
||||
for (const ep of await this.reservationEndpointsRepo.listIdAndDate(r.id)) {
|
||||
// DY18 — Plan 3d
|
||||
if (ep.local_date) await this.reservationEndpointsRepo.setLocalDate(ep.id, addDays(ep.local_date, delta));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -389,7 +389,8 @@ export class DaysService {
|
||||
const oldDate = oldDateById.get(r.end_day_id);
|
||||
const newDate = newDateById.get(r.end_day_id);
|
||||
if (oldDate && newDate && oldDate !== newDate) {
|
||||
setEndTime.run(withDatePart(r.reservation_end_time, newDate), r.id);
|
||||
// DY16 — Plan 3d
|
||||
await this.reservationsRepo.setReservationEndTime(r.id, withDatePart(r.reservation_end_time, newDate));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -408,6 +409,10 @@ export class DaysService {
|
||||
* docstring for why binding the raw route string here was the live bug.
|
||||
*/
|
||||
private async assertNoInvertedAccommodation(tripId: number): Promise<void> {
|
||||
// DY19 — Plan 3d (marker normalised to the `// <SITE> — Plan 3d` shape
|
||||
// every other survivor in this file uses, per the Task 0 review carry
|
||||
// item — this task does not own `day_accommodations`, so the statement
|
||||
// itself is untouched)
|
||||
const spans = this.db.all<{ id: number; start_no: number; end_no: number }>(`
|
||||
SELECT a.id, s.day_number AS start_no, e.day_number AS end_no
|
||||
FROM day_accommodations a
|
||||
@@ -432,13 +437,14 @@ export class DaysService {
|
||||
* whole trip still shifts everything together. The linked hotel reservation follows
|
||||
* its accommodation's start day in both branches.
|
||||
*
|
||||
* DY20/DY22/DY23 — Plan 3d (`day_accommodations`/`reservations`) stay raw;
|
||||
* DY20/DY22 — Plan 3d (`day_accommodations`) stay raw, Task 3's own table;
|
||||
* DY21 (`DaysRepository.findByTripAndDate`) and DY25
|
||||
* (`DaysRepository.findById`) convert — both root on `days`, which this
|
||||
* plan owns. DY24 (the `day_assignments` stop that follows its booking)
|
||||
* converts via `DayAssignmentsRepository.reanchorToDay`, a Kysely
|
||||
* statement proven to join this method's ambient transaction by rollback
|
||||
* (see the task report).
|
||||
* (see the task report). DY23 (`reservations`) converts onto
|
||||
* `ReservationsRepository.restampLinkedReservation` — Plan 3d Task 2.
|
||||
*
|
||||
* `tripId: number` (Task 9 fix wave, H2): callers now pass their own
|
||||
* `toRowId`-parsed value; DY20's raw bind uses it too (previously the raw
|
||||
@@ -458,13 +464,6 @@ export class DaysService {
|
||||
|
||||
// DY22 — Plan 3d
|
||||
const updateStay = this.db.prepare('UPDATE day_accommodations SET start_day_id = ?, end_day_id = ? WHERE id = ?');
|
||||
// DY23 — Plan 3d
|
||||
const restampLinkedRes = this.db.prepare(`
|
||||
UPDATE reservations SET day_id = :dayId,
|
||||
reservation_time = CASE WHEN reservation_time IS NULL THEN :date
|
||||
ELSE :date || SUBSTR(reservation_time, 11) END
|
||||
WHERE accommodation_id = :accId AND type = 'hotel'
|
||||
`);
|
||||
|
||||
for (const stay of stays) {
|
||||
const oldStartDate = prevDateByDayId.get(stay.start_day_id);
|
||||
@@ -489,7 +488,8 @@ export class DaysService {
|
||||
// DY25
|
||||
const startDay = await this.daysRepo.findById(stay.start_day_id);
|
||||
if (startDay?.date) {
|
||||
restampLinkedRes.run({ dayId: stay.start_day_id, date: startDay.date, accId: stay.id });
|
||||
// DY23 — Plan 3d
|
||||
await this.reservationsRepo.restampLinkedReservation(stay.id, stay.start_day_id, startDay.date);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,24 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { MikroOrmModule } from '@mikro-orm/nestjs';
|
||||
import { ReservationsReadService } from './reservations-read.service';
|
||||
import { Reservations } from '../../db/entities/Reservations.entity';
|
||||
import { ReservationEndpoints } from '../../db/entities/ReservationEndpoints.entity';
|
||||
import { ReservationTravelers } from '../../db/entities/ReservationTravelers.entity';
|
||||
|
||||
/**
|
||||
* A leaf on purpose (the trip-membership precedent): AirtrailCoreModule needs
|
||||
* the single-reservation hydration reads without dragging ReservationsModule
|
||||
* in — which would re-close the cycle that airtrail.bridge used to dodge.
|
||||
*
|
||||
* `MikroOrmModule.forFeature([Reservations, ReservationEndpoints,
|
||||
* ReservationTravelers])` registers `ReservationsRepository`/
|
||||
* `ReservationEndpointsRepository`/`ReservationTravelersRepository` for
|
||||
* `ReservationsReadService`'s `@InjectRepository` constructor params (Plan
|
||||
* 3d Task 2, R11) — the entity classes only, not `ReservationsModule`
|
||||
* itself, so this module stays the same leaf it always was.
|
||||
*/
|
||||
@Module({
|
||||
imports: [MikroOrmModule.forFeature([Reservations, ReservationEndpoints, ReservationTravelers])],
|
||||
providers: [ReservationsReadService],
|
||||
exports: [ReservationsReadService],
|
||||
})
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { DatabaseService } from '../database/database.service';
|
||||
import { InjectRepository } from '@mikro-orm/nestjs';
|
||||
import { avatarUrl } from '../common/avatarUrl';
|
||||
import type { ReservationRow, ReservationEndpoint, ReservationTraveler } from './reservations.service';
|
||||
import { Reservations } from '../../db/entities/Reservations.entity';
|
||||
import type { ReservationsRepository } from '../../db/repositories/Reservations.repository';
|
||||
import { ReservationEndpoints } from '../../db/entities/ReservationEndpoints.entity';
|
||||
import type { ReservationEndpointsRepository } from '../../db/repositories/ReservationEndpoints.repository';
|
||||
import { ReservationTravelers } from '../../db/entities/ReservationTravelers.entity';
|
||||
import type { ReservationTravelersRepository } from '../../db/repositories/ReservationTravelers.repository';
|
||||
|
||||
/** The one traveler projection every reservation read shares (avatar_url included). */
|
||||
export function toTraveler(r: { user_id: number; username: string; avatar: string | null; is_guest?: number | null }): ReservationTraveler {
|
||||
@@ -19,56 +25,47 @@ export function toTraveler(r: { user_id: number; username: string; avatar: strin
|
||||
* delegates, so there is exactly one copy of each query.
|
||||
*
|
||||
* Renamed from `ReservationsReadRepository` (Plan 3d Task 0, R11 — inventory
|
||||
* §18.3): this is a plain Nest `@Injectable` over `DatabaseService.prepare`,
|
||||
* not a MikroORM repository extending `TrekRepository`, so it must not carry
|
||||
* the `Repository` suffix — that name is reserved for the ORM layer's "one
|
||||
* repository per entity" convention (D4). Its three statements (RR1-RR3)
|
||||
* still convert to `ReservationsRepository`/`ReservationEndpointsRepository`/
|
||||
* `ReservationTravelersRepository` methods in a later Plan 3d task (per §18.3
|
||||
* option (a), which then deletes this class and turns
|
||||
* `ReservationsReadModule` into a plain `MikroOrmModule.forFeature([...])`
|
||||
* leaf, the `TripMembershipModule` precedent) — Task 0 converts no SQL site,
|
||||
* so only the rename happens here, with zero behaviour change.
|
||||
* §18.3): this is a plain Nest `@Injectable`, not a MikroORM repository
|
||||
* extending `TrekRepository`, so it must not carry the `Repository` suffix —
|
||||
* that name is reserved for the ORM layer's "one repository per entity"
|
||||
* convention (D4). Its three statements (RR1-RR3) now delegate to
|
||||
* `ReservationsRepository.findWithJoins`/`ReservationEndpointsRepository
|
||||
* .listForReservation`/`ReservationTravelersRepository.listForReservation`
|
||||
* (Plan 3d Task 2, §18.3 option (b) — the class stays a thin facade rather
|
||||
* than being deleted: `AirtrailCoreModule`/its own test suites reach it by
|
||||
* this exact public shape, and Task 0's rename already committed to keeping
|
||||
* it). Zero behaviour change beyond the SQL leaving this file.
|
||||
*/
|
||||
@Injectable()
|
||||
export class ReservationsReadService {
|
||||
constructor(private readonly db: DatabaseService) {}
|
||||
constructor(
|
||||
@InjectRepository(Reservations) private readonly reservationsRepo: ReservationsRepository,
|
||||
@InjectRepository(ReservationEndpoints) private readonly endpointsRepo: ReservationEndpointsRepository,
|
||||
@InjectRepository(ReservationTravelers) private readonly travelersRepo: ReservationTravelersRepository,
|
||||
) {}
|
||||
|
||||
async getReservationWithJoins(id: string | number): Promise<ReservationRow | undefined> {
|
||||
const row = this.db.get<ReservationRow>(`
|
||||
SELECT r.*, d.day_number, p.name as place_name, r.assignment_id,
|
||||
ap.place_id as accommodation_place_id, acc_p.name as accommodation_name,
|
||||
ap.start_day_id as accommodation_start_day_id, ap.end_day_id as accommodation_end_day_id
|
||||
FROM reservations r
|
||||
LEFT JOIN days d ON r.day_id = d.id
|
||||
LEFT JOIN places p ON r.place_id = p.id
|
||||
LEFT JOIN day_accommodations ap ON r.accommodation_id = ap.id
|
||||
LEFT JOIN places acc_p ON ap.place_id = acc_p.id
|
||||
WHERE r.id = ?
|
||||
`, id);
|
||||
// RR1
|
||||
const row = await this.reservationsRepo.findWithJoins(id);
|
||||
if (!row) return undefined;
|
||||
row.endpoints = await this.loadEndpoints(row.id);
|
||||
row.travelers = await this.loadTravelers(row.id);
|
||||
const result: ReservationRow = { ...row };
|
||||
result.endpoints = await this.loadEndpoints(row.id);
|
||||
result.travelers = await this.loadTravelers(row.id);
|
||||
// accommodation_id is a TEXT column; the integer FK reads back as a numeric
|
||||
// string (e.g. "14.0"). Normalize to an int so clients can parse it.
|
||||
row.accommodation_id = row.accommodation_id == null ? null : Math.trunc(Number(row.accommodation_id));
|
||||
return row;
|
||||
result.accommodation_id = row.accommodation_id == null ? null : Math.trunc(Number(row.accommodation_id));
|
||||
return result;
|
||||
}
|
||||
|
||||
async loadEndpoints(reservationId: number): Promise<ReservationEndpoint[]> {
|
||||
return this.db.all<ReservationEndpoint>(
|
||||
'SELECT * FROM reservation_endpoints WHERE reservation_id = ? ORDER BY sequence',
|
||||
reservationId
|
||||
);
|
||||
// RR2 — no ORDER BY difference: `ReservationEndpointsRepository
|
||||
// .listForReservation` already orders by `sequence` (RR2's own shape).
|
||||
return (await this.endpointsRepo.listForReservation(reservationId)) as ReservationEndpoint[];
|
||||
}
|
||||
|
||||
async loadTravelers(reservationId: number | string): Promise<ReservationTraveler[]> {
|
||||
const rows = this.db.all<ReservationTraveler>(`
|
||||
SELECT rt.user_id, COALESCE(u.display_name, u.username) AS username, u.avatar, u.is_guest
|
||||
FROM reservation_travelers rt
|
||||
JOIN users u ON rt.user_id = u.id
|
||||
WHERE rt.reservation_id = ?
|
||||
`, reservationId);
|
||||
// RR3 — no ORDER BY (unlike RS6), matching the legacy statement's own row order.
|
||||
const rows = await this.travelersRepo.listForReservation(Number(reservationId));
|
||||
return rows.map(r => toTraveler(r));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { MikroOrmModule } from '@mikro-orm/nestjs';
|
||||
import { ReservationsReadModule } from './reservations-read.module';
|
||||
import { AirtrailCoreModule } from '../integrations/airtrail-core.module';
|
||||
import { BudgetModule } from '../budget/budget.module';
|
||||
@@ -16,12 +17,33 @@ import { AssignmentsModule } from '../assignments/assignments.module';
|
||||
import { AccommodationsModule } from '../accommodations/accommodations.module';
|
||||
import { NotificationsModule } from '../notifications/notifications.module';
|
||||
import { McpSharedModule } from '../mcp-shared/mcp-shared.module';
|
||||
import { Reservations } from '../../db/entities/Reservations.entity';
|
||||
import { ReservationEndpoints } from '../../db/entities/ReservationEndpoints.entity';
|
||||
import { ReservationTravelers } from '../../db/entities/ReservationTravelers.entity';
|
||||
import { ReservationDayPositions } from '../../db/entities/ReservationDayPositions.entity';
|
||||
import { DayAccommodations } from '../../db/entities/DayAccommodations.entity';
|
||||
import { Days } from '../../db/entities/Days.entity';
|
||||
import { Places } from '../../db/entities/Places.entity';
|
||||
import { DayAssignments } from '../../db/entities/DayAssignments.entity';
|
||||
import { TripMembers } from '../../db/entities/TripMembers.entity';
|
||||
import { Users } from '../../db/entities/Users.entity';
|
||||
import { Trips } from '../../db/entities/Trips.entity';
|
||||
|
||||
/**
|
||||
* Reservations + accommodations domain (S5 — Phase 2 trip sub-domain).
|
||||
* Mounts: /api/trips/:tripId/reservations, /accommodations, and the cross-trip
|
||||
* /api/reservations/upcoming dashboard feed. ReservationsMcp carries the
|
||||
* decorator-registered MCP tools + resource for the domain.
|
||||
*
|
||||
* `MikroOrmModule.forFeature([...])` registers `ReservationsRepository`/
|
||||
* `ReservationEndpointsRepository`/`ReservationTravelersRepository`/
|
||||
* `ReservationDayPositionsRepository`/`DayAccommodationsRepository` (this
|
||||
* task's own) plus `DaysRepository`/`PlacesRepository`/
|
||||
* `DayAssignmentsRepository`/`TripMembersRepository`/`UsersRepository`/
|
||||
* `TripsRepository` (owned elsewhere, reached the same way
|
||||
* `AssignmentsDomainModule` reaches `Days`/`Places`/`TripMembers` — the
|
||||
* entity classes only, never the owning module) for `ReservationsService`'s
|
||||
* `@InjectRepository` constructor params (Plan 3d Task 2).
|
||||
*/
|
||||
@Module({
|
||||
// DaysModule: ReservationsMcp injects DaysService for its nine getDay calls.
|
||||
@@ -29,7 +51,10 @@ import { McpSharedModule } from '../mcp-shared/mcp-shared.module';
|
||||
// AccommodationsModule: a hotel booking writes its own day_accommodations row, and
|
||||
// that stay owes the day plan the same stop one entered under Days does. No edge
|
||||
// back — accommodations reaches neither days nor reservations (ACC-002).
|
||||
imports: [McpSharedModule, NotificationsModule, DaysModule, AssignmentsModule, AccommodationsModule, PermissionsModule, BudgetModule, AuthModule, RealtimeModule, PluginGuardsModule, ReservationsReadModule, AirtrailCoreModule],
|
||||
imports: [
|
||||
McpSharedModule, NotificationsModule, DaysModule, AssignmentsModule, AccommodationsModule, PermissionsModule, BudgetModule, AuthModule, RealtimeModule, PluginGuardsModule, ReservationsReadModule, AirtrailCoreModule,
|
||||
MikroOrmModule.forFeature([Reservations, ReservationEndpoints, ReservationTravelers, ReservationDayPositions, DayAccommodations, Days, Places, DayAssignments, TripMembers, Users, Trips]),
|
||||
],
|
||||
controllers: [ReservationsController, UpcomingReservationsController],
|
||||
providers: [ReservationsService, ReservationsMcp, ReservationsRpc],
|
||||
// For in-container consumers (ReservationsRpc, TripsService, BookingImportService).
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@mikro-orm/nestjs';
|
||||
import { DatabaseService, type TripAccess } from '../database/database.service';
|
||||
import { UnitOfWork } from '../database/unit-of-work';
|
||||
import type { TrekWsPayload, TrekWsTripEventName } from '@trek/shared';
|
||||
@@ -11,6 +12,29 @@ import { BudgetService } from '../budget/budget.service';
|
||||
import { typeToCostCategory } from '@trek/shared';
|
||||
import { NotificationsService } from '../notifications/notifications.service';
|
||||
import { AccommodationsService, noStayMirror, type AccommodationMirror } from '../accommodations/accommodations.service';
|
||||
import { toRowId } from '../common/row-id';
|
||||
import { Reservations } from '../../db/entities/Reservations.entity';
|
||||
import type { ReservationsRepository } from '../../db/repositories/Reservations.repository';
|
||||
import { ReservationEndpoints as ReservationEndpointsEntity } from '../../db/entities/ReservationEndpoints.entity';
|
||||
import type { ReservationEndpointsRepository, ReservationEndpointRow } from '../../db/repositories/ReservationEndpoints.repository';
|
||||
import { ReservationTravelers as ReservationTravelersEntity } from '../../db/entities/ReservationTravelers.entity';
|
||||
import type { ReservationTravelersRepository } from '../../db/repositories/ReservationTravelers.repository';
|
||||
import { ReservationDayPositions } from '../../db/entities/ReservationDayPositions.entity';
|
||||
import type { ReservationDayPositionsRepository } from '../../db/repositories/ReservationDayPositions.repository';
|
||||
import { DayAccommodations } from '../../db/entities/DayAccommodations.entity';
|
||||
import type { DayAccommodationsRepository } from '../../db/repositories/DayAccommodations.repository';
|
||||
import { Days } from '../../db/entities/Days.entity';
|
||||
import type { DaysRepository } from '../../db/repositories/Days.repository';
|
||||
import { Places } from '../../db/entities/Places.entity';
|
||||
import type { PlacesRepository } from '../../db/repositories/Places.repository';
|
||||
import { DayAssignments } from '../../db/entities/DayAssignments.entity';
|
||||
import type { DayAssignmentsRepository } from '../../db/repositories/DayAssignments.repository';
|
||||
import { TripMembers } from '../../db/entities/TripMembers.entity';
|
||||
import type { TripMembersRepository } from '../../db/repositories/TripMembers.repository';
|
||||
import { Users } from '../../db/entities/Users.entity';
|
||||
import type { UsersRepository } from '../../db/repositories/Users.repository';
|
||||
import { Trips } from '../../db/entities/Trips.entity';
|
||||
import type { TripsRepository } from '../../db/repositories/Trips.repository';
|
||||
|
||||
type Trip = TripAccess;
|
||||
type BudgetEntry = { total_price?: number; category?: string } | undefined;
|
||||
@@ -153,12 +177,40 @@ export class ReservationsService {
|
||||
private readonly reads: ReservationsReadService,
|
||||
private readonly accommodations: AccommodationsService,
|
||||
private readonly uow: UnitOfWork,
|
||||
@InjectRepository(Reservations) private readonly reservationsRepo: ReservationsRepository,
|
||||
@InjectRepository(ReservationEndpointsEntity) private readonly endpointsRepo: ReservationEndpointsRepository,
|
||||
@InjectRepository(ReservationTravelersEntity) private readonly travelersRepo: ReservationTravelersRepository,
|
||||
@InjectRepository(ReservationDayPositions) private readonly dayPositionsRepo: ReservationDayPositionsRepository,
|
||||
@InjectRepository(DayAccommodations) private readonly dayAccommodationsRepo: DayAccommodationsRepository,
|
||||
@InjectRepository(Days) private readonly daysRepo: DaysRepository,
|
||||
@InjectRepository(Places) private readonly placesRepo: PlacesRepository,
|
||||
@InjectRepository(DayAssignments) private readonly dayAssignmentsRepo: DayAssignmentsRepository,
|
||||
@InjectRepository(TripMembers) private readonly tripMembersRepo: TripMembersRepository,
|
||||
@InjectRepository(Users) private readonly usersRepo: UsersRepository,
|
||||
@InjectRepository(Trips) private readonly tripsRepo: TripsRepository,
|
||||
) {}
|
||||
|
||||
async verifyTripAccess(tripId: string | number, userId: number) {
|
||||
return await this.db.canAccessTrip(tripId, userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* `tripId` reaches every read/write below only downstream of
|
||||
* `TripAccessGuard` (REST) or an equivalent MCP/RPC check, which already
|
||||
* resolved it to a real trip with its own `Number()` coercion — this
|
||||
* mirrors that same coercion for repository calls whose typed filters
|
||||
* require a genuine `number` (program rule 23). `Number.isFinite`, not a
|
||||
* bare `Number(...)`: MikroORM inlines every bound parameter into the SQL
|
||||
* text (rule 22), so a stray `NaN` would render as the bare token `NaN`
|
||||
* and throw at prepare time (rule 15) rather than simply matching no
|
||||
* rows — `-1` never matches a real (positive, autoincrement) trip id, so
|
||||
* an already-impossible tripId here degrades to "no rows", never a 500.
|
||||
*/
|
||||
private rowIdNum(value: string | number): number {
|
||||
const n = Number(value);
|
||||
return Number.isFinite(n) ? n : -1;
|
||||
}
|
||||
|
||||
async canEdit(trip: Trip, user: User): Promise<boolean> {
|
||||
return this.permissions.checkPermission('reservation_edit', user.role, trip.user_id, user.id, trip.user_id !== user.id);
|
||||
}
|
||||
@@ -191,17 +243,19 @@ export class ReservationsService {
|
||||
// reaches nothing in this direction — and it hid the edge while handing the
|
||||
// send a second NotificationsService built outside the container.
|
||||
try {
|
||||
const actor = this.db.get<{ email: string }>('SELECT email FROM users WHERE id = ?', actorId);
|
||||
if (!actor) return;
|
||||
const trip = this.db.get<{ title: string }>('SELECT title FROM trips WHERE id = ?', tripId);
|
||||
// RS1
|
||||
const actorEmail = await this.usersRepo.getEmail(actorId);
|
||||
if (!actorEmail) return;
|
||||
// RS2
|
||||
const tripTitle = await this.tripsRepo.getTitle(tripId);
|
||||
this.notifications.send({
|
||||
event: 'booking_change',
|
||||
actorId,
|
||||
scope: 'trip',
|
||||
targetId: Number(tripId),
|
||||
params: {
|
||||
trip: trip?.title || 'Untitled',
|
||||
actor: actor.email,
|
||||
trip: tripTitle || 'Untitled',
|
||||
actor: actorEmail,
|
||||
booking,
|
||||
type: type || 'booking',
|
||||
tripId: String(tripId),
|
||||
@@ -213,39 +267,34 @@ export class ReservationsService {
|
||||
}
|
||||
|
||||
async loadEndpointsByTrip(tripId: string | number): Promise<Map<number, ReservationEndpoint[]>> {
|
||||
const rows = this.db.all<ReservationEndpoint>(`
|
||||
SELECT e.* FROM reservation_endpoints e
|
||||
JOIN reservations r ON e.reservation_id = r.id
|
||||
WHERE r.trip_id = ?
|
||||
ORDER BY e.reservation_id, e.sequence
|
||||
`, tripId);
|
||||
// RS3
|
||||
const rows: ReservationEndpointRow[] = await this.endpointsRepo.listForTrip(this.rowIdNum(tripId));
|
||||
const map = new Map<number, ReservationEndpoint[]>();
|
||||
for (const r of rows) {
|
||||
const list = map.get(r.reservation_id!) ?? [];
|
||||
list.push(r);
|
||||
map.set(r.reservation_id!, list);
|
||||
const list = map.get(r.reservation_id) ?? [];
|
||||
// `role` is TEXT at the DB (unconstrained); `ReservationEndpoint.role`
|
||||
// narrows it to the three values every writer of this column sends —
|
||||
// the same widen-on-read the legacy `this.db.all<ReservationEndpoint>`
|
||||
// generic parameter asserted without any runtime check.
|
||||
list.push(r as ReservationEndpoint);
|
||||
map.set(r.reservation_id, list);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/** Users assignable on a trip: its members (guests included) plus the owner. */
|
||||
/**
|
||||
* Users assignable on a trip: its members (guests included) plus the
|
||||
* owner. RS4+RS5 — `TripMembersRepository.rosterUserIds` (the same two
|
||||
* reads, merged through the same `Set`, per the inventory's §18.9 ruling
|
||||
* that the two are provably identical).
|
||||
*/
|
||||
private async assignableUserIds(tripId: string | number): Promise<Set<number>> {
|
||||
const members = this.db.all<{ user_id: number }>('SELECT user_id FROM trip_members WHERE trip_id = ?', tripId);
|
||||
const ids = new Set(members.map(m => m.user_id));
|
||||
const owner = this.db.get<{ user_id: number }>('SELECT user_id FROM trips WHERE id = ?', tripId);
|
||||
if (owner) ids.add(owner.user_id);
|
||||
return ids;
|
||||
return this.tripMembersRepo.rosterUserIds(tripId);
|
||||
}
|
||||
|
||||
async loadTravelersByTrip(tripId: string | number): Promise<Map<number, ReservationTraveler[]>> {
|
||||
const rows = this.db.all<ReservationTraveler & { reservation_id: number }>(`
|
||||
SELECT rt.reservation_id, rt.user_id, COALESCE(u.display_name, u.username) AS username, u.avatar, u.is_guest
|
||||
FROM reservation_travelers rt
|
||||
JOIN reservations r ON rt.reservation_id = r.id
|
||||
JOIN users u ON rt.user_id = u.id
|
||||
WHERE r.trip_id = ?
|
||||
ORDER BY rt.reservation_id
|
||||
`, tripId);
|
||||
// RS6
|
||||
const rows = await this.travelersRepo.listForTrip(this.rowIdNum(tripId));
|
||||
const map = new Map<number, ReservationTraveler[]>();
|
||||
for (const row of rows) {
|
||||
const list = map.get(row.reservation_id) ?? [];
|
||||
@@ -265,14 +314,15 @@ export class ReservationsService {
|
||||
* can't leak a cross-trip user. #1517.
|
||||
*/
|
||||
async setReservationTravelers(reservationId: number | string, tripId: string | number, userIds: number[]): Promise<void> {
|
||||
// RS7: the roster is computed BEFORE the transaction opens (unchanged).
|
||||
const allowed = await this.assignableUserIds(tripId);
|
||||
const ids = [...new Set(userIds)].filter(uid => allowed.has(uid));
|
||||
const reservationIdNum = this.rowIdNum(reservationId);
|
||||
await this.uow.transactional(async () => {
|
||||
this.db.run('DELETE FROM reservation_travelers WHERE reservation_id = ?', reservationId);
|
||||
if (ids.length > 0) {
|
||||
const insert = this.db.prepare('INSERT OR IGNORE INTO reservation_travelers (reservation_id, user_id) VALUES (?, ?)');
|
||||
for (const uid of ids) insert.run(reservationId, uid);
|
||||
}
|
||||
// RS8
|
||||
await this.travelersRepo.deleteForReservation(reservationIdNum);
|
||||
// RS9
|
||||
await this.travelersRepo.insertIgnore(reservationIdNum, ids);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -296,18 +346,17 @@ export class ReservationsService {
|
||||
if (!time) return null;
|
||||
const datePart = time.slice(0, 10);
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(datePart)) return null;
|
||||
const exact = this.db.get<{ id: number }>('SELECT id FROM days WHERE trip_id = ? AND date = ? LIMIT 1', tripId, datePart);
|
||||
// RS10
|
||||
const exact = await this.daysRepo.findByTripAndDate(this.rowIdNum(tripId), datePart);
|
||||
if (exact) return exact.id;
|
||||
// Fallback: clamp to the nearest day in the trip so an imported booking whose
|
||||
// exact date has no day row (or sits just outside the span) still lands on a day.
|
||||
// Skipped by callers (e.g. resyncReservationDays) that must leave a booking whose
|
||||
// date now falls outside the range untouched instead of snapping it to an edge day.
|
||||
if (!clampToNearest) return null;
|
||||
const nearest = this.db.get<{ id: number }>(
|
||||
'SELECT id FROM days WHERE trip_id = ? ORDER BY ABS(JULIANDAY(date) - JULIANDAY(?)) ASC, date ASC LIMIT 1',
|
||||
tripId, datePart
|
||||
);
|
||||
return nearest?.id ?? null;
|
||||
// RS11
|
||||
const nearestId = await this.reservationsRepo.findNearestDayId(this.rowIdNum(tripId), datePart);
|
||||
return nearestId ?? null;
|
||||
}
|
||||
|
||||
// After a trip's date range changes, generateDays positionally re-dates the day rows
|
||||
@@ -319,16 +368,8 @@ export class ReservationsService {
|
||||
// resyncAccommodationDays re-anchors the accommodation span and its linked reservation;
|
||||
// unlinked dated hotels (e.g. imported ones) re-anchor like any other booking.
|
||||
async resyncReservationDays(tripId: string | number): Promise<void> {
|
||||
const rows = this.db.all<{
|
||||
id: number; reservation_time: string | null; reservation_end_time: string | null;
|
||||
day_id: number | null; end_day_id: number | null;
|
||||
}>(
|
||||
`SELECT id, reservation_time, reservation_end_time, day_id, end_day_id
|
||||
FROM reservations
|
||||
WHERE trip_id = ? AND (type != 'hotel' OR accommodation_id IS NULL) AND reservation_time IS NOT NULL`,
|
||||
tripId
|
||||
);
|
||||
const update = this.db.prepare('UPDATE reservations SET day_id = ?, end_day_id = ? WHERE id = ?');
|
||||
// RS12 — read BEFORE the transaction (inside TripsService.updateTrip's outer tx), unchanged.
|
||||
const rows = await this.reservationsRepo.listResyncCandidates(this.rowIdNum(tripId));
|
||||
await this.uow.transactional(async () => {
|
||||
for (const r of rows) {
|
||||
const newDayId = await this.resolveDayIdFromTime(tripId, r.reservation_time, false);
|
||||
@@ -337,7 +378,8 @@ export class ReservationsService {
|
||||
? ((await this.resolveDayIdFromTime(tripId, r.reservation_end_time, false)) ?? r.end_day_id)
|
||||
: r.end_day_id;
|
||||
if (newDayId !== r.day_id || newEndDayId !== r.end_day_id) {
|
||||
update.run(newDayId, newEndDayId, r.id);
|
||||
// RS13
|
||||
await this.reservationsRepo.setDays(r.id, newDayId, newEndDayId);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -352,43 +394,46 @@ export class ReservationsService {
|
||||
// bound its transaction lazily per call for the same reason ("The
|
||||
// database connection is not open").
|
||||
await this.uow.transactional(async () => {
|
||||
this.db.run('DELETE FROM reservation_endpoints WHERE reservation_id = ?', reservationId);
|
||||
const insert = this.db.prepare(`
|
||||
INSERT INTO reservation_endpoints (reservation_id, role, sequence, name, code, lat, lng, timezone, local_time, local_date)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
// RS16
|
||||
await this.endpointsRepo.deleteForReservation(reservationId);
|
||||
// lat/lng are NOT NULL: an imported transport whose pick-up/return (or station/
|
||||
// stop) couldn't be geocoded reaches here with null coords. Skip those rows rather
|
||||
// than let the INSERT throw and fail the entire booking save — the dates still live
|
||||
// on reservation_time/reservation_end_time, so the booking lands on its day either way.
|
||||
endpoints
|
||||
.filter((e) => e.lat != null && e.lng != null)
|
||||
.forEach((e, i) => {
|
||||
insert.run(reservationId, e.role, e.sequence ?? i, e.name, e.code ?? null, e.lat, e.lng, e.timezone ?? null, e.local_time ?? null, e.local_date ?? null);
|
||||
const filtered = endpoints.filter((e) => e.lat != null && e.lng != null);
|
||||
for (const [i, e] of filtered.entries()) {
|
||||
// RS17
|
||||
await this.endpointsRepo.insertEndpoint({
|
||||
reservation_id: reservationId,
|
||||
role: e.role,
|
||||
sequence: e.sequence ?? i,
|
||||
name: e.name,
|
||||
code: e.code ?? null,
|
||||
lat: e.lat!,
|
||||
lng: e.lng!,
|
||||
timezone: e.timezone ?? null,
|
||||
local_time: e.local_time ?? null,
|
||||
local_date: e.local_date ?? null,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async list(tripId: string | number) {
|
||||
const reservations = this.db.all<ReservationRow>(`
|
||||
SELECT r.*, d.day_number, p.name as place_name, r.assignment_id,
|
||||
ap.place_id as accommodation_place_id, acc_p.name as accommodation_name,
|
||||
ap.start_day_id as accommodation_start_day_id, ap.end_day_id as accommodation_end_day_id
|
||||
FROM reservations r
|
||||
LEFT JOIN days d ON r.day_id = d.id
|
||||
LEFT JOIN places p ON r.place_id = p.id
|
||||
LEFT JOIN day_accommodations ap ON r.accommodation_id = ap.id
|
||||
LEFT JOIN places acc_p ON ap.place_id = acc_p.id
|
||||
WHERE r.trip_id = ?
|
||||
ORDER BY r.reservation_time ASC, r.created_at ASC
|
||||
`, tripId);
|
||||
// RS18. `ReservationRow`'s `status`/`type` are declared non-nullable
|
||||
// (every write path here always supplies a value — `status || 'pending'`
|
||||
// / `type || 'other'`, RS28/RS41) even though the physical columns are
|
||||
// nullable; the repository's honest `ReservationJoinRow` type keeps them
|
||||
// `string | null`. Neither the legacy raw row nor this one coerces a
|
||||
// genuine NULL at read time — spreading into a fresh object literal
|
||||
// (assignable to `ReservationRow`'s index signature the same way a
|
||||
// literal always is) changes only the compile-time claim, never a
|
||||
// runtime value, so a row a write path here never produces (a
|
||||
// hand-edited NULL) still reads back as `null` on the wire.
|
||||
const reservations: ReservationRow[] = (await this.reservationsRepo.listForTrip(tripId)).map((r) => ({ ...r }));
|
||||
|
||||
const dayPositions = this.db.all<{ reservation_id: number; day_id: number; position: number }>(`
|
||||
SELECT rdp.reservation_id, rdp.day_id, rdp.position
|
||||
FROM reservation_day_positions rdp
|
||||
JOIN reservations r ON rdp.reservation_id = r.id
|
||||
WHERE r.trip_id = ?
|
||||
`, tripId);
|
||||
// RS19
|
||||
const dayPositions = await this.dayPositionsRepo.listForTrip(this.rowIdNum(tripId));
|
||||
|
||||
const posMap = new Map<number, Record<number, number>>();
|
||||
for (const dp of dayPositions) {
|
||||
@@ -431,6 +476,12 @@ export class ReservationsService {
|
||||
* once and never restamps it, so moving the stay left the widget showing the
|
||||
* old date. Both halves of the report come from reading a hotel's date off
|
||||
* fields hotels do not use.
|
||||
*
|
||||
* RS20 — Task 4 (calendar + listUpcoming + the visibility predicate
|
||||
* consumers). Stays raw on `DatabaseService` in this task per the plan's
|
||||
* own split; the tier ruling (Kysely full statement vs. an explicit
|
||||
* exception vs. a JS-merge decomposition) is Task 4's, per Task 0's
|
||||
* concern #4.
|
||||
*/
|
||||
async listUpcoming(userId: number, limit = 6) {
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
@@ -536,32 +587,45 @@ export class ReservationsService {
|
||||
// and refusing the whole write instead would make such a booking
|
||||
// permanently uneditable. Only a row that exists in a DIFFERENT trip is one
|
||||
// the caller must not have reached for.
|
||||
const elsewhere = (table: 'days' | 'places' | 'day_accommodations', id: unknown) => {
|
||||
const row = this.db.get<{ trip_id: number }>(`SELECT trip_id FROM ${table} WHERE id = ?`, id);
|
||||
return !!row && String(row.trip_id) !== String(tripId);
|
||||
//
|
||||
// RS21 — a CLOSED union of table names dispatched to typed repository
|
||||
// calls (never an interpolated identifier): `DaysRepository.findById`,
|
||||
// the inherited `PlacesRepository.findOne` and
|
||||
// `DayAccommodationsRepository.getTripId`. R1: this was a SYNCHRONOUS
|
||||
// closure used as a boolean in the legacy code — now genuinely async, so
|
||||
// every call site below `await`s it; the async-closure mutation harness
|
||||
// (`tests/integration/async-closure-truthiness-guards.test.ts`,
|
||||
// RS21-001/RS21-002) proves a dropped `await` goes red.
|
||||
const elsewhere = async (table: 'days' | 'places' | 'day_accommodations', id: number): Promise<boolean> => {
|
||||
let rowTripId: number | undefined;
|
||||
if (table === 'days') {
|
||||
rowTripId = (await this.daysRepo.findById(id))?.trip_id;
|
||||
} else if (table === 'places') {
|
||||
rowTripId = (await this.placesRepo.findOne({ id }))?.trip_id;
|
||||
} else {
|
||||
rowTripId = await this.dayAccommodationsRepo.getTripId(id);
|
||||
}
|
||||
return rowTripId !== undefined && String(rowTripId) !== String(tripId);
|
||||
};
|
||||
|
||||
const check = (field: string, offending: boolean) => { if (offending) offenders.push(field); };
|
||||
|
||||
if (data.day_id != null) check('day_id', elsewhere('days', data.day_id));
|
||||
if (data.end_day_id != null) check('end_day_id', elsewhere('days', data.end_day_id));
|
||||
if (data.place_id != null) check('place_id', elsewhere('places', data.place_id));
|
||||
if (data.accommodation_id != null) check('accommodation_id', elsewhere('day_accommodations', data.accommodation_id));
|
||||
if (data.day_id != null) check('day_id', await elsewhere('days', data.day_id));
|
||||
if (data.end_day_id != null) check('end_day_id', await elsewhere('days', data.end_day_id));
|
||||
if (data.place_id != null) check('place_id', await elsewhere('places', data.place_id));
|
||||
if (data.accommodation_id != null) check('accommodation_id', await elsewhere('day_accommodations', data.accommodation_id));
|
||||
if (data.assignment_id != null) {
|
||||
// An assignment belongs to a trip through its day, so the join is the
|
||||
// lookup — same shape as the MCP tool's getAssignmentForTrip.
|
||||
const row = this.db.get<{ trip_id: number }>(
|
||||
'SELECT d.trip_id FROM day_assignments da JOIN days d ON da.day_id = d.id WHERE da.id = ?',
|
||||
data.assignment_id,
|
||||
);
|
||||
check('assignment_id', !!row && String(row.trip_id) !== String(tripId));
|
||||
// lookup — same shape as the MCP tool's getAssignmentForTrip. RS22.
|
||||
const rowTripId = await this.reservationsRepo.getAssignmentTripId(data.assignment_id);
|
||||
check('assignment_id', rowTripId !== undefined && String(rowTripId) !== String(tripId));
|
||||
}
|
||||
|
||||
const acc = data.create_accommodation;
|
||||
if (acc) {
|
||||
if (acc.place_id != null) check('create_accommodation.place_id', elsewhere('places', acc.place_id));
|
||||
if (acc.start_day_id != null) check('create_accommodation.start_day_id', elsewhere('days', acc.start_day_id));
|
||||
if (acc.end_day_id != null) check('create_accommodation.end_day_id', elsewhere('days', acc.end_day_id));
|
||||
if (acc.place_id != null) check('create_accommodation.place_id', await elsewhere('places', acc.place_id));
|
||||
if (acc.start_day_id != null) check('create_accommodation.start_day_id', await elsewhere('days', acc.start_day_id));
|
||||
if (acc.end_day_id != null) check('create_accommodation.end_day_id', await elsewhere('days', acc.end_day_id));
|
||||
}
|
||||
|
||||
return offenders;
|
||||
@@ -589,19 +653,24 @@ export class ReservationsService {
|
||||
*/
|
||||
async unresolvedReferences(tripId: string | number, data: CreateReservationData | UpdateReservationData): Promise<string[]> {
|
||||
const offenders: string[] = [];
|
||||
const onTrip = (table: 'days' | 'places', id: unknown) =>
|
||||
!!this.db.get(`SELECT id FROM ${table} WHERE id = ? AND trip_id = ?`, id, tripId);
|
||||
// RS23 — a CLOSED union ('days' | 'places') dispatched to the existing
|
||||
// typed `existsInTrip` methods. R1: this closure has NO typed
|
||||
// intermediate the way `check()` gives `elsewhere` above — `tsc` does
|
||||
// NOT catch a dropped `await` here (Task 0's finding), so every call
|
||||
// site below is `await`ed deliberately and the async-closure mutation
|
||||
// harness (RS23-001) is the only net.
|
||||
const onTrip = async (table: 'days' | 'places', id: number): Promise<boolean> => {
|
||||
const tripIdNum = this.rowIdNum(tripId);
|
||||
return table === 'days' ? this.daysRepo.existsInTrip(id, tripIdNum) : this.placesRepo.existsInTrip(id, tripIdNum);
|
||||
};
|
||||
|
||||
if (data.day_id && !onTrip('days', data.day_id)) offenders.push('day_id');
|
||||
if (data.end_day_id && !onTrip('days', data.end_day_id)) offenders.push('end_day_id');
|
||||
if (data.place_id && !onTrip('places', data.place_id)) offenders.push('place_id');
|
||||
if (data.day_id && !(await onTrip('days', data.day_id))) offenders.push('day_id');
|
||||
if (data.end_day_id && !(await onTrip('days', data.end_day_id))) offenders.push('end_day_id');
|
||||
if (data.place_id && !(await onTrip('places', data.place_id))) offenders.push('place_id');
|
||||
if (data.assignment_id) {
|
||||
// An assignment belongs to a trip through its day, the same join the
|
||||
// other guard walks.
|
||||
const row = this.db.get(
|
||||
'SELECT da.id FROM day_assignments da JOIN days d ON da.day_id = d.id WHERE da.id = ? AND d.trip_id = ?',
|
||||
data.assignment_id, tripId,
|
||||
);
|
||||
// other guard walks. RS24.
|
||||
const row = await this.dayAssignmentsRepo.findInTrip(data.assignment_id, tripId);
|
||||
if (!row) offenders.push('assignment_id');
|
||||
}
|
||||
|
||||
@@ -619,10 +688,17 @@ export class ReservationsService {
|
||||
return offenders;
|
||||
}
|
||||
|
||||
/** Is there still a row behind this id? Existence only — which trip it sits
|
||||
* on is the guards' question, and they answer it before the write. */
|
||||
private async referenceExists(table: 'days' | 'places' | 'day_assignments', id: unknown): Promise<boolean> {
|
||||
return !!this.db.get(`SELECT id FROM ${table} WHERE id = ?`, id);
|
||||
/**
|
||||
* Is there still a row behind this id? Existence only — which trip it sits
|
||||
* on is the guards' question, and they answer it before the write. RS25 —
|
||||
* a CLOSED union dispatched to each entity's own inherited `findOne`
|
||||
* (never an interpolated identifier); no new method on `Days`/`Places`/
|
||||
* `DayAssignments` repositories.
|
||||
*/
|
||||
private async referenceExists(table: 'days' | 'places' | 'day_assignments', id: number): Promise<boolean> {
|
||||
if (table === 'days') return (await this.daysRepo.findOne({ id })) !== null;
|
||||
if (table === 'places') return (await this.placesRepo.findOne({ id })) !== null;
|
||||
return (await this.dayAssignmentsRepo.findOne({ id })) !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -658,8 +734,12 @@ export class ReservationsService {
|
||||
private async requireResolvableStay(acc: CreateAccommodation): Promise<void> {
|
||||
const missing: string[] = [];
|
||||
if (acc.place_id && !(await this.referenceExists('places', acc.place_id))) missing.push('place_id');
|
||||
if (!(await this.referenceExists('days', acc.start_day_id))) missing.push('start_day_id');
|
||||
if (!(await this.referenceExists('days', acc.end_day_id))) missing.push('end_day_id');
|
||||
// Both days are already known to be set where this is called (the
|
||||
// docstring above) — every caller guards on `start_day_id && end_day_id`
|
||||
// before calling, so the non-null assertion here names a real invariant,
|
||||
// not a widened type.
|
||||
if (!(await this.referenceExists('days', acc.start_day_id!))) missing.push('start_day_id');
|
||||
if (!(await this.referenceExists('days', acc.end_day_id!))) missing.push('end_day_id');
|
||||
if (missing.length > 0) {
|
||||
throw new BadRequestException(`Unknown reference: ${missing.map((field) => `create_accommodation.${field}`).join(', ')}`);
|
||||
}
|
||||
@@ -695,11 +775,16 @@ export class ReservationsService {
|
||||
const { place_id: accPlaceId, start_day_id, end_day_id, check_in, check_out, confirmation: accConf } = create_accommodation;
|
||||
if (start_day_id && end_day_id) {
|
||||
await this.requireResolvableStay(create_accommodation);
|
||||
const accResult = this.db.run(
|
||||
'INSERT INTO day_accommodations (trip_id, place_id, start_day_id, end_day_id, check_in, check_out, confirmation) VALUES (?, ?, ?, ?, ?, ?, ?)',
|
||||
tripId, accPlaceId || null, start_day_id, end_day_id, check_in || null, check_out || null, accConf || confirmation_number || null
|
||||
);
|
||||
resolvedAccommodationId = Number(accResult.lastInsertRowid);
|
||||
// RS27
|
||||
resolvedAccommodationId = await this.dayAccommodationsRepo.insertBookingStay({
|
||||
trip_id: tripId,
|
||||
place_id: accPlaceId || null,
|
||||
start_day_id,
|
||||
end_day_id,
|
||||
check_in: check_in || null,
|
||||
check_out: check_out || null,
|
||||
confirmation: accConf || confirmation_number || null,
|
||||
});
|
||||
accommodationCreated = true;
|
||||
// Same night, same day header, so the same stop the road trip draws for a
|
||||
// night entered under Days. Without it the hotel booked on this form is the
|
||||
@@ -727,31 +812,33 @@ export class ReservationsService {
|
||||
const resolvedPlaceId = await this.resolvedOrNull('places', place_id || null);
|
||||
const resolvedAssignmentId = await this.resolvedOrNull('day_assignments', assignment_id || null);
|
||||
|
||||
const result = this.db.run(`
|
||||
INSERT INTO reservations (trip_id, day_id, end_day_id, place_id, assignment_id, title, reservation_time, reservation_end_time, location, confirmation_number, notes, url, status, type, accommodation_id, metadata, needs_review)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
tripId,
|
||||
resolvedDayId,
|
||||
resolvedEndDayId,
|
||||
resolvedPlaceId,
|
||||
resolvedAssignmentId,
|
||||
// RS28. `accommodation_id` bound as the TEXT column's string form (R2 —
|
||||
// the legacy statement bound a plain JS number and let better-sqlite3's
|
||||
// driver / SQLite's own TEXT-affinity conversion at write time store it
|
||||
// as text; `String(n)` on a normal integer id renders the identical
|
||||
// bytes, so this is byte-parity, not a behaviour change).
|
||||
const insertedId = await this.reservationsRepo.insertReservation({
|
||||
trip_id: this.rowIdNum(tripId),
|
||||
day_id: resolvedDayId,
|
||||
end_day_id: resolvedEndDayId,
|
||||
place_id: resolvedPlaceId,
|
||||
assignment_id: resolvedAssignmentId,
|
||||
title,
|
||||
reservation_time || null,
|
||||
reservation_end_time || null,
|
||||
location || null,
|
||||
confirmation_number || null,
|
||||
notes || null,
|
||||
url || null,
|
||||
status || 'pending',
|
||||
resolvedType,
|
||||
resolvedAccommodationId,
|
||||
metadata ? JSON.stringify(metadata) : null,
|
||||
needs_review ? 1 : 0
|
||||
);
|
||||
reservation_time: reservation_time || null,
|
||||
reservation_end_time: reservation_end_time || null,
|
||||
location: location || null,
|
||||
confirmation_number: confirmation_number || null,
|
||||
notes: notes || null,
|
||||
url: url || null,
|
||||
status: status || 'pending',
|
||||
type: resolvedType,
|
||||
accommodation_id: resolvedAccommodationId == null ? null : String(resolvedAccommodationId),
|
||||
metadata: metadata ? JSON.stringify(metadata) : null,
|
||||
needs_review: needs_review ? 1 : 0,
|
||||
});
|
||||
|
||||
if (endpoints && endpoints.length > 0) {
|
||||
await this.saveEndpoints(Number(result.lastInsertRowid), endpoints);
|
||||
await this.saveEndpoints(insertedId, endpoints);
|
||||
}
|
||||
|
||||
// Sync check-in/out to accommodation if linked. Keyed off the RESOLVED id
|
||||
@@ -761,21 +848,19 @@ export class ReservationsService {
|
||||
if (resolvedAccommodationId && metadata) {
|
||||
const meta = (typeof metadata === 'string' ? JSON.parse(metadata) : metadata) as AccommodationTimesMeta;
|
||||
if (meta.check_in_time || meta.check_in_end_time || meta.check_out_time) {
|
||||
this.db.run(
|
||||
'UPDATE day_accommodations SET check_in = COALESCE(?, check_in), check_in_end = COALESCE(?, check_in_end), check_out = COALESCE(?, check_out) WHERE id = ?',
|
||||
meta.check_in_time || null, meta.check_in_end_time || null, meta.check_out_time || null, resolvedAccommodationId
|
||||
// RS29
|
||||
await this.dayAccommodationsRepo.patchTimes(
|
||||
resolvedAccommodationId, meta.check_in_time || null, meta.check_in_end_time || null, meta.check_out_time || null,
|
||||
);
|
||||
}
|
||||
if (confirmation_number) {
|
||||
this.db.run(
|
||||
'UPDATE day_accommodations SET confirmation = COALESCE(?, confirmation) WHERE id = ?',
|
||||
confirmation_number, resolvedAccommodationId
|
||||
);
|
||||
// RS30
|
||||
await this.dayAccommodationsRepo.patchConfirmation(resolvedAccommodationId, confirmation_number);
|
||||
}
|
||||
}
|
||||
|
||||
// The row was just inserted, so the re-select can't miss (legacy typed this any).
|
||||
const reservation = (await this.getReservationWithJoins(Number(result.lastInsertRowid)))!;
|
||||
const reservation = (await this.getReservationWithJoins(insertedId))!;
|
||||
return { reservation, accommodationCreated, stayMirror };
|
||||
}
|
||||
|
||||
@@ -788,32 +873,45 @@ export class ReservationsService {
|
||||
// agree on it. Doing that in the statement rather than as a pre-check also
|
||||
// makes a stale id a quiet no-op instead of a foreign-key error surfacing
|
||||
// as a 500.
|
||||
const stmt = this.db.prepare(`
|
||||
INSERT OR REPLACE INTO reservation_day_positions (reservation_id, day_id, position)
|
||||
SELECT r.id, d.id, ?
|
||||
FROM reservations r
|
||||
JOIN days d ON d.trip_id = r.trip_id
|
||||
WHERE r.id = ? AND d.id = ? AND r.trip_id = ?
|
||||
`);
|
||||
const dayIdNum = this.rowIdNum(dayId);
|
||||
const tripIdNum = this.rowIdNum(tripId);
|
||||
await this.uow.transactional(async () => {
|
||||
for (const item of positions) {
|
||||
// position is NOT NULL while the wire contract leaves the value optional.
|
||||
stmt.run(item.day_plan_position ?? 0, item.id, dayId, tripId);
|
||||
// RS31/RS32. position is NOT NULL while the wire contract leaves the value optional.
|
||||
await this.dayPositionsRepo.upsertScoped(tripIdNum, item.id, dayIdNum, item.day_plan_position ?? 0);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// Legacy: update global position
|
||||
const stmt = this.db.prepare('UPDATE reservations SET day_plan_position = ? WHERE id = ? AND trip_id = ?');
|
||||
const tripIdNum = this.rowIdNum(tripId);
|
||||
await this.uow.transactional(async () => {
|
||||
for (const item of positions) {
|
||||
stmt.run(item.day_plan_position, item.id, tripId);
|
||||
// RS33/RS34. `?? null`, never `undefined` (R8) — a `nativeUpdate`
|
||||
// partial SKIPS a column whose value is `undefined`, which would
|
||||
// leave the row's old position in place instead of clearing it,
|
||||
// unlike the legacy statement's own bind of `undefined` (which
|
||||
// better-sqlite3 writes as SQL NULL).
|
||||
await this.reservationsRepo.setDayPlanPosition(item.id, tripIdNum, item.day_plan_position ?? null);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async getReservation(id: string | number, tripId: string | number) {
|
||||
return this.db.get<Reservation>('SELECT * FROM reservations WHERE id = ? AND trip_id = ?', id, tripId);
|
||||
async getReservation(id: string | number, tripId: string | number): Promise<Reservation | undefined> {
|
||||
// RS35 — the trip-scoping guard every write path re-reads through. Both
|
||||
// ids parsed ONCE here (rule 21); a miss reads as `undefined`, matching
|
||||
// the legacy raw-bind miss.
|
||||
const idNum = toRowId(id);
|
||||
const tripIdNum = toRowId(tripId);
|
||||
if (idNum === null || tripIdNum === null) return undefined;
|
||||
const row = await this.reservationsRepo.findInTrip(idNum, tripIdNum);
|
||||
if (!row) return undefined;
|
||||
// `status`/`type` are NOT NULL in every row a write path here ever
|
||||
// produces (`status || 'pending'` / `type || 'other'`, RS28/RS41); the
|
||||
// fallback only guards the physical column's own nullability for a row
|
||||
// this service never wrote, the same defensive shape `resolvedType`
|
||||
// already applies at every call site that reads `current.type`.
|
||||
return { ...row, status: row.status ?? 'pending', type: row.type ?? 'other' };
|
||||
}
|
||||
|
||||
/** The accommodation upsert, the reservation update, the endpoint replace
|
||||
@@ -835,12 +933,27 @@ export class ReservationsService {
|
||||
let accommodationChanged = false;
|
||||
let stayMirror = noStayMirror();
|
||||
|
||||
// Update or create accommodation for hotel reservations
|
||||
let resolvedAccId: number | null = accommodation_id !== undefined ? (accommodation_id || null) : (current.accommodation_id ?? null);
|
||||
// Update or create accommodation for hotel reservations.
|
||||
//
|
||||
// `resolvedAccId` deliberately keeps BOTH shapes alive, exactly as the
|
||||
// legacy code's own type lie did at runtime (`Reservation
|
||||
// .accommodation_id` claimed `number` while the raw row underneath was
|
||||
// always the TEXT column's string): when the payload names
|
||||
// `accommodation_id` it is a genuine `number` (`UpdateReservationData`'s
|
||||
// declared type); when it falls back to `current.accommodation_id` it is
|
||||
// the UNCONVERTED string this reservation already stored (R2 — a
|
||||
// `"14.0"`-shaped value must round-trip byte-identical through a no-op
|
||||
// update, which a `Number()`/`String()` round-trip would truncate to
|
||||
// `"14"`). `accIdForRead(...)` below is the READ-only numeric form every
|
||||
// repository call needs; the final write still binds `resolvedAccId`
|
||||
// itself (via `String(...)`, a no-op on an already-string value).
|
||||
let resolvedAccId: number | string | null = accommodation_id !== undefined ? (accommodation_id || null) : (current.accommodation_id ?? null);
|
||||
const accIdForRead = (v: number | string | null): number | null => (v == null ? null : Number(v));
|
||||
if (resolvedAccId) {
|
||||
// Scoped to the trip on purpose: an id belonging to someone else's trip
|
||||
// must read as absent here, not as an accommodation to write through to.
|
||||
const accExists = this.db.get('SELECT id FROM day_accommodations WHERE id = ? AND trip_id = ?', resolvedAccId, tripId);
|
||||
// RS37
|
||||
const accExists = await this.dayAccommodationsRepo.existsInTrip(accIdForRead(resolvedAccId)!, this.rowIdNum(tripId));
|
||||
if (!accExists) resolvedAccId = null;
|
||||
}
|
||||
if (type === 'hotel' && create_accommodation) {
|
||||
@@ -848,23 +961,35 @@ export class ReservationsService {
|
||||
if (start_day_id && end_day_id) {
|
||||
await this.requireResolvableStay(create_accommodation);
|
||||
if (resolvedAccId) {
|
||||
const prior = this.db.get<{ check_in: string | null }>('SELECT check_in FROM day_accommodations WHERE id = ?', resolvedAccId);
|
||||
this.db.run(
|
||||
'UPDATE day_accommodations SET place_id = ?, start_day_id = ?, end_day_id = ?, check_in = ?, check_out = ?, confirmation = ? WHERE id = ?',
|
||||
accPlaceId || null, start_day_id, end_day_id, check_in || null, check_out || null, accConf || confirmation_number || null, resolvedAccId
|
||||
);
|
||||
const resolvedAccIdNum = accIdForRead(resolvedAccId)!;
|
||||
// RS38
|
||||
const priorCheckIn = await this.dayAccommodationsRepo.getCheckIn(resolvedAccIdNum);
|
||||
// RS39
|
||||
await this.dayAccommodationsRepo.updateFromBooking(resolvedAccIdNum, {
|
||||
place_id: accPlaceId || null,
|
||||
start_day_id,
|
||||
end_day_id,
|
||||
check_in: check_in || null,
|
||||
check_out: check_out || null,
|
||||
confirmation: accConf || confirmation_number || null,
|
||||
});
|
||||
// The stay just moved. Its stop moves with it, or it is left sitting on a
|
||||
// day nobody sleeps there any more, hidden from the day list because it
|
||||
// still carries this booking's id and stranded in the middle of the drive.
|
||||
stayMirror = await this.accommodations.moveStayStop(resolvedAccId, accPlaceId || null, start_day_id, check_in, {
|
||||
checkInChanged: (check_in || null) !== (prior?.check_in ?? null),
|
||||
stayMirror = await this.accommodations.moveStayStop(resolvedAccIdNum, accPlaceId || null, start_day_id, check_in, {
|
||||
checkInChanged: (check_in || null) !== (priorCheckIn ?? null),
|
||||
});
|
||||
} else if (accPlaceId) {
|
||||
const accResult = this.db.run(
|
||||
'INSERT INTO day_accommodations (trip_id, place_id, start_day_id, end_day_id, check_in, check_out, confirmation) VALUES (?, ?, ?, ?, ?, ?, ?)',
|
||||
tripId, accPlaceId, start_day_id, end_day_id, check_in || null, check_out || null, accConf || confirmation_number || null
|
||||
);
|
||||
resolvedAccId = Number(accResult.lastInsertRowid);
|
||||
// RS40
|
||||
resolvedAccId = await this.dayAccommodationsRepo.insertBookingStay({
|
||||
trip_id: tripId,
|
||||
place_id: accPlaceId,
|
||||
start_day_id,
|
||||
end_day_id,
|
||||
check_in: check_in || null,
|
||||
check_out: check_out || null,
|
||||
confirmation: accConf || confirmation_number || null,
|
||||
});
|
||||
stayMirror = await this.accommodations.attachStayStop(resolvedAccId, accPlaceId, start_day_id, check_in);
|
||||
}
|
||||
accommodationChanged = true;
|
||||
@@ -919,65 +1044,53 @@ export class ReservationsService {
|
||||
const nextPlaceId = await this.resolvedOrNull('places', place_id !== undefined ? (place_id || null) : (current.place_id ?? null));
|
||||
const nextAssignmentId = await this.resolvedOrNull('day_assignments', assignment_id !== undefined ? (assignment_id || null) : (current.assignment_id ?? null));
|
||||
|
||||
this.db.run(`
|
||||
UPDATE reservations SET
|
||||
title = COALESCE(?, title),
|
||||
reservation_time = ?,
|
||||
reservation_end_time = ?,
|
||||
location = ?,
|
||||
confirmation_number = ?,
|
||||
notes = ?,
|
||||
url = ?,
|
||||
day_id = ?,
|
||||
end_day_id = ?,
|
||||
place_id = ?,
|
||||
assignment_id = ?,
|
||||
status = COALESCE(?, status),
|
||||
type = COALESCE(?, type),
|
||||
accommodation_id = ?,
|
||||
metadata = ?,
|
||||
needs_review = COALESCE(?, needs_review)
|
||||
WHERE id = ?
|
||||
`,
|
||||
title || null,
|
||||
nextReservationTime,
|
||||
nextReservationEndTime,
|
||||
location !== undefined ? (location || null) : current.location,
|
||||
confirmation_number !== undefined ? (confirmation_number || null) : current.confirmation_number,
|
||||
notes !== undefined ? (notes || null) : current.notes,
|
||||
url !== undefined ? (url || null) : (current as Reservation & { url?: string | null }).url,
|
||||
nextDayId,
|
||||
nextEndDayId,
|
||||
nextPlaceId,
|
||||
nextAssignmentId,
|
||||
status || null,
|
||||
type || null,
|
||||
resolvedAccId,
|
||||
nextMetadata !== undefined ? (nextMetadata ? JSON.stringify(nextMetadata) : null) : current.metadata,
|
||||
needs_review === undefined ? null : (needs_review ? 1 : 0),
|
||||
id
|
||||
);
|
||||
// RS41. The `PlacesRepository.updatePlace`/PL11 precedent: the four
|
||||
// legacy `COALESCE(?, col)` keep-if-null columns (`title`/`status`/
|
||||
// `type`/`needs_review`) are resolved to their FINAL value here, in JS,
|
||||
// against the `current` pre-image this method already holds — nothing
|
||||
// else in this same transaction touches those four columns between the
|
||||
// read and this write, so `current`'s snapshot is exactly what the
|
||||
// legacy statement's own SQL-side `COALESCE` against the live row would
|
||||
// have read.
|
||||
const idNum = this.rowIdNum(id);
|
||||
await this.reservationsRepo.updateReservation(idNum, {
|
||||
title: title || current.title,
|
||||
reservation_time: nextReservationTime,
|
||||
reservation_end_time: nextReservationEndTime,
|
||||
location: location !== undefined ? (location || null) : (current.location ?? null),
|
||||
confirmation_number: confirmation_number !== undefined ? (confirmation_number || null) : (current.confirmation_number ?? null),
|
||||
notes: notes !== undefined ? (notes || null) : (current.notes ?? null),
|
||||
url: url !== undefined ? (url || null) : ((current as Reservation & { url?: string | null }).url ?? null),
|
||||
day_id: nextDayId,
|
||||
end_day_id: nextEndDayId,
|
||||
place_id: nextPlaceId,
|
||||
assignment_id: nextAssignmentId,
|
||||
status: status || current.status,
|
||||
type: type || current.type,
|
||||
accommodation_id: resolvedAccId == null ? null : String(resolvedAccId),
|
||||
metadata: nextMetadata !== undefined ? (nextMetadata ? JSON.stringify(nextMetadata) : null) : (current.metadata ?? null),
|
||||
needs_review: needs_review === undefined ? (current.needs_review ?? 0) : (needs_review ? 1 : 0),
|
||||
});
|
||||
|
||||
if (endpoints !== undefined) {
|
||||
await this.saveEndpoints(Number(id), endpoints);
|
||||
await this.saveEndpoints(idNum, endpoints);
|
||||
}
|
||||
|
||||
// Sync check-in/out to accommodation if linked
|
||||
const resolvedMeta = nextMetadata !== undefined ? nextMetadata : (current.metadata ? JSON.parse(current.metadata as string) : null);
|
||||
if (resolvedAccId && resolvedMeta) {
|
||||
const meta = (typeof resolvedMeta === 'string' ? JSON.parse(resolvedMeta) : resolvedMeta) as AccommodationTimesMeta;
|
||||
const resolvedAccIdNum = accIdForRead(resolvedAccId)!;
|
||||
if (meta.check_in_time || meta.check_in_end_time || meta.check_out_time) {
|
||||
this.db.run(
|
||||
'UPDATE day_accommodations SET check_in = COALESCE(?, check_in), check_in_end = COALESCE(?, check_in_end), check_out = COALESCE(?, check_out) WHERE id = ?',
|
||||
meta.check_in_time || null, meta.check_in_end_time || null, meta.check_out_time || null, resolvedAccId
|
||||
// RS42
|
||||
await this.dayAccommodationsRepo.patchTimes(
|
||||
resolvedAccIdNum, meta.check_in_time || null, meta.check_in_end_time || null, meta.check_out_time || null,
|
||||
);
|
||||
}
|
||||
const resolvedConf = confirmation_number !== undefined ? confirmation_number : current.confirmation_number;
|
||||
if (resolvedConf) {
|
||||
this.db.run(
|
||||
'UPDATE day_accommodations SET confirmation = COALESCE(?, confirmation) WHERE id = ?',
|
||||
resolvedConf, resolvedAccId
|
||||
);
|
||||
// RS43
|
||||
await this.dayAccommodationsRepo.patchConfirmation(resolvedAccIdNum, resolvedConf);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -989,11 +1102,12 @@ export class ReservationsService {
|
||||
|
||||
/** The accommodation + budget-item + reservation deletes are one logical
|
||||
* cascade — all-or-nothing. */
|
||||
async remove(id: string | number, tripId: string | number): Promise<{ deleted: { id: number; title: string; type: string; accommodation_id: number | null } | undefined; accommodationDeleted: boolean; deletedBudgetItemId: number | null }> {
|
||||
async remove(id: string | number, tripId: string | number): Promise<{ deleted: { id: number; title: string; type: string | null; accommodation_id: string | null } | undefined; accommodationDeleted: boolean; deletedBudgetItemId: number | null }> {
|
||||
const idNum = this.rowIdNum(id);
|
||||
const tripIdNum = this.rowIdNum(tripId);
|
||||
const removed = await this.uow.transactional(async () => {
|
||||
const reservation = this.db.get<{ id: number; title: string; type: string; accommodation_id: number | null }>(
|
||||
'SELECT id, title, type, accommodation_id FROM reservations WHERE id = ? AND trip_id = ?', id, tripId
|
||||
);
|
||||
// RS45
|
||||
const reservation = await this.reservationsRepo.findHeaderInTrip(idNum, tripIdNum);
|
||||
if (!reservation) return { deleted: undefined, accommodationDeleted: false, deletedBudgetItemId: null, stayMirror: noStayMirror() };
|
||||
|
||||
let accommodationDeleted = false;
|
||||
@@ -1003,26 +1117,34 @@ export class ReservationsService {
|
||||
// before referencesOutsideTrip existed can still carry a foreign
|
||||
// accommodation_id, and the cascade must not follow it. The stops go by
|
||||
// accommodation id alone, which is exactly the reach that guard denies.
|
||||
const ownStay = this.db.get<{ id: number }>(
|
||||
'SELECT id FROM day_accommodations WHERE id = ? AND trip_id = ?', reservation.accommodation_id, tripId,
|
||||
);
|
||||
// accommodation_id is TEXT (R2); Number(...) here is a READ-time
|
||||
// normalisation only, the same `Math.trunc(Number(x))`-style
|
||||
// coercion every other reader of this column already applies — the
|
||||
// WRITE-side raw string (`reservation.accommodation_id`) still ships
|
||||
// unconverted in the `accommodation:deleted` broadcast payload below.
|
||||
const accIdNum = Number(reservation.accommodation_id);
|
||||
// RS46
|
||||
const ownStay = await this.dayAccommodationsRepo.existsInTrip(accIdNum, tripIdNum);
|
||||
if (ownStay) {
|
||||
// Released before the row goes, not after: the release looks the stops up
|
||||
// by accommodation id, and that pointer is cleared the moment the stay is
|
||||
// deleted. Reversed, the stop stands with nothing left to remove it, and
|
||||
// the day list hides it for carrying a booking id.
|
||||
stayMirror = await this.accommodations.dropStayStops(reservation.accommodation_id);
|
||||
this.db.run('DELETE FROM day_accommodations WHERE id = ? AND trip_id = ?', reservation.accommodation_id, tripId);
|
||||
stayMirror = await this.accommodations.dropStayStops(accIdNum);
|
||||
// RS47
|
||||
await this.dayAccommodationsRepo.deleteInTrip(accIdNum, tripIdNum);
|
||||
accommodationDeleted = true;
|
||||
}
|
||||
}
|
||||
|
||||
// RS48/RS49 — stay raw, Plan 3e's `budget_items` table.
|
||||
const linkedBudget = this.db.get<{ id: number }>('SELECT id FROM budget_items WHERE trip_id = ? AND reservation_id = ?', tripId, id);
|
||||
if (linkedBudget) {
|
||||
this.db.run('DELETE FROM budget_items WHERE id = ?', linkedBudget.id);
|
||||
}
|
||||
|
||||
this.db.run('DELETE FROM reservations WHERE id = ?', id);
|
||||
// RS50
|
||||
await this.reservationsRepo.deleteById(idNum);
|
||||
return { deleted: reservation, accommodationDeleted, deletedBudgetItemId: linkedBudget ? linkedBudget.id : null, stayMirror };
|
||||
});
|
||||
const { stayMirror, ...answer } = removed;
|
||||
|
||||
+8
-1
@@ -218,7 +218,14 @@ export interface Reservation {
|
||||
notes?: string | null;
|
||||
status: string;
|
||||
type: string;
|
||||
accommodation_id?: number | null;
|
||||
/**
|
||||
* TEXT at the DB, not INTEGER (Plan 3d inventory §18.1 — no FK, some rows
|
||||
* read back as `"14.0"`): every reader normalises it, so this stays a
|
||||
* string here rather than the `DayAssignment.accommodation_id` field
|
||||
* above's genuine INTEGER shape. Do not "fix" this to `number` — that
|
||||
* field is a different column on a different table.
|
||||
*/
|
||||
accommodation_id?: string | null;
|
||||
metadata?: string | null;
|
||||
needs_review?: number;
|
||||
endpoints?: ReservationEndpoint[];
|
||||
|
||||
Reference in New Issue
Block a user