mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-22 15:06:32 +00:00
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:
@@ -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);
|
||||
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user