chore: move transit backend to proper DI

Includes the quirk-repair pass (upstream timeout + size cap, LRU cache, NaN-duration guard) that maps landed as a sibling fix commit.
This commit is contained in:
jubnl
2026-07-29 21:39:51 +02:00
parent a7cb4eb880
commit f4bf7cf3dc
17 changed files with 813 additions and 623 deletions
+2 -2
View File
@@ -6,7 +6,6 @@ import { registerAtlasTools } from './tools/atlas';
import { registerPlaceTools } from './tools/places';
import { registerCollectionTools } from './tools/collections';
import { registerTransportTools } from './tools/transports';
import { registerTransitTools } from './tools/transit';
import { registerMcpPrompts } from './tools/prompts';
import { getMcpRegistry } from './registry-handoff';
@@ -46,7 +45,8 @@ export function registerTools(server: McpServer, userId: number, scopes: string[
registerTransportTools(server, userId, scopes);
registerTransitTools(server, userId, scopes);
// The transit tools moved to the DI-discovered src/nest/transit/transit.mcp.ts
// (@McpController, attached via the nest-mcp registry below).
registerJourneyTools(server, userId, scopes);
-217
View File
@@ -1,217 +0,0 @@
import { canAccessTrip } from '../../db/database';
import { RateLimitService } from '../../nest/auth/rate-limit.service';
import { isDemoUser } from '../../services/authService';
import { getDay, listDays } from '../../nest/days/days.bridge';
import { createReservation, notifyBookingChange } from '../../nest/reservations/reservations.bridge';
import {
buildTransitReservationParts,
cleanTransitItineraryNames,
transitCoordinatesMatch,
transitCoordinatesSchema,
transitItinerarySchema,
transitPlaceSchema,
} from '../../services/transitItineraryService';
import { geocode, plan, SCHEDULED_TRANSIT_MODES } from '../../services/transitService';
import { canRead, canWrite } from '../scopes';
import {
demoDenied,
hasTripPermission,
noAccess,
ok,
permissionDenied,
safeBroadcast,
TOOL_ANNOTATIONS_OPEN_WORLD_NON_IDEMPOTENT,
TOOL_ANNOTATIONS_OPEN_WORLD_READONLY,
} from './_shared';
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp';
import { z } from 'zod';
const TRANSIT_RATE_WINDOW = 15 * 60 * 1000;
const transitRateLimiter = new RateLimitService();
const transitModes = z.enum(['TRANSIT', ...SCHEDULED_TRANSIT_MODES]);
function errorResult(err: unknown, fallback: string) {
return {
content: [{ type: 'text' as const, text: err instanceof Error ? err.message : fallback }],
isError: true,
};
}
function rateLimit(userId: number, bucket: string, max: number) {
if (transitRateLimiter.check(bucket, String(userId), max, TRANSIT_RATE_WINDOW, Date.now())) return null;
return {
content: [{ type: 'text' as const, text: 'Too many transit requests. Please try again later.' }],
isError: true,
};
}
export function registerTransitTools(server: McpServer, userId: number, scopes: string[] | null): void {
if (canRead(scopes, 'geo')) {
server.registerTool(
'search_transit_stops',
{
description:
'Search real public-transit stops and stations via Transitous. Use the returned coordinates with search_transit_routes.',
inputSchema: {
query: z.string().min(2).max(200),
language: z.string().min(2).max(5).optional(),
near: z
.object(transitCoordinatesSchema.shape)
.optional()
.describe('Optional coordinates used to bias nearby results'),
},
annotations: TOOL_ANNOTATIONS_OPEN_WORLD_READONLY,
},
async ({ query, language, near }) => {
const limited = rateLimit(userId, 'mcp_transit_geocode', 300);
if (limited) return limited;
try {
return ok(await geocode(query, language, near ? `${near.lat},${near.lng}` : undefined));
} catch (err) {
return errorResult(err, 'Transit stop search failed.');
}
},
);
server.registerTool(
'search_transit_routes',
{
description:
'Search scheduled public-transit routes via Transitous between two coordinates. Returns itineraries that can be passed unchanged to create_transit_journey. `dropped` counts provider itineraries that failed validation and are therefore absent from the results — a non-zero value means the provider offered routes this tool could not represent.',
inputSchema: {
from: transitPlaceSchema,
to: transitPlaceSchema,
time: z
.string()
.datetime({ offset: true })
.optional()
.describe('ISO 8601 departure or arrival time with timezone offset'),
arriveBy: z.boolean().optional().default(false),
modes: z.array(transitModes).max(14).optional(),
maxTransfers: z.number().int().min(0).max(10).optional(),
},
annotations: TOOL_ANNOTATIONS_OPEN_WORLD_READONLY,
},
async ({ from, to, time, arriveBy, modes, maxTransfers }) => {
const limited = rateLimit(userId, 'mcp_transit_plan', 60);
if (limited) return limited;
try {
const result = await plan({
from: `${from.lat},${from.lng}`,
to: `${to.lat},${to.lng}`,
time,
arriveBy,
modes: modes?.join(','),
maxTransfers,
});
const itineraries = result.itineraries.flatMap((itinerary) => {
const parsed = transitItinerarySchema.safeParse(cleanTransitItineraryNames(itinerary, from.name, to.name));
if (!parsed.success) return [];
const firstStop = parsed.data.legs[0].from;
const lastStop = parsed.data.legs[parsed.data.legs.length - 1].to;
return transitCoordinatesMatch(from, firstStop) && transitCoordinatesMatch(to, lastStop)
? [parsed.data]
: [];
});
// A rejected itinerary is provider data we could not vouch for, but dropping it
// silently is indistinguishable from "no routes exist" — report the count so the
// caller knows the difference.
return ok({ itineraries, dropped: result.itineraries.length - itineraries.length });
} catch (err) {
return errorResult(err, 'Transit route search failed.');
}
},
);
}
if (!canWrite(scopes, 'reservations')) return;
server.registerTool(
'create_transit_journey',
{
description:
'Add one itinerary returned by search_transit_routes to a trip day as a first-class automated public-transit journey.',
inputSchema: {
tripId: z.number().int().positive(),
dayId: z.number().int().positive().describe('Trip day on which the journey departs'),
from: transitPlaceSchema,
to: transitPlaceSchema,
itinerary: transitItinerarySchema,
notes: z.string().max(1000).optional(),
},
annotations: TOOL_ANNOTATIONS_OPEN_WORLD_NON_IDEMPOTENT,
},
async ({ tripId, dayId, from, to, itinerary, notes }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
if (!hasTripPermission('reservation_edit', tripId, userId)) return permissionDenied();
const day = getDay(dayId, tripId);
if (!day) {
return { content: [{ type: 'text' as const, text: 'dayId does not belong to this trip.' }], isError: true };
}
const cleaned = cleanTransitItineraryNames(itinerary, from.name, to.name);
const firstStop = cleaned.legs[0].from;
const lastStop = cleaned.legs[cleaned.legs.length - 1].to;
if (!transitCoordinatesMatch(from, firstStop) || !transitCoordinatesMatch(to, lastStop)) {
return {
content: [
{ type: 'text' as const, text: 'The itinerary does not match the requested origin and destination.' },
],
isError: true,
};
}
let reservationParts: ReturnType<typeof buildTransitReservationParts>;
try {
reservationParts = buildTransitReservationParts(from, to, cleaned);
} catch (err) {
return errorResult(err, 'Unable to resolve the transit journey timezones.');
}
const { endpoints, metadata } = reservationParts;
const departure = endpoints[0];
const arrival = endpoints[endpoints.length - 1];
if (!day.date) {
return {
content: [{ type: 'text' as const, text: 'Automated transit requires a dated trip day.' }],
isError: true,
};
}
if (departure.local_date !== day.date) {
return {
content: [
{
type: 'text' as const,
text: `The journey departs on ${departure.local_date}, but dayId is ${day.date}.`,
},
],
isError: true,
};
}
const endDay = listDays(tripId).days.find((day) => day.date === arrival.local_date);
if (!endDay) {
return {
content: [{ type: 'text' as const, text: `No trip day exists for the arrival date ${arrival.local_date}.` }],
isError: true,
};
}
const { reservation } = createReservation(tripId, {
title: `${from.name} → ${to.name}`,
type: 'transit',
status: 'confirmed',
day_id: dayId,
end_day_id: endDay.id,
reservation_time: `${departure.local_date}T${departure.local_time}`,
reservation_end_time: `${arrival.local_date}T${arrival.local_time}`,
notes,
metadata,
endpoints,
needs_review: false,
});
safeBroadcast(tripId, 'reservation:created', { reservation });
notifyBookingChange(tripId, userId, reservation.title, reservation.type || '');
return ok({ reservation });
},
);
}
+22 -7
View File
@@ -28,8 +28,8 @@ mount to Nest and leaves the sibling trip routes (days, places, ...) on Express.
- **Phase 2 (trip sub-domains):** vacay (addon), packing, todo.
- **DI-native services (legacy `src/services/*` deleted):** tags, categories,
todo, packing, day-notes, trip-invite, assignments, share, settings, files,
collab, vacay, reservations, day, permissions, audit, budget, trip, maps — see
the migration recipe below.
collab, vacay, reservations, day, permissions, audit, budget, trip, maps,
transit — see the migration recipe below.
## Cross-cutting Foundation pieces
@@ -142,8 +142,9 @@ DI-native at the edge, so the 626-line legacy module folded into it — a 5-tool
reservation import (`notifyBookingChange`) swapped for the injected service,
`TripsService` and `BookingImportService` converted from function imports to
injection, and a 9-export + 3-type `reservations.bridge.ts` for the legacy
`tripService`, the airtrail import/sync pair and the still-legacy transit +
transports registrars; the DTO ratchet for its 4 grandfathered body contracts
`tripService`, the airtrail import/sync pair and the still-legacy transports
registrar (the transit registrar has since migrated to `transit.mcp.ts`); the
DTO ratchet for its 4 grandfathered body contracts
landed as a sibling commit, which also loosened the shared positions schema to
the real wire contract — `day_plan_position` optional, pinned by RESV-006);
day followed (the 592-line legacy `dayService` folded into the existing
@@ -154,7 +155,8 @@ reorder/insert converted to `db.transaction()`; a 7-tool + 2-resource
`days.mcp.ts`, the plugin host's 11-symbol import swapped for the injected
service, `TripsService` + the assignments/reservations MCP controllers
converted to injection, and a 6-export `days.bridge.ts` for the legacy
`tripService` and the still-legacy transit + transports registrars; the DTO
`tripService` and the still-legacy transports registrar (transit has since
migrated); the DTO
ratchet for its 4 day + 2 accommodation grandfathered body contracts landed
as a sibling commit);
permissions followed (the first Wave-2 **cross-cutting** migration and the
@@ -227,7 +229,7 @@ mapsService followed (the 1429-line geo core — Google Places, Nominatim,
Overpass mirror racing, Wikimedia photos, Maps-URL resolution — folded into the
wrapper `MapsService`; the pure parser/UA/POI-category helpers moved to
`maps.helpers.ts` as plain exports (files.constants/client-ip precedent —
transitService's User-Agent imports from there, not a bridge), the module-scoped
the DI-native TransitService's User-Agent imports from there, not a bridge), the module-scoped
POI cache / photo-fetch semaphore / frozen Overpass mirrors stayed module-scoped
on purpose (permissions-cache precedent: the bridge instance and the DI
singleton share them); the 3 geo tools left the mixed `mcp/tools/mapsWeather.ts`
@@ -236,8 +238,21 @@ its weather + airport tools await their own migrations); BookingImportService
injects `MapsService` for its Nominatim geocoding, and a 3-export
`maps.bridge.ts` serves the legacy placeEnrichment helper and the places
registrar until the place domain migrates).
transitService followed (the first fully SQL-free domain fold — the 333-line
Transitous/MOTIS proxy became a dep-free `TransitService` (no
`DatabaseService`; the ExchangeRatesService precedent), its response cache,
frozen-at-import `TRANSIT_API_BASE` and lazy User-Agent memo staying
module-scoped on purpose; the pure `deriveTransitStats` + mode whitelist +
itinerary types moved to `transit.helpers.ts` (maps.helpers precedent) so the
downstream legacy `transitItineraryService` needs no bridge; the whole 3-tool
`mcp/tools/transit.ts` registrar moved to `transit.mcp.ts` — the two geo
search tools on `access: { group: 'geo', mode: 'read' }` and
`create_transit_journey` on `reservations:write`, with its days/reservations
bridge imports becoming injected `DaysService`/`ReservationsService` (+
`DatabaseService` for `canAccessTrip`) — leaving **zero bridge files** and the
transports registrar as `days.bridge.ts`'s last consumer).
Repeat these steps per
service (next up: **transitService → placeService → transitItineraryService** —
service (next up: **placeService → transitItineraryService** —
or the notifications fan-in — per the dependency-honest order in
`migration-graph.md`). This is a
**pure relocation** — byte-identical
+5 -4
View File
@@ -6,15 +6,16 @@ import { PermissionsService } from '../permissions/permissions.service';
/**
* Non-Nest entry point for the day domain — for code running OUTSIDE the
* Nest container (the legacy transit/transports MCP registrars in
* src/mcp/tools/; the day MCP tools and resources moved to the DI-discovered
* days.mcp.ts, the plugin RPC host injects DaysService via
* Nest container (the legacy transports MCP registrar in src/mcp/tools/;
* the day MCP tools and resources moved to the DI-discovered days.mcp.ts,
* the transit tools to the DI-discovered transit.mcp.ts (which injects
* DaysService), the plugin RPC host injects DaysService via
* PluginHostDepsFactory, and the trip domain folded into the DI-native
* TripsService, which injects DaysService — its bridge exports were pruned
* with it). Exports only the legacy services/dayService names still consumed
* outside the container, 1:1, so repointing a consumer is an import-path-only
* diff. Inside the container, inject DaysService instead. Delete this file
* when the transit/transports registrars migrate.
* when the transports registrar migrates.
*
* Module-level construction is safe: `db` is the reinitialize-proof Proxy onto
* the shared better-sqlite3 singleton.
@@ -11,10 +11,11 @@ export type { EndpointInput, ReservationEndpoint, ReservationTraveler } from './
/**
* Non-Nest entry point for the reservations domain — for code running OUTSIDE
* the Nest container (the legacy tripService, the airtrail import/sync
* services, and the still-legacy transit + transports MCP registrars; the
* reservation MCP tools and resource moved to the DI-discovered
* reservations.mcp.ts, and the plugin RPC host injects ReservationsService via
* PluginHostDepsFactory). Exports only the legacy services/reservationService
* services, and the still-legacy transports MCP registrar; the reservation
* MCP tools and resource moved to the DI-discovered reservations.mcp.ts, the
* transit tools to the DI-discovered transit.mcp.ts (which injects
* ReservationsService), and the plugin RPC host injects ReservationsService
* via PluginHostDepsFactory). Exports only the legacy services/reservationService
* names still consumed outside the container, 1:1, so repointing a consumer is
* an import-path-only diff. Inside the container, inject ReservationsService
* instead. Delete exports here as their consumers migrate.
@@ -2,7 +2,7 @@ import { Controller, Get, HttpException, Query, Req, UseGuards } from '@nestjs/c
import type { Request } from 'express';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { RateLimitService } from '../auth/rate-limit.service';
import * as transit from '../../services/transitService';
import { TransitService } from './transit.service';
const RL_WINDOW = 15 * 60 * 1000;
@@ -15,7 +15,10 @@ const RL_WINDOW = 15 * 60 * 1000;
@Controller('api/transit')
@UseGuards(JwtAuthGuard)
export class TransitController {
constructor(private readonly rl: RateLimitService) {}
constructor(
private readonly rl: RateLimitService,
private readonly transit: TransitService,
) {}
private limit(bucket: string, req: Request, max: number): void {
if (!this.rl.check(bucket, req.ip || 'unknown', max, RL_WINDOW, Date.now())) {
@@ -38,7 +41,7 @@ export class TransitController {
) {
this.limit('transit_geocode', req, 300);
try {
return await transit.geocode(q || '', lang, near);
return await this.transit.geocode(q || '', lang, near);
} catch (err) { this.rethrow(err); }
}
@@ -54,7 +57,7 @@ export class TransitController {
) {
this.limit('transit_plan', req, 60);
try {
return await transit.plan({
return await this.transit.plan({
from: from || '',
to: to || '',
time,
+100
View File
@@ -0,0 +1,100 @@
/**
* Pure transit helpers shared by TransitService and plain-module code outside
* the container (the legacy services/transitItineraryService imports
* deriveTransitStats + the itinerary types). Kept as plain functions/consts
* (the maps.helpers.ts / files.constants.ts precedent) so no bridge is needed —
* helpers never block a migration.
*/
// Modes the client may request — a strict whitelist so the proxy can't be used
// to smuggle arbitrary query values upstream. TRANSIT covers everything; the
// others let the user filter (RAIL already includes subway/suburban etc.).
export const SCHEDULED_TRANSIT_MODES = [
'BUS',
'COACH',
'TRAM',
'SUBWAY',
'RAIL',
'FERRY',
'FUNICULAR',
'AERIAL_LIFT',
// Fine-grained rail modes so "train without subway" is expressible (RAIL
// itself includes SUBWAY per the MOTIS mode taxonomy).
'HIGHSPEED_RAIL',
'LONG_DISTANCE',
'NIGHT_RAIL',
'REGIONAL_RAIL',
'SUBURBAN',
] as const;
export interface TransitPlace {
name: string;
lat: number;
lng: number;
type: string;
area: string | null;
}
export interface TransitLegStop {
name: string;
lat: number;
lng: number;
time: string | null;
scheduledTime: string | null;
track: string | null;
}
export interface TransitLeg {
mode: string;
from: TransitLegStop;
to: TransitLegStop;
duration: number;
distance: number | null;
headsign: string | null;
line: string | null;
lineColor: string | null;
lineTextColor: string | null;
agency: string | null;
intermediateStops: number;
/** Encoded polyline of the leg's real path (Google encoding) + its precision. */
geometry: string | null;
geometryPrecision: number;
}
export interface TransitItinerary {
startTime: string;
endTime: string;
duration: number;
transfers: number;
walkSeconds: number;
legs: TransitLeg[];
}
export interface PlanQuery {
from: string;
to: string;
time?: string;
arriveBy?: boolean;
modes?: string;
maxTransfers?: number;
}
export function deriveTransitStats(
startTime: string,
endTime: string,
legs: TransitLeg[],
reportedTransfers?: number,
): Pick<TransitItinerary, 'duration' | 'transfers' | 'walkSeconds'> {
// Number.isFinite, not bare Math.max: an unparseable provider timestamp
// yields NaN, which Math.max passes through and JSON.stringify turns into
// null — report 0 instead of a null duration.
const wallClock = Math.round((new Date(endTime).getTime() - new Date(startTime).getTime()) / 1000);
return {
duration: Number.isFinite(wallClock) ? Math.max(0, wallClock) : 0,
transfers:
typeof reportedTransfers === 'number'
? reportedTransfers
: Math.max(0, legs.filter((leg) => leg.mode !== 'WALK').length - 1),
walkSeconds: legs.filter((leg) => leg.mode === 'WALK').reduce((total, leg) => total + leg.duration, 0),
};
}
+253
View File
@@ -0,0 +1,253 @@
import {
McpController, Tool, type McpContext,
TOOL_ANNOTATIONS_OPEN_WORLD_NON_IDEMPOTENT, TOOL_ANNOTATIONS_OPEN_WORLD_READONLY,
demoDenied, ok,
} from '@trek/nest-mcp';
import { z } from 'zod';
import { isDemoUser } from '../../services/authService';
import {
buildTransitReservationParts,
cleanTransitItineraryNames,
transitCoordinatesMatch,
transitCoordinatesSchema,
transitItinerarySchema,
transitPlaceSchema,
} from '../../services/transitItineraryService';
import { hasTripPermission, noAccess, permissionDenied, safeBroadcast } from '../../mcp/tools/_shared';
import { RateLimitService } from '../auth/rate-limit.service';
import { DatabaseService } from '../database/database.service';
import { DaysService } from '../days/days.service';
import { ReservationsService } from '../reservations/reservations.service';
import { SCHEDULED_TRANSIT_MODES, type TransitItinerary } from './transit.helpers';
import { TransitService } from './transit.service';
const TRANSIT_RATE_WINDOW = 15 * 60 * 1000;
// Deliberately its own instance, separate from the REST controller's: the MCP
// buckets are keyed by userId (mcp_transit_*), the REST ones by req.ip.
const transitRateLimiter = new RateLimitService();
const transitModes = z.enum(['TRANSIT', ...SCHEDULED_TRANSIT_MODES]);
// Local 2-arg variant (the @trek/nest-mcp errorResult takes a string only):
// surface the provider/validation message when there is one, else the fallback.
function errorResult(err: unknown, fallback: string) {
return {
content: [{ type: 'text' as const, text: err instanceof Error ? err.message : fallback }],
isError: true,
};
}
function rateLimit(userId: number, bucket: string, max: number) {
if (transitRateLimiter.check(bucket, String(userId), max, TRANSIT_RATE_WINDOW, Date.now())) return null;
return {
content: [{ type: 'text' as const, text: 'Too many transit requests. Please try again later.' }],
isError: true,
};
}
/**
* Transit MCP surface — ported 1:1 from the legacy registrar
* src/mcp/tools/transit.ts (identical names, descriptions, schemas,
* annotations, error/payload shapes, rate limits and broadcasts). The
* registration-time gates map to the declarative access markers: the
* `canRead(scopes, 'geo')` wrap on the two search tools ('geo' is a read-only
* scope group — there is no geo:write) and the `canWrite(scopes,
* 'reservations')` early return on create_transit_journey. The
* days/reservations bridge imports became injected services; the itinerary
* schemas/helpers still come from the legacy transitItineraryService until
* that domain migrates.
*/
@McpController()
export class TransitMcp {
constructor(
private readonly transit: TransitService,
private readonly days: DaysService,
private readonly reservations: ReservationsService,
private readonly db: DatabaseService,
) {}
@Tool({
name: 'search_transit_stops',
description:
'Search real public-transit stops and stations via Transitous. Use the returned coordinates with search_transit_routes.',
inputSchema: {
query: z.string().min(2).max(200),
language: z.string().min(2).max(5).optional(),
near: z
.object(transitCoordinatesSchema.shape)
.optional()
.describe('Optional coordinates used to bias nearby results'),
},
annotations: TOOL_ANNOTATIONS_OPEN_WORLD_READONLY,
access: { group: 'geo', mode: 'read' },
})
async searchTransitStops(
{ query, language, near }: { query: string; language?: string; near?: { lat: number; lng: number } },
ctx: McpContext,
) {
const limited = rateLimit(ctx.userId, 'mcp_transit_geocode', 300);
if (limited) return limited;
try {
return ok(await this.transit.geocode(query, language, near ? `${near.lat},${near.lng}` : undefined));
} catch (err) {
return errorResult(err, 'Transit stop search failed.');
}
}
@Tool({
name: 'search_transit_routes',
description:
'Search scheduled public-transit routes via Transitous between two coordinates. Returns itineraries that can be passed unchanged to create_transit_journey. `dropped` counts provider itineraries that failed validation and are therefore absent from the results — a non-zero value means the provider offered routes this tool could not represent.',
inputSchema: {
from: transitPlaceSchema,
to: transitPlaceSchema,
time: z
.string()
.datetime({ offset: true })
.optional()
.describe('ISO 8601 departure or arrival time with timezone offset'),
arriveBy: z.boolean().optional().default(false),
modes: z.array(transitModes).max(14).optional(),
maxTransfers: z.number().int().min(0).max(10).optional(),
},
annotations: TOOL_ANNOTATIONS_OPEN_WORLD_READONLY,
access: { group: 'geo', mode: 'read' },
})
async searchTransitRoutes(
{ from, to, time, arriveBy, modes, maxTransfers }: {
from: z.infer<typeof transitPlaceSchema>;
to: z.infer<typeof transitPlaceSchema>;
time?: string;
arriveBy?: boolean;
modes?: z.infer<typeof transitModes>[];
maxTransfers?: number;
},
ctx: McpContext,
) {
const limited = rateLimit(ctx.userId, 'mcp_transit_plan', 60);
if (limited) return limited;
try {
const result = await this.transit.plan({
from: `${from.lat},${from.lng}`,
to: `${to.lat},${to.lng}`,
time,
arriveBy,
modes: modes?.join(','),
maxTransfers,
});
const itineraries = result.itineraries.flatMap((itinerary) => {
const parsed = transitItinerarySchema.safeParse(cleanTransitItineraryNames(itinerary, from.name, to.name));
if (!parsed.success) return [];
const firstStop = parsed.data.legs[0].from;
const lastStop = parsed.data.legs[parsed.data.legs.length - 1].to;
return transitCoordinatesMatch(from, firstStop) && transitCoordinatesMatch(to, lastStop)
? [parsed.data]
: [];
});
// A rejected itinerary is provider data we could not vouch for, but dropping it
// silently is indistinguishable from "no routes exist" — report the count so the
// caller knows the difference.
return ok({ itineraries, dropped: result.itineraries.length - itineraries.length });
} catch (err) {
return errorResult(err, 'Transit route search failed.');
}
}
@Tool({
name: 'create_transit_journey',
description:
'Add one itinerary returned by search_transit_routes to a trip day as a first-class automated public-transit journey.',
inputSchema: {
tripId: z.number().int().positive(),
dayId: z.number().int().positive().describe('Trip day on which the journey departs'),
from: transitPlaceSchema,
to: transitPlaceSchema,
itinerary: transitItinerarySchema,
notes: z.string().max(1000).optional(),
},
annotations: TOOL_ANNOTATIONS_OPEN_WORLD_NON_IDEMPOTENT,
access: { group: 'reservations', mode: 'write' },
})
async createTransitJourney(
{ tripId, dayId, from, to, itinerary, notes }: {
tripId: number;
dayId: number;
from: z.infer<typeof transitPlaceSchema>;
to: z.infer<typeof transitPlaceSchema>;
// The schema validates exactly the wire TransitItinerary shape; its
// z.infer prints looser optionals, so name the contract type directly.
itinerary: TransitItinerary;
notes?: string;
},
ctx: McpContext,
) {
if (isDemoUser(ctx.userId)) return demoDenied();
if (!this.db.canAccessTrip(tripId, ctx.userId)) return noAccess();
if (!hasTripPermission('reservation_edit', tripId, ctx.userId)) return permissionDenied();
const day = this.days.getDay(dayId, tripId);
if (!day) {
return { content: [{ type: 'text' as const, text: 'dayId does not belong to this trip.' }], isError: true };
}
const cleaned = cleanTransitItineraryNames(itinerary, from.name, to.name);
const firstStop = cleaned.legs[0].from;
const lastStop = cleaned.legs[cleaned.legs.length - 1].to;
if (!transitCoordinatesMatch(from, firstStop) || !transitCoordinatesMatch(to, lastStop)) {
return {
content: [
{ type: 'text' as const, text: 'The itinerary does not match the requested origin and destination.' },
],
isError: true,
};
}
let reservationParts: ReturnType<typeof buildTransitReservationParts>;
try {
reservationParts = buildTransitReservationParts(from, to, cleaned);
} catch (err) {
return errorResult(err, 'Unable to resolve the transit journey timezones.');
}
const { endpoints, metadata } = reservationParts;
const departure = endpoints[0];
const arrival = endpoints[endpoints.length - 1];
if (!day.date) {
return {
content: [{ type: 'text' as const, text: 'Automated transit requires a dated trip day.' }],
isError: true,
};
}
if (departure.local_date !== day.date) {
return {
content: [
{
type: 'text' as const,
text: `The journey departs on ${departure.local_date}, but dayId is ${day.date}.`,
},
],
isError: true,
};
}
const endDay = this.days.list(tripId).days.find((day) => day.date === arrival.local_date);
if (!endDay) {
return {
content: [{ type: 'text' as const, text: `No trip day exists for the arrival date ${arrival.local_date}.` }],
isError: true,
};
}
const { reservation } = this.reservations.create(tripId, {
title: `${from.name} → ${to.name}`,
type: 'transit',
status: 'confirmed',
day_id: dayId,
end_day_id: endDay.id,
reservation_time: `${departure.local_date}T${departure.local_time}`,
reservation_end_time: `${arrival.local_date}T${arrival.local_time}`,
notes,
metadata,
endpoints,
needs_review: false,
});
safeBroadcast(tripId, 'reservation:created', { reservation });
this.reservations.notifyBookingChange(tripId, ctx.userId, reservation.title, reservation.type || '');
return ok({ reservation });
}
}
+13 -1
View File
@@ -1,9 +1,21 @@
import { Module } from '@nestjs/common';
import { TransitController } from './transit.controller';
import { TransitService } from './transit.service';
import { TransitMcp } from './transit.mcp';
import { RateLimitService } from '../auth/rate-limit.service';
import { DaysModule } from '../days/days.module';
import { ReservationsModule } from '../reservations/reservations.module';
/**
* Transit domain (#1065) — the Transitous/MOTIS proxy. TransitMcp carries the
* decorator-registered MCP tools; DaysModule/ReservationsModule feed
* create_transit_journey. Exports TransitService for in-container consumers.
*/
@Module({
// DaysModule + ReservationsModule: TransitMcp's create_transit_journey injects both.
imports: [DaysModule, ReservationsModule],
controllers: [TransitController],
providers: [RateLimitService],
providers: [TransitService, TransitMcp, RateLimitService],
exports: [TransitService],
})
export class TransitModule {}
+281
View File
@@ -0,0 +1,281 @@
import { Injectable } from '@nestjs/common';
import { getAppUrl, readEnv } from '../../app-config';
import { buildUserAgent } from '../maps/maps.helpers';
import {
deriveTransitStats,
SCHEDULED_TRANSIT_MODES,
type PlanQuery,
type TransitItinerary,
type TransitLeg,
type TransitLegStop,
type TransitPlace,
} from './transit.helpers';
/**
* Public transit routing (#1065) backed by Transitous (api.transitous.org), the
* community-run MOTIS instance over public GTFS feeds — free, no API key, fits
* TREK's no-paid-providers rule. Self-hosters can point TRANSIT_API_URL at their
* own MOTIS instance instead.
*
* This service is a thin, validating proxy: the browser never talks to
* Transitous directly (their usage policy wants an identifying User-Agent with
* contact info, which we send once from the server), and responses are mapped
* to a compact shape so the client isn't coupled to the MOTIS schema.
*
* Folded 1:1 from the legacy services/transitService.ts. The base URL, the
* lazy User-Agent memo and the response cache stay module-scoped on purpose
* (the permissions/exchange-rates precedent): the DI singleton and any future
* non-Nest caller share one cache, and the base URL keeps its frozen-at-import
* legacy timing.
*/
// Frozen at import on purpose (legacy timing).
const TRANSIT_API_BASE = readEnv().integrations.transitApiBase;
let userAgent: string | null = null;
function getUserAgent(): string {
userAgent ??= buildUserAgent(getAppUrl());
return userAgent;
}
const ALLOWED_MODES = new Set(['TRANSIT', ...SCHEDULED_TRANSIT_MODES]);
// Short-lived response cache: planning is the expensive call per the Transitous
// usage policy, and a user toggling filters re-requests identical plans.
const CACHE_TTL = 60 * 1000;
const CACHE_MAX = 200;
const cache = new Map<string, { at: number; data: unknown }>();
function cacheGet(key: string): unknown | null {
const hit = cache.get(key);
if (!hit) return null;
if (Date.now() - hit.at > CACHE_TTL) {
cache.delete(key);
return null;
}
// Refresh recency (true LRU): geocode and plan share this Map, so without
// the re-insert a burst of geocoding evicts still-hot plan entries.
cache.delete(key);
cache.set(key, hit);
return hit.data;
}
function cacheSet(key: string, data: unknown): void {
if (cache.size >= CACHE_MAX) {
const oldest = cache.keys().next().value;
if (oldest !== undefined) cache.delete(oldest);
}
cache.set(key, { at: Date.now(), data });
}
const COORD_RE = /^-?\d{1,3}(\.\d+)?,-?\d{1,3}(\.\d+)?$/;
function isCoord(v: string): boolean {
if (!COORD_RE.test(v)) return false;
const [lat, lng] = v.split(',').map(Number);
return Math.abs(lat) <= 90 && Math.abs(lng) <= 180;
}
// A plan response with eight geometry-carrying itineraries stays well under a
// megabyte; anything bigger is a misbehaving provider, not data we want to map.
const MAX_RESPONSE_BYTES = 5_000_000;
async function upstream(path: string, params: URLSearchParams): Promise<unknown> {
const url = `${TRANSIT_API_BASE}${path}?${params}`;
const res = await fetch(url, {
headers: { 'User-Agent': getUserAgent(), Accept: 'application/json' },
signal: AbortSignal.timeout(8000),
});
if (!res.ok) {
const err = new Error(`Transit provider error (HTTP ${res.status})`) as Error & { status: number };
err.status = res.status === 429 ? 429 : 502;
throw err;
}
const length = Number(res.headers?.get('content-length') ?? 0);
if (length > MAX_RESPONSE_BYTES) {
const err = new Error('Transit provider error (response too large)') as Error & { status: number };
err.status = 502;
throw err;
}
return res.json();
}
// GTFS colors come as bare hex ("FF0000"), with hash, or empty — normalise to
// a #-prefixed value or null so the client can use them in CSS directly.
function safeColor(v: unknown): string | null {
if (typeof v !== 'string') return null;
const hex = v.trim().replace(/^#/, '');
return /^[0-9a-fA-F]{6}$/.test(hex) || /^[0-9a-fA-F]{3}$/.test(hex) ? `#${hex}` : null;
}
interface MotisPlaceRaw {
name?: string;
lat?: number;
lon?: number;
departure?: string;
arrival?: string;
scheduledDeparture?: string;
scheduledArrival?: string;
track?: string;
scheduledTrack?: string;
}
function mapStop(p: MotisPlaceRaw | undefined, kind: 'departure' | 'arrival'): TransitLegStop {
return {
name: p?.name || '',
// A missing coordinate becomes 0 (null island) rather than null on
// purpose: TransitLegStop.lat/lng are non-nullable across the wire
// contract (transitStopSchema, the client picker) — making them nullable
// would ripple through transitItineraryService's validation and drop
// otherwise-usable itineraries. MOTIS places carry coordinates in
// practice; endpoint stops are re-checked by transitCoordinatesMatch.
lat: typeof p?.lat === 'number' ? p.lat : 0,
lng: typeof p?.lon === 'number' ? p.lon : 0,
time: (kind === 'departure' ? p?.departure : p?.arrival) || null,
scheduledTime: (kind === 'departure' ? p?.scheduledDeparture : p?.scheduledArrival) || null,
track: p?.track || p?.scheduledTrack || null,
};
}
@Injectable()
export class TransitService {
/** Station/place search for the from/to pickers. `near` biases results. */
async geocode(query: string, language?: string, near?: string): Promise<{ results: TransitPlace[] }> {
const text = (query || '').trim();
if (text.length < 2) return { results: [] };
if (text.length > 200) {
const e = new Error('Query too long') as Error & { status: number };
e.status = 400;
throw e;
}
const params = new URLSearchParams({ text });
if (language) params.set('language', language.slice(0, 5));
if (near && isCoord(near)) params.set('place', near);
const key = `geo:${params.toString()}`;
const cached = cacheGet(key);
if (cached) return cached as { results: TransitPlace[] };
const raw = (await upstream('/api/v1/geocode', params)) as Array<{
name?: string;
lat?: number;
lon?: number;
type?: string;
areas?: Array<{ name?: string; matched?: boolean; default?: boolean }>;
}>;
const results: TransitPlace[] = (Array.isArray(raw) ? raw : []).slice(0, 8).flatMap((m) => {
if (typeof m.lat !== 'number' || typeof m.lon !== 'number' || !m.name) return [];
const area = m.areas?.find((a) => a.default)?.name || m.areas?.[0]?.name || null;
return [{ name: m.name, lat: m.lat, lng: m.lon, type: m.type || 'PLACE', area }];
});
const data = { results };
cacheSet(key, data);
return data;
}
/** Route search between two coordinates. Returns compact itineraries for the picker. */
async plan(q: PlanQuery): Promise<{ itineraries: TransitItinerary[] }> {
const bad = (msg: string) => {
const e = new Error(msg) as Error & { status: number };
e.status = 400;
throw e;
};
if (!q.from || !isCoord(q.from)) bad('from must be "lat,lng"');
if (!q.to || !isCoord(q.to)) bad('to must be "lat,lng"');
const params = new URLSearchParams({ fromPlace: q.from, toPlace: q.to, numItineraries: '8' });
if (q.time) {
const parsed = new Date(q.time);
if (isNaN(parsed.getTime())) bad('time must be an ISO date-time');
params.set('time', parsed.toISOString());
}
if (q.arriveBy) params.set('arriveBy', 'true');
if (q.modes) {
const modes = q.modes
.split(',')
.map((m) => m.trim().toUpperCase())
.filter(Boolean);
if (modes.some((m) => !ALLOWED_MODES.has(m))) bad('unsupported transit mode');
if (modes.length > 0) params.set('transitModes', modes.join(','));
}
if (q.maxTransfers !== undefined && q.maxTransfers !== null) {
const n = Number(q.maxTransfers);
if (!Number.isInteger(n) || n < 0 || n > 10) bad('maxTransfers must be 0-10');
params.set('maxTransfers', String(n));
}
// We only want scheduled transit journeys in the results — a pure-walk
// "direct" connection is what the existing OSRM footpath routing is for.
params.set('directModes', 'WALK');
const key = `plan:${params.toString()}`;
const cached = cacheGet(key);
if (cached) return cached as { itineraries: TransitItinerary[] };
const raw = (await upstream('/api/v6/plan', params)) as {
itineraries?: Array<{
duration?: number;
startTime?: string;
endTime?: string;
transfers?: number;
legs?: Array<{
mode?: string;
duration?: number;
distance?: number;
headsign?: string;
routeShortName?: string;
displayName?: string;
routeColor?: string;
routeTextColor?: string;
agencyName?: string;
from?: MotisPlaceRaw;
to?: MotisPlaceRaw;
intermediateStops?: unknown[];
legGeometry?: { points?: string; precision?: number };
}>;
}>;
};
const itineraries: TransitItinerary[] = (raw.itineraries || []).flatMap((it) => {
if (!it.startTime || !it.endTime || !Array.isArray(it.legs)) return [];
const legs: TransitLeg[] = it.legs.map((leg) => ({
mode: (leg.mode || 'WALK').toUpperCase(),
from: mapStop(leg.from, 'departure'),
to: mapStop(leg.to, 'arrival'),
duration: typeof leg.duration === 'number' ? leg.duration : 0,
distance: typeof leg.distance === 'number' ? Math.round(leg.distance) : null,
headsign: leg.headsign || null,
// displayName is the public identifier MOTIS resolved for the run, taking
// the trip number into account where the feed has one. Plenty of operators
// (German long distance among them) keep routeShortName as an internal line
// number, so preferring it showed "20" instead of "ICE 72" (#1715).
line: leg.displayName || leg.routeShortName || null,
lineColor: safeColor(leg.routeColor),
lineTextColor: safeColor(leg.routeTextColor),
agency: leg.agencyName || null,
intermediateStops: Array.isArray(leg.intermediateStops) ? leg.intermediateStops.length : 0,
geometry: leg.legGeometry?.points || null,
geometryPrecision: leg.legGeometry?.precision ?? 6,
}));
const stats = deriveTransitStats(it.startTime, it.endTime, legs, it.transfers);
return [
{
startTime: it.startTime,
endTime: it.endTime,
// Wall-clock duration (start→end) so waits/transfers count — summing leg
// run-times would understate the journey and mis-slot it in the timeline.
...stats,
legs,
},
];
});
const data = { itineraries };
cacheSet(key, data);
return data;
}
}
@@ -1,7 +1,7 @@
import { haversineKm } from './distanceService';
import type { EndpointInput } from '../nest/reservations/reservations.bridge';
import { localParts, resolveTimeZone } from './timezoneService';
import { deriveTransitStats, type TransitItinerary, type TransitLeg } from './transitService';
import { deriveTransitStats, type TransitItinerary, type TransitLeg } from '../nest/transit/transit.helpers';
import { z } from 'zod';
-333
View File
@@ -1,333 +0,0 @@
import { readEnv } from '../app-config';
import { buildUserAgent } from '../nest/maps/maps.helpers';
import { getAppUrl } from '../app-config';
/**
* Public transit routing (#1065) backed by Transitous (api.transitous.org), the
* community-run MOTIS instance over public GTFS feeds — free, no API key, fits
* TREK's no-paid-providers rule. Self-hosters can point TRANSIT_API_URL at their
* own MOTIS instance instead.
*
* This service is a thin, validating proxy: the browser never talks to
* Transitous directly (their usage policy wants an identifying User-Agent with
* contact info, which we send once from the server), and responses are mapped
* to a compact shape so the client isn't coupled to the MOTIS schema.
*/
// Frozen at import on purpose (legacy timing).
const TRANSIT_API_BASE = readEnv().integrations.transitApiBase;
let userAgent: string | null = null;
function getUserAgent(): string {
userAgent ??= buildUserAgent(getAppUrl());
return userAgent;
}
// Modes the client may request — a strict whitelist so the proxy can't be used
// to smuggle arbitrary query values upstream. TRANSIT covers everything; the
// others let the user filter (RAIL already includes subway/suburban etc.).
export const SCHEDULED_TRANSIT_MODES = [
'BUS',
'COACH',
'TRAM',
'SUBWAY',
'RAIL',
'FERRY',
'FUNICULAR',
'AERIAL_LIFT',
// Fine-grained rail modes so "train without subway" is expressible (RAIL
// itself includes SUBWAY per the MOTIS mode taxonomy).
'HIGHSPEED_RAIL',
'LONG_DISTANCE',
'NIGHT_RAIL',
'REGIONAL_RAIL',
'SUBURBAN',
] as const;
const ALLOWED_MODES = new Set(['TRANSIT', ...SCHEDULED_TRANSIT_MODES]);
// Short-lived response cache: planning is the expensive call per the Transitous
// usage policy, and a user toggling filters re-requests identical plans.
const CACHE_TTL = 60 * 1000;
const CACHE_MAX = 200;
const cache = new Map<string, { at: number; data: unknown }>();
function cacheGet(key: string): unknown | null {
const hit = cache.get(key);
if (!hit) return null;
if (Date.now() - hit.at > CACHE_TTL) {
cache.delete(key);
return null;
}
return hit.data;
}
function cacheSet(key: string, data: unknown): void {
if (cache.size >= CACHE_MAX) {
const oldest = cache.keys().next().value;
if (oldest !== undefined) cache.delete(oldest);
}
cache.set(key, { at: Date.now(), data });
}
const COORD_RE = /^-?\d{1,3}(\.\d+)?,-?\d{1,3}(\.\d+)?$/;
function isCoord(v: string): boolean {
if (!COORD_RE.test(v)) return false;
const [lat, lng] = v.split(',').map(Number);
return Math.abs(lat) <= 90 && Math.abs(lng) <= 180;
}
async function upstream(path: string, params: URLSearchParams): Promise<unknown> {
const url = `${TRANSIT_API_BASE}${path}?${params}`;
const res = await fetch(url, { headers: { 'User-Agent': getUserAgent(), Accept: 'application/json' } });
if (!res.ok) {
const err = new Error(`Transit provider error (HTTP ${res.status})`) as Error & { status: number };
err.status = res.status === 429 ? 429 : 502;
throw err;
}
return res.json();
}
// ── Geocode ──────────────────────────────────────────────────────────────────
export interface TransitPlace {
name: string;
lat: number;
lng: number;
type: string;
area: string | null;
}
/** Station/place search for the from/to pickers. `near` biases results. */
export async function geocode(query: string, language?: string, near?: string): Promise<{ results: TransitPlace[] }> {
const text = (query || '').trim();
if (text.length < 2) return { results: [] };
if (text.length > 200) {
const e = new Error('Query too long') as Error & { status: number };
e.status = 400;
throw e;
}
const params = new URLSearchParams({ text });
if (language) params.set('language', language.slice(0, 5));
if (near && isCoord(near)) params.set('place', near);
const key = `geo:${params.toString()}`;
const cached = cacheGet(key);
if (cached) return cached as { results: TransitPlace[] };
const raw = (await upstream('/api/v1/geocode', params)) as Array<{
name?: string;
lat?: number;
lon?: number;
type?: string;
areas?: Array<{ name?: string; matched?: boolean; default?: boolean }>;
}>;
const results: TransitPlace[] = (Array.isArray(raw) ? raw : []).slice(0, 8).flatMap((m) => {
if (typeof m.lat !== 'number' || typeof m.lon !== 'number' || !m.name) return [];
const area = m.areas?.find((a) => a.default)?.name || m.areas?.[0]?.name || null;
return [{ name: m.name, lat: m.lat, lng: m.lon, type: m.type || 'PLACE', area }];
});
const data = { results };
cacheSet(key, data);
return data;
}
// ── Plan ─────────────────────────────────────────────────────────────────────
export interface TransitLegStop {
name: string;
lat: number;
lng: number;
time: string | null;
scheduledTime: string | null;
track: string | null;
}
export interface TransitLeg {
mode: string;
from: TransitLegStop;
to: TransitLegStop;
duration: number;
distance: number | null;
headsign: string | null;
line: string | null;
lineColor: string | null;
lineTextColor: string | null;
agency: string | null;
intermediateStops: number;
/** Encoded polyline of the leg's real path (Google encoding) + its precision. */
geometry: string | null;
geometryPrecision: number;
}
export interface TransitItinerary {
startTime: string;
endTime: string;
duration: number;
transfers: number;
walkSeconds: number;
legs: TransitLeg[];
}
export interface PlanQuery {
from: string;
to: string;
time?: string;
arriveBy?: boolean;
modes?: string;
maxTransfers?: number;
}
export function deriveTransitStats(
startTime: string,
endTime: string,
legs: TransitLeg[],
reportedTransfers?: number,
): Pick<TransitItinerary, 'duration' | 'transfers' | 'walkSeconds'> {
return {
duration: Math.max(0, Math.round((new Date(endTime).getTime() - new Date(startTime).getTime()) / 1000)),
transfers:
typeof reportedTransfers === 'number'
? reportedTransfers
: Math.max(0, legs.filter((leg) => leg.mode !== 'WALK').length - 1),
walkSeconds: legs.filter((leg) => leg.mode === 'WALK').reduce((total, leg) => total + leg.duration, 0),
};
}
// GTFS colors come as bare hex ("FF0000"), with hash, or empty — normalise to
// a #-prefixed value or null so the client can use them in CSS directly.
function safeColor(v: unknown): string | null {
if (typeof v !== 'string') return null;
const hex = v.trim().replace(/^#/, '');
return /^[0-9a-fA-F]{6}$/.test(hex) || /^[0-9a-fA-F]{3}$/.test(hex) ? `#${hex}` : null;
}
interface MotisPlaceRaw {
name?: string;
lat?: number;
lon?: number;
departure?: string;
arrival?: string;
scheduledDeparture?: string;
scheduledArrival?: string;
track?: string;
scheduledTrack?: string;
}
function mapStop(p: MotisPlaceRaw | undefined, kind: 'departure' | 'arrival'): TransitLegStop {
return {
name: p?.name || '',
lat: typeof p?.lat === 'number' ? p.lat : 0,
lng: typeof p?.lon === 'number' ? p.lon : 0,
time: (kind === 'departure' ? p?.departure : p?.arrival) || null,
scheduledTime: (kind === 'departure' ? p?.scheduledDeparture : p?.scheduledArrival) || null,
track: p?.track || p?.scheduledTrack || null,
};
}
/** Route search between two coordinates. Returns compact itineraries for the picker. */
export async function plan(q: PlanQuery): Promise<{ itineraries: TransitItinerary[] }> {
const bad = (msg: string) => {
const e = new Error(msg) as Error & { status: number };
e.status = 400;
throw e;
};
if (!q.from || !isCoord(q.from)) bad('from must be "lat,lng"');
if (!q.to || !isCoord(q.to)) bad('to must be "lat,lng"');
const params = new URLSearchParams({ fromPlace: q.from, toPlace: q.to, numItineraries: '8' });
if (q.time) {
const parsed = new Date(q.time);
if (isNaN(parsed.getTime())) bad('time must be an ISO date-time');
params.set('time', parsed.toISOString());
}
if (q.arriveBy) params.set('arriveBy', 'true');
if (q.modes) {
const modes = q.modes
.split(',')
.map((m) => m.trim().toUpperCase())
.filter(Boolean);
if (modes.some((m) => !ALLOWED_MODES.has(m))) bad('unsupported transit mode');
if (modes.length > 0) params.set('transitModes', modes.join(','));
}
if (q.maxTransfers !== undefined && q.maxTransfers !== null) {
const n = Number(q.maxTransfers);
if (!Number.isInteger(n) || n < 0 || n > 10) bad('maxTransfers must be 0-10');
params.set('maxTransfers', String(n));
}
// We only want scheduled transit journeys in the results — a pure-walk
// "direct" connection is what the existing OSRM footpath routing is for.
params.set('directModes', 'WALK');
const key = `plan:${params.toString()}`;
const cached = cacheGet(key);
if (cached) return cached as { itineraries: TransitItinerary[] };
const raw = (await upstream('/api/v6/plan', params)) as {
itineraries?: Array<{
duration?: number;
startTime?: string;
endTime?: string;
transfers?: number;
legs?: Array<{
mode?: string;
duration?: number;
distance?: number;
headsign?: string;
routeShortName?: string;
displayName?: string;
routeColor?: string;
routeTextColor?: string;
agencyName?: string;
from?: MotisPlaceRaw;
to?: MotisPlaceRaw;
intermediateStops?: unknown[];
legGeometry?: { points?: string; precision?: number };
}>;
}>;
};
const itineraries: TransitItinerary[] = (raw.itineraries || []).flatMap((it) => {
if (!it.startTime || !it.endTime || !Array.isArray(it.legs)) return [];
const legs: TransitLeg[] = it.legs.map((leg) => ({
mode: (leg.mode || 'WALK').toUpperCase(),
from: mapStop(leg.from, 'departure'),
to: mapStop(leg.to, 'arrival'),
duration: typeof leg.duration === 'number' ? leg.duration : 0,
distance: typeof leg.distance === 'number' ? Math.round(leg.distance) : null,
headsign: leg.headsign || null,
// displayName is the public identifier MOTIS resolved for the run, taking
// the trip number into account where the feed has one. Plenty of operators
// (German long distance among them) keep routeShortName as an internal line
// number, so preferring it showed "20" instead of "ICE 72" (#1715).
line: leg.displayName || leg.routeShortName || null,
lineColor: safeColor(leg.routeColor),
lineTextColor: safeColor(leg.routeTextColor),
agency: leg.agencyName || null,
intermediateStops: Array.isArray(leg.intermediateStops) ? leg.intermediateStops.length : 0,
geometry: leg.legGeometry?.points || null,
geometryPrecision: leg.legGeometry?.precision ?? 6,
}));
const stats = deriveTransitStats(it.startTime, it.endTime, legs, it.transfers);
return [
{
startTime: it.startTime,
endTime: it.endTime,
// Wall-clock duration (start→end) so waits/transfers count — summing leg
// run-times would understate the journey and mis-slot it in the timeline.
...stats,
legs,
},
];
});
const data = { itineraries };
cacheSet(key, data);
return data;
}