mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
feat(roadtrip): what a charger costs, before it is a stop
A tester found the charging panel once a station was on the trip and asked for it while still deciding. The availability and the published tariffs were reachable only for a saved place: the route takes a places id, and a hit found along the route has no row yet. ChargingService.load already needed nothing but a coordinate and a name, so it now has a public method that says so, behind a trip-scoped route with the same guard stack, a Zod contract in shared, and the parallel MCP tool in this same change. Same service, same caches, so a station looked at in the dialog and then added keys the entry already fetched. Trip-scoped even though the trip is not part of the question: without :tripId the trip access guard goes too, and what is left is an open proxy in front of somebody else's public API. It renders in the add dialog, and only while the selected kind is charging, so a fuel or food stop costs nothing and one opened dialog is one lookup. Deliberately not per result row: the corridor search publishes box by box, every mounted panel owns a refresh timer, a cache miss can cost seven upstream requests, and a station in a dense charging park is the case the upstream matcher refuses as ambiguous. Power and socket counts are not in this answer and were not asked of it. They are OSM tag data and already ride along on the corridor row for free.
This commit is contained in:
@@ -0,0 +1,38 @@
|
||||
import { Body, Controller, HttpCode, Post, UseGuards } from '@nestjs/common';
|
||||
import { chargingLookupSchema } from '@trek/shared';
|
||||
import { createZodDto } from 'nestjs-zod';
|
||||
import { ADDON_IDS } from '../../addons';
|
||||
import { AddonGuard } from '../addons/addon.guard';
|
||||
import { RequireAddon } from '../addons/require-addon.decorator';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { TripAccessGuard } from '../permissions/trip-access.guard';
|
||||
import { ChargingService } from './charging.service';
|
||||
|
||||
/** Exported so the parity test can hold the route and the MCP tool to the same contract. */
|
||||
export class ChargingLookupDto extends createZodDto(chargingLookupSchema) {}
|
||||
|
||||
/**
|
||||
* Charging availability for a station nobody has added yet.
|
||||
*
|
||||
* A sibling of `ChargingController` rather than another verb on it, because the two
|
||||
* ask in different ways: that one names a row in `places`, this one hands over a
|
||||
* coordinate. Same service, same cache, same answer.
|
||||
*
|
||||
* Trip-scoped on purpose, even though the trip is not part of the question. Leaving
|
||||
* `:tripId` out would take `TripAccessGuard` with it, and what is left is an open
|
||||
* proxy in front of somebody else's public API: anyone with the URL could aim it
|
||||
* anywhere on the planet at whatever rate they liked. Nothing asks this while not
|
||||
* planning a trip, so the scope costs the caller nothing.
|
||||
*
|
||||
* A POST for a read follows `RoadtripSearchController`: the input is a coordinate pair
|
||||
* and a name, which belongs in a body behind a contract rather than in a query string.
|
||||
*/
|
||||
@Controller('api/trips/:tripId/roadtrip/charging-lookup')
|
||||
@UseGuards(AddonGuard, JwtAuthGuard, TripAccessGuard)
|
||||
@RequireAddon(ADDON_IDS.ROADTRIP, 'Road trip')
|
||||
export class ChargingLookupController {
|
||||
constructor(private readonly charging: ChargingService) {}
|
||||
@Post()
|
||||
@HttpCode(200)
|
||||
lookup(@Body() input: ChargingLookupDto) { return this.charging.lookup(input.lat, input.lng, input.name); }
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { chargingLookupSchema } from '@trek/shared';
|
||||
import { z } from 'zod';
|
||||
import { ADDON_IDS } from '../../addons';
|
||||
import { noAccess } from '../../mcp/tools/_shared';
|
||||
@@ -17,4 +18,11 @@ export class ChargingMcp {
|
||||
if (!this.db.canAccessTrip(tripId, ctx.userId)) return noAccess();
|
||||
return ok(await this.charging.read(tripId, placeId));
|
||||
}
|
||||
@Tool({ name: 'lookup_roadtrip_charging_info', description: 'Read open charging availability and published tariffs for a charging station by coordinate, before it is added to a trip. Needs a trip only for access control. No API key required. Every caveat of get_roadtrip_charging_info applies unchanged: coverage varies by region and operator, unknown or stale status is not available capacity, prices are gross per-component figures that can carry conditions and extra fees and are not personalized roaming quotes. Report the source, licence and timestamp alongside any figure from it. Charging power, socket types and socket counts are not in this answer; they come from the place search.',
|
||||
inputSchema: { tripId: z.number().int().positive(), ...chargingLookupSchema.shape },
|
||||
annotations: TOOL_ANNOTATIONS_READONLY, access: { group: 'trips', mode: 'read' }, when: addonGate(ADDON_IDS.ROADTRIP) })
|
||||
async lookup({ tripId, lat, lng, name }: { tripId: number; lat: number; lng: number; name: string }, ctx: McpContext) {
|
||||
if (!this.db.canAccessTrip(tripId, ctx.userId)) return noAccess();
|
||||
return ok(await this.charging.lookup(lat, lng, name));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,8 @@ import { DatabaseService } from '../database/database.service';
|
||||
import { readCappedJson } from '../../utils/cappedFetch';
|
||||
import { chargingLocation, chargingSource, chargingTariff, matchChargingLocation, normalizeCharging, type ChargingSource, type ChargingTariff } from './charging.helpers';
|
||||
|
||||
const empty = (status: ChargingInfo['status']): ChargingInfo => ({ checkedAt: new Date().toISOString(), status, station: null, source: null, sourceUrl: null, license: null, updatedAt: null, stale: false, available: null, total: 0, unknown: 0, tariffs: [], pricesUnavailable: false });
|
||||
/** The shape of "we cannot say", exported so nothing has to restate its thirteen fields. */
|
||||
export const empty = (status: ChargingInfo['status']): ChargingInfo => ({ checkedAt: new Date().toISOString(), status, station: null, source: null, sourceUrl: null, license: null, updatedAt: null, stale: false, available: null, total: 0, unknown: 0, tariffs: [], pricesUnavailable: false });
|
||||
async function request(path: string) {
|
||||
const fetched = await fetch(`https://api.mobidata-bw.de/ocpdb${path}`, { headers: { 'User-Agent': 'TREK Roadtrip (https://github.com/liketrek/TREK)' }, signal: AbortSignal.timeout(12000), redirect: 'error' });
|
||||
if (!fetched.ok) { await fetched.body?.cancel(); throw new Error('Charging source unavailable'); }
|
||||
@@ -22,11 +23,28 @@ export class ChargingService {
|
||||
const place = this.db.get<{ name: string; lat: number | null; lng: number | null; stop_type: string }>('SELECT name, lat, lng, stop_type FROM places WHERE id = ? AND trip_id = ?', placeId, tripId);
|
||||
if (!place) throw new HttpException({ error: 'Place not found' }, 404);
|
||||
if (place.stop_type !== 'charging' || place.lat == null || place.lng == null) return empty('unknown');
|
||||
const key = `${place.lat},${place.lng}:${place.name}`;
|
||||
return this.lookup(place.lat, place.lng, place.name);
|
||||
}
|
||||
/**
|
||||
* The same answer for a station the trip has not saved yet.
|
||||
*
|
||||
* A hit found along the route has no `places` row, so it cannot be asked for by id,
|
||||
* but the match never needed one: a coordinate and a name is the whole input. Sharing
|
||||
* `cached` with the saved-stop path matters twice over. The answer a charger gives
|
||||
* while it is being considered is the answer it gives once it is on the trip, and the
|
||||
* minute of cover is what keeps a second look from becoming a second round of
|
||||
* upstream requests.
|
||||
*
|
||||
* The cache is keyed on the coordinate exactly as it arrived, not on a rounded one:
|
||||
* a stop added from a corridor hit carries the coordinate the hit had, so the two
|
||||
* paths land on the same entry without either of them knowing about the other.
|
||||
*/
|
||||
lookup(lat: number, lng: number, name: string): Promise<ChargingInfo> {
|
||||
const key = `${lat},${lng}:${name}`;
|
||||
const cached = this.cached.get(key);
|
||||
if (cached && cached.expires > Date.now()) return cached.value;
|
||||
if (this.cached.size >= 300) this.cached.delete(this.cached.keys().next().value!);
|
||||
const value = this.load(place.lat, place.lng, place.name).catch(() => empty('unavailable'));
|
||||
const value = this.load(lat, lng, name).catch(() => empty('unavailable'));
|
||||
this.cached.set(key, { expires: Date.now() + 60000, value });
|
||||
return value;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ChargingMcp } from './charging.mcp';
|
||||
import { ChargingService } from './charging.service';
|
||||
import { ChargingController } from './charging.controller';
|
||||
import { ChargingLookupController } from './charging-lookup.controller';
|
||||
import { GoogleRouteService } from './google-route.service';
|
||||
import { GoogleRouteController } from './google-route.controller';
|
||||
import { GoogleRouteMcp } from './google-route.mcp';
|
||||
@@ -47,7 +48,7 @@ import { RoadtripHazardsMcp } from './roadtrip-hazards.mcp';
|
||||
PluginsRuntimeModule,
|
||||
MapsModule, PlacesModule, AssignmentsModule,
|
||||
],
|
||||
controllers: [ChargingController, GoogleRouteController, RoadtripSearchController, RoadtripPreferencesController, RoadtripController, DayBoundariesController, RoadtripHazardsController],
|
||||
controllers: [ChargingController, ChargingLookupController, GoogleRouteController, RoadtripSearchController, RoadtripPreferencesController, RoadtripController, DayBoundariesController, RoadtripHazardsController],
|
||||
providers: [ChargingMcp, ChargingService, GoogleRouteService, GoogleRouteMcp,
|
||||
RoadtripSearchService,
|
||||
RoadtripHazardsService,
|
||||
|
||||
Reference in New Issue
Block a user