fix(server): the booked night settles in one pass, and the sibling paths seat it too

The audit over the 4.3.1 release found the seating rule reading a night by one
value and its neighbours by another, so two nights on one day never agreed:
the migration reported moves that changed nothing, flipped the pair on the
next run and dropped the road drawn between them, and in the service every
notes edit swapped them. One rule now lives in accommodations/night-seat.ts,
measures every night by its own check-in, breaks a tie by the booking made
first, and carries the vias; the service, the reseat migration and a trip
date change all seat through it, the last of which used to append the night
at the end of its day again. An account created by a first SSO login writes
the user.register row the password path writes. Tests cover the equal and
absent check-ins, the date change, the booking form path and a day that is
nothing but its night.
This commit is contained in:
Maurice
2026-09-22 12:10:20 +02:00
parent 757967bf18
commit 81292d4d2b
19 changed files with 826 additions and 301 deletions
+25 -84
View File
@@ -1,11 +1,9 @@
import Database from 'better-sqlite3';
type DayRow = { id: number; at: string | null; located: number };
type Via = { id: number; after_order_index: number; sequence: number };
import { carryVias, dayStops, locatedIds, seatAmong } from '../nest/accommodations/night-seat';
/**
* Seat every booked night where its check-in says, the way a night booked today is
* seated (AccommodationsService.positionForCheckIn).
* seated (night-seat.ts, the rule AccommodationsService applies).
*
* The stop a booking puts on its check-in day used to go last unless a stop pinned to
* a later hour pulled it forward, and the backfill that gave older bookings their stop
@@ -16,103 +14,46 @@ type Via = { id: number; after_order_index: number; sequence: number };
*
* Only the stops a booking owns move (accommodation_id). A stop the traveller placed
* themselves and then booked a night on is theirs, wherever it sits. The drawn roads
* (roadtrip_vias) are pinned to positions among the day's located stops, so they are
* carried along by the rules the service applies (AccommodationsService.reanchorVias):
* a via follows the stop it was drawn after, a via behind a stop that is last stays
* with it, and one left without a leg goes.
* (roadtrip_vias) are pinned to positions among the day's located stops and are
* carried along by the rules the service applies (carryVias): a via follows the stop
* it was drawn after, a via behind a stop that is last stays with it, and one left
* without a leg goes.
*
* Runs inside the migration's transaction. Re-runnable: a night already in its seat is
* passed over. Returns how many nights moved.
* Runs inside the migration's transaction. Re-runnable: the nights of a day are taken
* in the order the rule seats them (a night without a check-in first, then by
* check-in, ties by booking), so one pass leaves every night in its seat and a second
* pass moves nothing. Returns how many nights moved.
*/
export function reseatBookedNights(db: Database.Database): number {
const nights = db.prepare(`
SELECT da.id, da.day_id, a.check_in
SELECT da.id, da.day_id, a.id AS accommodation_id, a.check_in
FROM day_assignments da
JOIN day_accommodations a ON a.id = da.accommodation_id
ORDER BY da.day_id, da.order_index, da.id
`).all() as Array<{ id: number; day_id: number; check_in: string | null }>;
// Another night on the same day counts by its check-in, so two bookings on one
// day settle by the clock instead of by whichever was seated last.
const dayRows = db.prepare(`
SELECT da.id, COALESCE(da.assignment_time, p.place_time, other.check_in) AS at,
(p.lat IS NOT NULL AND p.lng IS NOT NULL) AS located
FROM day_assignments da JOIN places p ON p.id = da.place_id
LEFT JOIN day_accommodations other ON other.id = da.accommodation_id
WHERE da.day_id = ?
ORDER BY da.order_index ASC, da.created_at ASC, da.id ASC
`);
ORDER BY da.day_id, a.check_in IS NOT NULL, a.check_in, a.id
`).all() as Array<{ id: number; day_id: number; accommodation_id: number; check_in: string | null }>;
const setIndex = db.prepare('UPDATE day_assignments SET order_index = ? WHERE id = ?');
const viasOf = db.prepare('SELECT id, after_order_index, sequence FROM roadtrip_vias WHERE day_id = ?');
const dropVia = db.prepare('DELETE FROM roadtrip_vias WHERE id = ?');
const moveVia = db.prepare('UPDATE roadtrip_vias SET after_order_index = ? WHERE id = ?');
const setSequence = db.prepare('UPDATE roadtrip_vias SET sequence = ? WHERE id = ?');
let seated = 0;
const roads = { moved: 0, removed: 0 };
for (const night of nights) {
const rows = dayRows.all(night.day_id) as DayRow[];
const rows = dayStops(db, night.day_id);
const own = rows.findIndex((row) => row.id === night.id);
if (own < 0) continue;
const others = rows.filter((row) => row.id !== night.id);
let seat = 0;
if (night.check_in) {
others.forEach((row, i) => {
if (row.at !== null && row.at <= night.check_in!) seat = i + 1;
});
}
const next = [...others.slice(0, seat), rows[own]!, ...others.slice(seat)];
if (next.every((row, i) => row.id === rows[i]!.id)) continue;
const seat = seatAmong(others, { id: night.accommodation_id, check_in: night.check_in });
const next = [...others.slice(0, seat), rows[own], ...others.slice(seat)];
if (next.every((row, i) => row.id === rows[i].id)) continue;
const previousLocated = rows.filter((row) => row.located).map((row) => row.id);
next.forEach((row, i) => setIndex.run(i, row.id));
seated += 1;
const nextLocated = next.filter((row) => row.located).map((row) => row.id);
const vias = viasOf.all(night.day_id) as Via[];
if (!vias.length) continue;
const previousLast = previousLocated.length - 1;
const nextLast = nextLocated.length - 1;
const lastStayed = previousLast >= 0 && previousLocated[previousLast] === nextLocated[nextLast];
const landed = new Set<number>();
const kept: Array<Via & { was: number }> = [];
for (const via of vias) {
const leg = lastStayed && via.after_order_index === previousLast
? nextLast
: legAfter(via.after_order_index, previousLocated, nextLocated);
if (leg === null) {
dropVia.run(via.id);
continue;
}
if (leg !== via.after_order_index) {
moveVia.run(leg, via.id);
landed.add(via.id);
}
kept.push({ ...via, was: via.after_order_index, after_order_index: leg });
}
// Two legs that merged carry two sequence series side by side. Renumbered the way
// the service does it: the earlier leg's points first, then by their old sequence,
// then by id. Only a leg that received a via is touched.
const byLeg = new Map<number, Array<Via & { was: number }>>();
for (const via of kept) byLeg.set(via.after_order_index, [...(byLeg.get(via.after_order_index) ?? []), via]);
for (const onLeg of byLeg.values()) {
if (!onLeg.some((via) => landed.has(via.id))) continue;
onLeg.sort((a, b) => a.was - b.was || a.sequence - b.sequence || a.id - b.id);
onLeg.forEach((via, i) => {
if (via.sequence !== i) setSequence.run(i, via.id);
});
const carried = carryVias(db, night.day_id, locatedIds(rows), locatedIds(next));
if (carried) {
roads.moved += carried.moved;
roads.removed += carried.removed;
}
}
if (roads.moved || roads.removed) {
console.log(`[DB] Re-pinned ${roads.moved} drawn road(s) behind the reseated nights, dropped ${roads.removed} left without a leg`);
}
return seated;
}
/**
* The leg a via pinned behind the n-th stop of the old order is on in the new one
* (AccommodationsService.legAfter). Null when no leg is left for it.
*/
function legAfter(index: number, previousIds: number[], nextIds: number[]): number | null {
if (index > previousIds.length - 1) return null;
let at = index;
while (at >= 0 && !nextIds.includes(previousIds[at]!)) at -= 1;
if (at < 0) return null;
const next = nextIds.indexOf(previousIds[at]!);
return next >= nextIds.length - 1 ? null : next;
}