feat(roadtrip): shape the drive — vias, alternatives and stops along the way

The road-trip addon's second half: the parts that let a planned drive be
argued with rather than only read.

Reshaping. A via records which stop it follows by position, and that position
is resolved fresh every time the day is routed — so inserting, reordering or
removing a stop moved the ground under every via after it, and the drive
silently snapped back to the road it had been steered away from while the
handle still sat on the map where it was left. The correction is computed
against the day as it stands and written with the same change, in one
transaction: a half-applied re-anchoring mixes two numbering schemes in one
day. The one judgement that needs geometry — which half of a split leg a via
belongs to when a stop lands in the middle of it — is measured on the road
actually driven, so a fuel stop added to a bent leg keeps the bend on the side
it belongs to.

Alternatives. OSRM answers most long legs with exactly one route, which read as
"there is no other way" when a perfectly good slower road existed. When it
offers nothing, the same drive is asked for again without the motorway, and
failing that without the tolls — one question at a time, stopping as soon as
one lands. The offer says what it is rather than how much slower.

Stops along the way. Everything the corridor search can find is a pause, not a
destination: fuel, charging, rest areas, campsites, food and sights all sit
inside the leg they were found on, carry their own colour and icon, and are
left out of every stop count. That colour is defined once and read by the rail,
the corridor list, the map pin and the picker — and it replaced the chains'
logos, which turned a corridor full of petrol stations into a row of
advertisements and left the brands with no logo on file looking like something
else entirely.

How long a stop takes was readable in the rail and writable nowhere; new places
were given an invented hour that nobody had entered. It is now set from the
rail, cleared as a zero because the update statement folds a null into "leave
it alone", and new places start with nothing.

Both columns are rebuilt on one type scale, anchored to the day plan's own
sizes so switching tabs does not change the size of the text beside it. The
rail reads as trip totals, day cards and a chain of stops with the drives
between them; the corridor column as what to look for, how the search is
getting on, and what it found. Six category pills over three lines became one
dropdown, which is the space the results wanted.

Also: the offered routes are framed on the map when the picker opens (`fitKey`
carries no coordinates and each renderer reads it as "the selected day"), road
trip mode does not cluster at all so the whole drive stays readable zoomed out,
and the drive-time labels finally render in the app's own typeface instead of
Leaflet's Helvetica. All of it in both renderers.
This commit is contained in:
Maurice
2026-08-29 21:05:22 +02:00
committed by Maurice
parent 1db55f734c
commit e11ae6f056
110 changed files with 7375 additions and 569 deletions
+10 -2
View File
@@ -7,7 +7,8 @@ import type { INestApplication } from '@nestjs/common';
import type { ConfigType } from '@nestjs/config';
import { AppModule } from './nest/app.module';
import { httpConfig } from './nest/app-config';
import { applyGlobalMiddleware } from './middleware/globalMiddleware';
import { applyGlobalMiddleware, routingCspOrigins } from './middleware/globalMiddleware';
import { SettingsService } from './nest/settings/settings.service';
import { applyPlatformUploads, applyPlatformStatic } from './nest/platform/platform.routes';
import { apiDocsEnabled } from './nest/common/api-docs.kill-switch';
import { setupApiDocs } from './nest/platform/api-docs';
@@ -84,7 +85,14 @@ export async function buildApp(): Promise<INestApplication> {
// the one bridge that lets the pre-init Express layer consume the validated
// config instead of reading process.env itself.
const http = app.get<ConfigType<typeof httpConfig>>(httpConfig.KEY);
applyGlobalMiddleware(instance, { http });
// Same pre-init bridge: a self-hosted routing engine has to be named in connect-src, or
// the browser blocks every request to it without an error the app could report.
const settings = app.get(SettingsService, { strict: false });
const routingBase = settings?.getAdminUserDefaults()?.routing_base_url;
applyGlobalMiddleware(instance, {
http,
extraConnectSrc: routingCspOrigins([typeof routingBase === 'string' ? routingBase : null]),
});
// Same pre-init consumption bridge as httpConfig above: the StorageService
// instance is resolvable before init, and the handlers only *register* here —
// per-request resolution runs after app.init() completed the registry load.
+53
View File
@@ -4257,6 +4257,7 @@ function runMigrations(db: Database.Database): void {
db.exec('ALTER TABLE journey_entries ADD COLUMN stats_excluded INTEGER NOT NULL DEFAULT 0');
}
},
/**
/**
* Place shadow log: which search result a user actually picked.
*
@@ -4292,6 +4293,58 @@ function runMigrations(db: Database.Database): void {
// Retention deletes by age, the export pages by id.
db.exec('CREATE INDEX IF NOT EXISTS idx_place_shadow_created ON place_shadow_picks(created_at)');
},
/**
* What kind of stop a place is on a drive — fuel, charging, rest area, campsite.
*
* Deliberately NOT a `categories` row. Those are the traveller's own list, editable
* and instance-wide (`categories.service.ts` selects them without a user filter), so
* seeding four road-trip kinds there would push them into everyone's dropdown and hand
* their colour to whoever edits the list first. A refuelling stop is not a taste; it is
* a fact about the place, and the road-trip categories already own its icon and colour
* (`poiCategories.ts`).
*
* Free text rather than a CHECK constraint: the set grows with what the corridor
* search can look for, and SQLite cannot alter a constraint without rebuilding the
* table. NULL means an ordinary place, which is every row that exists today.
*
* Appended LAST: the array is index-addressed against schema_version.
*/
() => {
const cols = db.prepare("SELECT name FROM pragma_table_info('places')").all() as Array<{ name: string }>;
if (!cols.some(c => c.name === 'stop_type')) {
db.exec('ALTER TABLE places ADD COLUMN stop_type TEXT');
}
},
/**
* Points a day's drive is made to pass through, without being stops (#1797).
*
* The difference is the whole point: a stop is somewhere you go, and it takes a
* number in the chain, a place row, an arrival time and a line in the itinerary. A
* via is none of that — it only bends the route, which is what "take the coast road
* instead" means. Storing one as a place was the alternative, and it would have put a
* numbered stop in the middle of the day for a spot nobody stops at.
*
* Anchored to `after_order_index` rather than to an assignment id: a stop added
* mid-day is written with a temporary negative id and swapped for the real one moments
* later, so a foreign key to it would dangle. The index is what the routing request is
* built from anyway.
*
* Appended LAST: the array is index-addressed against schema_version.
*/
() => {
db.exec(`
CREATE TABLE IF NOT EXISTS roadtrip_vias (
id INTEGER PRIMARY KEY AUTOINCREMENT,
day_id INTEGER NOT NULL REFERENCES days(id) ON DELETE CASCADE,
after_order_index INTEGER NOT NULL,
sequence INTEGER NOT NULL DEFAULT 0,
lat REAL NOT NULL,
lng REAL NOT NULL,
created_at TEXT DEFAULT CURRENT_TIMESTAMP
)
`);
db.exec('CREATE INDEX IF NOT EXISTS idx_roadtrip_vias_day ON roadtrip_vias(day_id, after_order_index, sequence)');
},
];
if (currentVersion < migrations.length) {
+39 -3
View File
@@ -72,15 +72,47 @@ export function redact(value: unknown): unknown {
* bootstrap.ts. The flag that used to switch a second parser on had exactly one
* caller, which passed false.
*/
/**
* Turns a configured routing base URL into the origin CSP needs.
*
* Only the origin: a CSP source with a path only matches that path prefix, and a router
* is asked at several of them (`/route/v1/...`, `/table/v1/...`). Anything that is not an
* absolute http(s) URL is dropped rather than passed through — a bad value in a settings
* row must not be able to widen the policy.
*/
export function routingCspOrigins(baseUrls: (string | null | undefined)[]): string[] {
const origins = new Set<string>();
for (const raw of baseUrls) {
if (!raw) continue;
try {
const url = new URL(raw.trim());
if (url.protocol !== 'http:' && url.protocol !== 'https:') continue;
origins.add(url.origin);
} catch {
// Not a URL; the client falls back to the public hosts, so the policy stays as it was.
}
}
return [...origins];
}
export function applyGlobalMiddleware(
app: express.Application,
opts: { http?: AppEnv['http'] } = {},
opts: {
http?: AppEnv['http'];
/**
* Extra origins the browser may talk to, from the instance's own settings — today the
* self-hosted routing engine (#1797). Read once at apply time like everything else
* here, so changing it takes a restart; the alternative is a database read on every
* request for a value that changes about once in a deployment's life.
*/
extraConnectSrc?: string[];
} = {},
): void {
// The whole pipeline is configured at APPLY time (the per-request closures
// capture these values), so a snapshot is the correct semantic. bootstrap
// threads in the DI-loaded httpConfig; direct callers fall back to an
// apply-time readEnv() — same values, same freeze point.
const { http = readEnv().http } = opts;
const { http = readEnv().http, extraConnectSrc = [] } = opts;
const { nodeEnv, isProduction } = readEnv().app;
// Trust first proxy (nginx/Docker) for correct req.ip
@@ -207,7 +239,11 @@ export function applyGlobalMiddleware(
"https://geocoding-api.open-meteo.com", "https://api.frankfurter.dev",
"https://router.project-osrm.org/route/v1/", "https://routing.openstreetmap.de/",
"https://api.mapbox.com", "https://*.tiles.mapbox.com", "https://events.mapbox.com",
"https://tiles.openfreemap.org"
"https://tiles.openfreemap.org",
// A self-hosted routing engine, when the instance has one configured. Without
// this the browser blocks it silently: no error the app can catch, just legs
// that never route (#1797).
...extraConnectSrc,
],
workerSrc: ["'self'", "blob:"],
childSrc: ["'self'", "blob:"],
+2 -1
View File
@@ -11,6 +11,7 @@ import { GlobalAuthGuard } from './auth/global-auth.guard';
import { MfaPolicyGuard } from './auth/mfa-policy.guard';
import { ManagedGuard } from './common/managed.guard';
import { WeatherModule } from './weather/weather.module';
import { RoadtripModule } from './roadtrip/roadtrip.module';
import { PublicApiModule } from './public-api/public-api.module';
import { HelpModule } from './help/help.module';
import { AirportsModule } from './airports/airports.module';
@@ -77,7 +78,7 @@ import { RealtimeGatewayModule } from './realtime/realtime-gateway.module';
* migrated.
*/
@Module({
imports: [AppConfigModule, DatabaseModule, RealtimeModule, RealtimeGatewayModule, SchedulingModule, McpModule.forRoot({ accessPolicy: trekMcpAccessPolicy, validateAccess: trekMcpValidateAccess }), HealthModule, PlatformModule, McpTransportModule, WeatherModule, PublicApiModule, HelpModule, AirportsModule, ConfigModule, SystemNoticesModule, GeoModule, MapsModule, PlaceEnrichmentModule, PlaceShadowModule, CategoriesModule, TagsModule, NotificationsModule, AtlasModule, VacayModule, PackingModule, TodoModule, BudgetModule, ReservationsModule, DaysModule, DayNotesModule, AccommodationsModule, AssignmentsModule, PlacesModule, TripsModule, CollabModule, FilesModule, PhotosModule, MemoriesModule, AirtrailModule, JourneyModule, CollectionsModule, ShareModule, TripInviteModule, TransitModule, FeedsModule, SettingsModule, StorageModule, BackupModule, AuthModule, OidcModule, OauthModule, AdminModule, AddonsModule, AuditModule, PermissionsModule, PluginsModule, BookingImportModule, ReservationImportModule, LlmParseModule, ManagedExtModule],
imports: [AppConfigModule, DatabaseModule, RealtimeModule, RealtimeGatewayModule, SchedulingModule, McpModule.forRoot({ accessPolicy: trekMcpAccessPolicy, validateAccess: trekMcpValidateAccess }), HealthModule, PlatformModule, McpTransportModule, WeatherModule, PublicApiModule, HelpModule, AirportsModule, ConfigModule, SystemNoticesModule, GeoModule, MapsModule, PlaceEnrichmentModule, PlaceShadowModule, CategoriesModule, TagsModule, NotificationsModule, AtlasModule, VacayModule, PackingModule, TodoModule, BudgetModule, ReservationsModule, DaysModule, DayNotesModule, AccommodationsModule, AssignmentsModule, PlacesModule, RoadtripModule, TripsModule, CollabModule, FilesModule, PhotosModule, MemoriesModule, AirtrailModule, JourneyModule, CollectionsModule, ShareModule, TripInviteModule, TransitModule, FeedsModule, SettingsModule, StorageModule, BackupModule, AuthModule, OidcModule, OauthModule, AdminModule, AddonsModule, AuditModule, PermissionsModule, PluginsModule, BookingImportModule, ReservationImportModule, LlmParseModule, ManagedExtModule],
providers: [
// Default-deny: a route is authenticated unless it carries @Public() or
// @OptionalAuth(), or declares its own @UseGuards chain. Protection used to
+3
View File
@@ -43,6 +43,9 @@ export function formatAssignmentWithPlace(a: AssignmentRow, tags: Partial<Tag>[]
osm_id: a.osm_id,
website: a.website,
phone: a.phone,
// The rail marks a fuel stop as one; without it here every stop would need
// its own place request to find out what kind it is.
stop_type: a.stop_type ?? null,
category: a.category_id ? {
id: a.category_id,
name: a.category_name,
+6 -2
View File
@@ -10,6 +10,8 @@ import {
placeImageUrlSchema,
placeImportListRequestSchema,
placeWebsiteSchema,
roadtripStopTypeSchema,
type RoadtripStopType,
} from '@trek/shared';
import { z } from 'zod';
import { AuthService } from '../auth/auth.service';
@@ -74,22 +76,24 @@ export class PlacesMcp {
image_url: placeImageUrlSchema.optional().describe('Thumbnail for the place: an /uploads/ path, an /api/maps/place-photo/ path, an inline data: image, or an https URL'),
price: z.number().nonnegative().optional().describe('Cost of this place/activity (e.g. ticket price, entry fee)'),
currency: z.string().length(3).optional().describe('ISO 4217 currency code (e.g. "EUR", "USD")'),
stop_type: roadtripStopTypeSchema.optional().describe('Marks the place as a stop on a drive rather than a destination: fuel, charging, rest_area or campsite. Leave unset for an ordinary place.'),
},
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
access: { group: 'places', mode: 'write' },
})
async createPlace(
{ tripId, name, description, lat, lng, address, category_id, google_place_id, google_ftid, osm_id, notes, website, phone, image_url, price, currency }: {
{ tripId, name, description, lat, lng, address, category_id, google_place_id, google_ftid, osm_id, notes, website, phone, image_url, price, currency, stop_type }: {
tripId: number; name: string; description?: string; lat?: number; lng?: number; address?: string;
category_id?: number; google_place_id?: string; google_ftid?: string; osm_id?: string;
notes?: string; website?: string; phone?: string; image_url?: string; price?: number; currency?: string;
stop_type?: RoadtripStopType;
},
ctx: McpContext,
) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.db.canAccessTrip(tripId, ctx.userId)) return noAccess();
if (!this.guards.hasTripPermission('place_edit', tripId, ctx.userId)) return permissionDenied();
const place = this.places.create(String(tripId), { name, description, lat, lng, address, category_id, google_place_id, google_ftid, osm_id, notes, website, phone, image_url, price, currency });
const place = this.places.create(String(tripId), { name, description, lat, lng, address, category_id, google_place_id, google_ftid, osm_id, notes, website, phone, image_url, price, currency, stop_type });
this.guards.safeBroadcast(tripId, 'place:created', { place });
return ok({ place });
}
+18 -6
View File
@@ -67,6 +67,8 @@ export interface PlaceCreateInput {
place_time?: string; end_time?: string;
duration_minutes?: number; notes?: string; image_url?: string;
google_place_id?: string; google_ftid?: string; osm_id?: string; website?: string; phone?: string;
/** What kind of stop this is on a drive (fuel, charging, rest_area, campsite); null for an ordinary place. */
stop_type?: string | null;
transport_mode?: string; route_geometry?: string; route_color?: string; tags?: number[];
}
@@ -77,6 +79,8 @@ export interface PlaceUpdateInput {
place_time?: string; end_time?: string;
duration_minutes?: number; notes?: string; image_url?: string;
google_place_id?: string; google_ftid?: string; osm_id?: string; website?: string; phone?: string;
/** What kind of stop this is on a drive (fuel, charging, rest_area, campsite); null for an ordinary place. */
stop_type?: string | null;
transport_mode?: string; route_color?: string | null; tags?: number[];
}
@@ -224,24 +228,29 @@ export class PlacesService {
name, description, lat, lng, address, category_id, price, currency,
place_time, end_time,
duration_minutes, notes, image_url, google_place_id, google_ftid, osm_id, website, phone,
transport_mode, route_geometry, route_color, tags = [],
transport_mode, route_geometry, route_color, stop_type, tags = [],
} = body;
const result = this.dbs.run(`
INSERT INTO places (trip_id, name, description, lat, lng, address, category_id, price, currency,
place_time, end_time,
duration_minutes, notes, image_url, google_place_id, google_ftid, osm_id, website, phone, transport_mode,
route_geometry, route_color)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
route_geometry, route_color, stop_type)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`,
// lat/lng/price/duration_minutes use an explicit undefined check, not `||`:
// 0 is a legitimate value for all four (Null Island, a free entry, a
// drive-by stop) and the falsy coercion silently threw it away.
//
// `duration_minutes` stays NULL when nobody says otherwise, rather than being
// invented as an hour. It is read in exactly one place — the road trip rail's
// "Stay" — and a default meant every place ever created claimed a one-hour stop
// the traveller had never entered, with no way to tell the two apart.
tripId, name, description || null, lat ?? null, lng ?? null, address || null,
category_id || null, price ?? null, currency || null,
place_time || null, end_time || null, duration_minutes ?? 60, notes || null, image_url || null,
place_time || null, end_time || null, duration_minutes ?? null, notes || null, image_url || null,
google_place_id || null, google_ftid || null, osm_id || null, website || null, phone || null, transport_mode || 'walking',
route_geometry || null, route_color || null,
route_geometry || null, route_color || null, stop_type || null,
);
const placeId = result.lastInsertRowid;
@@ -306,7 +315,7 @@ export class PlacesService {
name, description, lat, lng, address, category_id, price, currency,
place_time, end_time,
duration_minutes, notes, image_url, google_place_id, google_ftid, osm_id, website, phone,
transport_mode, route_color, tags,
transport_mode, route_color, stop_type, tags,
} = body;
this.dbs.run(`
@@ -331,6 +340,7 @@ export class PlacesService {
phone = ?,
transport_mode = COALESCE(?, transport_mode),
route_color = ?,
stop_type = ?,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?
`,
@@ -358,6 +368,8 @@ export class PlacesService {
// Deliberately not COALESCE: an explicit null is how the picker resets a
// track back to its category colour (#776).
route_color !== undefined ? route_color : existingPlace.route_color,
// Same shape: an explicit null is how a fuel stop becomes an ordinary place again.
stop_type !== undefined ? stop_type : existingPlace.stop_type,
placeId,
);
@@ -0,0 +1,109 @@
import { Body, Controller, Delete, Get, HttpCode, HttpException, Param, Post, Put, UseGuards } from '@nestjs/common';
import type { RoadtripVia } from '@trek/shared';
import { RoadtripService } from './roadtrip.service';
import { RoadtripViaCreateDto, RoadtripViaReanchorDto, RoadtripViaUpdateDto } from './roadtrip.dto';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { RequirePermission, TripAccessGuard } from '../permissions/trip-access.guard';
import { RequireAddon } from '../addons/require-addon.decorator';
import { ADDON_IDS } from '../../addons';
/**
* /api/trips/:tripId/roadtrip — the points a drive is routed through (#1797).
*
* Gated on the road trip addon: an instance with it switched off gets 404s here, the same
* as every other addon surface, and nothing about the plain day plan changes.
*
* No WebSocket broadcast, unlike the assignment routes. A via is a detail of how one
* person's route is drawn rather than a change to the itinerary everybody reads, and
* adding an event family for it would mean extending the typed trip-event catalogue for
* something a collaborator sees on their next load anyway. Worth revisiting if vias ever
* become shared editing rather than route shaping.
*/
@Controller('api/trips/:tripId/roadtrip')
@UseGuards(JwtAuthGuard, TripAccessGuard)
@RequireAddon(ADDON_IDS.ROADTRIP, 'Road trip')
export class RoadtripController {
constructor(private readonly roadtrip: RoadtripService) {}
/** 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) };
}
@Get('days/:dayId/vias')
list(@Param('tripId') tripId: string, @Param('dayId') dayId: string): { vias: RoadtripVia[] } {
this.requireDay(dayId, tripId);
return { vias: this.roadtrip.listForDay(dayId) };
}
@RequirePermission('day_edit')
@Post('days/:dayId/vias')
create(
@Param('tripId') tripId: string,
@Param('dayId') dayId: string,
@Body() body: RoadtripViaCreateDto,
): { via: RoadtripVia } {
this.requireDay(dayId, tripId);
return { via: this.roadtrip.create(dayId, body) };
}
/**
* Re-pin this day's vias after its stops changed shape.
*
* Declared before `vias/:id` so the static path is matched first, and taken as one
* batch rather than a PUT per via: the anchors are only correct as a set, and a
* half-applied shift leaves two vias on one leg and a third past the end of the day.
*/
@RequirePermission('day_edit')
@Put('days/:dayId/vias')
@HttpCode(200)
reanchor(
@Param('tripId') tripId: string,
@Param('dayId') dayId: string,
@Body() body: RoadtripViaReanchorDto,
): { vias: RoadtripVia[] } {
this.requireDay(dayId, tripId);
return { vias: this.roadtrip.reanchor(dayId, body) };
}
@RequirePermission('day_edit')
@Put('days/:dayId/vias/:id')
@HttpCode(200)
update(
@Param('tripId') tripId: string,
@Param('dayId') dayId: string,
@Param('id') id: string,
@Body() body: RoadtripViaUpdateDto,
): { via: RoadtripVia } {
this.requireDay(dayId, tripId);
const via = this.roadtrip.move(id, dayId, body.lat, body.lng);
if (!via) throw new HttpException({ error: 'Via not found' }, 404);
return { via };
}
@RequirePermission('day_edit')
@Delete('days/:dayId/vias/:id')
remove(
@Param('tripId') tripId: string,
@Param('dayId') dayId: string,
@Param('id') id: string,
): { success: true } {
this.requireDay(dayId, tripId);
if (!this.roadtrip.remove(id, dayId)) {
throw new HttpException({ error: 'Via not found' }, 404);
}
return { success: true };
}
/**
* The guard proves the caller may reach the trip; this proves the day is part of it.
* Without it a valid day id from someone else's trip would be editable through a trip
* the caller does have access to.
*/
private requireDay(dayId: string, tripId: string): void {
if (!this.roadtrip.dayExists(dayId, tripId)) {
throw new HttpException({ error: 'Day not found' }, 404);
}
}
}
+11
View File
@@ -0,0 +1,11 @@
import { createZodDto } from 'nestjs-zod';
import { roadtripViaCreateRequestSchema, roadtripViaReanchorRequestSchema, roadtripViaUpdateRequestSchema } from '@trek/shared';
/**
* createZodDto wrappers over the @trek/shared road-trip contracts. The global
* ZodValidationPipe validates any @Body() typed with one of these by metatype, so the
* schemas in shared/ stay the single source of truth for the wire contract.
*/
export class RoadtripViaCreateDto extends createZodDto(roadtripViaCreateRequestSchema) {}
export class RoadtripViaUpdateDto extends createZodDto(roadtripViaUpdateRequestSchema) {}
export class RoadtripViaReanchorDto extends createZodDto(roadtripViaReanchorRequestSchema) {}
+115
View File
@@ -0,0 +1,115 @@
import { McpController, Tool, TOOL_ANNOTATIONS_READONLY, TOOL_ANNOTATIONS_NON_IDEMPOTENT, ok, type McpContext } from '../../nest-mcp';
import { z } from 'zod';
import { RoadtripService } from './roadtrip.service';
import { DatabaseService } from '../database/database.service';
import { McpToolGuardsService } from '../mcp-shared/mcp-tool-guards.service';
import { demoDenied, noAccess, permissionDenied } from '../../mcp/tools/_shared';
import { AuthService } from '../auth/auth.service';
/**
* Road-trip via points over MCP, the same surface the REST routes expose (#1797).
*
* Parity is the point: the permission checked here is `day_edit`, the same action string
* the controller demands, so an assistant can never do through a tool what a person
* cannot do through the UI. Reads go through the same day-belongs-to-trip check, so a
* valid day id from another trip is not reachable by way of one the caller can see.
*/
@McpController()
export class RoadtripMcp {
constructor(
private readonly roadtrip: RoadtripService,
private readonly db: DatabaseService,
private readonly guards: McpToolGuardsService,
private readonly auth: AuthService,
) {}
@Tool({
name: 'list_route_vias',
description: 'List the points a day\'s drive is routed through without stopping at them. These bend the route (a scenic road, a pass, avoiding a motorway) and are not stops on the itinerary.',
inputSchema: {
tripId: z.number().int().positive(),
dayId: z.number().int().positive().optional().describe('Omit to list the vias of every day of the trip'),
},
annotations: TOOL_ANNOTATIONS_READONLY,
access: { group: 'trips', mode: 'read' },
})
async listVias({ tripId, dayId }: { tripId: number; dayId?: number }, ctx: McpContext) {
if (!this.db.canAccessTrip(tripId, ctx.userId)) return noAccess();
if (dayId != null) {
if (!this.roadtrip.dayExists(dayId, tripId)) return noAccess();
return ok({ vias: this.roadtrip.listForDay(dayId) });
}
return ok({ vias: this.roadtrip.listForTrip(tripId) });
}
@Tool({
name: 'add_route_via',
description: 'Make a day\'s drive pass through a point without stopping there — use it to send the route over a particular road or away from one. For somewhere the traveller actually stops, add a place and assign it to the day instead.',
inputSchema: {
tripId: z.number().int().positive(),
dayId: z.number().int().positive(),
after_order_index: z.number().int().min(0).describe('Which stop of the day the via follows, counting from 0'),
lat: z.number().min(-90).max(90),
lng: z.number().min(-180).max(180),
},
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
access: { group: 'trips', mode: 'write' },
})
async addVia(
{ tripId, dayId, after_order_index, lat, lng }: { tripId: number; dayId: number; after_order_index: number; lat: number; lng: number },
ctx: McpContext,
) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
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({ via: this.roadtrip.create(dayId, { after_order_index, lat, lng }) });
}
@Tool({
name: 'reanchor_route_vias',
description: 'Re-pin a day\'s via points after its stops changed. A via records which stop it follows by position, so adding, removing or reordering a stop leaves every later via pointing at the wrong leg and the drive silently reverts to the road it was steered away from. Send the corrected positions for the whole day at once; ids left out keep the position they have.',
inputSchema: {
tripId: z.number().int().positive(),
dayId: z.number().int().positive(),
vias: z.array(z.object({
id: z.number().int().positive(),
after_order_index: z.number().int().min(0).describe('Which stop of the day the via now follows, counting from 0'),
})).max(500),
remove: z.array(z.number().int().positive()).max(500).optional()
.describe('Vias whose leg no longer exists at all — deleting the last stop of a day leaves the leg into it with nothing to sit on'),
},
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
access: { group: 'trips', mode: 'write' },
})
async reanchorVias(
{ tripId, dayId, vias, remove }: { tripId: number; dayId: number; vias: { id: number; after_order_index: number }[]; remove?: number[] },
ctx: McpContext,
) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
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.reanchor(dayId, { vias, remove }) });
}
@Tool({
name: 'remove_route_via',
description: 'Remove a via point, letting the drive take the direct route again.',
inputSchema: {
tripId: z.number().int().positive(),
dayId: z.number().int().positive(),
viaId: z.number().int().positive(),
},
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
access: { group: 'trips', mode: 'write' },
})
async removeVia({ tripId, dayId, viaId }: { tripId: number; dayId: number; viaId: number }, ctx: McpContext) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
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();
if (!this.roadtrip.remove(viaId, dayId)) return noAccess();
return ok({ success: true });
}
}
@@ -0,0 +1,17 @@
import { Module } from '@nestjs/common';
import { RoadtripController } from './roadtrip.controller';
import { RoadtripService } from './roadtrip.service';
import { RoadtripMcp } from './roadtrip.mcp';
import { McpSharedModule } from '../mcp-shared/mcp-shared.module';
import { PermissionsModule } from '../permissions/permissions.module';
import { AuthModule } from '../auth/auth.module';
/** Road trip domain (#1797): the points a drive is routed through. Registered in AppModule. */
@Module({
// McpShared brings the tool guards, Auth the demo check, Permissions the trip guard.
imports: [McpSharedModule, PermissionsModule, AuthModule],
controllers: [RoadtripController],
providers: [RoadtripService, RoadtripMcp],
exports: [RoadtripService],
})
export class RoadtripModule {}
@@ -0,0 +1,123 @@
import { Injectable } from '@nestjs/common';
import type { RoadtripVia } from '@trek/shared';
import { DatabaseService } from '../database/database.service';
/**
* Via points: the places a day's drive is made to pass through without stopping.
*
* Kept apart from `day_assignments` on purpose. A stop is somewhere you go — it takes a
* number in the chain, an arrival time and a line in the itinerary. A via only bends the
* route. Modelling one as a place would put a numbered stop in the middle of the day for
* a spot nobody stops at, and it would show up in the PDF, the map pins and the schedule.
*/
@Injectable()
export class RoadtripService {
constructor(private readonly db: DatabaseService) {}
/** The day exists and belongs to this trip. 404 material, checked before every write. */
dayExists(dayId: string | number, tripId: string | number): boolean {
return !!this.db.get<{ id: number }>(
'SELECT id FROM days WHERE id = ? AND trip_id = ?',
dayId,
tripId,
);
}
listForDay(dayId: string | number): RoadtripVia[] {
return this.db.all<RoadtripVia>(
`SELECT id, day_id, after_order_index, sequence, lat, lng, created_at
FROM roadtrip_vias
WHERE day_id = ?
ORDER BY after_order_index, sequence, id`,
dayId,
);
}
/** Every via of a trip, so the client can route all days without one request per day. */
listForTrip(tripId: string | number): RoadtripVia[] {
return this.db.all<RoadtripVia>(
`SELECT v.id, v.day_id, v.after_order_index, v.sequence, v.lat, v.lng, v.created_at
FROM roadtrip_vias v
JOIN days d ON d.id = v.day_id
WHERE d.trip_id = ?
ORDER BY v.day_id, v.after_order_index, v.sequence, v.id`,
tripId,
);
}
create(dayId: string | number, input: { after_order_index: number; lat: number; lng: number; sequence?: number }): RoadtripVia {
// Appended after whatever already follows that stop, unless the caller says where.
const sequence = input.sequence ?? (
this.db.get<{ next: number }>(
'SELECT COALESCE(MAX(sequence) + 1, 0) AS next FROM roadtrip_vias WHERE day_id = ? AND after_order_index = ?',
dayId,
input.after_order_index,
)?.next ?? 0
);
const result = this.db.run(
'INSERT INTO roadtrip_vias (day_id, after_order_index, sequence, lat, lng) VALUES (?, ?, ?, ?, ?)',
dayId,
input.after_order_index,
sequence,
input.lat,
input.lng,
);
return this.byId(Number(result.lastInsertRowid))!;
}
/** 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 }>(
'SELECT id FROM roadtrip_vias WHERE id = ? AND day_id = ?',
id,
dayId,
);
if (!existing) return null;
this.db.run('UPDATE roadtrip_vias SET lat = ?, lng = ? WHERE id = ?', lat, lng, id);
return this.byId(Number(id));
}
/**
* Re-pin a day's vias in one go, after its stops changed shape.
*
* One transaction, because the numbers only mean anything as a set: applying half of a
* shift leaves two vias claiming the same leg and a third pinned past the end of the
* day. `AND day_id = ?` on every statement is what stops an id from another day — or
* another trip — being renumbered through a day the caller does happen to reach.
*
* Silent about ids it does not find. The caller computed this list from a snapshot of
* the day, and a via somebody else deleted in the meantime is not a failure of the
* re-anchoring; failing the batch over it would leave the rest of the day mis-pinned.
*/
reanchor(
dayId: string | number,
input: { vias: { id: number; after_order_index: number }[]; remove?: number[] },
): RoadtripVia[] {
return this.db.transaction(() => {
for (const id of input.remove ?? []) {
this.db.run('DELETE FROM roadtrip_vias WHERE id = ? AND day_id = ?', id, dayId);
}
for (const via of input.vias) {
this.db.run(
'UPDATE roadtrip_vias SET after_order_index = ? WHERE id = ? AND day_id = ?',
via.after_order_index,
via.id,
dayId,
);
}
return this.listForDay(dayId);
});
}
remove(id: string | number, dayId: string | number): boolean {
const result = this.db.run('DELETE FROM roadtrip_vias WHERE id = ? AND day_id = ?', id, dayId);
return result.changes > 0;
}
private byId(id: number): RoadtripVia | null {
return this.db.get<RoadtripVia>(
'SELECT id, day_id, after_order_index, sequence, lat, lng, created_at FROM roadtrip_vias WHERE id = ?',
id,
) ?? null;
}
}
@@ -30,6 +30,18 @@ export const DEFAULTABLE_USER_SETTING_KEYS = [
'default_currency',
'blur_booking_codes',
'map_tile_url',
/**
* Base URL of the routing engine, when the instance runs its own (#1797).
*
* Empty means the public OSRM hosts TREK ships with, which allow roughly one request a
* second — enough for a day plan, not for a road trip that routes every leg of every
* day, and they answer nothing beyond plain routing. Defaultable rather than per-user
* because a routing engine is a property of the deployment, not a taste.
*
* Changing it needs a server restart: the browser's connect-src allowlist is assembled
* once at boot, and a router the policy does not name fails silently.
*/
'routing_base_url',
// CARTO stamps an "API KEY REQUIRED" watermark into keyless tiles (#2054), and
// the key is per-instance rather than per-person: defaultable so one admin
// value clears the watermark for everybody at once.
+3
View File
@@ -78,6 +78,8 @@ export interface Place {
website?: string | null;
phone?: string | null;
transport_mode?: string;
/** What kind of stop this is on a drive (#1797); null for an ordinary place. */
stop_type?: string | null;
created_at?: string;
updated_at?: string;
}
@@ -335,6 +337,7 @@ export interface AssignmentRow extends DayAssignment {
osm_id: string | null;
website: string | null;
phone: string | null;
stop_type: string | null;
category_name: string | null;
category_color: string | null;
category_icon: string | null;