mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-24 07:53:09 +00:00
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:
@@ -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) };
|
||||
}
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 }>(
|
||||
|
||||
Reference in New Issue
Block a user