mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
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:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { RealtimeService } from '../realtime/realtime.service';
|
||||
import { DatabaseService, type PlaceWithTags, type TripAccess } from '../database/database.service';
|
||||
import { PermissionsService } from '../permissions/permissions.service';
|
||||
import { AssignmentsService } from '../assignments/assignments.service';
|
||||
import { carryVias, dayStops, locatedStopIds, reseatOwnStop, seatHolds, seatIndex, type Night } from './night-seat';
|
||||
import type { User } from '../../types';
|
||||
|
||||
type Trip = TripAccess;
|
||||
@@ -13,9 +14,6 @@ type MirroredAssignment = ReturnType<AssignmentsService['createAssignment']>;
|
||||
/** One day's drawn roads after a write re-pinned them, as the road trip broadcasts them. */
|
||||
type DayVias = { dayId: number; vias: RoadtripVia[] };
|
||||
|
||||
/** A via as the re-pinning reads it: where it is pinned and its place on that leg. */
|
||||
type PinnedVia = { id: number; after_order_index: number; sequence: number };
|
||||
|
||||
/** How a surface sends the mirror's events; see announceMirror. */
|
||||
export type MirrorSender = <E extends TrekWsTripEventName>(event: E, payload: TrekWsPayload<E>) => void;
|
||||
|
||||
@@ -271,69 +269,6 @@ export class AccommodationsService {
|
||||
return errors;
|
||||
}
|
||||
|
||||
/**
|
||||
* Put the booking's check-in day on the map.
|
||||
*
|
||||
* Only the check-in day gets a stop, even for a fortnight's stay: that is the
|
||||
* day you drive there, and it is exactly what the road-trip side writes for a
|
||||
* night it books itself. The later nights ride on the stay row, which is where
|
||||
* the rail reads check-out from.
|
||||
*
|
||||
* Runs inside the caller's transaction.
|
||||
*/
|
||||
/**
|
||||
* Where in the day the booked night belongs.
|
||||
*
|
||||
* First. The hotel is where the day is based: the traveller checks in and the stops
|
||||
* they placed without an hour follow from there. Only a stop with a clock of its
|
||||
* own can stand ahead of it, and only when that clock is at or before the check-in:
|
||||
* a stop pinned to eight with a check-in at ten puts the night second, one pinned
|
||||
* to the afternoon does not. It used to go last unless something pinned later
|
||||
* pulled it forward, and a night booked for ten in the morning sat behind a whole
|
||||
* day of unpinned stops, reached at a quarter past twelve.
|
||||
*/
|
||||
private positionForCheckIn(dayId: number, checkIn: string | null | undefined, excludeId?: number): number {
|
||||
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, 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;
|
||||
for (const row of rows) if (row.at !== null && row.at <= checkIn) seat = row.order_index + 1;
|
||||
return seat;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the night sits somewhere its check-in allows, on an edit that did not
|
||||
* touch the check-in.
|
||||
*
|
||||
* Read off the chain rather than recomputed, because the index a fresh insert would
|
||||
* get is not the index this row already occupies. Two ways to be wrong: something
|
||||
* with a later hour ahead of it, or something with an earlier one behind it. Stops
|
||||
* without an hour are passed over in both directions: the night may have been
|
||||
* dragged past them on purpose, and a change of notes is no reason to undo that.
|
||||
*/
|
||||
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, 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);
|
||||
if (own < 0) return true;
|
||||
const laterAhead = rows.slice(0, own).some(row => row.at !== null && row.at > checkIn);
|
||||
const earlierBehind = rows.slice(own + 1).some(row => row.at !== null && row.at <= checkIn);
|
||||
return !laterAhead && !earlierBehind;
|
||||
}
|
||||
|
||||
/**
|
||||
* Type the place as lodging, unless the traveller already typed it themselves.
|
||||
*
|
||||
@@ -358,58 +293,23 @@ export class AccommodationsService {
|
||||
* coordinates are never routed and so never counted.
|
||||
*/
|
||||
private stopOrders(dayIds: number[]): Map<number, number[]> {
|
||||
return new Map([...new Set(dayIds)].map((dayId): [number, number[]] => [dayId, this.locatedStopIds(dayId)]));
|
||||
}
|
||||
|
||||
private locatedStopIds(dayId: number): number[] {
|
||||
return this.db.all<{ id: number }>(`
|
||||
SELECT da.id FROM day_assignments da JOIN places p ON p.id = da.place_id
|
||||
WHERE da.day_id = ? AND p.lat IS NOT NULL AND p.lng IS NOT NULL
|
||||
ORDER BY da.order_index ASC, da.created_at ASC, da.id ASC
|
||||
`, dayId).map(row => row.id);
|
||||
return new Map([...new Set(dayIds)].map((dayId): [number, number[]] => [dayId, locatedStopIds(this.db, dayId)]));
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep every drawn road behind the stop it was drawn after, now that this write
|
||||
* has seated, moved or taken out a stop on these days.
|
||||
* has seated, moved or taken out a stop on these days (carryVias has the rules).
|
||||
*
|
||||
* A night seated by its check-in ahead of the afternoon renumbers everything
|
||||
* behind it, and a via pinned to position one would otherwise bend the drive
|
||||
* into the hotel instead of the leg it was drawn on. Persisted and visible to
|
||||
* everyone, so it is put right where the stop moved, by the rules the planner
|
||||
* applies when a stop is dragged or taken out: a via follows its stop, a stop
|
||||
* that left the day hands its road to the stop before it, and a stop that is
|
||||
* last has no leg to keep a via on.
|
||||
* everyone, so it is put right where the stop moved and reported on the mirror.
|
||||
*
|
||||
* Runs inside the caller's transaction.
|
||||
*/
|
||||
private reanchorVias(mirror: AccommodationMirror, before: Map<number, number[]>): void {
|
||||
for (const [dayId, previousIds] of before) {
|
||||
const nextIds = this.locatedStopIds(dayId);
|
||||
if (previousIds.length === nextIds.length && previousIds.every((id, i) => id === nextIds[i])) continue;
|
||||
const vias = this.db.all<PinnedVia>('SELECT id, after_order_index, sequence FROM roadtrip_vias WHERE day_id = ?', dayId);
|
||||
if (!vias.length) continue;
|
||||
|
||||
// A via behind the day's last stop bends the drive into the next day. It stays
|
||||
// with that stop when the stop is still last, whatever its number is now:
|
||||
// measured as a leg it would be dropped, because a last stop has no leg.
|
||||
const previousLast = previousIds.length - 1;
|
||||
const nextLast = nextIds.length - 1;
|
||||
const lastStayed = previousLast >= 0 && previousIds[previousLast] === nextIds[nextLast];
|
||||
const remove: number[] = [];
|
||||
const moved: { id: number; after_order_index: number }[] = [];
|
||||
for (const via of vias) {
|
||||
const next = lastStayed && via.after_order_index === previousLast
|
||||
? nextLast
|
||||
: this.legAfter(via.after_order_index, previousIds, nextIds);
|
||||
if (next === null) remove.push(via.id);
|
||||
else if (next !== via.after_order_index) moved.push({ id: via.id, after_order_index: next });
|
||||
}
|
||||
if (!remove.length && !moved.length) continue;
|
||||
|
||||
for (const viaId of remove) this.db.run('DELETE FROM roadtrip_vias WHERE id = ? AND day_id = ?', viaId, dayId);
|
||||
for (const via of moved) this.db.run('UPDATE roadtrip_vias SET after_order_index = ? WHERE id = ? AND day_id = ?', via.after_order_index, via.id, dayId);
|
||||
this.renumberMergedLegs(dayId, vias.filter(via => !remove.includes(via.id)), moved);
|
||||
if (!carryVias(this.db, dayId, previousIds, locatedStopIds(this.db, dayId))) continue;
|
||||
this.noteVias(mirror, {
|
||||
dayId,
|
||||
vias: this.db.all<RoadtripVia>(
|
||||
@@ -418,47 +318,6 @@ export class AccommodationsService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The leg a via pinned behind the n-th stop of the old order is on in the new one.
|
||||
*
|
||||
* A stop this write took off the day hands its road to the stop before it: the
|
||||
* leg it was drawn on merges into the one ahead, the way taking a stop out of the
|
||||
* drive merges them (`RoadtripService.reanchor`). Null when no leg is left for it,
|
||||
* because the stop it follows is the last one now, nothing ahead of it survived,
|
||||
* or it was pinned past the day's end to begin with.
|
||||
*/
|
||||
private 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Two legs that merged carry two sequence series side by side, and everything
|
||||
* that draws the route orders by sequence: the drive would run through the first
|
||||
* leg's point, the second leg's, and back. Renumbered the way
|
||||
* `RoadtripService.reanchor` 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.
|
||||
*/
|
||||
private renumberMergedLegs(dayId: number, kept: PinnedVia[], moved: { id: number; after_order_index: number }[]): void {
|
||||
const landed = new Map(moved.map(via => [via.id, via.after_order_index]));
|
||||
const byLeg = new Map<number, PinnedVia[]>();
|
||||
for (const via of kept) {
|
||||
const leg = landed.get(via.id) ?? via.after_order_index;
|
||||
byLeg.set(leg, [...(byLeg.get(leg) ?? []), via]);
|
||||
}
|
||||
for (const onLeg of byLeg.values()) {
|
||||
if (!onLeg.some(via => landed.has(via.id))) continue;
|
||||
onLeg.sort((a, b) => a.after_order_index - b.after_order_index || a.sequence - b.sequence || a.id - b.id);
|
||||
onLeg.forEach((via, index) => {
|
||||
if (via.sequence !== index) this.db.run('UPDATE roadtrip_vias SET sequence = ? WHERE id = ? AND day_id = ?', index, via.id, dayId);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** One entry per day: a write that takes a stop off a day and puts one back on
|
||||
* the same day reports the state it left behind, not both steps. */
|
||||
private noteVias(mirror: AccommodationMirror, day: DayVias): void {
|
||||
@@ -483,39 +342,25 @@ export class AccommodationsService {
|
||||
stop: { id: number; day_id: number; order_index: number },
|
||||
placeId: number,
|
||||
dayId: number,
|
||||
checkIn?: string | null,
|
||||
night: Night,
|
||||
): MirroredAssignment | null {
|
||||
if (this.db.get('SELECT id FROM day_assignments WHERE day_id = ? AND place_id = ? AND id != ?', dayId, placeId, stop.id)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Close the gap the row leaves behind. A no-op when it is not changing days,
|
||||
// because it is put back into that same numbering two statements down.
|
||||
this.db.run(
|
||||
'UPDATE day_assignments SET order_index = order_index - 1 WHERE day_id = ? AND order_index > ?',
|
||||
stop.day_id, stop.order_index,
|
||||
);
|
||||
|
||||
// Park it at the end of the target day first, then seat it by the check-in the
|
||||
// same way a fresh insert would. Two steps, because the index it should get is
|
||||
// read off the chain it is not part of yet.
|
||||
const max = this.db.get<{ max: number | null }>(
|
||||
'SELECT MAX(order_index) AS max FROM day_assignments WHERE day_id = ? AND id != ?', dayId, stop.id)!;
|
||||
const end = (max.max !== null ? max.max : -1) + 1;
|
||||
this.db.run('UPDATE day_assignments SET day_id = ?, place_id = ?, order_index = ? WHERE id = ?', dayId, placeId, end, stop.id);
|
||||
|
||||
const seat = this.positionForCheckIn(dayId, checkIn, stop.id);
|
||||
if (seat < end) {
|
||||
this.db.run(
|
||||
'UPDATE day_assignments SET order_index = order_index + 1 WHERE day_id = ? AND order_index >= ? AND id != ?',
|
||||
dayId, seat, stop.id,
|
||||
);
|
||||
this.db.run('UPDATE day_assignments SET order_index = ? WHERE id = ?', seat, stop.id);
|
||||
}
|
||||
|
||||
reseatOwnStop(this.db, stop, placeId, dayId, night);
|
||||
return this.assignments.getAssignmentWithPlace(stop.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Put the booking's check-in day on the map.
|
||||
*
|
||||
* Only the check-in day gets a stop, even for a fortnight's stay: that is the
|
||||
* day you drive there, and it is exactly what the road-trip side writes for a
|
||||
* night it books itself. The later nights ride on the stay row, which is where
|
||||
* the rail reads check-out from.
|
||||
*
|
||||
* Runs inside the caller's transaction.
|
||||
*/
|
||||
private mirrorStay(accommodationId: number, placeId: number | null, dayId: number, checkIn?: string | null): AccommodationMirror {
|
||||
const mirror = noMirror();
|
||||
// A stay can outlive its place (place_id is ON DELETE SET NULL) and the booking
|
||||
@@ -531,8 +376,8 @@ export class AccommodationsService {
|
||||
if (this.db.get('SELECT id FROM day_assignments WHERE day_id = ? AND place_id = ?', dayId, placeId)) return mirror;
|
||||
|
||||
const before = this.stopOrders([dayId]);
|
||||
// Through AssignmentsService, seated where the check-in says (see
|
||||
// positionForCheckIn), with everything behind it moved up one.
|
||||
// Through AssignmentsService, seated where the check-in says (night-seat.ts),
|
||||
// with everything behind it moved up one.
|
||||
//
|
||||
// The booking id goes in with the INSERT, not as an UPDATE afterwards: what this
|
||||
// returns is the row the answer hands the client, and stamping the id on later
|
||||
@@ -541,7 +386,7 @@ export class AccommodationsService {
|
||||
// time until the next reload.
|
||||
mirror.created = this.assignments.createAssignment(dayId, placeId, null, {
|
||||
accommodationId,
|
||||
orderIndex: this.positionForCheckIn(dayId, checkIn),
|
||||
orderIndex: seatIndex(this.db, dayId, { id: accommodationId, check_in: checkIn }),
|
||||
});
|
||||
this.reanchorVias(mirror, before);
|
||||
return mirror;
|
||||
@@ -610,14 +455,17 @@ export class AccommodationsService {
|
||||
): AccommodationMirror {
|
||||
const own = this.ownStops(accommodationId);
|
||||
if (own.length === 0) return noMirror();
|
||||
const night: Night = { id: accommodationId, check_in: checkIn };
|
||||
if (own.length === 1 && own[0].day_id === dayId && own[0].place_id === placeId) {
|
||||
// Same place, same day. A check-in given a new hour seats the night afresh, the
|
||||
// way booking it with that hour would have; any other edit leaves a stop where
|
||||
// it is unless the clocks around it say it is in the wrong place, so a night the
|
||||
// traveller dragged somewhere stays there through a change of notes.
|
||||
const settled = opts.checkInChanged
|
||||
? this.positionForCheckIn(dayId, checkIn, own[0].id) === own[0].order_index
|
||||
: this.seatedByCheckIn(dayId, own[0].id, checkIn);
|
||||
// Same place, same day. A stop already where a fresh seat would put it stays,
|
||||
// whatever the edit: relocating it would land it in the same place and report
|
||||
// a move that moved nothing. A check-in given a new hour seats the night afresh
|
||||
// otherwise, the way booking it with that hour would have; any other edit
|
||||
// leaves the stop alone unless the clocks around it say it is in the wrong
|
||||
// place, so a night the traveller dragged somewhere stays there through a
|
||||
// change of notes.
|
||||
const settled = seatIndex(this.db, dayId, night, own[0].id) === own[0].order_index
|
||||
|| (!opts.checkInChanged && seatHolds(dayStops(this.db, dayId), own[0].id, checkIn));
|
||||
if (settled) return noMirror();
|
||||
}
|
||||
|
||||
@@ -629,7 +477,7 @@ export class AccommodationsService {
|
||||
// on its id, all of which a DELETE takes with it.
|
||||
if (own.length === 1 && placeId) {
|
||||
const before = this.stopOrders([own[0].day_id, dayId]);
|
||||
const moved = this.relocateOwnStop(own[0], placeId, dayId, checkIn);
|
||||
const moved = this.relocateOwnStop(own[0], placeId, dayId, night);
|
||||
if (moved) {
|
||||
mirror.moved = { assignment: moved, oldDayId: own[0].day_id };
|
||||
mirror.stamped = this.stampLodging(placeId);
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
import type Database from 'better-sqlite3';
|
||||
|
||||
/**
|
||||
* Where a booked night sits in its day, and how the drawn roads follow when a stop
|
||||
* moves.
|
||||
*
|
||||
* One rule, applied by everything that seats a night: AccommodationsService when a
|
||||
* night is booked or edited, DaysService when a trip's dates move and carry the
|
||||
* night to another day row, and the migration that brought the trips planned under
|
||||
* the old rule along. Written over the connection's `prepare` so a raw better-sqlite3
|
||||
* handle fits as well as DatabaseService. Everything here runs inside the caller's
|
||||
* transaction.
|
||||
*/
|
||||
export interface SeatConnection {
|
||||
prepare(sql: string): Database.Statement;
|
||||
}
|
||||
|
||||
/** A stop of one day as the rule sees it: the clock it is measured by (dayStops), the
|
||||
* booking that owns it, and whether the router counts it. */
|
||||
export interface SeatRow {
|
||||
id: number;
|
||||
order_index: number;
|
||||
at: string | null;
|
||||
night_id: number | null;
|
||||
located: number;
|
||||
}
|
||||
|
||||
/** The booking whose stop is being seated. */
|
||||
export interface Night {
|
||||
id: number;
|
||||
check_in: string | null | undefined;
|
||||
}
|
||||
|
||||
/** A via as the re-pinning reads it: where it is pinned and its place on that leg. */
|
||||
interface PinnedVia {
|
||||
id: number;
|
||||
after_order_index: number;
|
||||
sequence: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* A day's stops in order, each with the hour it is measured by: the visit's own, else
|
||||
* the place's.
|
||||
*
|
||||
* A stop a booking owns is measured by that booking's check-in instead, and by nothing
|
||||
* else. That is the value the night seats itself by, so it has to be the value its
|
||||
* peers see as well: read the same row as an hour pinned on the stop and the two nights
|
||||
* of a day would answer the question differently depending on which of them is asking,
|
||||
* and a pass that seats them both would never settle.
|
||||
*/
|
||||
export function dayStops(db: SeatConnection, dayId: number): SeatRow[] {
|
||||
return db.prepare(`
|
||||
SELECT da.id, da.order_index,
|
||||
CASE WHEN other.id IS NULL THEN COALESCE(da.assignment_time, p.place_time) ELSE other.check_in END AS at,
|
||||
other.id AS night_id, (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
|
||||
`).all(dayId) as SeatRow[];
|
||||
}
|
||||
|
||||
/** The stops the router counts, in day order: the positions the vias are pinned to. */
|
||||
export function locatedIds(rows: readonly SeatRow[]): number[] {
|
||||
return rows.filter(row => row.located).map(row => row.id);
|
||||
}
|
||||
|
||||
export function locatedStopIds(db: SeatConnection, dayId: number): number[] {
|
||||
return locatedIds(dayStops(db, dayId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether `row` stands ahead of the night on its day.
|
||||
*
|
||||
* The night leads its day: the hotel is where the day is based, and the stops placed
|
||||
* without an hour follow from there. Only a stop with a clock of its own stands ahead
|
||||
* of it, and only when that clock is at or before the check-in: a stop pinned to eight
|
||||
* with a check-in at ten puts the night second, one pinned to the afternoon does not.
|
||||
*
|
||||
* Two nights on one day settle by their check-ins. When those agree, or both are
|
||||
* missing, the earlier booking leads; without that each would count the other as
|
||||
* ahead and the pair would trade places on every pass. A night without a check-in
|
||||
* leads the day outright, ahead of one with a clock.
|
||||
*
|
||||
* Among the nights of a day that is a strict order: the ones without a check-in by
|
||||
* booking, then the rest by check-in and booking. What each of them counts as ahead
|
||||
* grows along that order, which is what lets a caller seat a whole day's nights in
|
||||
* one pass and find them all settled (reseat-booked-nights.ts).
|
||||
*/
|
||||
export function standsAhead(row: SeatRow, night: Night): boolean {
|
||||
const earlierBooking = row.night_id !== null && row.night_id < night.id;
|
||||
if (!night.check_in) return row.at === null && earlierBooking;
|
||||
if (row.at === null) return row.night_id !== null;
|
||||
if (row.at === night.check_in) return row.night_id === null || earlierBooking;
|
||||
return row.at < night.check_in;
|
||||
}
|
||||
|
||||
/** The position among `others` (the day's stops without the night's own) the night
|
||||
* takes: right behind the last row that stands ahead of it, else first. */
|
||||
export function seatAmong(others: readonly SeatRow[], night: Night): number {
|
||||
let seat = 0;
|
||||
others.forEach((row, i) => {
|
||||
if (standsAhead(row, night)) seat = i + 1;
|
||||
});
|
||||
return seat;
|
||||
}
|
||||
|
||||
/**
|
||||
* The order_index a fresh insert of the night gets, with everything from there on
|
||||
* moved down (AssignmentsService.createAssignment). `excludeId` leaves the night's
|
||||
* own row out of the chain it is measured against: without it a night parked at
|
||||
* the end of the day can find itself.
|
||||
*/
|
||||
export function seatIndex(db: SeatConnection, dayId: number, night: Night, excludeId?: number): number {
|
||||
const others = dayStops(db, dayId).filter(row => row.id !== excludeId);
|
||||
const seat = seatAmong(others, night);
|
||||
return seat === 0 ? 0 : others[seat - 1].order_index + 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the night already sits somewhere its check-in allows, on an edit that did
|
||||
* not touch the check-in.
|
||||
*
|
||||
* Read off the chain rather than recomputed, because the index a fresh insert would
|
||||
* get is not the index this row occupies. Two ways to be wrong: something with a
|
||||
* later hour ahead of it, or something with an earlier one behind it. Stops without
|
||||
* an hour are passed over in both directions, and so is another night with the same
|
||||
* check-in: the two may have been dragged into this order on purpose, and a change
|
||||
* of notes is no reason to undo that.
|
||||
*/
|
||||
export function seatHolds(rows: readonly SeatRow[], ownId: number, checkIn: string | null | undefined): boolean {
|
||||
if (!checkIn) return true;
|
||||
const own = rows.findIndex(row => row.id === ownId);
|
||||
if (own < 0) return true;
|
||||
const laterAhead = rows.slice(0, own).some(row => row.at !== null && row.at > checkIn);
|
||||
const earlierBehind = rows.slice(own + 1).some(row =>
|
||||
row.at !== null && (row.night_id === null ? row.at <= checkIn : row.at < checkIn));
|
||||
return !laterAhead && !earlierBehind;
|
||||
}
|
||||
|
||||
/**
|
||||
* Carry a night's own stop to `dayId` in place, seated where its check-in says.
|
||||
*
|
||||
* The gap it leaves on the day it came from is closed. Then it is parked at the end
|
||||
* of the target day and seated the way a fresh insert would be: two steps, because
|
||||
* the index it should get is read off a chain it is not part of yet.
|
||||
*/
|
||||
export function reseatOwnStop(
|
||||
db: SeatConnection,
|
||||
stop: { id: number; day_id: number; order_index: number },
|
||||
placeId: number,
|
||||
dayId: number,
|
||||
night: Night,
|
||||
): void {
|
||||
db.prepare('UPDATE day_assignments SET order_index = order_index - 1 WHERE day_id = ? AND order_index > ?')
|
||||
.run(stop.day_id, stop.order_index);
|
||||
const max = db.prepare('SELECT MAX(order_index) AS max FROM day_assignments WHERE day_id = ? AND id != ?')
|
||||
.get(dayId, stop.id) as { max: number | null };
|
||||
const end = (max.max !== null ? max.max : -1) + 1;
|
||||
db.prepare('UPDATE day_assignments SET day_id = ?, place_id = ?, order_index = ? WHERE id = ?').run(dayId, placeId, end, stop.id);
|
||||
|
||||
const seat = seatIndex(db, dayId, night, stop.id);
|
||||
if (seat < end) {
|
||||
db.prepare('UPDATE day_assignments SET order_index = order_index + 1 WHERE day_id = ? AND order_index >= ? AND id != ?')
|
||||
.run(dayId, seat, stop.id);
|
||||
db.prepare('UPDATE day_assignments SET order_index = ? WHERE id = ?').run(seat, stop.id);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep every drawn road behind the stop it was drawn after, now that a write has
|
||||
* seated, moved or taken out a stop on this day. `previousIds` and `nextIds` are the
|
||||
* located stops in order before and after: the index space the vias are pinned to.
|
||||
*
|
||||
* The rules the planner applies when a stop is dragged or taken out: a via follows
|
||||
* its stop, a stop that left the day hands its road to the stop before it, and a
|
||||
* stop that is last has no leg to keep a via on. A via behind the day's last stop
|
||||
* bends the drive into the next day and stays with that stop while it is still last,
|
||||
* whatever its number is now; measured as a leg it would be dropped.
|
||||
*
|
||||
* Returns what changed, or null when nothing did.
|
||||
*/
|
||||
export function carryVias(db: SeatConnection, dayId: number, previousIds: number[], nextIds: number[]): { moved: number; removed: number } | null {
|
||||
if (previousIds.length === nextIds.length && previousIds.every((id, i) => id === nextIds[i])) return null;
|
||||
const vias = db.prepare('SELECT id, after_order_index, sequence FROM roadtrip_vias WHERE day_id = ?').all(dayId) as PinnedVia[];
|
||||
if (!vias.length) return null;
|
||||
|
||||
const previousLast = previousIds.length - 1;
|
||||
const nextLast = nextIds.length - 1;
|
||||
const lastStayed = previousLast >= 0 && previousIds[previousLast] === nextIds[nextLast];
|
||||
const remove: number[] = [];
|
||||
const moved: { id: number; after_order_index: number }[] = [];
|
||||
for (const via of vias) {
|
||||
const next = lastStayed && via.after_order_index === previousLast
|
||||
? nextLast
|
||||
: legAfter(via.after_order_index, previousIds, nextIds);
|
||||
if (next === null) remove.push(via.id);
|
||||
else if (next !== via.after_order_index) moved.push({ id: via.id, after_order_index: next });
|
||||
}
|
||||
if (!remove.length && !moved.length) return null;
|
||||
|
||||
for (const viaId of remove) db.prepare('DELETE FROM roadtrip_vias WHERE id = ? AND day_id = ?').run(viaId, dayId);
|
||||
for (const via of moved) {
|
||||
db.prepare('UPDATE roadtrip_vias SET after_order_index = ? WHERE id = ? AND day_id = ?').run(via.after_order_index, via.id, dayId);
|
||||
}
|
||||
renumberMergedLegs(db, dayId, vias.filter(via => !remove.includes(via.id)), moved);
|
||||
return { moved: moved.length, removed: remove.length };
|
||||
}
|
||||
|
||||
/**
|
||||
* The leg a via pinned behind the n-th stop of the old order is on in the new one.
|
||||
*
|
||||
* A stop the write took off the day hands its road to the stop before it: the leg
|
||||
* it was drawn on merges into the one ahead, the way taking a stop out of the drive
|
||||
* merges them (`RoadtripService.reanchor`). Null when no leg is left for it, because
|
||||
* the stop it follows is the last one now, nothing ahead of it survived, or it was
|
||||
* pinned past the day's end to begin with. In a pure reorder every stop survives, so
|
||||
* nothing merges and each via lands on the leg of the very stop it was drawn after.
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Two legs that merged carry two sequence series side by side, and everything that
|
||||
* draws the route orders by sequence: the drive would run through the first leg's
|
||||
* point, the second leg's, and back. Renumbered the way `RoadtripService.reanchor`
|
||||
* 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.
|
||||
*/
|
||||
function renumberMergedLegs(db: SeatConnection, dayId: number, kept: PinnedVia[], moved: { id: number; after_order_index: number }[]): void {
|
||||
const landed = new Map(moved.map(via => [via.id, via.after_order_index]));
|
||||
const byLeg = new Map<number, PinnedVia[]>();
|
||||
for (const via of kept) {
|
||||
const leg = landed.get(via.id) ?? via.after_order_index;
|
||||
byLeg.set(leg, [...(byLeg.get(leg) ?? []), via]);
|
||||
}
|
||||
for (const onLeg of byLeg.values()) {
|
||||
if (!onLeg.some(via => landed.has(via.id))) continue;
|
||||
onLeg.sort((a, b) => a.after_order_index - b.after_order_index || a.sequence - b.sequence || a.id - b.id);
|
||||
onLeg.forEach((via, index) => {
|
||||
if (via.sequence !== index) db.prepare('UPDATE roadtrip_vias SET sequence = ? WHERE id = ? AND day_id = ?').run(index, via.id, dayId);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { RealtimeService } from '../realtime/realtime.service';
|
||||
import { DatabaseService, type TripAccess } from '../database/database.service';
|
||||
import { PermissionsService } from '../permissions/permissions.service';
|
||||
import { QueryHelpersService } from '../query-helpers/query-helpers.service';
|
||||
import { carryVias, locatedStopIds, reseatOwnStop } from '../accommodations/night-seat';
|
||||
import { formatAssignmentWithPlace } from '../common/rowShape';
|
||||
import type { AssignmentRow, Day, DayNote, User } from '../../types';
|
||||
|
||||
@@ -354,8 +355,8 @@ export class DaysService {
|
||||
tripId: string | number,
|
||||
prevDateByDayId: Map<number, string | null>,
|
||||
): void {
|
||||
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 = ?',
|
||||
const stays = this.db.all<{ id: number; start_day_id: number; end_day_id: number; check_in: string | null }>(
|
||||
'SELECT id, start_day_id, end_day_id, check_in FROM day_accommodations WHERE trip_id = ?',
|
||||
tripId
|
||||
);
|
||||
if (stays.length === 0) return;
|
||||
@@ -368,16 +369,6 @@ export class DaysService {
|
||||
ELSE :date || SUBSTR(reservation_time, 11) END
|
||||
WHERE accommodation_id = :accId AND type = 'hotel'
|
||||
`);
|
||||
// The day stop a booking wrote moves with it, the way its linked booking does.
|
||||
// Left behind it would sit on a day the traveller no longer sleeps there, with
|
||||
// nothing on screen to say why. Re-indexed to the end of the target day, because
|
||||
// its old position belonged to a day it is leaving.
|
||||
const moveStayStop = this.db.prepare(`
|
||||
UPDATE day_assignments
|
||||
SET day_id = :dayId,
|
||||
order_index = COALESCE((SELECT MAX(order_index) FROM day_assignments WHERE day_id = :dayId), -1) + 1
|
||||
WHERE accommodation_id = :accId
|
||||
`);
|
||||
|
||||
for (const stay of stays) {
|
||||
const oldStartDate = prevDateByDayId.get(stay.start_day_id);
|
||||
@@ -388,7 +379,7 @@ export class DaysService {
|
||||
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);
|
||||
moveStayStop.run({ dayId: newStart.id, accId: stay.id });
|
||||
if (newStart.id !== stay.start_day_id) this.carryStayStop(stay, newStart.id);
|
||||
stay.start_day_id = newStart.id;
|
||||
}
|
||||
}
|
||||
@@ -401,6 +392,31 @@ export class DaysService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The day stop a booking wrote moves with it, the way its linked booking does.
|
||||
* Left behind it would sit on a day the traveller no longer sleeps there, with
|
||||
* nothing on screen to say why. Seated on its new day by its check-in like a
|
||||
* night booked there (night-seat.ts), with the drawn roads of both days re-pinned
|
||||
* behind the stops they were drawn after. A day that already holds the place under
|
||||
* a stop of the traveller's keeps that one, and the booking rides along with it.
|
||||
*/
|
||||
private carryStayStop(stay: { id: number; check_in: string | null }, dayId: number): void {
|
||||
const own = this.db.all<{ id: number; day_id: number; place_id: number; order_index: number }>(
|
||||
'SELECT id, day_id, place_id, order_index FROM day_assignments WHERE accommodation_id = ? AND day_id != ?', stay.id, dayId
|
||||
);
|
||||
for (const stop of own) {
|
||||
const before = [stop.day_id, dayId].map((id): [number, number[]] => [id, locatedStopIds(this.db, id)]);
|
||||
if (this.db.get('SELECT id FROM day_assignments WHERE day_id = ? AND place_id = ?', dayId, stop.place_id)) {
|
||||
this.db.run('DELETE FROM day_assignments WHERE id = ?', stop.id);
|
||||
this.db.run('UPDATE day_assignments SET order_index = order_index - 1 WHERE day_id = ? AND order_index > ?',
|
||||
stop.day_id, stop.order_index);
|
||||
} else {
|
||||
reseatOwnStop(this.db, stop, stop.place_id, dayId, { id: stay.id, check_in: stay.check_in });
|
||||
}
|
||||
for (const [id, previousIds] of before) carryVias(this.db, id, previousIds, locatedStopIds(this.db, id));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reorder whole days. `orderedIds` is the desired full sequence of this trip's
|
||||
* day ids (a permutation of the current ids).
|
||||
|
||||
@@ -765,7 +765,7 @@ export class MapsService {
|
||||
return this.isSettingDisabled('places_photos_enabled');
|
||||
}
|
||||
|
||||
// ── Controller-facing surface (unchanged signatures) ───────────────────────
|
||||
// ── Controller-facing surface ──────────────────────────────────────────────
|
||||
|
||||
search(
|
||||
userId: number,
|
||||
|
||||
@@ -167,6 +167,18 @@ export class OidcController {
|
||||
|
||||
const result = this.oidc.findOrCreateUser(userInfo, config, pending.inviteToken);
|
||||
if ('error' in result) return f('/login?oidc_error=' + result.error);
|
||||
if (result.created) {
|
||||
// An account the callback just made is a registration, the way a password
|
||||
// signup is (auth-public.controller): the same row, plus the way in. Without
|
||||
// it an admin reading user.register for who got an account never sees the
|
||||
// SSO ones, and the log looks complete while it is not.
|
||||
this.audit.writeAudit({
|
||||
userId: result.user.id,
|
||||
action: 'user.register',
|
||||
ip: getClientIp(req),
|
||||
details: { username: result.user.username, email: result.user.email, role: result.user.role, method: 'oidc' },
|
||||
});
|
||||
}
|
||||
if (result.roleChange) {
|
||||
// The claim mapping changing someone's privileges is a security event, and
|
||||
// the row is written here because this is where the client IP is. The claim
|
||||
|
||||
@@ -611,7 +611,7 @@ export class OidcService implements OnModuleDestroy {
|
||||
userInfo: OidcUserInfo,
|
||||
config: OidcConfig,
|
||||
inviteToken?: string,
|
||||
): { user: User; roleChange?: OidcRoleChange } | { error: string } {
|
||||
): { user: User; roleChange?: OidcRoleChange; created?: true } | { error: string } {
|
||||
// Defense-in-depth for direct callers — the controller redirects on a
|
||||
// missing email before it ever calls this; the same code flows through its
|
||||
// `oidc_error=' + result.error` pass-through if reached here.
|
||||
@@ -752,7 +752,9 @@ export class OidcService implements OnModuleDestroy {
|
||||
// is_guest, created_at, …) instead of a hand-built partial — same shape
|
||||
// the existing-user branch returns.
|
||||
user = this.db.prepare('SELECT * FROM users WHERE id = ?').get(Number(result.lastInsertRowid)) as User;
|
||||
return { user };
|
||||
// The one branch that makes an account. The caller writes the registration row
|
||||
// off this, so it has to be set here and nowhere else.
|
||||
return { user, created: true };
|
||||
} catch (err) {
|
||||
if (err === inviteRaceError) {
|
||||
console.warn(`[OIDC] Invite token ${inviteToken?.slice(0, 8)}... exhausted — concurrent callback won the last slot`);
|
||||
|
||||
@@ -301,12 +301,12 @@ export class PlacesMcp {
|
||||
|
||||
@Tool({
|
||||
name: 'search_place',
|
||||
description: 'Search for a real-world place by name or address. Returns results with osm_id (and google_place_id/google_ftid if configured). Use these IDs when calling create_place so the app can display opening hours, ratings, and map links. Pass locationBias whenever the trip has a destination: a bare name like "Central Station" or "Museum of Modern Art" otherwise resolves wherever the provider guesses, which is regularly the wrong continent. Searches the TREK index and OpenStreetMap only: if the instance has a plugin providing its own search index, call search_places_via_plugins as well, and note that ratings can only come from there.',
|
||||
description: 'Search for a real-world place by name or address. Returns results with osm_id (and google_place_id/google_ftid if configured). Use these IDs when calling create_place so the app can display opening hours, ratings, and map links. Pass locationBias whenever the trip has a destination: a bare name like "Central Station" or "Museum of Modern Art" otherwise resolves wherever the provider guesses, which is regularly the wrong continent. Searches the TREK index and OpenStreetMap, or Google Places alone with provider "google" and whenever the admin switch "Search with Google only" is on: if the instance has a plugin providing its own search index, call search_places_via_plugins as well, and note that ratings can only come from there.',
|
||||
inputSchema: {
|
||||
query: z.string().min(1).max(500).describe('Place name or address to search for'),
|
||||
locationBias: mapsSearchRequestSchema.shape.locationBias.describe('Centre the search on a coordinate: { lat, lng, radius? } with radius in metres (default 50000). Only the Google provider honours it; the OpenStreetMap fallback ignores it'),
|
||||
lang: z.string().max(35).optional().describe('BCP 47 language for the result names, e.g. "de" or "ja". Defaults to English'),
|
||||
provider: mapsSearchRequestSchema.shape.provider.describe('"google" sends this one search to Google Places alone instead of the TREK index and OpenStreetMap, the same as the "search Google instead" link under the results in the app. Ignored when the instance has no Google key'),
|
||||
provider: mapsSearchRequestSchema.shape.provider.describe('"google" sends this one search to Google Places alone instead of the TREK index and OpenStreetMap, the same as the "search Google instead" link under the results in the app. Ignored unless Google holds the keyed slot: an instance without a Google key, or one whose admin picked Amap or OpenStreetMap as the places provider, answers from the TREK index and OpenStreetMap as usual'),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_READONLY,
|
||||
access: { group: 'places', mode: 'read' },
|
||||
|
||||
@@ -10,7 +10,15 @@ import {
|
||||
type RoadtripPlanRequest,
|
||||
type RoadtripCorridorRequest,
|
||||
} from '@trek/shared';
|
||||
import { corridorTiles, projectOntoRoute, simplifyLine } from '@trek/shared/roadtrip';
|
||||
import {
|
||||
corridorTiles,
|
||||
drivenPieces,
|
||||
inRiddenRange,
|
||||
projectOntoRoute,
|
||||
rideGaps,
|
||||
riddenRanges,
|
||||
simplifyLine,
|
||||
} from '@trek/shared/roadtrip';
|
||||
import { answeringRefusals } from './roadtrip-mcp.helpers';
|
||||
|
||||
import { z } from 'zod';
|
||||
@@ -84,7 +92,7 @@ export class RoadtripPlanningMcp {
|
||||
@Tool({
|
||||
name: 'search_roadtrip_corridor',
|
||||
description:
|
||||
'Search for fuel, charging, rest areas, campsites, food, sights or accommodation along a calculated roadtrip day, without a browser. Uses the routed road, not a straight line. Returns distance along the route and distance from it. Search rectangles are paged in batches of six; continue with nextOffset until null for full coverage. Optional name, socket family and minimum power filter the matches; missing charging details mean unknown. Does not add stops. Add a chosen place and assign it to the appropriate stored day, preserving any via anchors. Routing failures refuse the corridor search rather than searching an invented line.',
|
||||
'Search for fuel, charging, rest areas, campsites, food, sights or accommodation along a calculated roadtrip day, without a browser. Uses the routed road, not a straight line, and leaves out the ride between the two terminals of a flight, train, ferry, cruise or bus booking: no rectangle is searched under it and nothing found there is a hit. Returns distance along the route and distance from it. Search rectangles are paged in batches of six; continue with nextOffset until null for full coverage. Optional name, socket family and minimum power filter the matches; missing charging details mean unknown. Does not add stops. Add a chosen place and assign it to the appropriate stored day, preserving any via anchors. Routing failures refuse the corridor search rather than searching an invented line.',
|
||||
inputSchema: roadtripCorridorRequestSchema.shape,
|
||||
annotations: TOOL_ANNOTATIONS_READONLY,
|
||||
access: { group: 'trips', mode: 'read' },
|
||||
@@ -105,7 +113,10 @@ export class RoadtripPlanningMcp {
|
||||
day.geometry.map(([lat, lng]) => ({ lat, lng })),
|
||||
Math.max(1, input.widthKm / 3),
|
||||
);
|
||||
const tiles = corridorTiles(line, input.widthKm);
|
||||
// The line jumps straight from a departure terminal to its arrival, and the car is
|
||||
// not on that stretch: nothing under it is tiled or on the way (#2428).
|
||||
const ridden = riddenRanges(line, rideGaps(day.stops));
|
||||
const tiles = drivenPieces(line, ridden).flatMap((piece) => corridorTiles(piece, input.widthKm));
|
||||
const hits = new Map<
|
||||
string,
|
||||
{ poi: Awaited<ReturnType<RoadtripSearchService['search']>>['pois'][number]; alongKm: number; distanceKm: number }
|
||||
@@ -123,7 +134,8 @@ export class RoadtripPlanningMcp {
|
||||
if (found.truncated || found.clamped) truncatedAreas++;
|
||||
for (const poi of found.pois) {
|
||||
const projection = projectOntoRoute(poi, line);
|
||||
if (!projection || projection.offRouteKm > input.widthKm) continue;
|
||||
if (!projection || projection.offRouteKm > input.widthKm || inRiddenRange(ridden, projection.alongKm))
|
||||
continue;
|
||||
if (
|
||||
(input.fromKm != null && projection.alongKm < input.fromKm) ||
|
||||
(input.toKm != null && projection.alongKm > input.toKm)
|
||||
|
||||
@@ -48,7 +48,7 @@ export const SYSTEM_NOTICES: SystemNotice[] = [
|
||||
titleKey: 'system_notice.release_notes.headline',
|
||||
bodyKey: 'system_notice.release_notes.intro',
|
||||
release: {
|
||||
version: '4.3.0',
|
||||
version: '4.3.1',
|
||||
eyebrowKey: 'system_notice.release_notes.eyebrow',
|
||||
headlineKey: 'system_notice.release_notes.headline',
|
||||
introKey: 'system_notice.release_notes.intro',
|
||||
@@ -84,7 +84,7 @@ export const SYSTEM_NOTICES: SystemNotice[] = [
|
||||
footnoteKey: 'system_notice.release_notes.footnote',
|
||||
notes: {
|
||||
labelKey: 'system_notice.release_notes.notes_label',
|
||||
href: 'https://github.com/liketrek/TREK/releases/tag/v4.3.0',
|
||||
href: 'https://github.com/liketrek/TREK/releases/tag/v4.3.1',
|
||||
},
|
||||
note: {
|
||||
eyebrowKey: 'system_notice.release_notes.note_eyebrow',
|
||||
|
||||
@@ -229,8 +229,14 @@ describe('GET /api/auth/oidc/callback', () => {
|
||||
expect(res.headers.location).toContain('/login?oidc_code=');
|
||||
|
||||
// Verify user was created in DB
|
||||
const newUser = testDb.prepare("SELECT * FROM users WHERE email = 'newuser@example.com'").get();
|
||||
const newUser = testDb.prepare("SELECT * FROM users WHERE email = 'newuser@example.com'").get() as { id: number; username: string; role: string } | undefined;
|
||||
expect(newUser).toBeDefined();
|
||||
// Registered, the way the audit log reports a password signup, and then logged in:
|
||||
// an admin reading user.register for who got an account sees the SSO ones too.
|
||||
const rows = testDb.prepare('SELECT action, details FROM audit_log WHERE user_id = ? ORDER BY id').all(newUser!.id) as
|
||||
{ action: string; details: string | null }[];
|
||||
expect(rows.map(r => r.action)).toEqual(['user.register', 'user.login']);
|
||||
expect(JSON.parse(rows[0].details || '{}')).toEqual({ username: newUser!.username, email: 'newuser@example.com', role: newUser!.role, method: 'oidc' });
|
||||
});
|
||||
|
||||
it('OIDC-006: invalid state → redirects with invalid_state error', async () => {
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
*
|
||||
* 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.
|
||||
* night leads its day now (night-seat.ts, the rule AccommodationsService applies), 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)
|
||||
@@ -189,6 +189,108 @@ describe('booked night reseat', () => {
|
||||
expect(reseatBookedNights(db)).toBe(0);
|
||||
});
|
||||
|
||||
it('RESEAT-011: two nights with the same check-in, booked in that order, stay put with their roads', () => {
|
||||
// Each used to count the other as "at or before" its check-in, so the step reported
|
||||
// two moves it never made and the reanchor dropped the road between them.
|
||||
const db = freshDb();
|
||||
const [first, second] = [place(db, 'Adlon'), place(db, 'Mercure')];
|
||||
stop(db, first, 0, null, night(db, first, '15:00'));
|
||||
stop(db, second, 1, null, night(db, second, '15:00'));
|
||||
const between = via(db, 0);
|
||||
const intoTomorrow = via(db, 1);
|
||||
|
||||
expect(reseatBookedNights(db)).toBe(0);
|
||||
|
||||
expect(order(db)).toEqual([first, second]);
|
||||
expect(vias(db)).toEqual([
|
||||
{ id: between, after_order_index: 0, sequence: 0 },
|
||||
{ id: intoTomorrow, after_order_index: 1, sequence: 0 },
|
||||
]);
|
||||
expect(reseatBookedNights(db)).toBe(0);
|
||||
});
|
||||
|
||||
it('RESEAT-012: two nights with the same check-in settle by their booking, and only once', () => {
|
||||
const db = freshDb();
|
||||
const [fuel, first, second] = [place(db, 'Aral'), place(db, 'Adlon'), place(db, 'Mercure')];
|
||||
const firstNight = night(db, first, '15:00');
|
||||
const secondNight = night(db, second, '15:00');
|
||||
stop(db, fuel, 0);
|
||||
stop(db, second, 1, null, secondNight);
|
||||
stop(db, first, 2, null, firstNight);
|
||||
|
||||
expect(reseatBookedNights(db)).toBe(2);
|
||||
|
||||
expect(order(db)).toEqual([first, second, fuel]);
|
||||
expect(indexes(db)).toEqual([0, 1, 2]);
|
||||
expect(reseatBookedNights(db)).toBe(0);
|
||||
expect(order(db)).toEqual([first, second, fuel]);
|
||||
});
|
||||
|
||||
it('RESEAT-013: two nights without a check-in settle by their booking too', () => {
|
||||
// Both used to want the front, and every pass swapped them.
|
||||
const db = freshDb();
|
||||
const [first, second] = [place(db, 'Adlon'), place(db, 'Mercure')];
|
||||
const firstNight = night(db, first, null);
|
||||
const secondNight = night(db, second, null);
|
||||
stop(db, second, 0, null, secondNight);
|
||||
stop(db, first, 1, null, firstNight);
|
||||
|
||||
expect(reseatBookedNights(db)).toBe(1);
|
||||
|
||||
expect(order(db)).toEqual([first, second]);
|
||||
expect(reseatBookedNights(db)).toBe(0);
|
||||
expect(order(db)).toEqual([first, second]);
|
||||
});
|
||||
|
||||
it('RESEAT-014: a night without a check-in leads one with a clock', () => {
|
||||
const db = freshDb();
|
||||
const [fuel, clocked, open] = [place(db, 'Aral'), place(db, 'Adlon'), place(db, 'Mercure')];
|
||||
stop(db, fuel, 0);
|
||||
stop(db, clocked, 1, null, night(db, clocked, '15:00'));
|
||||
stop(db, open, 2, null, night(db, open, null));
|
||||
|
||||
reseatBookedNights(db);
|
||||
|
||||
expect(order(db)).toEqual([open, clocked, fuel]);
|
||||
expect(reseatBookedNights(db)).toBe(0);
|
||||
expect(order(db)).toEqual([open, clocked, fuel]);
|
||||
});
|
||||
|
||||
it('RESEAT-015: an hour pinned on a night own stop is not what the other nights read it by', () => {
|
||||
// The stop carries eight, the booking carries three, and a night seats itself by
|
||||
// its check-in. Read the pinned hour off the row instead and the two of them
|
||||
// answer the same question differently: each counts the other as ahead, the step
|
||||
// reports two moves on every run for ever, and the swap in between drops the road.
|
||||
const db = freshDb();
|
||||
const [first, second] = [place(db, 'Adlon'), place(db, 'Mercure')];
|
||||
const firstNight = night(db, first, '15:00');
|
||||
const secondNight = night(db, second, '15:00');
|
||||
stop(db, first, 0, null, firstNight);
|
||||
stop(db, second, 1, '08:00', secondNight);
|
||||
const between = via(db, 0);
|
||||
|
||||
expect(reseatBookedNights(db)).toBe(0);
|
||||
|
||||
expect(order(db)).toEqual([first, second]);
|
||||
expect(vias(db)).toEqual([{ id: between, after_order_index: 0, sequence: 0 }]);
|
||||
expect(reseatBookedNights(db)).toBe(0);
|
||||
});
|
||||
|
||||
it('RESEAT-016: a night without a check-in leads on its booking, whatever hour its place carries', () => {
|
||||
const db = freshDb();
|
||||
const [first, second] = [place(db, 'Adlon', '09:00'), place(db, 'Mercure')];
|
||||
const firstNight = night(db, first, null);
|
||||
const secondNight = night(db, second, null);
|
||||
stop(db, second, 0, null, secondNight);
|
||||
stop(db, first, 1, null, firstNight);
|
||||
|
||||
expect(reseatBookedNights(db)).toBe(1);
|
||||
|
||||
expect(order(db)).toEqual([first, second]);
|
||||
expect(reseatBookedNights(db)).toBe(0);
|
||||
expect(order(db)).toEqual([first, second]);
|
||||
});
|
||||
|
||||
it('RESEAT-008: runs again without moving anything', () => {
|
||||
const db = freshDb();
|
||||
const [fuel, hotel] = [place(db, 'Aral'), place(db, 'Rostock')];
|
||||
|
||||
@@ -789,6 +789,73 @@ describe('the day stop a booking implies', () => {
|
||||
expect(stopsOn(day.id).map(a => a.place_id)).toEqual([hotel.id, harbour.id, museum.id]);
|
||||
});
|
||||
|
||||
it('ACC-022j two nights with the same check-in settle by their booking and stay settled through a change of notes', () => {
|
||||
// Each used to read the other as "at or before" its own check-in, so a notes edit on
|
||||
// the front one moved it behind the other, took the road between them with it, and
|
||||
// a notes edit on the other swapped them back.
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const day = createDay(testDb, trip.id);
|
||||
const [first, second] = ['Adlon', 'Mercure'].map(name => createPlace(testDb, trip.id, { name }));
|
||||
const a = svc.createAccommodation(trip.id, { place_id: first.id, start_day_id: day.id, end_day_id: day.id, check_in: '15:00' }) as any;
|
||||
const b = svc.createAccommodation(trip.id, { place_id: second.id, start_day_id: day.id, end_day_id: day.id, check_in: '15:00' }) as any;
|
||||
expect(stopsOn(day.id).map(s => s.place_id)).toEqual([first.id, second.id]);
|
||||
const between = Number(testDb.prepare('INSERT INTO roadtrip_vias (day_id, after_order_index, sequence, lat, lng) VALUES (?, 0, 0, 52.5, 13.4)')
|
||||
.run(day.id).lastInsertRowid);
|
||||
|
||||
for (const booked of [a, b]) {
|
||||
const existing = svc.getAccommodation(booked.accommodation.id, trip.id)!;
|
||||
const quiet = svc.updateAccommodation(booked.accommodation.id, existing, { notes: 'late arrival' }) as any;
|
||||
expect(quiet.mirror.moved).toBeNull();
|
||||
expect(quiet.mirror.vias).toBeUndefined();
|
||||
}
|
||||
expect(stopsOn(day.id).map(s => s.place_id)).toEqual([first.id, second.id]);
|
||||
expect(testDb.prepare('SELECT after_order_index FROM roadtrip_vias WHERE id = ?').get(between)).toEqual({ after_order_index: 0 });
|
||||
|
||||
// Dragged the other way round on purpose: an equal clock is no contradiction.
|
||||
testDb.prepare('UPDATE day_assignments SET order_index = CASE place_id WHEN ? THEN 1 ELSE 0 END WHERE day_id = ?').run(first.id, day.id);
|
||||
for (const booked of [a, b]) {
|
||||
const existing = svc.getAccommodation(booked.accommodation.id, trip.id)!;
|
||||
expect((svc.updateAccommodation(booked.accommodation.id, existing, { notes: 'later' }) as any).mirror.moved).toBeNull();
|
||||
}
|
||||
expect(stopsOn(day.id).map(s => s.place_id)).toEqual([second.id, first.id]);
|
||||
});
|
||||
|
||||
it('ACC-022k two nights without a check-in: the earlier booking leads', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const day = createDay(testDb, trip.id);
|
||||
const [museum, first, second] = ['Pergamon', 'Adlon', 'Mercure'].map(name => createPlace(testDb, trip.id, { name }));
|
||||
createDayAssignment(testDb, day.id, museum.id);
|
||||
svc.createAccommodation(trip.id, { place_id: first.id, start_day_id: day.id, end_day_id: day.id });
|
||||
const b = svc.createAccommodation(trip.id, { place_id: second.id, start_day_id: day.id, end_day_id: day.id }) as any;
|
||||
|
||||
expect(stopsOn(day.id).map(s => s.place_id)).toEqual([first.id, second.id, museum.id]);
|
||||
const existing = svc.getAccommodation(b.accommodation.id, trip.id)!;
|
||||
expect((svc.updateAccommodation(b.accommodation.id, existing, { notes: 'late' }) as any).mirror.moved).toBeNull();
|
||||
expect(stopsOn(day.id).map(s => s.place_id)).toEqual([first.id, second.id, museum.id]);
|
||||
});
|
||||
|
||||
it('ACC-022l a night on a day dragged out of clock order is settled where a fresh seat puts it', () => {
|
||||
// Pinned two, then pinned eight, on purpose. A check-in at ten goes behind the eight,
|
||||
// and nothing else satisfies both clocks; a change of notes used to relocate it to
|
||||
// the very same seat and report a move for it.
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const day = createDay(testDb, trip.id);
|
||||
const [late, early, hotel] = ['Museum', 'Aral', 'Rostock'].map(name => createPlace(testDb, trip.id, { name }));
|
||||
testDb.prepare("UPDATE day_assignments SET assignment_time = '14:00' WHERE id = ?").run(createDayAssignment(testDb, day.id, late.id).id);
|
||||
testDb.prepare("UPDATE day_assignments SET assignment_time = '08:00' WHERE id = ?").run(createDayAssignment(testDb, day.id, early.id).id);
|
||||
const { accommodation } = svc.createAccommodation(trip.id, { place_id: hotel.id, start_day_id: day.id, end_day_id: day.id, check_in: '10:00' }) as any;
|
||||
expect(stopsOn(day.id).map(s => s.place_id)).toEqual([late.id, early.id, hotel.id]);
|
||||
|
||||
const existing = svc.getAccommodation(accommodation.id, trip.id)!;
|
||||
const quiet = svc.updateAccommodation(accommodation.id, existing, { notes: 'late arrival' }) as any;
|
||||
|
||||
expect(quiet.mirror.moved).toBeNull();
|
||||
expect(stopsOn(day.id).map(s => s.place_id)).toEqual([late.id, early.id, hotel.id]);
|
||||
});
|
||||
|
||||
it('ACC-023 the place is typed as lodging, so the rail draws it as a service stop', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
@@ -604,6 +604,91 @@ describe('resyncAccommodationDays', () => {
|
||||
expect(testDb.prepare('SELECT day_id, reservation_time FROM reservations WHERE id = ?').get(res))
|
||||
.toMatchObject({ day_id: null, reservation_time: null });
|
||||
});
|
||||
|
||||
// The stop a booking wrote goes along when the stay is carried to another day row.
|
||||
const stopsOn = (dayId: number) =>
|
||||
testDb.prepare('SELECT place_id, order_index, accommodation_id FROM day_assignments WHERE day_id = ? ORDER BY order_index').all(dayId) as
|
||||
{ place_id: number; order_index: number; accommodation_id: number | null }[];
|
||||
const addVia = (dayId: number, afterOrderIndex: number) =>
|
||||
Number(testDb.prepare('INSERT INTO roadtrip_vias (day_id, after_order_index, sequence, lat, lng) VALUES (?, ?, 0, 48.1, 11.5)')
|
||||
.run(dayId, afterOrderIndex).lastInsertRowid);
|
||||
const viasOn = (dayId: number) =>
|
||||
testDb.prepare('SELECT id, after_order_index FROM roadtrip_vias WHERE day_id = ? ORDER BY id').all(dayId) as
|
||||
{ id: number; after_order_index: number }[];
|
||||
|
||||
it('DAY-SVC-055: the carried stop is seated by its check-in on its new day, and both days keep their roads in place', () => {
|
||||
// The trip gained a day at the front: d1 now holds the day before, d2 the stay's
|
||||
// date. The night used to be appended behind d2's unpinned stops, the very order
|
||||
// a night booked today no longer gets, and neither day's roads were re-pinned.
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const d1 = createDay(testDb, trip.id, { date: '2026-05-01' });
|
||||
const d2 = createDay(testDb, trip.id, { date: '2026-05-02' });
|
||||
const [hotel, a, b, c, d] = ['Rostock', 'Aral', 'Hafen', 'Museum', 'Markt'].map(name => createPlace(testDb, trip.id, { name }));
|
||||
const { accommodation } = accommodations.createAccommodation(trip.id, { place_id: hotel.id, start_day_id: d1.id, end_day_id: d1.id, check_in: '10:00' }) as any;
|
||||
createDayAssignment(testDb, d1.id, a.id);
|
||||
createDayAssignment(testDb, d1.id, b.id);
|
||||
createDayAssignment(testDb, d2.id, c.id);
|
||||
createDayAssignment(testDb, d2.id, d.id);
|
||||
expect(stopsOn(d1.id).map(s => s.place_id)).toEqual([hotel.id, a.id, b.id]);
|
||||
const outOfHotel = addVia(d1.id, 0);
|
||||
const outOfA = addVia(d1.id, 1);
|
||||
const outOfC = addVia(d2.id, 0);
|
||||
testDb.prepare('UPDATE days SET date = ? WHERE id = ?').run('2026-04-30', d1.id);
|
||||
testDb.prepare('UPDATE days SET date = ? WHERE id = ?').run('2026-05-01', d2.id);
|
||||
|
||||
svc.resyncAccommodationDays(trip.id, new Map([[d1.id, '2026-05-01'], [d2.id, '2026-05-02']]));
|
||||
|
||||
expect(testDb.prepare('SELECT start_day_id FROM day_accommodations WHERE id = ?').get(accommodation.id)).toMatchObject({ start_day_id: d2.id });
|
||||
expect(stopsOn(d2.id)).toEqual([
|
||||
{ place_id: hotel.id, order_index: 0, accommodation_id: accommodation.id },
|
||||
{ place_id: c.id, order_index: 1, accommodation_id: null },
|
||||
{ place_id: d.id, order_index: 2, accommodation_id: null },
|
||||
]);
|
||||
expect(stopsOn(d1.id).map(s => [s.place_id, s.order_index])).toEqual([[a.id, 0], [b.id, 1]]);
|
||||
// The hotel's road on d1 had no stop ahead to fall back on and goes; A's follows A
|
||||
// to leg zero. On d2 the road out of C is one number further on, behind C.
|
||||
expect(viasOn(d1.id)).toEqual([{ id: outOfA, after_order_index: 0 }]);
|
||||
expect(viasOn(d1.id).map(v => v.id)).not.toContain(outOfHotel);
|
||||
expect(viasOn(d2.id)).toEqual([{ id: outOfC, after_order_index: 1 }]);
|
||||
});
|
||||
|
||||
it('DAY-SVC-056: a day that already holds the place by hand keeps that stop, and the booking rides along with it', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const d1 = createDay(testDb, trip.id, { date: '2026-05-01' });
|
||||
const d2 = createDay(testDb, trip.id, { date: '2026-05-02' });
|
||||
const hotel = createPlace(testDb, trip.id, { name: 'Rostock' });
|
||||
const { accommodation } = accommodations.createAccommodation(trip.id, { place_id: hotel.id, start_day_id: d1.id, end_day_id: d1.id }) as any;
|
||||
const own = createDayAssignment(testDb, d2.id, hotel.id);
|
||||
testDb.prepare('UPDATE days SET date = ? WHERE id = ?').run('2026-04-30', d1.id);
|
||||
testDb.prepare('UPDATE days SET date = ? WHERE id = ?').run('2026-05-01', d2.id);
|
||||
|
||||
svc.resyncAccommodationDays(trip.id, new Map([[d1.id, '2026-05-01'], [d2.id, '2026-05-02']]));
|
||||
|
||||
expect(testDb.prepare('SELECT start_day_id FROM day_accommodations WHERE id = ?').get(accommodation.id)).toMatchObject({ start_day_id: d2.id });
|
||||
expect(stopsOn(d1.id)).toEqual([]);
|
||||
expect(stopsOn(d2.id)).toEqual([{ place_id: hotel.id, order_index: own.order_index, accommodation_id: null }]);
|
||||
});
|
||||
|
||||
it('DAY-SVC-057: a stay whose end alone moves leaves its stop where it is', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const d1 = createDay(testDb, trip.id, { date: '2026-05-01' });
|
||||
const d2 = createDay(testDb, trip.id, { date: '2026-05-02' });
|
||||
const d3 = createDay(testDb, trip.id, { date: '2026-05-03' });
|
||||
const [hotel, a] = ['Rostock', 'Aral'].map(name => createPlace(testDb, trip.id, { name }));
|
||||
const { accommodation } = accommodations.createAccommodation(trip.id, { place_id: hotel.id, start_day_id: d1.id, end_day_id: d2.id, check_in: '10:00' }) as any;
|
||||
createDayAssignment(testDb, d1.id, a.id);
|
||||
testDb.prepare('UPDATE days SET date = ? WHERE id = ?').run('2026-05-03', d2.id);
|
||||
testDb.prepare('UPDATE days SET date = ? WHERE id = ?').run('2026-05-02', d3.id);
|
||||
|
||||
svc.resyncAccommodationDays(trip.id, new Map([[d1.id, '2026-05-01'], [d2.id, '2026-05-02'], [d3.id, '2026-05-03']]));
|
||||
|
||||
expect(testDb.prepare('SELECT start_day_id, end_day_id FROM day_accommodations WHERE id = ?').get(accommodation.id))
|
||||
.toMatchObject({ start_day_id: d1.id, end_day_id: d3.id });
|
||||
expect(stopsOn(d1.id).map(s => [s.place_id, s.order_index])).toEqual([[hotel.id, 0], [a.id, 1]]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('reorder', () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* MAPS-AUTO-001..008 — the suggestion list behind the place search box.
|
||||
* MAPS-AUTO-001..013: the suggestion list behind the place search box.
|
||||
*
|
||||
* This is the path the index was built for. Nominatim's usage policy names
|
||||
* autocomplete as unacceptable use in its own words, whatever the rate, and the
|
||||
@@ -73,19 +73,32 @@ const osmHit = (over: Record<string, unknown> = {}) => ({
|
||||
});
|
||||
|
||||
/**
|
||||
* `enabled` drives the admin kill switch, which is read straight off
|
||||
* app_settings; an unset row reads as on, because the switch is fail-open.
|
||||
* `enabled` drives the index switch, an environment variable; `rows` are the
|
||||
* app_settings the instance holds, by key: a Google key, the provider choice,
|
||||
* the Google-only switch. Anything else reads as absent.
|
||||
*
|
||||
* Keyed on the statement rather than answering everything the same way: the
|
||||
* same `get` also resolves the Google key, and a blanket answer would hand
|
||||
* `'false'` to the key resolver and send the fallback at Google for real.
|
||||
*/
|
||||
function make(enabled = true) {
|
||||
function make(enabled = true, rows: Record<string, string> = {}) {
|
||||
trekPlaces.on = enabled;
|
||||
const database = { get: vi.fn(() => undefined) } as unknown as DatabaseService;
|
||||
const database = {
|
||||
get: vi.fn((sql: string, key?: unknown) =>
|
||||
typeof key === 'string' && sql.includes('app_settings') && rows[key] !== undefined ? { value: rows[key] } : undefined,
|
||||
),
|
||||
} as unknown as DatabaseService;
|
||||
return new MapsService(database, {} as PlacePhotoCacheService);
|
||||
}
|
||||
|
||||
/** Google's autocomplete envelope, which is not the shape its text search answers in. */
|
||||
const googleSuggestions = (name: string) => ({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
suggestions: [{ placePrediction: { placeId: 'g1', structuredFormat: { mainText: { text: name }, secondaryText: { text: 'Chiyoda' } } } }],
|
||||
}),
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
mockSearch.mockReset();
|
||||
mockSearch.mockResolvedValue([]);
|
||||
@@ -229,4 +242,43 @@ describe('MapsService.autocompletePlaces', () => {
|
||||
expect(mockSearch).not.toHaveBeenCalled();
|
||||
expect(result.source).not.toBe('trek-places');
|
||||
});
|
||||
|
||||
it('MAPS-AUTO-012: the Google-only switch sends the keystroke to Google and skips the index', async () => {
|
||||
// The admin row promises every search AND every suggestion. The search half
|
||||
// is pinned next door (MAPS-SEARCH-011); this is the other call site.
|
||||
mockSearch.mockResolvedValue([hit()]);
|
||||
const fetchMock = vi.fn().mockResolvedValue(googleSuggestions('Tokyo Station'));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const result = await make(true, { maps_api_key: 'key', places_google_only: 'true' }).autocompletePlaces(1, INPUT);
|
||||
|
||||
expect(mockSearch).not.toHaveBeenCalled();
|
||||
expect(result.source).toBe('google');
|
||||
expect(result.suggestions[0]).toMatchObject({ placeId: 'g1', mainText: 'Tokyo Station' });
|
||||
expect(String(fetchMock.mock.calls[0][0])).toContain('places:autocomplete');
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('MAPS-AUTO-013: the switch changes nothing without a Google key, or when Google does not hold the slot', async () => {
|
||||
mockSearch.mockResolvedValue([hit()]);
|
||||
|
||||
// No key at all: the index answers, as before.
|
||||
const keyless = await make(true, { places_google_only: 'true' }).autocompletePlaces(1, INPUT);
|
||||
expect(mockSearch).toHaveBeenCalledTimes(1);
|
||||
expect(keyless.source).toBe('trek-places');
|
||||
|
||||
// A key, but the admin picked OpenStreetMap: Google holds no slot, so
|
||||
// there is nothing for the switch to hand the keystroke to.
|
||||
mockSearch.mockClear();
|
||||
const osmOnly = await make(true, { maps_api_key: 'key', places_google_only: 'true', places_provider: 'openstreetmap' })
|
||||
.autocompletePlaces(1, INPUT);
|
||||
expect(mockSearch).toHaveBeenCalledTimes(1);
|
||||
expect(osmOnly.source).toBe('trek-places');
|
||||
|
||||
// A key and the switch off: the index still answers first.
|
||||
mockSearch.mockClear();
|
||||
const off = await make(true, { maps_api_key: 'key', places_google_only: 'false' }).autocompletePlaces(1, INPUT);
|
||||
expect(mockSearch).toHaveBeenCalledTimes(1);
|
||||
expect(off.source).toBe('trek-places');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -221,6 +221,25 @@ describe('OidcController /callback', () => {
|
||||
expect(res.redirectedTo).toBe('https://app/login?oidc_code=ac');
|
||||
});
|
||||
|
||||
it('OIDC-CB-LOGIN-003: an account the callback creates writes the user.register row a password signup writes, then the login', async () => {
|
||||
const res = makeRes();
|
||||
const reqIp = { query: {}, headers: {}, ip: '203.0.113.9', cookies: { trek_oidc_state: 's' } } as unknown as Request;
|
||||
await ctl(svc({
|
||||
exchangeCodeForToken: vi.fn().mockResolvedValue({ _ok: true, access_token: 'at', id_token: 'it' }),
|
||||
verifyIdToken: vi.fn().mockResolvedValue({ ok: true, claims: { sub: 'u1' } }),
|
||||
getUserInfo: vi.fn().mockResolvedValue({ email: 'a@b.c', sub: 'u1' }),
|
||||
findOrCreateUser: vi.fn().mockReturnValue({ user: { id: 7, username: 'a', email: 'a@b.c', role: 'user' }, created: true }),
|
||||
})).callback('c', 's', undefined, reqIp, res);
|
||||
|
||||
expect(writeAudit).toHaveBeenCalledTimes(2);
|
||||
expect(writeAudit).toHaveBeenNthCalledWith(1, {
|
||||
userId: 7, action: 'user.register', ip: '203.0.113.9',
|
||||
details: { username: 'a', email: 'a@b.c', role: 'user', method: 'oidc' },
|
||||
});
|
||||
expect(writeAudit).toHaveBeenNthCalledWith(2, { userId: 7, action: 'user.login', ip: '203.0.113.9', details: { method: 'oidc' } });
|
||||
expect(res.redirectedTo).toBe('https://app/login?oidc_code=ac');
|
||||
});
|
||||
|
||||
it('OIDC-CB-LOGIN-002: a login refused by the user lookup writes no login row', async () => {
|
||||
const res = makeRes();
|
||||
await ctl(svc({
|
||||
|
||||
@@ -1309,6 +1309,40 @@ describe('the day stop a hotel booking implies', () => {
|
||||
expect(stopsOn(days[0].id)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('RESV-STAY-008: a save from the booking form re-seats the night only when its check-in changed', () => {
|
||||
// The form sends the whole stay on every save, check-in included, so this is the
|
||||
// door a title edit on a hotel booking comes through. Its own reading of "the
|
||||
// check-in changed" (the prior row, read before the write) decides whether a night
|
||||
// the traveller dragged is left alone or seated afresh, the way ACC-022g pins it
|
||||
// for the stay route.
|
||||
const { trip, days } = tripWithDays();
|
||||
const [harbour, market, hotel] = ['Hafen', 'Markt', 'Rostock'].map(name => createPlace(testDb, trip.id, { name }));
|
||||
createDayAssignment(testDb, days[0].id, harbour.id);
|
||||
createDayAssignment(testDb, days[0].id, market.id);
|
||||
const stay = (check_in: string) => ({ place_id: hotel.id, start_day_id: days[0].id, end_day_id: days[0].id, check_in });
|
||||
const { reservation } = svc.create(String(trip.id), { title: 'Hotel', type: 'hotel', create_accommodation: stay('15:00') });
|
||||
const order = () => stopsOn(days[0].id).map(s => s.place_id);
|
||||
expect(order()).toEqual([hotel.id, harbour.id, market.id]);
|
||||
|
||||
// Dragged to the end of the day by hand.
|
||||
const own = stopsOn(days[0].id).find(s => s.place_id === hotel.id)!;
|
||||
testDb.prepare('UPDATE day_assignments SET order_index = order_index - 1 WHERE day_id = ? AND order_index > 0').run(days[0].id);
|
||||
testDb.prepare('UPDATE day_assignments SET order_index = 2 WHERE id = ?').run(own.id);
|
||||
expect(order()).toEqual([harbour.id, market.id, hotel.id]);
|
||||
|
||||
vi.clearAllMocks();
|
||||
let current = svc.getReservation(String(reservation.id), String(trip.id))!;
|
||||
svc.update(String(reservation.id), String(trip.id), { title: 'Hotel, late arrival', type: 'hotel', create_accommodation: stay('15:00') } as never, current);
|
||||
expect(order()).toEqual([harbour.id, market.id, hotel.id]);
|
||||
expect(broadcast).not.toHaveBeenCalledWith(String(trip.id), 'assignment:moved', expect.anything());
|
||||
|
||||
current = svc.getReservation(String(reservation.id), String(trip.id))!;
|
||||
svc.update(String(reservation.id), String(trip.id), { type: 'hotel', create_accommodation: stay('10:00') } as never, current);
|
||||
expect(order()).toEqual([hotel.id, harbour.id, market.id]);
|
||||
expect(stopsOn(days[0].id).map(s => s.id)).toContain(own.id);
|
||||
expect(broadcast).toHaveBeenCalledWith(String(trip.id), 'assignment:moved', expect.objectContaining({ assignment: expect.objectContaining({ id: own.id }) }));
|
||||
});
|
||||
|
||||
it('RESV-STAY-007: a foreign accommodation_id reaches no stop on the other trip', () => {
|
||||
// The trip_id guard on the stay delete is what denies that reach; the stops go
|
||||
// by accommodation id alone and would otherwise follow it straight over.
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
import { db } from '../../../src/db/database';
|
||||
import { DatabaseService } from '../../../src/nest/database/database.service';
|
||||
import { RoadtripPlanService } from '../../../src/nest/roadtrip/roadtrip-plan.service';
|
||||
import { createDay, createDayAssignment, createPlace, createTrip, createUser } from '../../helpers/factories';
|
||||
import { createDay, createDayAccommodation, createDayAssignment, createPlace, createTrip, createUser } from '../../helpers/factories';
|
||||
import { resetTestDb } from '../../helpers/test-db';
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
@@ -117,6 +117,46 @@ describe('a visit end time on the road trip', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('a booked night on the road trip (#2410)', () => {
|
||||
// The night flag comes off the planning SELECT (stay.id), not off a row the tests
|
||||
// hand the service, so a column dropped from the statement would flip every stop
|
||||
// to a night here and nowhere else.
|
||||
it('marks the stop the night is booked at, and a day that is only its night stands among the days', async () => {
|
||||
const { user, trip, plans } = setup();
|
||||
const arrival = createDay(db, trip.id);
|
||||
const hotel = createPlace(db, trip.id, { name: 'Rostock', lat: 54.09, lng: 12.1 });
|
||||
createDayAssignment(db, arrival.id, hotel.id);
|
||||
createDayAccommodation(db, trip.id, hotel.id, arrival.id, arrival.id, { check_in: '15:00' });
|
||||
|
||||
const { context, calculated } = await plans.calculate(trip.id, user.id);
|
||||
|
||||
expect(context.visits.map((v) => [v.name, v.stay_id !== null])).toEqual([
|
||||
['Hamburg', false],
|
||||
['Lueneburg', false],
|
||||
['Celle', false],
|
||||
['Rostock', true],
|
||||
]);
|
||||
expect(calculated.quietDays).toEqual([]);
|
||||
expect(calculated.days.map((d) => d.dayId)).toContain(arrival.id);
|
||||
const night = calculated.days.find((d) => d.dayId === arrival.id)!;
|
||||
expect(night.stops.map((s) => [s.name, s.night])).toEqual([['Rostock', true]]);
|
||||
expect(night.legs).toEqual([]);
|
||||
expect(night.schedule.entries[0]).toMatchObject({ arrival: '15:00', anchored: true });
|
||||
});
|
||||
|
||||
it('files a lone stop that is no night under the quiet days', async () => {
|
||||
const { user, trip, plans } = setup();
|
||||
const quiet = createDay(db, trip.id);
|
||||
const museum = createPlace(db, trip.id, { name: 'Museum', lat: 54.09, lng: 12.1 });
|
||||
createDayAssignment(db, quiet.id, museum.id);
|
||||
|
||||
const { calculated } = await plans.calculate(trip.id, user.id);
|
||||
|
||||
expect(calculated.days.map((d) => d.dayId)).not.toContain(quiet.id);
|
||||
expect(calculated.quietDays.map((d) => d.dayId)).toEqual([quiet.id]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('a booking the traveller rides (#2428)', () => {
|
||||
function withFlight(dayId: number, tripId: number, over: { end_day_id?: number; dep?: string; arr?: string } = {}) {
|
||||
const result = db
|
||||
|
||||
@@ -302,6 +302,7 @@ describe('Roadtrip MCP registration and search', () => {
|
||||
days: [
|
||||
{
|
||||
dayNumber: 1,
|
||||
stops: [],
|
||||
geometry: [
|
||||
[48, 10],
|
||||
[48, 10.1],
|
||||
@@ -388,7 +389,7 @@ describe('MCP trip preferences authorization', () => {
|
||||
describe('corridor filtering', () => {
|
||||
const input = { tripId: 10, dayNumber: 1, category: 'charging' as const, widthKm: 5, offset: 0 };
|
||||
function tool() {
|
||||
const plan = { failures: [] as unknown[], omittedVisits: [] as number[], calculated: { dayWindowIssue: null as string | null, days: [{ dayNumber: 1, geometry: [[48, 10], [48, 10.1]] }] } };
|
||||
const plan = { failures: [] as unknown[], omittedVisits: [] as number[], calculated: { dayWindowIssue: null as string | null, days: [{ dayNumber: 1, stops: [], geometry: [[48, 10], [48, 10.1]] }] } };
|
||||
const maps = { search: vi.fn(async () => ({ pois: [
|
||||
{ osm_id: 'a', name: 'Fast', lat: 48, lng: 10.03, category: 'charging', charging: { sockets: [{ type: 'type2', kw: 150 }] } },
|
||||
{ osm_id: 'b', name: 'Slow', lat: 48, lng: 10.07, category: 'charging', charging: { sockets: [{ type: 'type2', kw: 11 }] } },
|
||||
@@ -421,6 +422,44 @@ describe('corridor filtering', () => {
|
||||
expect((await read({ toKm: 0 })).hits).toHaveLength(0);
|
||||
expect((await read({})).failedSources).toEqual(['plugin']);
|
||||
});
|
||||
it('leaves the ride between two terminals out of the search and drops a hit under the flight path (#2428)', async () => {
|
||||
// The day drives to the airport, flies two degrees north and east, and drives on
|
||||
// from the far one: the geometry jumps straight between the terminals, and the
|
||||
// browser hook already cuts that stretch out. The tool tiles the same pieces.
|
||||
const terminal = (role: 'departure' | 'arrival', lat: number, lng: number) => ({
|
||||
carrier: { reservationId: 7, type: 'flight', role, title: 'LH 2020', code: null, at: null },
|
||||
lat,
|
||||
lng,
|
||||
});
|
||||
const plan = {
|
||||
failures: [],
|
||||
omittedVisits: [],
|
||||
calculated: {
|
||||
dayWindowIssue: null,
|
||||
days: [
|
||||
{
|
||||
dayNumber: 1,
|
||||
stops: [{ lat: 48, lng: 10 }, terminal('departure', 48, 10.1), terminal('arrival', 50, 12), { lat: 50, lng: 12.1 }],
|
||||
geometry: [[48, 10], [48, 10.1], [50, 12], [50, 12.1]],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
const underTheFlight = { lat: 49, lng: 11 };
|
||||
type Box = { south: number; north: number; west: number; east: number };
|
||||
const maps = { search: vi.fn(async (_query: { bbox: Box }) => ({ pois: [
|
||||
{ osm_id: 'road', name: 'On the road', lat: 48, lng: 10.05, category: 'fuel' },
|
||||
{ osm_id: 'field', name: 'Under the flight', ...underTheFlight, category: 'fuel' },
|
||||
], sources: ['osm'], failedSources: [], truncated: false, clamped: false })) };
|
||||
const mcp = new RoadtripPlanningMcp({ calculate: async () => plan } as never, maps as never, {} as never);
|
||||
const body = JSON.parse((await mcp.corridor({ ...input, category: 'fuel' }, ctx)).content[0].text as string);
|
||||
const asked = maps.search.mock.calls.map(([query]) => query.bbox);
|
||||
expect(asked.length).toBeGreaterThan(0);
|
||||
expect(asked.some((b) => b.south <= underTheFlight.lat && b.north >= underTheFlight.lat && b.west <= underTheFlight.lng && b.east >= underTheFlight.lng)).toBe(false);
|
||||
expect(body.hits.map((h: { poi: { osm_id: string } }) => h.poi.osm_id)).toEqual(['road']);
|
||||
expect(body.totalAreas).toBe(asked.length);
|
||||
expect(body.complete).toBe(true);
|
||||
});
|
||||
});
|
||||
it('reads the saved roadtrip context without routing', async () => {
|
||||
const s = setup();
|
||||
|
||||
Reference in New Issue
Block a user