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
+1 -1
View File
@@ -32,7 +32,7 @@ Wave 4 — the coupled cluster (order matters here)
Wave 5 — the heavyweights, last
10. adminService (14 Nest + 11 MCP consumers), journeyService/atlasService/vacayService (done 2026-07 — pulled forward as a zero-dependency frontier member, see migration-graph.md)/collectionsService/mapsService (done 2026-07 — pulled forward as step 4 of the dependency-honest order: the 1429-line geo core folded into the wrapper `MapsService`, pure helpers to `maps.helpers.ts`, the 3 geo MCP tools to the decorator-driven `maps.mcp.ts` (weather/airport tools stay in the legacy registrar), BookingImportService injects it, and a 3-export `maps.bridge.ts` serves placeEnrichment + the places registrar — transitService → placeService now unblocked), and finally the auth stack (authService at 1494 lines with 18 MCP consumers, plus
10. adminService (14 Nest + 11 MCP consumers), journeyService/atlasService/vacayService (done 2026-07 — pulled forward as a zero-dependency frontier member, see migration-graph.md)/collectionsService/mapsService (done 2026-07 — pulled forward as step 4 of the dependency-honest order: the 1429-line geo core folded into the wrapper `MapsService`, pure helpers to `maps.helpers.ts`, the 3 geo MCP tools to the decorator-driven `maps.mcp.ts` (weather/airport tools stay in the legacy registrar), BookingImportService injects it, and a 3-export `maps.bridge.ts` serves placeEnrichment + the places registrar — transitService → placeService now unblocked)/transitService (done 2026-07 — pulled forward as step 5 of the dependency-honest order: the first fully SQL-free domain fold — the 333-line Transitous/MOTIS proxy became a dep-free `TransitService` (no `DatabaseService`); pure `deriveTransitStats` + mode whitelist + itinerary types moved to `transit.helpers.ts` so the downstream `transitItineraryService` needs no bridge; the whole 3-tool registrar moved to the decorator-driven `transit.mcp.ts`, its days/reservations bridge imports becoming injected `DaysService`/`ReservationsService` — zero bridge files, **placeService → transitItineraryService now unblocked**), and finally the auth stack (authService at 1494 lines with 18 MCP consumers, plus
oauthService/oidcService/passkeyService) — highest risk, most consumers, security-sensitive; do it when the pattern is thoroughly proven. backupService also stays late: it owns closeDb/reinitialize lifecycle, which is deliberately
outside the DI wrapper.
+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;
}
+27 -14
View File
@@ -1,7 +1,8 @@
/**
* Transit proxy e2e — /api/transit/geocode + /api/transit/plan through the real
* JwtAuthGuard against a temp SQLite db. The transit service is mocked; this
* focuses on auth (401), param pass-through and error propagation (#1065).
* JwtAuthGuard against a temp SQLite db. The DI-native TransitService's provider
* methods are stubbed via instance spies (no outbound HTTP); this focuses on
* auth (401), param pass-through and error propagation (#1065).
*/
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest';
import request from 'supertest';
@@ -20,19 +21,28 @@ const { db } = vi.hoisted(() => {
return { db: tmp };
});
vi.mock('../../src/db/database', () => ({ db, closeDb: () => {}, reinitialize: () => {} }));
const { transitSvc } = vi.hoisted(() => ({ transitSvc: { geocode: vi.fn(), plan: vi.fn() } }));
vi.mock('../../src/services/transitService', () => transitSvc);
// TransitMcp (in the module graph, never invoked here) reaches _shared →
// src/websocket at import time — silence the real ws module.
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn() }));
import { TransitModule } from '../../src/nest/transit/transit.module';
import { TransitService } from '../../src/nest/transit/transit.service';
import { DatabaseModule } from '../../src/nest/database/database.module';
import { RealtimeModule } from '../../src/nest/realtime/realtime.module';
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
describe('Transit proxy e2e (real auth guard + temp SQLite)', () => {
let server: Server;
let app: Awaited<ReturnType<typeof build>>;
let geocodeSpy: ReturnType<typeof vi.spyOn>;
let planSpy: ReturnType<typeof vi.spyOn>;
async function build() {
const moduleRef = await Test.createTestingModule({ imports: [TransitModule] }).compile();
const moduleRef = await Test.createTestingModule({
// DatabaseModule + RealtimeModule are @Global in the app graph;
// TransitModule's DaysModule/ReservationsModule imports need them here.
imports: [DatabaseModule, RealtimeModule, TransitModule],
}).compile();
const nest = moduleRef.createNestApplication();
nest.use(cookieParser());
nest.useGlobalFilters(new TrekExceptionFilter());
@@ -44,11 +54,14 @@ describe('Transit proxy e2e (real auth guard + temp SQLite)', () => {
seedUser(db as never, { id: 1 });
app = await build();
server = app.getHttpServer();
const transit = app.get(TransitService);
geocodeSpy = vi.spyOn(transit, 'geocode') as ReturnType<typeof vi.spyOn>;
planSpy = vi.spyOn(transit, 'plan') as ReturnType<typeof vi.spyOn>;
});
beforeEach(() => {
transitSvc.geocode.mockReset();
transitSvc.plan.mockReset();
geocodeSpy.mockReset();
planSpy.mockReset();
});
afterAll(async () => { await app.close(); });
@@ -59,20 +72,20 @@ describe('Transit proxy e2e (real auth guard + temp SQLite)', () => {
});
it('geocode passes q/lang/near through and returns the service result', async () => {
transitSvc.geocode.mockResolvedValueOnce({ results: [{ name: 'Alexanderplatz' }] });
geocodeSpy.mockResolvedValueOnce({ results: [{ name: 'Alexanderplatz' }] });
const res = await request(server).get('/api/transit/geocode?q=alex&lang=de&near=52.5,13.4').set('Cookie', sessionCookie(1));
expect(res.status).toBe(200);
expect(res.body.results[0].name).toBe('Alexanderplatz');
expect(transitSvc.geocode).toHaveBeenCalledWith('alex', 'de', '52.5,13.4');
expect(geocodeSpy).toHaveBeenCalledWith('alex', 'de', '52.5,13.4');
});
it('plan passes all params through (arriveBy + maxTransfers coerced)', async () => {
transitSvc.plan.mockResolvedValueOnce({ itineraries: [] });
planSpy.mockResolvedValueOnce({ itineraries: [] });
const res = await request(server)
.get('/api/transit/plan?from=52.5,13.4&to=52.6,13.5&time=2026-07-13T09:00:00Z&arriveBy=true&modes=BUS&maxTransfers=2')
.set('Cookie', sessionCookie(1));
expect(res.status).toBe(200);
expect(transitSvc.plan).toHaveBeenCalledWith({
expect(planSpy).toHaveBeenCalledWith({
from: '52.5,13.4', to: '52.6,13.5', time: '2026-07-13T09:00:00Z', arriveBy: true, modes: 'BUS', maxTransfers: 2,
});
});
@@ -80,7 +93,7 @@ describe('Transit proxy e2e (real auth guard + temp SQLite)', () => {
it('service validation errors propagate with their status', async () => {
const err = new Error('from must be "lat,lng"') as Error & { status: number };
err.status = 400;
transitSvc.plan.mockRejectedValueOnce(err);
planSpy.mockRejectedValueOnce(err);
const res = await request(server).get('/api/transit/plan?from=bad&to=1,2').set('Cookie', sessionCookie(1));
expect(res.status).toBe(400);
expect(res.body.error).toContain('lat,lng');
@@ -89,7 +102,7 @@ describe('Transit proxy e2e (real auth guard + temp SQLite)', () => {
it('upstream failures surface as 502', async () => {
const err = new Error('Transit provider error (HTTP 500)') as Error & { status: number };
err.status = 502;
transitSvc.plan.mockRejectedValueOnce(err);
planSpy.mockRejectedValueOnce(err);
const res = await request(server).get('/api/transit/plan?from=1,2&to=3,4').set('Cookie', sessionCookie(1));
expect(res.status).toBe(502);
});
+5 -1
View File
@@ -29,6 +29,8 @@ import { ShareMcp } from '../../src/nest/share/share.mcp';
import { ShareService } from '../../src/nest/share/share.service';
import { TodoMcp } from '../../src/nest/todo/todo.mcp';
import { TodoService } from '../../src/nest/todo/todo.service';
import { TransitMcp } from '../../src/nest/transit/transit.mcp';
import { TransitService } from '../../src/nest/transit/transit.service';
import { FilesService } from '../../src/nest/files/files.service';
import { TripsMcp } from '../../src/nest/trips/trips.mcp';
import { TripsService } from '../../src/nest/trips/trips.service';
@@ -53,6 +55,7 @@ export function createMcpTestRegistry(): McpRegistry {
const todoService = new TodoService(dbService, permissionsService, realtimeService);
const packingService = new PackingService(dbService, permissionsService, realtimeService);
const collabService = new CollabService(dbService, permissionsService, realtimeService);
const reservationsService = new ReservationsService(dbService, permissionsService, budgetService, realtimeService);
const tripsService = new TripsService(
dbService,
todoService,
@@ -73,7 +76,7 @@ export function createMcpTestRegistry(): McpRegistry {
new TodoMcp(todoService),
new PackingMcp(packingService),
new BudgetMcp(budgetService, exchangeRatesService, dbService),
new ReservationsMcp(new ReservationsService(dbService, permissionsService, budgetService, realtimeService), daysService, budgetService),
new ReservationsMcp(reservationsService, daysService, budgetService),
new DayNotesMcp(new DayNotesService(dbService, permissionsService, realtimeService)),
new DaysMcp(daysService, dbService),
new AssignmentsMcp(new AssignmentsService(dbService, permissionsService, realtimeService), daysService),
@@ -82,6 +85,7 @@ export function createMcpTestRegistry(): McpRegistry {
new TripsMcp(tripsService, todoService, collabService),
new ShareMcp(new ShareService(dbService, new SettingsService(dbService), permissionsService)),
new MapsMcp(new MapsService(dbService)),
new TransitMcp(new TransitService(), daysService, reservationsService, dbService),
],
{ accessPolicy: trekMcpAccessPolicy, validateAccess: trekMcpValidateAccess },
);
+17 -14
View File
@@ -28,30 +28,32 @@ const { testDb, dbMock } = vi.hoisted(() => {
return { testDb: db, dbMock: mock };
});
const { geocodeMock, planMock, broadcastMock, notifyBookingChangeMock } = vi.hoisted(() => ({
geocodeMock: vi.fn(),
planMock: vi.fn(),
const { broadcastMock } = vi.hoisted(() => ({
broadcastMock: vi.fn(),
notifyBookingChangeMock: vi.fn(),
}));
vi.mock('../../../src/db/database', () => dbMock);
vi.mock('../../../src/services/transitService', async (importOriginal) => ({
...(await importOriginal<typeof import('../../../src/services/transitService')>()),
geocode: geocodeMock,
plan: planMock,
}));
vi.mock('../../../src/websocket', () => ({ broadcast: broadcastMock }));
vi.mock('../../../src/nest/reservations/reservations.bridge', async (importOriginal) => ({
...(await importOriginal<typeof import('../../../src/nest/reservations/reservations.bridge')>()),
notifyBookingChange: notifyBookingChangeMock,
}));
vi.mock('../../../src/config', () => ({
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
updateJwtSecret: () => {},
}));
import { ReservationsService } from '../../../src/nest/reservations/reservations.service';
import { TransitService } from '../../../src/nest/transit/transit.service';
// The transit tools live on the DI-discovered transit.mcp.ts since the transit
// fold; the test registry builds a real TransitService (and injects a real
// ReservationsService over the mocked db proxy), so stub the provider methods
// on the prototype (no auto-restore in the vitest config — these survive
// across tests, exactly like the old module mocks did).
const geocodeMock = vi.spyOn(TransitService.prototype, 'geocode');
const planMock = vi.spyOn(TransitService.prototype, 'plan');
const notifyBookingChangeMock = vi
.spyOn(ReservationsService.prototype, 'notifyBookingChange')
.mockImplementation(() => {});
const from = { name: 'Namba', lat: 34.667, lng: 135.501 };
const to = { name: 'Umeda', lat: 34.702, lng: 135.496 };
const itinerary = {
@@ -118,7 +120,8 @@ beforeEach(() => {
geocodeMock.mockReset();
planMock.mockReset();
broadcastMock.mockReset();
notifyBookingChangeMock.mockReset();
// mockReset would fall back to the real notification write — keep it stubbed.
notifyBookingChangeMock.mockReset().mockImplementation(() => {});
delete process.env.DEMO_MODE;
invalidatePermissionsCache();
});
@@ -1,9 +1,16 @@
/**
* Unit tests for transitService — TRANSIT-SVC-001..010.
* The Transitous/MOTIS proxy (#1065): input validation, mode whitelist,
* response mapping (colors, walk time, wall-clock duration) and caching.
* Unit tests for the DI-native TransitService — TRANSIT-SVC-001..011, moved
* 1:1 from the legacy tests/unit/services/transitService.test.ts when the
* transit domain went DI-native. The Transitous/MOTIS proxy (#1065): input
* validation, mode whitelist, response mapping (colors, walk time, wall-clock
* duration) and caching.
*
* The response cache is deliberately MODULE-scoped (shared by every
* TransitService instance), so it persists across the tests in this file —
* every case uses its own coordinates/query to stay isolated.
*/
import { deriveTransitStats, geocode, plan, type TransitLeg } from '../../../src/services/transitService';
import { deriveTransitStats, type TransitLeg } from '../../../src/nest/transit/transit.helpers';
import { TransitService } from '../../../src/nest/transit/transit.service';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
@@ -14,6 +21,7 @@ vi.mock('../../../src/app-config', async (importOriginal) => {
vi.mock('../../../src/nest/maps/maps.helpers', () => ({ buildUserAgent: () => 'TREK-Test-UA' }));
const fetchMock = vi.fn();
const svc = new TransitService();
beforeEach(() => {
vi.stubGlobal('fetch', fetchMock);
@@ -21,13 +29,17 @@ beforeEach(() => {
});
afterEach(() => vi.unstubAllGlobals());
function okJson(data: unknown) {
return { ok: true, json: async () => data };
function okJson(data: unknown, contentLength: number | null = null) {
return {
ok: true,
headers: { get: () => (contentLength === null ? null : String(contentLength)) },
json: async () => data,
};
}
describe('geocode', () => {
it('TRANSIT-SVC-001: returns [] without calling upstream for short queries', async () => {
const r = await geocode('a');
const r = await svc.geocode('a');
expect(r.results).toEqual([]);
expect(fetchMock).not.toHaveBeenCalled();
});
@@ -39,7 +51,7 @@ describe('geocode', () => {
{ name: 'no-coords' },
]),
);
const r = await geocode('alexanderplatz-u1');
const r = await svc.geocode('alexanderplatz-u1');
expect(r.results).toEqual([{ name: 'Alexanderplatz', lat: 52.52, lng: 13.41, type: 'STOP', area: 'Berlin' }]);
const [url, init] = fetchMock.mock.calls[0];
expect(String(url)).toContain('/api/v1/geocode?');
@@ -48,30 +60,30 @@ describe('geocode', () => {
it('TRANSIT-SVC-003: ignores an invalid near bias instead of forwarding it', async () => {
fetchMock.mockResolvedValueOnce(okJson([]));
await geocode('hauptbahnhof-x1', undefined, 'not,coords');
await svc.geocode('hauptbahnhof-x1', undefined, 'not,coords');
expect(String(fetchMock.mock.calls[0][0])).not.toContain('place=');
});
});
describe('plan validation', () => {
it('TRANSIT-SVC-004: rejects malformed coordinates with 400', async () => {
await expect(plan({ from: 'x', to: '52.5,13.4' })).rejects.toMatchObject({ status: 400 });
await expect(plan({ from: '95,13.4', to: '52.5,13.4' })).rejects.toMatchObject({ status: 400 });
await expect(svc.plan({ from: 'x', to: '52.5,13.4' })).rejects.toMatchObject({ status: 400 });
await expect(svc.plan({ from: '95,13.4', to: '52.5,13.4' })).rejects.toMatchObject({ status: 400 });
expect(fetchMock).not.toHaveBeenCalled();
});
it('TRANSIT-SVC-005: rejects modes outside the whitelist', async () => {
await expect(plan({ from: '52.50,13.40', to: '52.51,13.41', modes: 'BUS,CAR' })).rejects.toMatchObject({
await expect(svc.plan({ from: '52.50,13.40', to: '52.51,13.41', modes: 'BUS,CAR' })).rejects.toMatchObject({
status: 400,
});
expect(fetchMock).not.toHaveBeenCalled();
});
it('TRANSIT-SVC-006: rejects out-of-range maxTransfers and bad time', async () => {
await expect(plan({ from: '52.50,13.40', to: '52.51,13.41', maxTransfers: 99 })).rejects.toMatchObject({
await expect(svc.plan({ from: '52.50,13.40', to: '52.51,13.41', maxTransfers: 99 })).rejects.toMatchObject({
status: 400,
});
await expect(plan({ from: '52.50,13.40', to: '52.51,13.41', time: 'not-a-date' })).rejects.toMatchObject({
await expect(svc.plan({ from: '52.50,13.40', to: '52.51,13.41', time: 'not-a-date' })).rejects.toMatchObject({
status: 400,
});
});
@@ -126,7 +138,7 @@ describe('plan mapping', () => {
it('TRANSIT-SVC-007: maps legs compactly, normalises GTFS colors and counts walk seconds', async () => {
fetchMock.mockResolvedValueOnce(okJson(motisResponse));
const r = await plan({ from: '52.5000,13.4000', to: '52.5100,13.4100' });
const r = await svc.plan({ from: '52.5000,13.4000', to: '52.5100,13.4100' });
expect(r.itineraries).toHaveLength(1);
const it = r.itineraries[0];
// Wall-clock 08:00→08:30, not the reported 999s.
@@ -173,7 +185,7 @@ describe('plan mapping', () => {
],
}),
);
const r = await plan({ from: '49.0090,8.4004', to: '50.0510,8.5706' });
const r = await svc.plan({ from: '49.0090,8.4004', to: '50.0510,8.5706' });
const [longDistance, regional] = r.itineraries[0].legs;
expect(longDistance.line).toBe('ICE 72');
// Feeds that only supply routeShortName keep working.
@@ -182,7 +194,7 @@ describe('plan mapping', () => {
it('TRANSIT-SVC-008: forwards only whitelisted params and pins directModes=WALK', async () => {
fetchMock.mockResolvedValueOnce(okJson({ itineraries: [] }));
await plan({
await svc.plan({
from: '48.1000,11.5000',
to: '48.2000,11.6000',
modes: 'BUS,TRAM',
@@ -200,13 +212,55 @@ describe('plan mapping', () => {
it('TRANSIT-SVC-009: identical plans hit the cache (single upstream call)', async () => {
fetchMock.mockResolvedValue(okJson({ itineraries: [] }));
await plan({ from: '40.0000,-3.0000', to: '40.1000,-3.1000' });
await plan({ from: '40.0000,-3.0000', to: '40.1000,-3.1000' });
await svc.plan({ from: '40.0000,-3.0000', to: '40.1000,-3.1000' });
await svc.plan({ from: '40.0000,-3.0000', to: '40.1000,-3.1000' });
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it('TRANSIT-SVC-010: upstream failure surfaces as a 502-style error', async () => {
fetchMock.mockResolvedValueOnce({ ok: false, status: 500 });
await expect(plan({ from: '41.0000,2.0000', to: '41.1000,2.1000' })).rejects.toMatchObject({ status: 502 });
await expect(svc.plan({ from: '41.0000,2.0000', to: '41.1000,2.1000' })).rejects.toMatchObject({ status: 502 });
});
});
describe('quirk repairs (DI fold fix pass)', () => {
it('TRANSIT-SVC-012: upstream requests carry an abort-timeout signal', async () => {
fetchMock.mockResolvedValueOnce(okJson([]));
await svc.geocode('timeout-probe-station');
const [, init] = fetchMock.mock.calls[0];
expect(init.signal).toBeInstanceOf(AbortSignal);
});
it('TRANSIT-SVC-013: unparseable provider timestamps yield duration 0, not NaN', () => {
const stats = deriveTransitStats('not-a-date', 'also-not-a-date', []);
expect(stats.duration).toBe(0);
// Parseable dates keep the wall-clock behaviour.
expect(deriveTransitStats('2026-07-13T08:00:00Z', '2026-07-13T08:30:00Z', []).duration).toBe(1800);
});
it('TRANSIT-SVC-014: an oversized upstream response is refused as a provider error', async () => {
fetchMock.mockResolvedValueOnce(okJson([], 6_000_000));
await expect(svc.geocode('oversize-probe-station')).rejects.toMatchObject({
status: 502,
message: 'Transit provider error (response too large)',
});
});
it('TRANSIT-SVC-015: the cache is LRU — a re-read entry survives eviction pressure', async () => {
fetchMock.mockResolvedValue(okJson([]));
// Flush whatever earlier tests cached: 205 distinct fillers guarantee the
// 200-entry Map holds only fillers afterwards.
for (let i = 0; i < 205; i++) await svc.geocode(`lru-fill-${i}`);
// Insert the probe, then age it to the oldest position with 199 more inserts.
await svc.geocode('lru-probe-station');
for (let i = 0; i < 199; i++) await svc.geocode(`lru-age-${i}`);
const fetchesBeforeTouch = fetchMock.mock.calls.length;
// Touch the probe (cache hit — no fetch) so LRU moves it off the eviction edge…
await svc.geocode('lru-probe-station');
expect(fetchMock.mock.calls.length).toBe(fetchesBeforeTouch);
// …then trigger one eviction. FIFO would evict the probe; LRU evicts lru-age-0.
await svc.geocode('lru-evictor-station');
await svc.geocode('lru-probe-station');
expect(fetchMock.mock.calls.length).toBe(fetchesBeforeTouch + 1);
});
});