feat(roadtrip): remember which track a day follows

The vias already made the day follow the road; nothing said which road. After a
reload the badge could only say "shaped by hand", and re-fitting after the stops
changed meant finding the track again by eye.

One row per day, written in the same transaction as the chain it describes: a
day claiming to follow a road whose vias never landed would be worse than a day
claiming nothing. Three states rather than two, which is why the field is an
object and not an id — absent leaves whatever the day followed alone, null says
it follows nothing, an object records the road it now takes. A bare nullable id
could not tell "do not touch this" apart from "forget it", and a hand-dragged
via on the same day must not wipe the label.

The foreign key is the point: delete the imported track and the label cascades
with it, so no day is left claiming to follow a line nobody can see. The vias
stay, because they are the drive.

Both write paths check that the place is a track of this trip before it becomes
a label — permission alone would let another trip's place id, or an ordinary
place, become a name that can never be drawn.

Also clears three reliability findings the scanner raised on this branch: two
default sorts given an explicit comparator, and a trailing-slash regex the
scanner is right to call backtracking, walked instead.
This commit is contained in:
Maurice
2026-09-05 00:49:59 +02:00
committed by Maurice
parent 6fca238440
commit 676c82a308
40 changed files with 356 additions and 31 deletions
+29
View File
@@ -4345,6 +4345,35 @@ function runMigrations(db: Database.Database): void {
`);
db.exec('CREATE INDEX IF NOT EXISTS idx_roadtrip_vias_day ON roadtrip_vias(day_id, after_order_index, sequence)');
},
/**
* Which imported track a day's drive was fitted to.
*
* The vias alone already make the day follow it, so this stores nothing the drive
* needs — it stores what the traveller needs: the name of the road they chose, still
* on the day after a reload, and something to re-fit against when the stops change.
*
* One row per day, hence `day_id` as the key: a day follows one road or none. The
* cascade on `place_id` is the point of the foreign key — delete the imported track
* and the label goes with it, rather than leaving a day claiming to follow a line
* nobody can see any more. The vias it laid down stay, because they are the drive.
*
* `stray_km` is how far the fitted route still ran from the track at its worst point,
* kept so the day can say how good a fit it is without routing again.
*
* Appended LAST: the array is index-addressed against schema_version.
*/
() => {
db.exec(`
CREATE TABLE IF NOT EXISTS roadtrip_day_tracks (
day_id INTEGER PRIMARY KEY REFERENCES days(id) ON DELETE CASCADE,
place_id INTEGER NOT NULL REFERENCES places(id) ON DELETE CASCADE,
stray_km REAL,
created_at TEXT DEFAULT CURRENT_TIMESTAMP
)
`);
db.exec('CREATE INDEX IF NOT EXISTS idx_roadtrip_day_tracks_place ON roadtrip_day_tracks(place_id)');
},
];
if (currentVersion < migrations.length) {
@@ -1,5 +1,5 @@
import { Body, Controller, Delete, Get, HttpCode, HttpException, Param, Post, Put, UseGuards } from '@nestjs/common';
import type { RoadtripVia } from '@trek/shared';
import type { RoadtripDayTrack, RoadtripVia } from '@trek/shared';
import { RoadtripService } from './roadtrip.service';
import { RoadtripViaBatchDto, RoadtripViaCreateDto, RoadtripViaReanchorDto, RoadtripViaUpdateDto } from './roadtrip.dto';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
@@ -32,8 +32,8 @@ export class RoadtripController {
/** Every via of the trip, so the client can route all days without a request per day. */
@Get('vias')
listAll(@Param('tripId') tripId: string): { vias: RoadtripVia[] } {
return { vias: this.roadtrip.listForTrip(tripId) };
listAll(@Param('tripId') tripId: string): { vias: RoadtripVia[]; tracks: RoadtripDayTrack[] } {
return { vias: this.roadtrip.listForTrip(tripId), tracks: this.roadtrip.tracksForTrip(tripId) };
}
@Get('days/:dayId/vias')
@@ -70,6 +70,12 @@ export class RoadtripController {
@Body() body: RoadtripViaBatchDto,
): { vias: RoadtripVia[] } {
this.requireDay(dayId, tripId);
// Permission is not enough on its own: a place id from somebody else's trip would
// otherwise become this day's label, and a place that is not a track would become a
// label that can never be drawn.
if (body.track && !this.roadtrip.trackExists(body.track.place_id, tripId)) {
throw new HttpException({ error: 'Track not found' }, 404);
}
return { vias: this.roadtrip.createMany(dayId, body) };
}
+14 -3
View File
@@ -52,7 +52,7 @@ export class RoadtripMcp {
if (!this.roadtrip.dayExists(dayId, tripId)) return noAccess();
return ok({ vias: this.roadtrip.listForDay(dayId) });
}
return ok({ vias: this.roadtrip.listForTrip(tripId) });
return ok({ vias: this.roadtrip.listForTrip(tripId), tracks: this.roadtrip.tracksForTrip(tripId) });
}
@Tool({
@@ -93,16 +93,23 @@ export class RoadtripMcp {
})).max(100).describe('In the order the drive passes through them'),
replace_legs: z.array(z.number().int().min(0)).max(100).optional()
.describe('Legs to clear before inserting, by the index of the stop they follow'),
track: z.object({
place_id: z.number().int().positive().describe('The imported track this chain was fitted to, as its place id'),
stray_km: z.number().min(0).max(40_000).nullable().optional()
.describe('How far the fitted route still runs from the track at its worst point'),
}).nullable().optional()
.describe('Records which imported track the day now follows. Leave it out to keep whatever it followed before; pass null to say it follows nothing.'),
},
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
access: { group: 'trips', mode: 'write' },
when: roadtripAddonOn,
})
async addVias(
{ tripId, dayId, vias, replace_legs }: {
{ tripId, dayId, vias, replace_legs, track }: {
tripId: number; dayId: number;
vias: { after_order_index: number; lat: number; lng: number }[];
replace_legs?: number[];
track?: { place_id: number; stray_km?: number | null } | null;
},
ctx: McpContext,
) {
@@ -110,7 +117,11 @@ export class RoadtripMcp {
if (!this.db.canAccessTrip(tripId, ctx.userId)) return noAccess();
if (!this.guards.hasTripPermission('day_edit', tripId, ctx.userId)) return permissionDenied();
if (!this.roadtrip.dayExists(dayId, tripId)) return noAccess();
return ok({ vias: this.roadtrip.createMany(dayId, { vias, replace_legs }) });
// The same check the REST route makes, in the same change: permission alone would let
// a place id from another trip, or a place that is not a track at all, become this
// day's label.
if (track && !this.roadtrip.trackExists(track.place_id, tripId)) return noAccess();
return ok({ vias: this.roadtrip.createMany(dayId, { vias, replace_legs, track }) });
}
@Tool({
+45 -2
View File
@@ -1,5 +1,5 @@
import { Injectable } from '@nestjs/common';
import type { RoadtripVia } from '@trek/shared';
import type { RoadtripDayTrack, RoadtripVia } from '@trek/shared';
import { DatabaseService } from '../database/database.service';
/**
@@ -77,9 +77,26 @@ export class RoadtripService {
*/
createMany(
dayId: string | number,
input: { vias: { after_order_index: number; lat: number; lng: number }[]; replace_legs?: number[] },
input: {
vias: { after_order_index: number; lat: number; lng: number }[];
replace_legs?: number[];
track?: { place_id: number; stray_km?: number | null } | null;
},
): RoadtripVia[] {
return this.db.transaction(() => {
// Inside the same transaction as the chain it describes. A day that says it follows
// a road whose vias never landed is worse than a day that says nothing.
if (input.track === null) {
this.db.run('DELETE FROM roadtrip_day_tracks WHERE day_id = ?', dayId);
} else if (input.track) {
this.db.run(
`INSERT INTO roadtrip_day_tracks (day_id, place_id, stray_km) VALUES (?, ?, ?)
ON CONFLICT(day_id) DO UPDATE SET place_id = excluded.place_id, stray_km = excluded.stray_km`,
dayId,
input.track.place_id,
input.track.stray_km ?? null,
);
}
for (const leg of input.replace_legs ?? []) {
this.db.run('DELETE FROM roadtrip_vias WHERE day_id = ? AND after_order_index = ?', dayId, leg);
}
@@ -110,6 +127,32 @@ export class RoadtripService {
});
}
/**
* The tracks this trip's days follow.
*
* Read with the vias in one go: both are wanted on every load of road-trip mode, and a
* second route for a handful of rows would be a second round trip for nothing.
*/
tracksForTrip(tripId: string | number): RoadtripDayTrack[] {
return this.db.all<RoadtripDayTrack>(
`SELECT t.day_id, t.place_id, t.stray_km
FROM roadtrip_day_tracks t
JOIN days d ON d.id = t.day_id
WHERE d.trip_id = ?
ORDER BY t.day_id`,
tripId,
);
}
/** Whether a place is on this trip, and is a track rather than an ordinary place. */
trackExists(placeId: number, tripId: string | number): boolean {
return !!this.db.get<{ id: number }>(
"SELECT id FROM places WHERE id = ? AND trip_id = ? AND route_geometry IS NOT NULL AND route_geometry != ''",
placeId,
tripId,
);
}
/** Moving a via is the whole edit; where it sits in the chain does not change. */
move(id: string | number, dayId: string | number, lat: number, lng: number): RoadtripVia | null {
const existing = this.db.get<{ id: number }>(