mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
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:
@@ -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) {
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user