From 417d0445b94cf1d6b2e9447aa0631ad549ca6854 Mon Sep 17 00:00:00 2001 From: Maurice Date: Sun, 20 Sep 2026 18:45:37 +0200 Subject: [PATCH 1/2] 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. --- server/src/db/migrations.ts | 14 ++ server/src/db/reseat-booked-nights.ts | 115 ++++++++++ .../tests/unit/db/booked-night-reseat.test.ts | 211 ++++++++++++++++++ 3 files changed, 340 insertions(+) create mode 100644 server/src/db/reseat-booked-nights.ts create mode 100644 server/tests/unit/db/booked-night-reseat.test.ts diff --git a/server/src/db/migrations.ts b/server/src/db/migrations.ts index ceaf909d2..596ea745f 100644 --- a/server/src/db/migrations.ts +++ b/server/src/db/migrations.ts @@ -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) { diff --git a/server/src/db/reseat-booked-nights.ts b/server/src/db/reseat-booked-nights.ts new file mode 100644 index 000000000..458e969ed --- /dev/null +++ b/server/src/db/reseat-booked-nights.ts @@ -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(); + const kept: Array = []; + 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>(); + 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; +} diff --git a/server/tests/unit/db/booked-night-reseat.test.ts b/server/tests/unit/db/booked-night-reseat.test.ts new file mode 100644 index 000000000..14246ef25 --- /dev/null +++ b/server/tests/unit/db/booked-night-reseat.test.ts @@ -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]); + }); +}); From be9a38e3b0395519c5afc109fec900a527920923 Mon Sep 17 00:00:00 2001 From: Maurice Date: Sun, 20 Sep 2026 18:48:26 +0200 Subject: [PATCH 2/2] fix(accommodations): a second night on the day counts by its check-in Two bookings on one day used to settle by whichever was seated last, because the other night's stop carries no hour of its own. Its check-in counts now, in the service and in the migration alike. --- server/src/db/reseat-booked-nights.ts | 5 ++++- .../nest/accommodations/accommodations.service.ts | 8 ++++++-- server/tests/unit/db/booked-night-reseat.test.ts | 12 ++++++++++++ .../tests/unit/nest/accommodations.service.test.ts | 14 ++++++++++++++ 4 files changed, 36 insertions(+), 3 deletions(-) diff --git a/server/src/db/reseat-booked-nights.ts b/server/src/db/reseat-booked-nights.ts index 458e969ed..cfe904449 100644 --- a/server/src/db/reseat-booked-nights.ts +++ b/server/src/db/reseat-booked-nights.ts @@ -31,10 +31,13 @@ export function reseatBookedNights(db: Database.Database): number { 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) AS at, + 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 `); diff --git a/server/src/nest/accommodations/accommodations.service.ts b/server/src/nest/accommodations/accommodations.service.ts index 1d0eade62..a7403fe7d 100644 --- a/server/src/nest/accommodations/accommodations.service.ts +++ b/server/src/nest/accommodations/accommodations.service.ts @@ -296,9 +296,12 @@ export class AccommodationsService { if (!checkIn) return 0; // excludeId leaves the row being re-seated out of the chain it is measured // against. Without it a night parked at the end of the day can find itself. + // Another booked night on the day counts by its check-in, so two bookings on + // one day settle by the clock. const rows = this.db.all<{ order_index: number; at: string | null }>(` - SELECT da.order_index, COALESCE(da.assignment_time, p.place_time) AS at + SELECT da.order_index, COALESCE(da.assignment_time, p.place_time, other.check_in) AS at 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 = ? AND da.id != ? ORDER BY da.order_index `, dayId, excludeId ?? -1); let seat = 0; @@ -319,8 +322,9 @@ export class AccommodationsService { private seatedByCheckIn(dayId: number, ownId: number, checkIn: string | null | undefined): boolean { if (!checkIn) return true; const rows = this.db.all<{ id: number; at: string | null }>(` - SELECT da.id, COALESCE(da.assignment_time, p.place_time) AS at + SELECT da.id, COALESCE(da.assignment_time, p.place_time, other.check_in) AS at 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 `, dayId); const own = rows.findIndex(row => row.id === ownId); diff --git a/server/tests/unit/db/booked-night-reseat.test.ts b/server/tests/unit/db/booked-night-reseat.test.ts index 14246ef25..58797af18 100644 --- a/server/tests/unit/db/booked-night-reseat.test.ts +++ b/server/tests/unit/db/booked-night-reseat.test.ts @@ -177,6 +177,18 @@ describe('booked night reseat', () => { expect(vias(db).map((v) => v.id)).not.toContain(afterA); }); + it('RESEAT-010: two nights on one day settle by their check-ins', () => { + const db = freshDb(); + const [later, earlier] = [place(db, 'Rue de Paris'), place(db, 'Brandenburger Tor')]; + stop(db, later, 0, null, night(db, later, '12:00')); + stop(db, earlier, 1, null, night(db, earlier, '10:00')); + + reseatBookedNights(db); + + expect(order(db)).toEqual([earlier, later]); + expect(reseatBookedNights(db)).toBe(0); + }); + it('RESEAT-008: runs again without moving anything', () => { const db = freshDb(); const [fuel, hotel] = [place(db, 'Aral'), place(db, 'Rostock')]; diff --git a/server/tests/unit/nest/accommodations.service.test.ts b/server/tests/unit/nest/accommodations.service.test.ts index 758a363d1..d017e2332 100644 --- a/server/tests/unit/nest/accommodations.service.test.ts +++ b/server/tests/unit/nest/accommodations.service.test.ts @@ -743,6 +743,20 @@ describe('the day stop a booking implies', () => { expect(stopsOn(day.id).map(a => a.place_id)).toEqual([afternoon.id, hotel.id]); }); + it('ACC-022i a second night on the day counts by its check-in', () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const day = createDay(testDb, trip.id); + const [noon, morning] = ['Rue de Paris', 'Brandenburger Tor'].map(name => createPlace(testDb, trip.id, { name })); + svc.createAccommodation(trip.id, { place_id: noon.id, start_day_id: day.id, end_day_id: day.id, check_in: '12:00' }); + svc.createAccommodation(trip.id, { place_id: morning.id, start_day_id: day.id, end_day_id: day.id, check_in: '10:00' }); + expect(stopsOn(day.id).map(a => a.place_id)).toEqual([morning.id, noon.id]); + + const evening = createPlace(testDb, trip.id, { name: 'Late' }); + svc.createAccommodation(trip.id, { place_id: evening.id, start_day_id: day.id, end_day_id: day.id, check_in: '18:00' }); + expect(stopsOn(day.id).map(a => a.place_id)).toEqual([morning.id, noon.id, evening.id]); + }); + it('ACC-022h an edit that leaves the check-in alone still corrects a night the clocks contradict', () => { // Dragged behind a stop pinned to the afternoon, with a check-in at ten: a change of // notes puts it back ahead of that stop, because a night sitting after an afternoon it