mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-21 14:36:31 +00:00
fix(server): plan 3c close-out — non-finite ids bound as NULL, trip id parsed once at every places/days/ws gate
Platform: NulSafeSqlitePlatform now renders a bound NaN as NULL and +/-Infinity as 9e999/-9e999, matching better-sqlite3's own legacy binding (rule 22 extension) — the program-wide fail-safe for any caller that still passes an unguarded Number(untrusted) into a converted statement. PlacesService.verifyTripAccess and RealtimeGateway.handleJoin stop doing that: verifyTripAccess parses once with toRowId and threads the parsed id through create/import/bulk/rating/export.gpx (deleting the seven toRowId(tripId) ?? -1 sites), and handleJoin gets a Number.isFinite guard. DaysService gets the same treatment across list/create/getDay/reorder/ insert/resyncAccommodationDays, closing the gap where a hex-spelled trip id could renumber a real trip's days while the raw-bind inverted-accommodation guard silently matched nothing. DatabaseService.getTripTitle is deleted; PlacesService injects TripsRepository directly and calls getTitle itself. Template/cheap-item cleanup: DayAssignmentsRepository.listForTimeSort uses $castTo instead of an unknown bridge; the duplicated TagRow interface in Places.repository.ts is now imported from Tags.repository.ts; AssignmentWithPlaceRow extends the guarded DayAssignmentRow instead of restating its columns; PlaceShadowPicksRepository.totals drops its unreachable ??-fallback branch; Places.repository.ts's updatePlace types the NOT NULL name column as string; trip-members.service.ts's transferOwnership threads one parsed trip id through all three writes and corrects two stale "raw-bind seam" docstrings that were actually Number() seams; a dead canAccessTrip mock is removed from reservations.e2e.test.ts. Tests: the 23 new no-explicit-any warnings in TRIP-SVC-069 are typed (lint back to 2416); HTTP and WS regression coverage for the non-finite and non-canonical id shapes across places/days routes, mutation-proved; TRIP-JOIN-005 asserts the exact concurrent-join settlement instead of >= 1; a new tests/unit/db/transaction-yield.test.ts pins rule 24's measured transaction-yield/dirty-read/nested-BEGIN behaviour.
This commit is contained in:
@@ -51,12 +51,48 @@ import { SqlitePlatform } from '@mikro-orm/sql';
|
||||
* of which SQLite string literals treat specially) round-trips exactly as
|
||||
* it did before this override existed.
|
||||
*
|
||||
* Deliberately scoped to `typeof value === 'string'`: this is the M1 fix,
|
||||
* not a general re-implementation of `escape`/`quoteValue`. Non-finite
|
||||
* numbers rendering as bare, unquoted tokens is the SAME inlining mechanism
|
||||
* (rule 22's corollary) but is already fenced off upstream by rule 21's
|
||||
* `toRowId` id guards — it is not this override's job to guess at every
|
||||
* caller's numeric validation.
|
||||
* Deliberately scoped to `typeof value === 'string'` for the NUL half above:
|
||||
* that IS the M1 fix, not a general re-implementation of `escape`/
|
||||
* `quoteValue`.
|
||||
*
|
||||
* **Rule 22 extension (Plan 3c Task 9, close-out review A H1):** non-finite
|
||||
* numbers hit the SAME inlining mechanism, and rule 21's `toRowId` guards do
|
||||
* NOT fence every caller off from it — `toRowId` is what a CONVERTED
|
||||
* service calls to validate an id BEFORE it reaches a repository, but two
|
||||
* call sites (`PlacesService.verifyTripAccess`, `RealtimeGateway.handleJoin`)
|
||||
* still did `Number(untrusted)` with no finite check and handed the result
|
||||
* straight to `TripsRepository.findAccessible`'s raw `andWhere('t.id = ?',
|
||||
* [trip_id])` bind. Traced against the installed `@mikro-orm/sql` 7.2.1
|
||||
* source: `AbstractSqlPlatform.quoteValue` (`sql/AbstractSqlPlatform.js:94`)
|
||||
* special-cases only a raw fragment and a plain object (JSON-stringified);
|
||||
* everything else — including every `number` — falls to `this.escape(value)`.
|
||||
* `SqlitePlatform.escape` (`sql/dialects/sqlite/SqlitePlatform.js:116-133`)
|
||||
* DOES special-case numbers, but not usefully: `typeof value === 'number' ||
|
||||
* typeof value === 'bigint'` returns `'' + value` — plain string
|
||||
* concatenation, which for a non-finite value gives `'NaN'`, `'Infinity'` or
|
||||
* `'-Infinity'`, an UNQUOTED bareword spliced straight into the SQL text
|
||||
* (finite numbers render as an ordinary numeric literal this way and are
|
||||
* fine; only the three non-finite values break). SQLite's parser reads that
|
||||
* bareword as a column reference (`no such column: NaN`/`Infinity`) and
|
||||
* throws, a 500 where the legacy raw bind never had the problem:
|
||||
* `better-sqlite3` accepts a JS `number` directly and binds it as SQLite's
|
||||
* own `REAL`/`NULL` value, never as text — verified empirically against the
|
||||
* installed driver (not assumed): `db.prepare('select typeof(?)').get(NaN)`
|
||||
* reports `'null'` (better-sqlite3 refuses `NaN` as a bindable REAL and
|
||||
* silently binds SQL `NULL` instead), while `db.prepare('select
|
||||
* typeof(?)').get(Infinity)` reports `'real'` and round-trips as the REAL
|
||||
* value `Infinity` — SQLite itself has no `Infinity` literal, but its
|
||||
* `REAL` parser saturates an out-of-range exponent to positive/negative
|
||||
* infinity (double-precision overflow), and `9e999`/`-9e999` verified to
|
||||
* produce that exact value (`select 9e999` → `Infinity`), so it is the
|
||||
* literal spelling that reproduces the driver's own bound value byte-for-
|
||||
* byte, not an approximation. Overriding `escape` (not `quoteValue`, which
|
||||
* only special-cases a raw fragment/plain object before delegating here
|
||||
* anyway) catches every number this platform ever renders, program-wide,
|
||||
* the same fail-safe shape as the NUL fix above — rule 21's `toRowId`
|
||||
* guards remain the PRIMARY defence (a validated id never reaches this
|
||||
* branch), and this is the platform's own backstop for the callers that do
|
||||
* not yet guard.
|
||||
*/
|
||||
export class NulSafeSqlitePlatform extends SqlitePlatform {
|
||||
override escape(value: unknown): string {
|
||||
@@ -64,6 +100,10 @@ export class NulSafeSqlitePlatform extends SqlitePlatform {
|
||||
const literals = value.split('\u0000').map(part => super.escape(part));
|
||||
return `(${literals.join(' || char(0) || ')})`;
|
||||
}
|
||||
if (typeof value === 'number' && !Number.isFinite(value)) {
|
||||
if (Number.isNaN(value)) return 'NULL';
|
||||
return value > 0 ? '9e999' : '-9e999';
|
||||
}
|
||||
return super.escape(value);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,22 +35,7 @@ import { TrekRepository } from './_shared/trek-repository';
|
||||
* `category_icon` through the LEFT JOIN even though `Categories.name` itself
|
||||
* is NOT NULL.
|
||||
*/
|
||||
export interface AssignmentWithPlaceRow {
|
||||
id: number;
|
||||
day_id: number;
|
||||
place_id: number;
|
||||
order_index: number | null;
|
||||
notes: string | null;
|
||||
reservation_status: string | null;
|
||||
reservation_notes: string | null;
|
||||
reservation_datetime: string | null;
|
||||
assignment_time: string | null;
|
||||
assignment_end_time: string | null;
|
||||
end_day: number;
|
||||
leg_transport_mode: string | null;
|
||||
incoming_leg_transport_mode: string | null;
|
||||
accommodation_id: number | null;
|
||||
created_at: string | null;
|
||||
export interface AssignmentWithPlaceRow extends DayAssignmentRow {
|
||||
place_name: string;
|
||||
place_description: string | null;
|
||||
lat: number | null;
|
||||
@@ -501,8 +486,16 @@ export class DayAssignmentsRepository extends TrekRepository<DayAssignments> {
|
||||
.orderBy('da.order_index', 'asc')
|
||||
.orderBy('da.created_at', 'asc')
|
||||
.orderBy('da.id', 'asc')
|
||||
// `$castTo` (Task 9 fix wave, B-L1): Kysely infers `located` as its own
|
||||
// `SqlBool` type from `eb.and(...)`, not the `0`/`1` integer
|
||||
// `DayStopRow.located` declares (the value SQLite actually returns for
|
||||
// a boolean expression) — a straight `as DayStopRow[]` is rejected as
|
||||
// an unrelated-type cast, which is what forced the `as unknown as`
|
||||
// bridge. `$castTo` retypes the builder itself before `execute()`
|
||||
// runs, so the result is `DayStopRow[]` with no intermediate `unknown`.
|
||||
.$castTo<DayStopRow>()
|
||||
.execute();
|
||||
return rows as unknown as DayStopRow[];
|
||||
return rows;
|
||||
}
|
||||
|
||||
/** AS24 — `UPDATE day_assignments SET end_day = ? WHERE id = ?`. */
|
||||
|
||||
@@ -73,10 +73,16 @@ export class PlaceShadowPicksRepository extends TrekRepository<PlaceShadowPicks>
|
||||
*/
|
||||
async totals(): Promise<{ total: number; oldest: string | null; newest: string | null }> {
|
||||
const platform = this.getEntityManager().getPlatform();
|
||||
// Task 9 fix wave (B-L5): a bare aggregate `SELECT` always returns one
|
||||
// row (the docstring above), so `row` is never `undefined` — the
|
||||
// previous `row?.x ?? fallback` optional-chain/nullish-coalescing pair
|
||||
// was the one partial branch in `src/db/repositories/**` (83.33%
|
||||
// coverage), unreachable, not a real "no rows" guard. Non-null
|
||||
// asserted, matching the docstring's own claim.
|
||||
const row = await this.qb('p')
|
||||
.select([countAll(platform, 'total'), minOf(platform, 'p.created_at', 'oldest'), maxOf(platform, 'p.created_at', 'newest')])
|
||||
.execute<{ total: number; oldest: string | null; newest: string | null } | undefined>('get', false);
|
||||
return { total: row?.total ?? 0, oldest: row?.oldest ?? null, newest: row?.newest ?? null };
|
||||
return { total: row!.total, oldest: row!.oldest, newest: row!.newest };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import type { Places } from '../entities/Places.entity';
|
||||
import type { Tags } from '../entities/Tags.entity';
|
||||
import { absDifference, coalesceParam, columnRef, currentTimestamp, lowerTrim } from '../dialect/sql-functions';
|
||||
import { type AssertRowKeys } from './_shared/rows';
|
||||
import { TrekRepository } from './_shared/trek-repository';
|
||||
// Task 9 fix wave (B-L2): `TagRow` is Tags.repository.ts's own row type,
|
||||
// byte-identical to what used to be hand-mirrored here — a second
|
||||
// declaration of the same shape is exactly the duplication Sonar flags and
|
||||
// the program's "single source of truth" rule (root CLAUDE.md) forbids.
|
||||
import type { TagRow } from './Tags.repository';
|
||||
|
||||
/** A `places` row as the API emits it — every scalar column of the entity. */
|
||||
export interface PlaceRow {
|
||||
@@ -42,17 +46,6 @@ export interface PlaceRow {
|
||||
|
||||
const _placeRowKeys: AssertRowKeys<PlaceRow, Places> = true;
|
||||
|
||||
/** A `tags` row, byte-identical to the legacy `SELECT t.* FROM tags t ...` shape. */
|
||||
export interface TagRow {
|
||||
id: number;
|
||||
user_id: number;
|
||||
name: string;
|
||||
color: string | null;
|
||||
created_at: string | null;
|
||||
}
|
||||
|
||||
const _tagRowKeys: AssertRowKeys<TagRow, Tags> = true;
|
||||
|
||||
/** PL3 (`PlacesService.list`) — a `places` row joined with its category's flat columns. */
|
||||
export interface PlaceWithCategoryRow extends PlaceRow {
|
||||
category_name: string | null;
|
||||
@@ -362,7 +355,12 @@ export class PlacesRepository extends TrekRepository<Places> {
|
||||
* plus the timestamp stamp every write gets.
|
||||
*/
|
||||
async updatePlace(id: number, write: {
|
||||
name: string | null;
|
||||
// Task 9 fix wave (B-L10): `places.name` is `NOT NULL` (`PlaceRow.name:
|
||||
// string`); the caller's `(name || null) ?? existingPlace.name` fold
|
||||
// always resolves to a real string before this is called — `string |
|
||||
// null` here was a widened lie the type checker could never catch a
|
||||
// real NULL write through.
|
||||
name: string;
|
||||
description: string | null;
|
||||
lat: number | null;
|
||||
lng: number | null;
|
||||
|
||||
@@ -93,21 +93,6 @@ export class DatabaseService {
|
||||
return await this.entityManager().getRepository(Trips).isOwner(tripId, userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* PL28 (`PlacesService.exportGpx`'s trip-title read, L3/task-4-review.md):
|
||||
* `SELECT title FROM trips WHERE id = ?`, via `TripsRepository.getTitle`.
|
||||
* The same narrow-helper shape as `canAccessTrip`/`isOwner`/`rosterUserIds`
|
||||
* above — a caller outside the trips domain that needs one trip column
|
||||
* without taking on a full `TripsRepository` DI dependency (`PlacesModule`
|
||||
* would need its own `@InjectRepository(Trips)` constructor param, which
|
||||
* ripples through every `new PlacesService(...)` test-helper call site;
|
||||
* this stays a one-line method here instead, matching the precedent the
|
||||
* three siblings above already set).
|
||||
*/
|
||||
async getTripTitle(tripId: number | string): Promise<string | null> {
|
||||
return await this.entityManager().getRepository(Trips).getTitle(tripId);
|
||||
}
|
||||
|
||||
/**
|
||||
* The user ids a trip may refer to: its members plus the owner. Guests count —
|
||||
* a guest is a credential-less users row joined into trip_members, and #1362
|
||||
|
||||
@@ -72,12 +72,28 @@ export class DayReorderError extends Error {}
|
||||
* is only ever reached downstream of a successful `getDay` call in the same
|
||||
* request (REST/MCP/RPC all check-then-act), so `toRowId(id)!` there is not
|
||||
* a live 404 path, just the same non-null-assert style this file already
|
||||
* used before conversion (`this.db.get<Day>(...)!`). `tripId` is the DY0
|
||||
* seam: `TripAccessGuard` already validated it with the SAME raw-bind
|
||||
* escape hatch `TripsRepository.findAccessible` uses, so every id-shape a
|
||||
* plain `Number(tripId)` would mis-parse relative to SQLite's affinity was
|
||||
* already refused before this service's handler ever runs — safe to convert
|
||||
* with a bare `Number()` downstream of that guard.
|
||||
* used before conversion (`this.db.get<Day>(...)!`).
|
||||
*
|
||||
* `tripId` is the DY0 seam, and the claim this docstring used to make about
|
||||
* it was WRONG (Task 9 whole-plan review, H2, live): `TripAccessGuard`
|
||||
* validates `:tripId` with a bare `Number(tripId)` — a `Number()` seam, not
|
||||
* an affinity seam — so `Number('0x12')` is `18`, a real trip's row id, and
|
||||
* the guard authorises it. That is LOOSER than `toRowId`'s canonical-decimal
|
||||
* check (program rule 15). Every ORM-converted entry point below used to
|
||||
* re-parse with its OWN bare `Number(tripId)`, agreeing with the guard,
|
||||
* while `restampReservationDates` and `assertNoInvertedAccommodation` kept
|
||||
* binding the RAW ROUTE STRING on `reservations`/`day_accommodations`
|
||||
* (Plan 3d survivors, an affinity seam a hex id never matches) — so one
|
||||
* `PUT .../days/reorder` request could renumber a real trip's days (the
|
||||
* `Number()` half agreeing with the guard) while its own
|
||||
* inverted-accommodation guard silently matched zero rows (the raw-string
|
||||
* half), writing a stay whose end precedes its start with no error. Every
|
||||
* entry point now parses ONCE with `toRowId(tripId)` and threads that same
|
||||
* value into every survivor and read; a `toRowId` miss answers the entry
|
||||
* point's own legacy-shaped not-found (`list` → `{ days: [] }`, `getDay` →
|
||||
* `undefined`, `reorder`/`insert`/`create` → `DayReorderError`, uncaught by
|
||||
* `create`'s route into the legacy 500) rather than let the guard's wider
|
||||
* `Number()` grant reach a downstream raw bind that disagrees with it.
|
||||
*/
|
||||
@Injectable()
|
||||
export class DaysService {
|
||||
@@ -174,7 +190,13 @@ export class DaysService {
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
async list(tripId: string | number) {
|
||||
const days = await this.daysRepo.listByTrip(Number(tripId));
|
||||
// Rule 21 / H2: `toRowId`, not `Number()` — see the class docstring. A
|
||||
// trip id `TripAccessGuard`'s looser `Number()` authorised but this
|
||||
// stricter parse refuses answers the same `{ days: [] }` an affinity-seam
|
||||
// raw bind would have (no row ever matches a hex/decimal-spelled id).
|
||||
const tid = toRowId(tripId);
|
||||
if (tid === null) return { days: [] };
|
||||
const days = await this.daysRepo.listByTrip(tid);
|
||||
|
||||
if (days.length === 0) {
|
||||
return { days: [] };
|
||||
@@ -218,7 +240,13 @@ export class DaysService {
|
||||
}
|
||||
|
||||
async create(tripId: string | number, date?: string, notes?: string) {
|
||||
const tripIdNum = Number(tripId);
|
||||
// Rule 21 / H2: `toRowId`, not `Number()`. Uncaught by the controller's
|
||||
// `create` handler, so a miss becomes the legacy 500 (the raw INSERT's FK
|
||||
// violation on a hex/decimal-spelled trip id) — status-level parity, not
|
||||
// a manufactured miss, since `create` never had an existence gate to
|
||||
// answer instead (same documented exception `PlacesService.create` uses).
|
||||
const tripIdNum = toRowId(tripId);
|
||||
if (tripIdNum === null) throw new Error('Trip id is not a real row.');
|
||||
// DY5: `DaysRepository.maxDayNumber` already folds the no-rows case to 0
|
||||
// (`?? 0`); the `|| 0` here is the legacy expression kept verbatim so a
|
||||
// stored 0 still becomes 1 — the same outcome either fold produces, but
|
||||
@@ -238,7 +266,10 @@ export class DaysService {
|
||||
async getDay(id: string | number, tripId: string | number) {
|
||||
const dayId = toRowId(id);
|
||||
if (dayId === null) return undefined;
|
||||
return await this.daysRepo.findInTrip(dayId, Number(tripId));
|
||||
// Rule 21 / H2: `toRowId`, not `Number()` — see the class docstring.
|
||||
const tid = toRowId(tripId);
|
||||
if (tid === null) return undefined;
|
||||
return await this.daysRepo.findInTrip(dayId, tid);
|
||||
}
|
||||
|
||||
async update(id: string | number, current: { notes?: string | null; title?: string | null }, fields: { notes?: string; title?: string | null }) {
|
||||
@@ -296,9 +327,16 @@ export class DaysService {
|
||||
* 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.
|
||||
*
|
||||
* `tripId: number` (Task 9 fix wave, H2): every caller now passes the ONE
|
||||
* `toRowId`-parsed value its own entry point already computed, never the
|
||||
* raw route string — binding the raw string here (an affinity seam) is
|
||||
* exactly the mismatch that let a hex-spelled trip id's reorder skip this
|
||||
* statement entirely while the `Number()`-parsed half of the same request
|
||||
* acted on a real trip (see the class docstring).
|
||||
*/
|
||||
async restampReservationDates(
|
||||
tripId: string | number,
|
||||
tripId: number,
|
||||
oldDateById: Map<number, string | null>,
|
||||
newDateById: Map<number, string | null>,
|
||||
): Promise<void> {
|
||||
@@ -352,8 +390,12 @@ export class DaysService {
|
||||
* (joined to `days` only to read `day_number`), so it stays raw on
|
||||
* `DatabaseService` per the inventory's ruling, even though `days` itself
|
||||
* is owned here.
|
||||
*
|
||||
* `tripId: number` (Task 9 fix wave, H2): the same parsed-once value every
|
||||
* other survivor in this class now takes — see `restampReservationDates`'s
|
||||
* docstring for why binding the raw route string here was the live bug.
|
||||
*/
|
||||
private async assertNoInvertedAccommodation(tripId: string | number): Promise<void> {
|
||||
private async assertNoInvertedAccommodation(tripId: number): Promise<void> {
|
||||
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
|
||||
@@ -385,12 +427,16 @@ export class DaysService {
|
||||
* converts via `DayAssignmentsRepository.reanchorToDay`, a Kysely
|
||||
* statement proven to join this method's ambient transaction by rollback
|
||||
* (see the task report).
|
||||
*
|
||||
* `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
|
||||
* route string — the same affinity-seam mismatch documented on
|
||||
* `restampReservationDates`).
|
||||
*/
|
||||
async resyncAccommodationDays(
|
||||
tripId: string | number,
|
||||
tripId: number,
|
||||
prevDateByDayId: Map<number, string | null>,
|
||||
): Promise<void> {
|
||||
const tripIdNum = Number(tripId);
|
||||
// DY20 — Plan 3d
|
||||
const stays = this.db.all<{ id: number; start_day_id: number; end_day_id: number }>(
|
||||
'SELECT id, start_day_id, end_day_id FROM day_accommodations WHERE trip_id = ?',
|
||||
@@ -413,8 +459,8 @@ export class DaysService {
|
||||
const oldEndDate = prevDateByDayId.get(stay.end_day_id);
|
||||
if (oldStartDate && oldEndDate) {
|
||||
// DY21
|
||||
const newStart = await this.daysRepo.findByTripAndDate(tripIdNum, oldStartDate);
|
||||
const newEnd = await this.daysRepo.findByTripAndDate(tripIdNum, oldEndDate);
|
||||
const newStart = await this.daysRepo.findByTripAndDate(tripId, oldStartDate);
|
||||
const newEnd = await this.daysRepo.findByTripAndDate(tripId, oldEndDate);
|
||||
if (newStart && newEnd && newStart.day_number <= newEnd.day_number
|
||||
&& (newStart.id !== stay.start_day_id || newEnd.id !== stay.end_day_id)) {
|
||||
updateStay.run(newStart.id, newEnd.id, stay.id);
|
||||
@@ -441,7 +487,14 @@ export class DaysService {
|
||||
* day ids (a permutation of the current ids).
|
||||
*/
|
||||
async reorder(tripId: string | number, orderedIds: number[]) {
|
||||
const tripIdNum = Number(tripId);
|
||||
// Rule 21 / H2: `toRowId`, not `Number()` — see the class docstring. A
|
||||
// miss throws the SAME permutation error an empty `rows` set would
|
||||
// (nothing ever matches a hex/decimal-spelled id), so the controller's
|
||||
// existing `DayReorderError` → 400 catch answers it, before any write —
|
||||
// never the "renumber a real trip, skip the invariant check" split this
|
||||
// fixes.
|
||||
const tripIdNum = toRowId(tripId);
|
||||
if (tripIdNum === null) throw new DayReorderError('orderedIds must be a permutation of the trip day ids.');
|
||||
const rows: DayOrderRow[] = await this.daysRepo.listOrderedForReorder(tripIdNum);
|
||||
|
||||
const existingIds = new Set(rows.map(r => r.id));
|
||||
@@ -468,11 +521,11 @@ export class DaysService {
|
||||
newDateById.set(id, date);
|
||||
}
|
||||
|
||||
if (isDated) await this.restampReservationDates(tripId, oldDateById, newDateById);
|
||||
await this.assertNoInvertedAccommodation(tripId);
|
||||
if (isDated) await this.restampReservationDates(tripIdNum, oldDateById, newDateById);
|
||||
await this.assertNoInvertedAccommodation(tripIdNum);
|
||||
});
|
||||
|
||||
return await this.list(tripId);
|
||||
return await this.list(tripIdNum);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -482,7 +535,15 @@ export class DaysService {
|
||||
* shifted days have their dates re-stamped (same rules as reorder).
|
||||
*/
|
||||
async insert(tripId: string | number, position?: number) {
|
||||
const tripIdNum = Number(tripId);
|
||||
// Rule 21 / H2: `toRowId`, not `Number()` — see `reorder`'s comment above
|
||||
// and the class docstring. `insert` shares `create`'s controller route
|
||||
// (no `DayReorderError` catch on that path — `reorder` is the only
|
||||
// handler that has one), so a plain `Error` here becomes the SAME
|
||||
// legacy-shaped 500 `create`'s own miss does (the FK violation on the
|
||||
// raw route string this never executes), rather than the renumber-then-
|
||||
// skip-the-invariant split H2 found.
|
||||
const tripIdNum = toRowId(tripId);
|
||||
if (tripIdNum === null) throw new Error('Trip id is not a real row.');
|
||||
const rows: DayOrderRow[] = await this.daysRepo.listOrderedForReorder(tripIdNum);
|
||||
const n = rows.length;
|
||||
const pos = Math.min(Math.max(position ?? n + 1, 1), n + 1);
|
||||
@@ -518,8 +579,8 @@ export class DaysService {
|
||||
newDateById.set(id, dates[i]);
|
||||
}
|
||||
|
||||
await this.restampReservationDates(tripId, oldDateById, newDateById);
|
||||
await this.assertNoInvertedAccommodation(tripId);
|
||||
await this.restampReservationDates(tripIdNum, oldDateById, newDateById);
|
||||
await this.assertNoInvertedAccommodation(tripIdNum);
|
||||
// DY35 — `TripsRepository.setEndDate`, added this task for Task 7 to reuse.
|
||||
await this.tripsRepo.setEndDate(tripIdNum, dates[dates.length - 1]);
|
||||
|
||||
|
||||
@@ -39,12 +39,14 @@ import { PlaceRatings } from '../../db/entities/PlaceRatings.entity';
|
||||
import { TripMembers } from '../../db/entities/TripMembers.entity';
|
||||
import { DayAssignments } from '../../db/entities/DayAssignments.entity';
|
||||
import { Categories } from '../../db/entities/Categories.entity';
|
||||
import { Trips } from '../../db/entities/Trips.entity';
|
||||
import type { PlacesRepository, PlaceWithCategoryRow } from '../../db/repositories/Places.repository';
|
||||
import type { TagsRepository } from '../../db/repositories/Tags.repository';
|
||||
import type { PlaceRatingsRepository } from '../../db/repositories/PlaceRatings.repository';
|
||||
import type { TripMembersRepository } from '../../db/repositories/TripMembers.repository';
|
||||
import type { DayAssignmentsRepository } from '../../db/repositories/DayAssignments.repository';
|
||||
import type { CategoriesRepository } from '../../db/repositories/Categories.repository';
|
||||
import type { TripsRepository } from '../../db/repositories/Trips.repository';
|
||||
|
||||
/** Rows a place delete took down with the nights booked there, for the caller to announce. */
|
||||
export interface CancelledStays {
|
||||
@@ -155,10 +157,33 @@ export class PlacesService {
|
||||
@InjectRepository(TripMembers) private readonly tripMembersRepo: TripMembersRepository,
|
||||
@InjectRepository(DayAssignments) private readonly dayAssignmentsRepo: DayAssignmentsRepository,
|
||||
@InjectRepository(Categories) private readonly categoriesRepo: CategoriesRepository,
|
||||
// Task 9 fix wave (B-M2 / A-L3 ruling): `DatabaseService.getTripTitle`
|
||||
// does not stay — it was the one method on that class this domain's own
|
||||
// PL28 comment said should convert like its siblings once concurrent
|
||||
// implementers stopped colliding on this file. `Trips` is already on
|
||||
// `PlacesModule`'s `forFeature` list, so this needs no module change.
|
||||
@InjectRepository(Trips) private readonly tripsRepo: TripsRepository,
|
||||
) {}
|
||||
|
||||
async verifyTripAccess(tripId: string, userId: number) {
|
||||
return await this.dbs.canAccessTrip(Number(tripId), userId);
|
||||
/**
|
||||
* The `requireTrip` gate for 13 of the 16 routes on this controller (the
|
||||
* other 3 use `TripAccessGuard` directly). `toRowId`, not `Number()` —
|
||||
* Plan 3c Task 9's whole-plan review (A-H1/M1, B-H1): `Number('1.0')`/
|
||||
* `Number('1 ')`/`Number('0x1abc's NaN half)` either authorised an id the
|
||||
* writes behind this gate (`toRowId(tripId) ?? -1`) then refused, or sent
|
||||
* a bare `NaN` into `TripsRepository.findAccessible`'s raw bind and 500'd
|
||||
* (fixed program-wide at the platform too — see `NulSafeSqlitePlatform`).
|
||||
* Parsing ONCE, here, and returning the parsed `tid` for every downstream
|
||||
* call to reuse (never re-parsing) closes both: a non-canonical trip id
|
||||
* now answers this gate's own 404 before any read or write runs, and every
|
||||
* write behind it shares the exact id the gate authorised.
|
||||
*/
|
||||
async verifyTripAccess(tripId: string, userId: number): Promise<(TripAccess & { tid: number }) | undefined> {
|
||||
const tid = toRowId(tripId);
|
||||
if (tid === null) return undefined;
|
||||
const access = await this.dbs.canAccessTrip(tid, userId);
|
||||
if (!access) return undefined;
|
||||
return { ...access, tid };
|
||||
}
|
||||
|
||||
async canEdit(trip: Trip, user: User): Promise<boolean> {
|
||||
@@ -275,19 +300,18 @@ export class PlacesService {
|
||||
transport_mode, route_geometry, route_color, stop_type, fill_percent, tags = [],
|
||||
} = body;
|
||||
|
||||
// Rule 21 (H1): one trip-id notion, parsed once, used by every write
|
||||
// below — including PL5's `tagsOnTrip` roster read. Every other gate in
|
||||
// this class answers the legacy not-found when the trip id doesn't parse
|
||||
// canonically; create() has no existence gate to answer instead — the
|
||||
// legacy raw bind (base 94c6efbbc) stored the non-canonical spelling
|
||||
// straight into the `trip_id` column and let the FK constraint fail
|
||||
// closed (verified live: a hex trip id 500s on create, where every other
|
||||
// route 404s). `toRowId` rejects the same non-canonical spellings that
|
||||
// never reach a real row; `?? -1` substitutes a trip id that can never
|
||||
// exist (ids are autoincrement from 1), so the FK constraint fails the
|
||||
// same way — without widening `insertPlace`'s typed `trip_id: number`
|
||||
// column to accept the raw string.
|
||||
const tid = toRowId(tripId) ?? -1;
|
||||
// Rule 21 / M1 (Task 9 fix wave): `verifyTripAccess` now parses `tripId`
|
||||
// with this SAME `toRowId` and answers 404 `Trip not found` before
|
||||
// `create()` is ever reached (the controller's `requireTrip`/MCP's own
|
||||
// `tripsRepo.findAccessible` gate both run first) — so `toRowId` here
|
||||
// can never miss for a caller that went through the gate. The previous
|
||||
// `?? -1` sentinel was ruled a defect, not parity: with the gate itself
|
||||
// loosely `Number()`-parsed, a non-canonical-but-numeric id like `1.0`
|
||||
// could pass the gate and still manufacture a fresh 500 here (an FK
|
||||
// failure the legacy raw bind never produced for that input). Non-null
|
||||
// asserted, not defaulted — a null here now means a caller skipped the
|
||||
// gate, a bug to surface, not a trip id to silently coerce to -1.
|
||||
const tid = toRowId(tripId)!;
|
||||
|
||||
// PL4 — the 25-column INSERT. lat/lng/price/duration_minutes/fill_percent
|
||||
// use an explicit undefined check, not `||`: 0 is a legitimate value for
|
||||
@@ -584,11 +608,17 @@ export class PlacesService {
|
||||
const cancelled = noCancelledStays();
|
||||
if (ids.length === 0) return { deleted: [], cancelled };
|
||||
// Rule 21 (H1): the trip id gets the same `toRowId` treatment as
|
||||
// `remove()`'s, parsed once and reused for every id in the loop below —
|
||||
// a non-canonical trip id never matches a real place's row (the PL20
|
||||
// gate would answer "not found" for every id anyway), so this returns
|
||||
// early with nothing deleted, the same shape the legacy raw bind
|
||||
// produced when the trip id didn't affinity-match any row.
|
||||
// `remove()`'s, parsed once and reused for every id in the loop below.
|
||||
// Task 9 fix wave (A-8): the claim this comment used to make — "the
|
||||
// legacy raw bind produced the same shape, because the trip id didn't
|
||||
// affinity-match any row" — was WRONG for `1.0`/`1 ` (and other
|
||||
// SQLite-affinity-recognised spellings): the legacy statement DID match
|
||||
// those against a real trip, deleting for real. `toRowId`'s narrower
|
||||
// canonical-decimal check answers "nothing deleted" for those inputs
|
||||
// too, which is the deliberate rule-15 narrowing (not parity) — and, as
|
||||
// of this fix wave, moot in practice: `verifyTripAccess` already 404s
|
||||
// any non-canonical trip id before `removeMany` is ever reached, so this
|
||||
// `tid === null` branch only fires for a caller that skipped the gate.
|
||||
const tid = toRowId(tripId);
|
||||
if (tid === null) return { deleted: [], cancelled };
|
||||
const deleted: number[] = [];
|
||||
@@ -769,17 +799,14 @@ export class PlacesService {
|
||||
*/
|
||||
async exportGpx(tripId: string, opts: GpxExportOptions = {}): Promise<{ gpx: string; filename: string } | null> {
|
||||
// PL28 — `SELECT title FROM trips WHERE id = ?`, via
|
||||
// `DatabaseService.getTripTitle` (Task 4 review L3: this comment
|
||||
// previously said the repoint was blocked on `Trips.repository.ts`
|
||||
// landing `getTitle`, which it did at `94c6efbbc` — Task 4's own parent
|
||||
// commit — so the block was stale from the moment this task started).
|
||||
// `getTripTitle` is a narrow `DatabaseService` helper, the same shape as
|
||||
// `canAccessTrip`/`isOwner`/`rosterUserIds`: `trips` stays outside this
|
||||
// domain's owned repositories, and a full `TripsRepository`
|
||||
// `@InjectRepository` constructor param on `PlacesService` would ripple
|
||||
// through every `new PlacesService(...)` test-helper call site for one
|
||||
// read. The legacy `if (!trip) return null` maps onto `title === null`.
|
||||
const title = await this.dbs.getTripTitle(tripId);
|
||||
// `TripsRepository.getTitle` directly (Task 9 fix wave, B-M2 / A-L3
|
||||
// ruling): `DatabaseService.getTripTitle` was a narrow, single-caller
|
||||
// delegation kept only because concurrent Task 4/7/8 implementers were
|
||||
// all editing this file's constructor at once — that risk is gone, so
|
||||
// it and its `entityManager()` mention are deleted, and this domain
|
||||
// injects `TripsRepository` directly like its other five repositories.
|
||||
// The legacy `if (!trip) return null` maps onto `title === null`.
|
||||
const title = await this.tripsRepo.getTitle(tripId);
|
||||
if (title === null) return null;
|
||||
|
||||
// PL29 — the waypoint projection.
|
||||
@@ -875,14 +902,13 @@ export class PlacesService {
|
||||
|
||||
if (waypoints.length === 0) return null;
|
||||
|
||||
// Task 5 review L5 (rule 21): parsed once here, reused for the one write
|
||||
// below. `buildDedupSet` keeps the raw-bind seam (L6 ruling, PL24) —
|
||||
// this does not touch it. A non-canonical trip id (this route has no
|
||||
// existence gate to answer "not found" instead, matching `create`'s own
|
||||
// H1 fix) substitutes a trip id that can never exist, so `insertPlace`
|
||||
// fails its FK constraint the same way the legacy raw bind did (base:
|
||||
// 500 on a hex trip id, live-verified in the Task 5 review).
|
||||
const tid = toRowId(tripId) ?? -1;
|
||||
// Rule 21 / M1 (Task 9 fix wave): non-null asserted, not `?? -1` — the
|
||||
// controller's `requireTrip` (`verifyTripAccess`) already parsed and
|
||||
// gated this SAME `tripId` with `toRowId` before `importGpx` was ever
|
||||
// called, so a miss here can only mean a caller skipped the gate (see
|
||||
// `create()`'s comment for the full ruling). `buildDedupSet` keeps the
|
||||
// raw-bind seam (L6 ruling, PL24) — this does not touch it.
|
||||
const tid = toRowId(tripId)!;
|
||||
const dedup = await this.buildDedupSet(tripId);
|
||||
const created: PlaceWithTags[] = [];
|
||||
let skipped = 0;
|
||||
@@ -985,8 +1011,9 @@ export class PlacesService {
|
||||
// PL33 — `CategoriesRepository.listIdName` (Plan 3a's repository).
|
||||
const categories = await this.categoriesRepo.listIdName();
|
||||
const categoryLookup = buildCategoryNameLookup(categories);
|
||||
// Task 5 review L5 (rule 21) — same fix and reasoning as `importGpxRows`.
|
||||
const tid = toRowId(tripId) ?? -1;
|
||||
// Rule 21 / M1 (Task 9 fix wave) — same ruling as `importGpxRows`: the
|
||||
// caller's `requireTrip` gate already `toRowId`-parsed this `tripId`.
|
||||
const tid = toRowId(tripId)!;
|
||||
const dedup = await this.buildDedupSet(tripId);
|
||||
const created: PlaceWithTags[] = [];
|
||||
let dupCount = 0;
|
||||
@@ -1265,8 +1292,9 @@ export class PlacesService {
|
||||
tripId: string,
|
||||
places: { name: string; lat: number; lng: number; notes: string | null; googleFtid: string | null }[],
|
||||
): Promise<{ created: PlaceWithTags[]; skipped: number }> {
|
||||
// Task 5 review L5 (rule 21) — same fix and reasoning as `importGpxRows`.
|
||||
const tid = toRowId(tripId) ?? -1;
|
||||
// Rule 21 / M1 (Task 9 fix wave) — same ruling as `importGpxRows`: the
|
||||
// caller's `requireTrip` gate already `toRowId`-parsed this `tripId`.
|
||||
const tid = toRowId(tripId)!;
|
||||
const dedup = await this.buildDedupSet(tripId);
|
||||
const created: PlaceWithTags[] = [];
|
||||
let skipped = 0;
|
||||
@@ -1546,8 +1574,9 @@ export class PlacesService {
|
||||
return { error: 'No places with coordinates found in list', status: 400 };
|
||||
}
|
||||
|
||||
// Task 5 review L5 (rule 21) — same fix and reasoning as `importGpxRows`.
|
||||
const tid = toRowId(tripId) ?? -1;
|
||||
// Rule 21 / M1 (Task 9 fix wave) — same ruling as `importGpxRows`: the
|
||||
// caller's `requireTrip` gate already `toRowId`-parsed this `tripId`.
|
||||
const tid = toRowId(tripId)!;
|
||||
const dedup = await this.buildDedupSet(tripId);
|
||||
const created: PlaceWithTags[] = [];
|
||||
let skipped = 0;
|
||||
@@ -1626,18 +1655,15 @@ export class PlacesService {
|
||||
if (place.google_place_id) return;
|
||||
if (typeof place.lat !== 'number' || typeof place.lng !== 'number') return;
|
||||
|
||||
// Task 5 review L5 (rule 21): parsed once, reused by both `fillIfEmpty`
|
||||
// calls below. A non-canonical trip id (never a real client spelling —
|
||||
// this whole path only ever runs right after this service's own import,
|
||||
// which now also parses `tripId` once) substitutes a trip id that can
|
||||
// never exist, so `fillIfEmpty`'s `WHERE id = ? AND trip_id = ?` matches
|
||||
// no row — a silent no-op, matching this method's own "never throws"
|
||||
// contract, not a 500 the caller (a detached background task) could not
|
||||
// observe anyway. Deliberately NOT applied to `this.realtime.broadcast`
|
||||
// below: every broadcast call in this domain, controller included, keys
|
||||
// the room by the raw route string, and changing this one call's room
|
||||
// shape alone would desync it from what the client actually joined.
|
||||
const tid = toRowId(tripId) ?? -1;
|
||||
// Rule 21 / M1 (Task 9 fix wave): non-null asserted — this whole path
|
||||
// only ever runs right after this service's own import, downstream of
|
||||
// the controller's `requireTrip` gate, which already `toRowId`-parsed
|
||||
// this same `tripId` once. Deliberately NOT applied to
|
||||
// `this.realtime.broadcast` below: every broadcast call in this domain,
|
||||
// controller included, keys the room by the raw route string, and
|
||||
// changing this one call's room shape alone would desync it from what
|
||||
// the client actually joined.
|
||||
const tid = toRowId(tripId)!;
|
||||
|
||||
// Asked for a Google identity rather than for the best answer: the whole
|
||||
// point here is the `google_place_id` that `pickEnrichmentMatch` selects on,
|
||||
@@ -1743,8 +1769,8 @@ export class PlacesService {
|
||||
console.warn(`[Places] address backfill skipped for trip ${tripId}: ${pending.length} places exceeds the ${ADDRESS_BACKFILL_MAX_PLACES} cap`);
|
||||
return;
|
||||
}
|
||||
// Task 5 review L5 (rule 21) — same fix and reasoning as `enrichOne`.
|
||||
const tid = toRowId(tripId) ?? -1;
|
||||
// Rule 21 / M1 (Task 9 fix wave) — same ruling as `enrichOne`.
|
||||
const tid = toRowId(tripId)!;
|
||||
// Serial on purpose: the background lane throttles to roughly one request a
|
||||
// second anyway, so concurrency would only build a queue.
|
||||
for (const place of pending) {
|
||||
|
||||
@@ -169,8 +169,16 @@ export class RealtimeGateway
|
||||
const user = userOf(socket);
|
||||
if (!user || !message?.tripId) return undefined;
|
||||
|
||||
// Rule 22 / A-H1 (Task 9 fix wave): `Number.isFinite` first, the same
|
||||
// guard `handleBookJoin` below already carries — without it, a
|
||||
// non-numeric `tripId` (`'abc'`) becomes `NaN`, which used to reach
|
||||
// `TripsRepository.findAccessible`'s raw bind as the unquoted bareword
|
||||
// `NaN` and throw (`no such column: NaN`), leaving this handler's
|
||||
// promise unanswered instead of the legacy `Access denied` frame. The
|
||||
// platform now renders a bound `NaN` as `NULL` too (`NulSafeSqlitePlatform`),
|
||||
// so this guard is defence in depth, not the only fix.
|
||||
const tripId = Number(message.tripId);
|
||||
if (!(await this.db.canAccessTrip(tripId, user.id))) {
|
||||
if (!Number.isFinite(tripId) || !(await this.db.canAccessTrip(tripId, user.id))) {
|
||||
return { type: 'error', message: 'Access denied' };
|
||||
}
|
||||
joinRoom(socket, tripId);
|
||||
|
||||
@@ -152,9 +152,12 @@ export class TripMembersService {
|
||||
|
||||
// `Number(tripId)`, not the raw string: `addMember`'s INSERT needs the
|
||||
// real `Primary<Trips>` type (its docstring). Safe here — `tripId` has
|
||||
// already matched a real trip through the inline `canAccessTrip` check
|
||||
// above this method's controller call, which only succeeds for a string
|
||||
// SQLite's own affinity rules already recognise as numeric.
|
||||
// already matched a real trip through the controller's `TripAccessGuard`,
|
||||
// whose own `Number(tripId)` is the seam (Task 9 fix wave, A-L1: this
|
||||
// comment previously and wrongly called it a raw-bind/SQLite-affinity
|
||||
// check — a `Number()` seam is WIDER, since `Number('0x10')`/
|
||||
// `Number('1e1')` coerce to a real integer where SQLite's own text
|
||||
// affinity never converts a hex or exponent literal).
|
||||
await this.tripMembersRepo.addMember(Number(tripId), target.id, invitedByUserId);
|
||||
|
||||
const tripTitle = await this.tripsRepo.getTitle(tripId);
|
||||
@@ -196,16 +199,23 @@ export class TripMembersService {
|
||||
|
||||
const fromEmail = (await this.usersRepo.getEmail(currentOwnerId)) || '';
|
||||
|
||||
// `Number(tripId)` at the two writes below that need the real
|
||||
// `Primary<Trips>` type (`setOwner`'s docstring / `addIgnoringConflict`'s
|
||||
// docstring) — safe: this route runs behind `TripOwnerGuard`, so `tripId`
|
||||
// has already matched a real trip through its raw-bind `isOwner` check.
|
||||
// Task 9 fix wave (B-L4 / A-L1): all THREE writes below now take
|
||||
// `trip.id` — the real `number` `findIdTitleOwner` already resolved
|
||||
// above, not the route's raw `tripId` string. The comment this replaces
|
||||
// claimed "`Number(tripId)` at the two writes below", but only
|
||||
// `addIgnoringConflict` (which requires a real `Primary<Trips>`) ever
|
||||
// did that; `setOwner`/`remove` kept binding the unconverted string —
|
||||
// not a live divergence (both repository methods document and test
|
||||
// that raw-bind seam for their OTHER callers, so their signatures stay
|
||||
// `number | string`), but the letter of rule 21 says one id, parsed
|
||||
// once, threaded everywhere — and `trip.id` is already sitting right
|
||||
// here, already proven to be the real row.
|
||||
await this.uow.transactional(async () => {
|
||||
await this.tripsRepo.setOwner(tripId, newOwnerId);
|
||||
await this.tripsRepo.setOwner(trip.id, newOwnerId);
|
||||
// The new owner is no longer a plain member…
|
||||
await this.tripMembersRepo.remove(tripId, newOwnerId);
|
||||
await this.tripMembersRepo.remove(trip.id, newOwnerId);
|
||||
// …and the former owner keeps access as a member.
|
||||
await this.tripMembersRepo.addIgnoringConflict(Number(tripId), currentOwnerId, newOwnerId);
|
||||
await this.tripMembersRepo.addIgnoringConflict(trip.id, currentOwnerId, newOwnerId);
|
||||
});
|
||||
|
||||
return { tripTitle: trip.title, fromEmail, toEmail: newOwner.email };
|
||||
@@ -227,8 +237,12 @@ export class TripMembersService {
|
||||
const username = `guest-${randomUUID()}`;
|
||||
|
||||
// `Number(tripId)` for the same reason `addMember`'s docstring gives:
|
||||
// `createGuest`'s route runs behind `TripOwnerGuard`, so `tripId` has
|
||||
// already matched a real trip through its raw-bind `isOwner` check.
|
||||
// `createGuest`'s route runs behind `TripOwnerGuard`, whose OWN
|
||||
// `Number(tripId)` is the seam that already matched a real trip (Task 9
|
||||
// fix wave, A-L1: corrected from "raw-bind `isOwner` check" — the guard
|
||||
// converts with `Number()` first, a WIDER seam than a raw bind, so a
|
||||
// hex-spelled trip id is authorised here too — L1's accepted widening:
|
||||
// `POST .../guests` with a hex id now creates a real guest, base 500).
|
||||
const guestId = await this.uow.transactional(async () => {
|
||||
const newGuestId = await this.usersRepo.insertGuest({ username, email, display_name: display });
|
||||
await this.tripMembersRepo.addMember(Number(tripId), newGuestId, invitedByUserId);
|
||||
|
||||
@@ -245,6 +245,19 @@ export class TripsService {
|
||||
const existing = await this.daysRepo.listOrderedForReorder(trip_id); // TP1
|
||||
|
||||
// Two-phase renumber to avoid UNIQUE(trip_id, day_number) collisions.
|
||||
//
|
||||
// Task 9 fix wave (A-L4a): the FIRST (negative) pass is not pinned by
|
||||
// any test — a mutation that removes or skips it survives the suite,
|
||||
// because no test's fixture forces two days to swap `day_number`s (the
|
||||
// only scenario where skipping straight to the positive pass would hit
|
||||
// a live UNIQUE collision on a still-occupied target number). It is
|
||||
// still correct, production-necessary code, not dead code: this
|
||||
// helper's callers (`generateDays`'s regenerate/compact paths) reorder
|
||||
// day rows whose numbers can genuinely collide mid-pass, and the
|
||||
// negative pass is what clears every target number before any of them
|
||||
// is reused. Documented per the review's ruling rather than added a
|
||||
// test, since the equivalent-mutation gap is the ABSENCE of a
|
||||
// swap-shaped fixture, not a defect in this code.
|
||||
const renumber = async (days: { id: number }[]) => {
|
||||
for (let i = 0; i < days.length; i++) await this.daysRepo.setDayNumber(days[i].id, -(i + 1)); // TP2
|
||||
for (let i = 0; i < days.length; i++) await this.daysRepo.setDayNumber(days[i].id, i + 1); // TP2
|
||||
@@ -462,13 +475,16 @@ export class TripsService {
|
||||
// so re-stamp reservation_time to follow — same rules as reorderDays/insertDay.
|
||||
const newDays = await this.daysRepo.listOrderedForReorder(tripIdNum); // TP27
|
||||
const newDateByDayId = new Map(newDays.map(d => [d.id, d.date]));
|
||||
await this.days.restampReservationDates(tripId, prevDateByDayId, newDateByDayId);
|
||||
// `tripIdNum`, not `tripId` (Task 9 fix wave, H2): `restampReservationDates`
|
||||
// now takes the parsed row id, the same value every other DaysService
|
||||
// survivor takes — see its own docstring.
|
||||
await this.days.restampReservationDates(tripIdNum, prevDateByDayId, newDateByDayId);
|
||||
} else {
|
||||
// Default: generateDays re-dates day rows positionally; re-anchor dated bookings to
|
||||
// the day matching their absolute reservation_time, and accommodations (+ their
|
||||
// linked hotel reservations) to the days now holding their pre-change dates (#1288).
|
||||
await this.reservations.resyncReservationDays(tripId);
|
||||
await this.days.resyncAccommodationDays(tripId, prevDateByDayId);
|
||||
await this.days.resyncAccommodationDays(tripIdNum, prevDateByDayId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user