add roadtrip warning areas and tidy driving settings

This commit is contained in:
Maurice
2026-09-12 11:23:09 +02:00
parent ec281e5974
commit af45b0ca74
47 changed files with 652 additions and 45 deletions
@@ -0,0 +1,16 @@
import { Controller, Get, UseGuards } from '@nestjs/common';
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 { RoadtripHazardsService } from './roadtrip-hazards.service';
@Controller('api/trips/:tripId/roadtrip/hazards')
@UseGuards(AddonGuard, JwtAuthGuard, TripAccessGuard)
@RequireAddon(ADDON_IDS.ROADTRIP, 'Road trip')
export class RoadtripHazardsController {
constructor(private readonly hazards: RoadtripHazardsService) {}
@Get()
read() { return this.hazards.read(); }
}
@@ -0,0 +1,23 @@
import { z } from 'zod';
import { ADDON_IDS } from '../../addons';
import { noAccess } from '../../mcp/tools/_shared';
import { McpController, Tool, TOOL_ANNOTATIONS_READONLY, ok, type McpContext } from '../../nest-mcp';
import { addonGate } from '../addons/addon-gate';
import { AddonsService } from '../addons/addons.service';
import { DatabaseService } from '../database/database.service';
import { RoadtripHazardsService } from './roadtrip-hazards.service';
@McpController()
export class RoadtripHazardsMcp {
constructor(private readonly hazards: RoadtripHazardsService, private readonly db: DatabaseService, readonly addons: AddonsService) {}
@Tool({
name: 'get_roadtrip_hazards',
description: 'Read current DWD and GDACS hazard notices, geometry, source timestamps and feed availability. These are current notices, not forecasts for the trip dates or confirmed road closures. A point means no affected-area polygon is available. Coverage can be incomplete. This does not change routes. The shared roadtrip_show_hazards preference controls map display.',
inputSchema: { tripId: z.number().int().positive() },
annotations: TOOL_ANNOTATIONS_READONLY, access: { group: 'trips', mode: 'read' }, when: addonGate(ADDON_IDS.ROADTRIP),
})
async read({ tripId }: { tripId: number }, ctx: McpContext) {
if (!this.db.canAccessTrip(tripId, ctx.userId)) return noAccess();
return ok(await this.hazards.read());
}
}
@@ -0,0 +1,98 @@
import { Injectable } from '@nestjs/common';
import { hazardGeometrySchema, roadtripHazardSchema, type RoadtripHazard, type RoadtripHazards } from '@trek/shared';
import { z } from 'zod';
import { readCappedJson } from '../../utils/cappedFetch';
const collection = z.object({
type: z.literal('FeatureCollection'), features: z.array(z.object({
id: z.union([z.string(), z.number()]).optional(), geometry: z.unknown(), properties: z.record(z.string(), z.unknown()),
})).max(1000), numberMatched: z.union([z.number(), z.string()]).optional(),
});
const dwdProperties = z.object({
HEADLINE: z.string(), DESCRIPTION: z.string().nullish(), SENT: z.string(), EXPIRES: z.string(),
});
const gdacsProperties = z.object({
eventtype: z.string().regex(/^[A-Z]{2}$/), eventid: z.number().int().positive(), episodeid: z.number().int().nonnegative(),
name: z.string(), description: z.string(), datemodified: z.string(), iscurrent: z.union([z.string(), z.boolean()]),
});
const utc = (date: string) => new Date(/(?:Z|[+-]\d\d:\d\d)$/.test(date) ? date : `${date}Z`).toISOString();
async function fetchCollection(url: string) {
const fetched = await fetch(url, { signal: AbortSignal.timeout(15000), redirect: 'error' });
if (!fetched.ok) { await fetched.body?.cancel(); throw new Error('Hazard source unavailable'); }
return collection.parse(await readCappedJson<unknown>(fetched, 8_000_000));
}
@Injectable()
export class RoadtripHazardsService {
private cached?: RoadtripHazards;
private pending?: Promise<RoadtripHazards>;
async read(): Promise<RoadtripHazards> {
if (this.cached && Date.now() - Date.parse(this.cached.fetchedAt) < 600000) return this.cached;
if (this.pending) return this.pending;
this.pending = this.load();
try { this.cached = await this.pending; return this.cached; }
finally { this.pending = undefined; }
}
private async load(): Promise<RoadtripHazards> {
const loaded = await Promise.allSettled([this.dwd(), this.gdacs()]);
return {
fetchedAt: new Date().toISOString(),
hazards: loaded.flatMap(entry => entry.status === 'fulfilled' ? entry.value.hazards : []),
sources: loaded.map((entry, index) => ({ source: index === 0 ? 'DWD' : 'GDACS', status: entry.status === 'fulfilled' ? entry.value.status : 'unavailable' })),
};
}
private async dwd() {
const feed = await fetchCollection('https://maps.dwd.de/geoserver/dwd/ows?service=WFS&version=2.0.0&request=GetFeature&typeNames=dwd:Warnungen_Gemeinden_vereinigt&outputFormat=application/json&srsName=CRS:84&count=500');
const hazards: RoadtripHazard[] = [];
let partial = Number(feed.numberMatched) > feed.features.length;
for (const [index, feature] of feed.features.entries()) {
try {
const props = dwdProperties.parse(feature.properties);
if (Date.parse(props.EXPIRES) <= Date.now()) continue;
hazards.push(roadtripHazardSchema.parse({
id: `dwd-${feature.id ?? index}`, source: 'DWD', title: props.HEADLINE, description: props.DESCRIPTION ?? '',
updatedAt: utc(props.SENT), validUntil: utc(props.EXPIRES),
url: 'https://www.dwd.de/DE/wetter/warnungen_gemeinden/warnWetter_node.html', geometry: feature.geometry,
}));
} catch { partial = true; }
}
return { hazards, status: partial ? 'partial' as const : 'ok' as const };
}
private async gdacs() {
const feed = await fetchCollection('https://www.gdacs.org/gdacsapi/api/events/geteventlist/events4app');
const hazards: RoadtripHazard[] = [];
let partial = feed.features.length >= 100;
const current = feed.features.filter(feature => feature.properties.iscurrent === 'true' || feature.properties.iscurrent === true);
for (const feature of current) {
try {
const props = gdacsProperties.parse(feature.properties);
hazards.push(roadtripHazardSchema.parse({
id: `gdacs-${props.eventtype}-${props.eventid}-${props.episodeid}`, source: 'GDACS',
title: props.name, description: props.description, updatedAt: utc(props.datemodified), validUntil: null,
url: `https://www.gdacs.org/report.aspx?eventtype=${props.eventtype}&eventid=${props.eventid}&episodeid=${props.episodeid}`,
geometry: feature.geometry,
}));
} catch { partial = true; }
}
const areas = hazards.filter(hazard => /^gdacs-(FL|WF|TC)-/.test(hazard.id));
if (areas.length > 12) partial = true;
await Promise.all(areas.slice(0, 12).map(async hazard => {
const [, eventtype, eventid, episodeid] = hazard.id.split('-');
try {
const shapes = await fetchCollection(`https://www.gdacs.org/gdacsapi/api/polygons/getgeometry?eventtype=${eventtype}&eventid=${eventid}&episodeid=${episodeid}`);
const polygons = shapes.features.filter(feature => feature.properties.Class === 'Poly_Affected').flatMap(feature => {
const parsed = hazardGeometrySchema.safeParse(feature.geometry);
if (!parsed.success) { partial = true; return []; }
return parsed.data.type === 'Polygon' ? [parsed.data.coordinates] : parsed.data.type === 'MultiPolygon' ? parsed.data.coordinates : [];
});
if (polygons.length) hazard.geometry = hazardGeometrySchema.parse({ type: 'MultiPolygon', coordinates: polygons });
} catch { partial = true; }
}));
return { hazards, status: partial ? 'partial' as const : 'ok' as const };
}
}
+6 -1
View File
@@ -19,6 +19,9 @@ import { RoadtripController } from './roadtrip.controller';
import { RoadtripMcp } from './roadtrip.mcp';
import { RoadtripService } from './roadtrip.service';
import { Module } from '@nestjs/common';
import { RoadtripHazardsController } from './roadtrip-hazards.controller';
import { RoadtripHazardsService } from './roadtrip-hazards.service';
import { RoadtripHazardsMcp } from './roadtrip-hazards.mcp';
/** Road trip domain (#1797): the points a drive is routed through. Registered in AppModule. */
@Module({
@@ -34,8 +37,10 @@ import { Module } from '@nestjs/common';
PluginsRuntimeModule,
MapsModule,
],
controllers: [RoadtripPreferencesController, RoadtripController, DayBoundariesController],
controllers: [RoadtripPreferencesController, RoadtripController, DayBoundariesController, RoadtripHazardsController],
providers: [
RoadtripHazardsService,
RoadtripHazardsMcp,
RoadtripService,
RoadtripMcp,
DayBoundariesService,
+12
View File
@@ -17,6 +17,7 @@ import { DatabaseModule } from '../../src/nest/database/database.module';
import { PermissionsService } from '../../src/nest/permissions/permissions.service';
import { RealtimeModule } from '../../src/nest/realtime/realtime.module';
import { RoadtripModule } from '../../src/nest/roadtrip/roadtrip.module';
import { RoadtripHazardsService } from '../../src/nest/roadtrip/roadtrip-hazards.service';
import { seedUser, sessionCookie } from './harness';
import { Test } from '@nestjs/testing';
@@ -130,6 +131,17 @@ describe('Roadtrip e2e (real guard chain + temp SQLite)', () => {
});
const cookie = () => sessionCookie(1);
it('gates live hazards by addon, authentication and trip access', async () => {
const read = vi.spyOn(app.get(RoadtripHazardsService), 'read').mockResolvedValue({ fetchedAt: new Date().toISOString(), hazards: [], sources: [] });
try {
await request(server).get('/api/trips/5/roadtrip/hazards').expect(401);
await request(server).get('/api/trips/6/roadtrip/hazards').set('Cookie', cookie()).expect(404);
await request(server).get('/api/trips/5/roadtrip/hazards').set('Cookie', cookie()).expect(200);
setAddon(false);
await request(server).get('/api/trips/5/roadtrip/hazards').set('Cookie', cookie()).expect(404);
expect(read).toHaveBeenCalledTimes(1);
} finally { read.mockRestore(); }
});
describe('shared trip driving preferences', () => {
it('shares values with members and isolates other trips', async () => {
@@ -0,0 +1,49 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { RoadtripHazardsService } from '../../../src/nest/roadtrip/roadtrip-hazards.service';
const polygon = { type: 'Polygon', coordinates: [[[10, 50], [11, 50], [11, 51], [10, 50]]] };
const feed = (features: unknown[]) => new Response(JSON.stringify({ type: 'FeatureCollection', features }));
afterEach(() => vi.unstubAllGlobals());
describe('roadtrip hazard sources', () => {
it('isolates failed providers and caches concurrent reads', async () => {
const fetcher = vi.fn(async (url: string) => {
if (url.includes('dwd.de')) throw new Error('down');
return feed([]);
});
vi.stubGlobal('fetch', fetcher);
const service = new RoadtripHazardsService();
const [first, second] = await Promise.all([service.read(), service.read()]);
expect(first).toEqual(second);
expect(first.sources).toEqual([{ source: 'DWD', status: 'unavailable' }, { source: 'GDACS', status: 'ok' }]);
await service.read();
expect(fetcher).toHaveBeenCalledTimes(2);
});
it('drops expired warnings and marks malformed records as incomplete', async () => {
vi.stubGlobal('fetch', vi.fn(async (url: string) => feed(url.includes('dwd.de') ? [
{ geometry: polygon, properties: { HEADLINE: 'Rain', SENT: '2026-01-01T00:00:00Z', EXPIRES: '2099-01-01T00:00:00Z' } },
{ geometry: polygon, properties: { HEADLINE: 'Old', SENT: '2020-01-01T00:00:00Z', EXPIRES: '2020-01-02T00:00:00Z' } },
{ geometry: null, properties: {} },
] : [])));
const saved = await new RoadtripHazardsService().read();
expect(saved.hazards).toHaveLength(1);
expect(saved.hazards[0].title).toBe('Rain');
expect(saved.sources[0].status).toBe('partial');
});
it('uses affected polygons rather than the global event footprint', async () => {
vi.stubGlobal('fetch', vi.fn(async (url: string) => {
if (url.includes('dwd.de')) return feed([]);
if (url.includes('getgeometry')) return feed([
{ geometry: polygon, properties: { Class: 'Poly_Affected' } },
{ geometry: polygon, properties: { Class: 'Poly_Global' } },
]);
return feed([{ geometry: { type: 'Point', coordinates: [10, 50] }, properties: {
eventtype: 'FL', eventid: 1, episodeid: 2, name: 'Flood', description: 'Flood report',
datemodified: '2026-09-12T08:00:00', iscurrent: 'true',
} }]);
}));
const saved = await new RoadtripHazardsService().read();
expect(saved.hazards[0].geometry).toEqual({ type: 'MultiPolygon', coordinates: [polygon.coordinates] });
expect(saved.hazards[0].updatedAt).toBe('2026-09-12T08:00:00.000Z');
});
});