fix(roadtrip): seat the nights booked before this release where their check-in says

A booked night leads its day now, but the stops the bookings put on their
check-in day before this release sit last, and the backfill for older
bookings appended them too. Every trip planned before the change kept that
order until somebody edited the check-in.

A migration seats every night a booking owns by the same rule the service
applies: first, behind only a stop whose own clock is at or before the
check-in. The drawn roads follow the stops they were drawn after, by the
rules the service uses when it moves a night. Stops the traveller placed
themselves are not touched. Re-runnable.
This commit is contained in:
Maurice
2026-09-20 18:45:37 +02:00
parent a86e4b560a
commit 417d0445b9
3 changed files with 340 additions and 0 deletions
+14
View File
@@ -2,6 +2,7 @@ import { ROADTRIP_PREFERENCE_KEYS } from '@trek/shared';
import { readEnv } from '../app-config';
import { encrypt_api_key } from '../nest/common/crypto/apiKeyCrypto';
import { seedDocumentProviders } from './document-provider-seed';
import { reseatBookedNights } from './reseat-booked-nights';
import Database from 'better-sqlite3';
import fs from 'fs';
@@ -5212,6 +5213,19 @@ function runMigrations(db: Database.Database): void {
ALTER TABLE roadtrip_day_boundaries_new RENAME TO roadtrip_day_boundaries;
`);
},
/*
* Seat every booked night where its check-in says, the way a night booked
* today is seated. The night leads its day now; the stops a booking put on
* their check-in day before this release sit last, and the trips people
* already have would keep that order until somebody edits the check-in.
* The rules, and the drawn roads that follow the stops, are in
* reseat-booked-nights.ts. Re-runnable.
*/
() => {
const seated = reseatBookedNights(db);
if (seated > 0) console.log(`[DB] Seated ${seated} booked night(s) at the head of their day`);
},
];
if (currentVersion < migrations.length) {
+115
View File
@@ -0,0 +1,115 @@
import Database from 'better-sqlite3';
type DayRow = { id: number; at: string | null; located: number };
type Via = { id: number; after_order_index: number; sequence: number };
/**
* Seat every booked night where its check-in says, the way a night booked today is
* seated (AccommodationsService.positionForCheckIn).
*
* 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
* appended it too. The night leads its day now: first, behind only a stop whose own
* clock is at or before the check-in, with the stops that carry no hour following it.
* Without this step every trip planned before the change keeps the old order until
* somebody edits the check-in.
*
* 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.
*
* Runs inside the migration's transaction. Re-runnable: a night already in its seat is
* passed over. 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
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 }>;
const dayRows = db.prepare(`
SELECT da.id, COALESCE(da.assignment_time, p.place_time) 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
WHERE da.day_id = ?
ORDER BY da.order_index ASC, da.created_at ASC, da.id ASC
`);
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;
for (const night of nights) {
const rows = dayRows.all(night.day_id) as DayRow[];
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 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);
});
}
}
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;
}
@@ -0,0 +1,211 @@
/**
* The step that seats every booked night where its check-in says.
*
* A night's stop used to go last on its check-in day unless a stop pinned to a later
* hour pulled it forward, and the backfill for older bookings appended it as well. The
* night leads its day now (AccommodationsService.positionForCheckIn), and this step
* brings the trips planned under the old rule along, drawn roads included.
*
* The rules live in reseat-booked-nights.ts and are tested there directly on a fully
* migrated database: the columns the step reads (assignment_time, accommodation_id)
* and the vias table come from migrations, so seeding before runMigrations is not an
* option, and rewinding schema_version would tie this file to the tail of the
* append-only array.
*/
import { describe, it, expect } from 'vitest';
import Database from 'better-sqlite3';
import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
import { reseatBookedNights } from '../../../src/db/reseat-booked-nights';
function freshDb() {
const db = new Database(':memory:');
db.exec('PRAGMA journal_mode = WAL');
db.exec('PRAGMA foreign_keys = ON');
createTables(db);
runMigrations(db);
db.prepare("INSERT INTO users (id, username, email, password_hash, role) VALUES (1, 'u', 'u@test.local', 'x', 'user')").run();
db.prepare("INSERT INTO trips (id, user_id, title) VALUES (1, 1, 'T')").run();
db.prepare("INSERT INTO days (id, trip_id, day_number, date) VALUES (1, 1, 1, '2026-10-02')").run();
return db;
}
let nextPlace = 1;
function place(db: Database.Database, name: string, time: string | null = null, located = true) {
const id = nextPlace++;
db.prepare('INSERT INTO places (id, trip_id, name, lat, lng, place_time) VALUES (?, 1, ?, ?, ?, ?)')
.run(id, name, located ? 50 + id / 100 : null, located ? 10 : null, time);
return id;
}
let nextStop = 1;
function stop(db: Database.Database, placeId: number, orderIndex: number, time: string | null = null, accommodationId: number | null = null) {
const id = nextStop++;
db.prepare('INSERT INTO day_assignments (id, day_id, place_id, order_index, assignment_time, accommodation_id) VALUES (?, 1, ?, ?, ?, ?)')
.run(id, placeId, orderIndex, time, accommodationId);
return id;
}
function night(db: Database.Database, placeId: number, checkIn: string | null) {
return Number(db.prepare('INSERT INTO day_accommodations (trip_id, place_id, start_day_id, end_day_id, check_in) VALUES (1, ?, 1, 1, ?)')
.run(placeId, checkIn).lastInsertRowid);
}
const order = (db: Database.Database) =>
(db.prepare('SELECT place_id FROM day_assignments WHERE day_id = 1 ORDER BY order_index').all() as { place_id: number }[]).map((r) => r.place_id);
const indexes = (db: Database.Database) =>
(db.prepare('SELECT order_index FROM day_assignments WHERE day_id = 1 ORDER BY order_index').all() as { order_index: number }[]).map((r) => r.order_index);
const vias = (db: Database.Database) =>
db.prepare('SELECT id, after_order_index, sequence FROM roadtrip_vias WHERE day_id = 1 ORDER BY after_order_index, sequence, id').all() as
{ id: number; after_order_index: number; sequence: number }[];
const via = (db: Database.Database, afterOrderIndex: number, sequence = 0) =>
Number(db.prepare('INSERT INTO roadtrip_vias (day_id, after_order_index, sequence, lat, lng) VALUES (1, ?, ?, 50, 10)').run(afterOrderIndex, sequence).lastInsertRowid);
describe('booked night reseat', () => {
it('RESEAT-001: a night appended behind stops without an hour leads the day', () => {
const db = freshDb();
const [fuel, farm, hotel] = [place(db, 'Aral'), place(db, 'Karls'), place(db, 'Rostock')];
stop(db, fuel, 0);
stop(db, farm, 1);
stop(db, hotel, 2, null, night(db, hotel, '10:00'));
expect(reseatBookedNights(db)).toBe(1);
expect(order(db)).toEqual([hotel, fuel, farm]);
expect(indexes(db)).toEqual([0, 1, 2]);
});
it('RESEAT-002: a night without a check-in leads the day too', () => {
const db = freshDb();
const [museum, hotel] = [place(db, 'Museum'), place(db, 'Adlon')];
stop(db, museum, 0);
stop(db, hotel, 1, null, night(db, hotel, null));
reseatBookedNights(db);
expect(order(db)).toEqual([hotel, museum]);
});
it('RESEAT-003: a stop with its own hour at or before the check-in stays ahead', () => {
const db = freshDb();
const [early, loose, late, hotel] = [place(db, 'Aral'), place(db, 'Hafen'), place(db, 'Museum'), place(db, 'Rostock')];
stop(db, early, 0, '08:00');
stop(db, loose, 1);
// The place's own hour counts as much as the visit's.
stop(db, late, 2, null);
db.prepare("UPDATE places SET place_time = '14:00' WHERE id = ?").run(late);
stop(db, hotel, 3, null, night(db, hotel, '10:00'));
reseatBookedNights(db);
expect(order(db)).toEqual([early, hotel, loose, late]);
});
it('RESEAT-004: a night already in its seat, and a stop the traveller placed, are left alone', () => {
const db = freshDb();
const [hotel, museum, own] = [place(db, 'Adlon'), place(db, 'Museum'), place(db, 'Campsite')];
stop(db, hotel, 0, null, night(db, hotel, '15:00'));
stop(db, museum, 1);
// Booked in road trip mode: the stop is the traveller's, the booking owns no row.
night(db, own, '18:00');
stop(db, own, 2);
expect(reseatBookedNights(db)).toBe(0);
expect(order(db)).toEqual([hotel, museum, own]);
});
it('RESEAT-005: the drawn roads follow the stops they were drawn after', () => {
const db = freshDb();
const [a, b, hotel] = [place(db, 'Harbour'), place(db, 'Mercure'), place(db, 'Rostock')];
stop(db, a, 0);
stop(db, b, 1);
stop(db, hotel, 2, null, night(db, hotel, '10:00'));
const afterA = via(db, 0);
const afterB = via(db, 1);
const afterHotel = via(db, 2);
reseatBookedNights(db);
// The rules the service applies when it moves a night: a via follows the stop
// it was drawn after, so A's road is on leg one now and the hotel's on leg
// zero; B is last now and has no leg to keep a via on, so its road goes.
expect(order(db)).toEqual([hotel, a, b]);
expect(vias(db)).toEqual([
{ id: afterHotel, after_order_index: 0, sequence: 0 },
{ id: afterA, after_order_index: 1, sequence: 0 },
]);
expect(vias(db).map((v) => v.id)).not.toContain(afterB);
});
it('RESEAT-006: a road behind the last stop stays with it, and the roads ahead follow their stops', () => {
// The hotel moves from the middle to the front. C is last before and after, so the
// drive into the next day stays behind it; the hotel's road follows the hotel to
// leg zero and A's follows A to leg one. Nothing merges in a pure reorder.
const db = freshDb();
const [a, hotel, c] = [place(db, 'Harbour'), place(db, 'Rostock'), place(db, 'Museum')];
stop(db, a, 0);
stop(db, hotel, 1, null, night(db, hotel, '10:00'));
stop(db, c, 2);
const afterHotel = via(db, 1);
const afterA = via(db, 0);
const intoTomorrow = via(db, 2);
reseatBookedNights(db);
expect(order(db)).toEqual([hotel, a, c]);
expect(vias(db)).toEqual([
{ id: afterHotel, after_order_index: 0, sequence: 0 },
{ id: afterA, after_order_index: 1, sequence: 0 },
{ id: intoTomorrow, after_order_index: 2, sequence: 0 },
]);
});
it('RESEAT-007: a stop without coordinates is not counted among the positions the roads are pinned to', () => {
const db = freshDb();
const [a, ghost, hotel] = [place(db, 'Harbour'), place(db, 'No map', null, false), place(db, 'Rostock')];
stop(db, a, 0);
stop(db, ghost, 1);
stop(db, hotel, 2, null, night(db, hotel, '10:00'));
const afterA = via(db, 0);
reseatBookedNights(db);
expect(order(db)).toEqual([hotel, a, ghost]);
// Located stops went [A, hotel] to [hotel, A]: A is last now, so its road has
// no leg left.
expect(vias(db).map((v) => v.id)).not.toContain(afterA);
});
it('RESEAT-008: runs again without moving anything', () => {
const db = freshDb();
const [fuel, hotel] = [place(db, 'Aral'), place(db, 'Rostock')];
stop(db, fuel, 0);
stop(db, hotel, 1, null, night(db, hotel, '10:00'));
reseatBookedNights(db);
const once = order(db);
expect(reseatBookedNights(db)).toBe(0);
expect(order(db)).toEqual(once);
});
it('RESEAT-009: the migration runs the step on a database being upgraded', () => {
// Seeded through the tables the base schema has, so the step fires on its normal
// pass: the backfill for older bookings appends the night last first, then the
// reseat brings it to the front.
const db = new Database(':memory:');
db.exec('PRAGMA journal_mode = WAL');
db.exec('PRAGMA foreign_keys = ON');
createTables(db);
db.prepare("INSERT INTO users (id, username, email, password_hash, role) VALUES (1, 'u', 'u@test.local', 'x', 'user')").run();
db.prepare("INSERT INTO trips (id, user_id, title) VALUES (1, 1, 'T')").run();
db.prepare("INSERT INTO days (id, trip_id, day_number, date) VALUES (1, 1, 1, '2026-10-02')").run();
db.prepare("INSERT INTO places (id, trip_id, name, lat, lng) VALUES (901, 1, 'Aral', 50.1, 10), (902, 1, 'Rostock', 50.2, 10)").run();
db.prepare('INSERT INTO day_assignments (day_id, place_id, order_index) VALUES (1, 901, 0)').run();
db.prepare("INSERT INTO day_accommodations (trip_id, place_id, start_day_id, end_day_id, check_in) VALUES (1, 902, 1, 1, '10:00')").run();
runMigrations(db);
expect(order(db)).toEqual([902, 901]);
});
});