diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index b0269c2..56d8df7 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -14,12 +14,15 @@ import { } from "@watermaps/shared"; import { loadEnv, type ApiEnv } from "./env.js"; import { createCache, type Cache } from "./services/cache.js"; -import { appConfig } from "./services/config.js"; +import { buildAppConfig } from "./services/config.js"; import { FairwayService, type FairwayGraphLookup } from "./services/fairways.js"; -import { FeatureService } from "./services/features.js"; +import { + FeatureService, + FeatureSourceUnavailableError +} from "./services/features.js"; import { getNearestTideSummary } from "./services/tides.js"; import { getMarineForecast } from "./services/weather.js"; import type { FetchLike } from "./services/http.js"; @@ -126,9 +129,33 @@ export async function buildServer(deps: AppDeps = {}): Promise await fairwayService.close?.(); }); - app.get("/health", async () => ({ ok: true, service: "watermaps-api" })); + app.get("/health", async () => ({ + ok: true, + service: "watermaps-api", + featureSource: featureService.sourceMode + })); - app.get("/api/config", async () => appConfig); + app.get("/ready", async (_request, reply) => { + const featureSource = await featureService.checkReadiness(); + const body = { + ok: featureSource.ready, + service: "watermaps-api", + checks: { + features: featureSource + } + }; + return featureSource.ready + ? body + : reply.code(503).send(body); + }); + + app.get("/api/config", async () => { + const featureSource = await featureService.checkReadiness(); + return buildAppConfig({ + featureSource: featureSource.ready ? featureSource.source : "unavailable", + liveFairwayExtraction: env.liveOsmFairways + }); + }); app.get("/api/weather/marine", async (request, reply) => { const parsed = coordinateQuerySchema.safeParse(request.query); @@ -172,7 +199,18 @@ export async function buildServer(deps: AppDeps = {}): Promise return reply.code(400).send({ error: "invalid_query", details: parsed.error.flatten() }); } - return featureService.getFeatures(parsed.data); + try { + return await featureService.getFeatures(parsed.data); + } catch (error) { + if (error instanceof FeatureSourceUnavailableError) { + app.log.warn({ error }, "feature source unavailable"); + return reply.code(503).send({ + error: error.code, + message: error.message + }); + } + throw error; + } }); app.post("/api/routes", async (request, reply) => { diff --git a/apps/api/src/env.ts b/apps/api/src/env.ts index b311a83..0015c06 100644 --- a/apps/api/src/env.ts +++ b/apps/api/src/env.ts @@ -12,8 +12,8 @@ export function loadEnv(env: NodeJS.ProcessEnv = process.env): ApiEnv { return { port: Number(env.PORT ?? 5174), host: env.HOST ?? "0.0.0.0", - databaseUrl: env.DATABASE_URL, - redisUrl: env.REDIS_URL, + databaseUrl: nonEmptyString(env.DATABASE_URL), + redisUrl: nonEmptyString(env.REDIS_URL), localFairwaysPath: env.WATERMAPS_LOCAL_FAIRWAYS_PATH ?? env.SEA_COMPASS_LOCAL_FAIRWAYS_PATH ?? @@ -26,3 +26,8 @@ export function loadEnv(env: NodeJS.ProcessEnv = process.env): ApiEnv { env.NODE_ENV !== "test" }; } + +function nonEmptyString(value: string | undefined) { + const normalized = value?.trim(); + return normalized || undefined; +} diff --git a/apps/api/src/services/config.ts b/apps/api/src/services/config.ts index ee62e10..27dcd3d 100644 --- a/apps/api/src/services/config.ts +++ b/apps/api/src/services/config.ts @@ -1,67 +1,78 @@ import type { AppConfig } from "@watermaps/shared"; +import type { FeatureSourceMode } from "./features.js"; -export const appConfig: AppConfig = { - appName: "Watermaps", - region: "Deutschland/EU", - disclaimer: - "Freie Karten- und Modelldaten sind eine Fahr- und Planungshilfe, aber kein Ersatz für amtlich zugelassene Seekarten und eigene Navigation.", - featureFlags: { - gpsTracking: true, - compass: true, - marineWeather: true, - tides: true, - manualRouting: true, - liveFairwayExtraction: true, - postgisFeatures: true, - bridgeAndDepthOverlays: true, - inlandWaterwayRouting: true, - routeAlternatives: true, - lockAndHarbourContacts: true, - routeWaypoints: true, - departureTimeForecasts: true, - liveWaterLevels: true, - voyageStages: true, - gpxExport: true, - offlineRoutes: true, - offlineVisitedMapResources: true, - routeDeviationAlarm: true, - offlineTiles: true - }, - layers: [ - { - id: "openfreemap", - name: "Basiskarte", - kind: "style", - url: "https://tiles.openfreemap.org/styles/bright", - attribution: "Map data © OpenStreetMap contributors, style © OpenFreeMap", - defaultVisible: true - }, - { - id: "openseamap-seamarks", - name: "Seezeichen", - kind: "raster-tile", - tileUrl: "https://tiles.openseamap.org/seamark/{z}/{x}/{y}.png", - attribution: "Seamarks © OpenSeaMap / OpenStreetMap contributors", - defaultVisible: true, - opacity: 0.95 - }, - { - id: "emodnet-bathymetry", - name: "Bathymetrie", - kind: "wms", - tileUrl: - "https://ows.emodnet-bathymetry.eu/wms?SERVICE=WMS&VERSION=1.1.1&REQUEST=GetMap&LAYERS=emodnet:mean_multicolour&STYLES=&FORMAT=image/png&TRANSPARENT=true&SRS=EPSG:3857&BBOX={bbox-epsg-3857}&WIDTH=256&HEIGHT=256", - attribution: "Bathymetry © EMODnet Bathymetry", - defaultVisible: false, - opacity: 0.55 - } - ], - attribution: [ - "© OpenStreetMap contributors", - "© OpenSeaMap contributors", - "© EMODnet Bathymetry", - "© Bundesamt für Seeschifffahrt und Hydrographie (BSH), CC BY 4.0", - "Wasserstände © Wasserstraßen- und Schifffahrtsverwaltung des Bundes / PEGELONLINE", - "Weather, waves and current forecast © Open-Meteo" - ] +type AppConfigCapabilities = { + featureSource: FeatureSourceMode; + liveFairwayExtraction: boolean; }; + +export function buildAppConfig(capabilities: AppConfigCapabilities): AppConfig { + const postgisFeatures = capabilities.featureSource === "postgis"; + + return { + appName: "Watermaps", + region: "Deutschland/EU", + disclaimer: + "Freie Karten- und Modelldaten sind eine Fahr- und Planungshilfe, aber kein Ersatz für amtlich zugelassene Seekarten und eigene Navigation.", + featureFlags: { + gpsTracking: true, + compass: true, + marineWeather: true, + tides: true, + manualRouting: true, + liveFairwayExtraction: capabilities.liveFairwayExtraction, + postgisFeatures, + bridgeAndDepthOverlays: postgisFeatures, + inlandWaterwayRouting: true, + routeAlternatives: true, + lockAndHarbourContacts: postgisFeatures, + routeEvents: postgisFeatures, + routeWaypoints: true, + departureTimeForecasts: true, + liveWaterLevels: true, + voyageStages: true, + gpxExport: true, + offlineRoutes: true, + offlineVisitedMapResources: true, + routeDeviationAlarm: true, + offlineTiles: true + }, + layers: [ + { + id: "openfreemap", + name: "Basiskarte", + kind: "style", + url: "https://tiles.openfreemap.org/styles/bright", + attribution: "Map data © OpenStreetMap contributors, style © OpenFreeMap", + defaultVisible: true + }, + { + id: "openseamap-seamarks", + name: "Seezeichen", + kind: "raster-tile", + tileUrl: "https://tiles.openseamap.org/seamark/{z}/{x}/{y}.png", + attribution: "Seamarks © OpenSeaMap / OpenStreetMap contributors", + defaultVisible: true, + opacity: 0.95 + }, + { + id: "emodnet-bathymetry", + name: "Bathymetrie", + kind: "wms", + tileUrl: + "https://ows.emodnet-bathymetry.eu/wms?SERVICE=WMS&VERSION=1.1.1&REQUEST=GetMap&LAYERS=emodnet:mean_multicolour&STYLES=&FORMAT=image/png&TRANSPARENT=true&SRS=EPSG:3857&BBOX={bbox-epsg-3857}&WIDTH=256&HEIGHT=256", + attribution: "Bathymetry © EMODnet Bathymetry", + defaultVisible: false, + opacity: 0.55 + } + ], + attribution: [ + "© OpenStreetMap contributors", + "© OpenSeaMap contributors", + "© EMODnet Bathymetry", + "© Bundesamt für Seeschifffahrt und Hydrographie (BSH), CC BY 4.0", + "Wasserstände © Wasserstraßen- und Schifffahrtsverwaltung des Bundes / PEGELONLINE", + "Weather, waves and current forecast © Open-Meteo" + ] + }; +} diff --git a/apps/api/src/services/features.ts b/apps/api/src/services/features.ts index fad5377..d3c02d7 100644 --- a/apps/api/src/services/features.ts +++ b/apps/api/src/services/features.ts @@ -11,6 +11,21 @@ export type FeatureQuery = { layers: string[]; }; +export type FeatureSourceMode = "postgis" | "demo" | "unavailable"; + +export type FeatureSourceReadiness = { + ready: boolean; + source: FeatureSourceMode; + detail?: + | "not_configured" + | "database_unreachable" + | "schema_missing" + | "data_missing"; + missingLayers?: Array<"harbours" | "locks" | "bridges">; +}; + +export type FeatureDatabase = Pick; + type FeatureCollection = { type: "FeatureCollection"; features: Array<{ @@ -20,7 +35,7 @@ type FeatureCollection = { properties: Record; }>; metadata: { - source: "postgis" | "demo" | "unavailable"; + source: Exclude; warning?: string; deduplication?: { inputPoiCount: number; @@ -30,6 +45,15 @@ type FeatureCollection = { }; }; +export class FeatureSourceUnavailableError extends Error { + readonly code = "feature_source_unavailable"; + + constructor(message: string, options?: ErrorOptions) { + super(message, options); + this.name = "FeatureSourceUnavailableError"; + } +} + const demoFeatures = [ { type: "Feature" as const, @@ -65,29 +89,110 @@ const demoFeatures = [ ]; export class FeatureService { - private pool: pg.Pool | null; + private pool: FeatureDatabase | null; private demoData: boolean; - constructor(env: Pick) { - this.pool = env.databaseUrl ? new pg.Pool({ connectionString: env.databaseUrl }) : null; + constructor( + env: Pick, + dependencies: { pool?: FeatureDatabase } = {} + ) { + this.pool = + env.databaseUrl && !env.demoData + ? dependencies.pool ?? + new pg.Pool({ + connectionString: env.databaseUrl, + connectionTimeoutMillis: 3_000, + query_timeout: 3_000 + }) + : null; this.demoData = env.demoData; } + get sourceMode(): FeatureSourceMode { + if (this.demoData) { + return "demo"; + } + return this.pool ? "postgis" : "unavailable"; + } + + async checkReadiness(): Promise { + if (this.demoData) { + return { ready: true, source: "demo" }; + } + if (!this.pool) { + return { + ready: false, + source: "unavailable", + detail: "not_configured" + }; + } + + try { + const result = await this.pool.query<{ + marine_features: string | null; + marine_fairway_edges: string | null; + }>(` + SELECT + to_regclass('public.marine_features')::text AS marine_features, + to_regclass('public.marine_fairway_edges')::text AS marine_fairway_edges + `); + const schema = result.rows[0]; + if (!schema?.marine_features || !schema.marine_fairway_edges) { + return { + ready: false, + source: "postgis", + detail: "schema_missing" + }; + } + + const dataResult = await this.pool.query<{ + harbours: boolean; + locks: boolean; + bridges: boolean; + }>(` + SELECT + EXISTS(SELECT 1 FROM marine_features WHERE layer = 'harbours') AS harbours, + EXISTS(SELECT 1 FROM marine_features WHERE layer = 'locks') AS locks, + EXISTS(SELECT 1 FROM marine_features WHERE layer = 'bridges') AS bridges + `); + const data = dataResult.rows[0]; + const missingLayers = (["harbours", "locks", "bridges"] as const).filter( + (layer) => !data?.[layer] + ); + if (missingLayers.length > 0) { + return { + ready: false, + source: "postgis", + detail: "data_missing", + missingLayers + }; + } + return { ready: true, source: "postgis" }; + } catch { + return { + ready: false, + source: "postgis", + detail: "database_unreachable" + }; + } + } + async getFeatures(query: FeatureQuery): Promise { if (this.pool && !this.demoData) { - return this.getPostgisFeatures(query); + try { + return await this.getPostgisFeatures(query); + } catch (error) { + throw new FeatureSourceUnavailableError( + "Die lokale Karten- und Ereignisdatenbank ist momentan nicht erreichbar.", + { cause: error } + ); + } } if (!this.demoData) { - return { - type: "FeatureCollection", - features: [], - metadata: { - source: "unavailable", - warning: - "Keine lokale Feature-Datenbank konfiguriert. Kartenkacheln und Live-Dienste bleiben davon unberührt." - } - }; + throw new FeatureSourceUnavailableError( + "Keine lokale Karten- und Ereignisdatenbank konfiguriert." + ); } const [minLon, minLat, maxLon, maxLat] = query.bbox; @@ -151,16 +256,6 @@ export class FeatureService { FROM marine_features WHERE layer = ANY($1::text[]) AND geom && ST_MakeEnvelope($2, $3, $4, $5, 4326) - AND ( - layer <> 'bridges' - OR properties ? 'seamark:bridge:clearance_height' - OR properties ? 'seamark:bridge:clearance_height_safe' - OR properties ? 'maxheight' - OR properties ? 'maxheight:physical' - OR lower(COALESCE(properties->>'bridge', '')) = ANY( - ARRAY['movable', 'bascule', 'lift', 'swing', 'drawbridge', 'retractable', 'submersible', 'opening'] - ) - ) ) SELECT id::text, @@ -364,8 +459,16 @@ export function normalizeMarineFeatureProperties(input: MarineFeaturePropertiesI "seamark:lock_basin:communication_channel", "seamark:radio_station:channel" ]); - const openingHours = firstStringProperty(sourceProperties, ["opening_hours", "service_times"]); - const operator = firstStringProperty(sourceProperties, ["operator"]); + const openingHours = firstStringProperty(sourceProperties, [ + "opening_hours", + "lock:opening_hours", + "service_times" + ]); + const operator = firstStringProperty(sourceProperties, [ + "operator", + "operator:name", + "owner" + ]); const address = normalizedAddress(sourceProperties); const sourceUrl = firstStringProperty(sourceProperties, [ "sourceUrl", @@ -452,6 +555,7 @@ const MARINE_PROPERTY_KEYS = new Set([ "lock", "obstacle", "bridge", + "bridge:movable", "maxheight", "maxheight:physical", "height", @@ -529,8 +633,7 @@ function bridgeClearanceM(properties: Record) { "seamark:bridge:clearance_height", "seamark:bridge:clearance_height_safe", "maxheight", - "maxheight:physical", - "height" + "maxheight:physical" ]; for (const key of candidates) { diff --git a/apps/api/tests/api.test.ts b/apps/api/tests/api.test.ts index 36bb0b3..9e66578 100644 --- a/apps/api/tests/api.test.ts +++ b/apps/api/tests/api.test.ts @@ -3,6 +3,10 @@ import type { Coordinate, FairwayGraph } from "@watermaps/shared"; import { buildServer } from "../src/app.js"; import { loadEnv } from "../src/env.js"; import { createCache } from "../src/services/cache.js"; +import { + type FeatureDatabase, + FeatureService +} from "../src/services/features.js"; import type { LockOperationInfo, NavigationDataAdapter @@ -90,11 +94,209 @@ describe("Watermaps API", () => { }); it("returns app config with map layers", async () => { - const app = await buildServer({ cache: createCache() }); + const app = await buildServer({ cache: createCache(), env: productionEnv }); const response = await app.inject({ method: "GET", url: "/api/config" }); expect(response.statusCode).toBe(200); expect(response.json().layers).toHaveLength(3); + expect(response.json().featureFlags).toMatchObject({ + liveFairwayExtraction: false, + postgisFeatures: false, + bridgeAndDepthOverlays: false, + lockAndHarbourContacts: false, + routeEvents: false + }); + await app.close(); + }); + + it("advertises feature and contact capabilities only for a configured PostGIS source", async () => { + const pool = { + query: vi.fn().mockImplementation((sql: unknown) => + Promise.resolve( + String(sql).includes("to_regclass") + ? { + rows: [ + { + marine_features: "marine_features", + marine_fairway_edges: "marine_fairway_edges" + } + ] + } + : { + rows: [{ harbours: true, locks: true, bridges: true }] + } + ) + ), + end: vi.fn().mockResolvedValue(undefined) + } as unknown as FeatureDatabase; + const featureService = new FeatureService( + { databaseUrl: "postgres://features.test/watermaps", demoData: false }, + { pool } + ); + const app = await buildServer({ + cache: createCache(), + env: { ...productionEnv, liveOsmFairways: true }, + featureService + }); + const response = await app.inject({ method: "GET", url: "/api/config" }); + + expect(response.statusCode).toBe(200); + expect(response.json().featureFlags).toMatchObject({ + liveFairwayExtraction: true, + postgisFeatures: true, + bridgeAndDepthOverlays: true, + lockAndHarbourContacts: true, + routeEvents: true + }); + await app.close(); + }); + + it("keeps route-event capabilities disabled until all event layers contain data", async () => { + const pool = { + query: vi.fn().mockImplementation((sql: unknown) => + Promise.resolve( + String(sql).includes("to_regclass") + ? { + rows: [ + { + marine_features: "marine_features", + marine_fairway_edges: "marine_fairway_edges" + } + ] + } + : { + rows: [{ harbours: false, locks: false, bridges: false }] + } + ) + ), + end: vi.fn().mockResolvedValue(undefined) + } as unknown as FeatureDatabase; + const featureService = new FeatureService( + { databaseUrl: "postgres://features.test/watermaps", demoData: false }, + { pool } + ); + const app = await buildServer({ + cache: createCache(), + env: productionEnv, + featureService + }); + const config = await app.inject({ method: "GET", url: "/api/config" }); + const readiness = await app.inject({ method: "GET", url: "/ready" }); + + expect(config.statusCode).toBe(200); + expect(config.json().featureFlags).toMatchObject({ + postgisFeatures: false, + bridgeAndDepthOverlays: false, + lockAndHarbourContacts: false, + routeEvents: false + }); + expect(readiness.statusCode).toBe(503); + expect(readiness.json().checks.features).toEqual({ + ready: false, + source: "postgis", + detail: "data_missing", + missingLayers: ["harbours", "locks", "bridges"] + }); + await app.close(); + }); + + it("does not advertise PostGIS capabilities when the configured database is unreachable", async () => { + const pool = { + query: vi.fn().mockRejectedValue(new Error("connection refused")), + end: vi.fn().mockResolvedValue(undefined) + } as unknown as FeatureDatabase; + const featureService = new FeatureService( + { databaseUrl: "postgres://features.test/watermaps", demoData: false }, + { pool } + ); + const app = await buildServer({ + cache: createCache(), + env: productionEnv, + featureService + }); + const config = await app.inject({ method: "GET", url: "/api/config" }); + const readiness = await app.inject({ method: "GET", url: "/ready" }); + + expect(config.statusCode).toBe(200); + expect(config.json().featureFlags).toMatchObject({ + postgisFeatures: false, + bridgeAndDepthOverlays: false, + lockAndHarbourContacts: false, + routeEvents: false + }); + expect(readiness.statusCode).toBe(503); + expect(readiness.json().checks.features).toEqual({ + ready: false, + source: "postgis", + detail: "database_unreachable" + }); + await app.close(); + }); + + it("returns 503 when no feature source is configured", async () => { + const app = await buildServer({ cache: createCache(), env: productionEnv }); + const response = await app.inject({ + method: "GET", + url: "/api/features?bbox=7.1,53.3,7.5,53.5&layers=harbours,locks,bridges" + }); + + expect(response.statusCode).toBe(503); + expect(response.json()).toEqual({ + error: "feature_source_unavailable", + message: "Keine lokale Karten- und Ereignisdatenbank konfiguriert." + }); + await app.close(); + }); + + it("returns 503 when the configured feature database query fails", async () => { + const pool = { + query: vi.fn().mockRejectedValue(new Error("database unavailable")), + end: vi.fn().mockResolvedValue(undefined) + } as unknown as FeatureDatabase; + const featureService = new FeatureService( + { databaseUrl: "postgres://features.test/watermaps", demoData: false }, + { pool } + ); + const app = await buildServer({ + cache: createCache(), + env: productionEnv, + featureService + }); + const response = await app.inject({ + method: "GET", + url: "/api/features?bbox=7.1,53.3,7.5,53.5&layers=harbours,locks,bridges" + }); + + expect(response.statusCode).toBe(503); + expect(response.json()).toEqual({ + error: "feature_source_unavailable", + message: "Die lokale Karten- und Ereignisdatenbank ist momentan nicht erreichbar." + }); + await app.close(); + }); + + it("separates liveness from feature-data readiness", async () => { + const app = await buildServer({ cache: createCache(), env: productionEnv }); + const health = await app.inject({ method: "GET", url: "/health" }); + const readiness = await app.inject({ method: "GET", url: "/ready" }); + + expect(health.statusCode).toBe(200); + expect(health.json()).toMatchObject({ + ok: true, + featureSource: "unavailable" + }); + expect(readiness.statusCode).toBe(503); + expect(readiness.json()).toEqual({ + ok: false, + service: "watermaps-api", + checks: { + features: { + ready: false, + source: "unavailable", + detail: "not_configured" + } + } + }); await app.close(); }); diff --git a/apps/api/tests/env.test.ts b/apps/api/tests/env.test.ts index 629126f..7bfade5 100644 --- a/apps/api/tests/env.test.ts +++ b/apps/api/tests/env.test.ts @@ -18,4 +18,15 @@ describe("API environment", () => { loadEnv({ NODE_ENV: "production", SEA_COMPASS_DEMO_DATA: "true" }).demoData ).toBe(true); }); + + it("does not treat blank service URLs as configured capabilities", () => { + const env = loadEnv({ + NODE_ENV: "production", + DATABASE_URL: " ", + REDIS_URL: "\t" + }); + + expect(env.databaseUrl).toBeUndefined(); + expect(env.redisUrl).toBeUndefined(); + }); }); diff --git a/apps/api/tests/features.test.ts b/apps/api/tests/features.test.ts index d66c571..d2f7fb9 100644 --- a/apps/api/tests/features.test.ts +++ b/apps/api/tests/features.test.ts @@ -1,25 +1,154 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { deduplicateMarineContactFeatures, + type FeatureDatabase, FeatureService, + FeatureSourceUnavailableError, normalizeDepthFeatureProperties, normalizeMarineFeatureProperties } from "../src/services/features.js"; describe("marine feature data modes", () => { - it("does not expose demo markers when production disables demo data without PostGIS", async () => { + it("fails explicitly when production has no configured feature source", async () => { const service = new FeatureService({ databaseUrl: undefined, demoData: false }); - const result = await service.getFeatures({ - bbox: [5, 50, 15, 56], - layers: ["seamarks", "locks", "harbours"] + await expect( + service.getFeatures({ + bbox: [5, 50, 15, 56], + layers: ["seamarks", "locks", "harbours"] + }) + ).rejects.toBeInstanceOf(FeatureSourceUnavailableError); + expect(service.sourceMode).toBe("unavailable"); + expect(await service.checkReadiness()).toEqual({ + ready: false, + source: "unavailable", + detail: "not_configured" }); + await service.close(); + }); - expect(result.features).toEqual([]); - expect(result.metadata.source).toBe("unavailable"); + it("checks that the configured PostGIS schema is ready", async () => { + const pool = featurePool( + { + rows: [ + { + marine_features: "marine_features", + marine_fairway_edges: "marine_fairway_edges" + } + ] + }, + { + rows: [{ harbours: true, locks: true, bridges: true }] + } + ); + const service = new FeatureService( + { databaseUrl: "postgres://features.test/watermaps", demoData: false }, + { pool } + ); + + expect(service.sourceMode).toBe("postgis"); + expect(await service.checkReadiness()).toEqual({ + ready: true, + source: "postgis" + }); + expect(pool.query).toHaveBeenCalledWith(expect.stringContaining("to_regclass")); + expect(pool.query).toHaveBeenCalledWith(expect.stringContaining("EXISTS")); + await service.close(); + expect(pool.end).toHaveBeenCalledOnce(); + }); + + it("reports a configured database with missing feature tables as not ready", async () => { + const pool = featurePool({ + rows: [{ marine_features: "marine_features", marine_fairway_edges: null }] + }); + const service = new FeatureService( + { databaseUrl: "postgres://features.test/watermaps", demoData: false }, + { pool } + ); + + expect(await service.checkReadiness()).toEqual({ + ready: false, + source: "postgis", + detail: "schema_missing" + }); + await service.close(); + }); + + it("reports initialized PostGIS tables without event data as not ready", async () => { + const pool = featurePool( + { + rows: [ + { + marine_features: "marine_features", + marine_fairway_edges: "marine_fairway_edges" + } + ] + }, + { + rows: [{ harbours: false, locks: true, bridges: false }] + } + ); + const service = new FeatureService( + { databaseUrl: "postgres://features.test/watermaps", demoData: false }, + { pool } + ); + + expect(await service.checkReadiness()).toEqual({ + ready: false, + source: "postgis", + detail: "data_missing", + missingLayers: ["harbours", "bridges"] + }); + await service.close(); + }); + + it("returns ordinary bridges from the requested bbox for route-side filtering", async () => { + const pool = featurePool({ + rows: [ + { + id: "bridge-ordinary", + layer: "bridges", + name: "Normale Kanalbrücke", + source: "osm", + source_id: "w100", + properties: { bridge: "yes" }, + updated_at: "2026-07-20T10:00:00.000Z", + geometry: { + type: "LineString", + coordinates: [ + [7.2, 53.4], + [7.21, 53.4] + ] + } + } + ] + }); + const service = new FeatureService( + { databaseUrl: "postgres://features.test/watermaps", demoData: false }, + { pool } + ); + + const result = await service.getFeatures({ + bbox: [7.1, 53.3, 7.5, 53.5], + layers: ["bridges"] + }); + const sql = String(vi.mocked(pool.query).mock.calls[0]?.[0]); + + expect(sql).not.toContain("properties ? 'maxheight'"); + expect(sql).not.toContain("properties->>'bridge'"); + expect(result.features).toEqual([ + expect.objectContaining({ + id: "bridge-ordinary", + properties: expect.objectContaining({ + layer: "bridges", + name: "Normale Kanalbrücke", + clearance_m: null + }) + }) + ]); await service.close(); }); }); @@ -58,6 +187,23 @@ describe("marine feature normalization", () => { expect(properties.label).toBeNull(); }); + it("does not mistake a bridge structure height for navigable clearance", () => { + const properties = normalizeMarineFeatureProperties({ + layer: "bridges", + name: "Klappbrücke", + source: "osm", + sourceId: "w125", + properties: { + bridge: "movable", + height: "18" + } + }); + + expect(properties.clearance_m).toBeNull(); + expect(properties.clearance_label).toBeNull(); + expect(properties.label).toBe("Klappbrücke"); + }); + it("normalizes contact aliases, address and database timestamps", () => { const properties = normalizeMarineFeatureProperties({ layer: "locks", @@ -71,7 +217,7 @@ describe("marine feature normalization", () => { "contact:email": "schleuse@example.test", "seamark:lock_basin:communication_channel": "18", opening_hours: "24/7", - operator: "WSV", + "operator:name": "WSV", "addr:street": "Fährstraße", "addr:housenumber": "1", "addr:postcode": "59071", @@ -92,6 +238,20 @@ describe("marine feature normalization", () => { expect(properties.updatedAt).toBe("2026-07-19T08:30:00.000Z"); }); + it("normalizes lock-specific opening hours for event details", () => { + const properties = normalizeMarineFeatureProperties({ + layer: "locks", + name: "Schleuse Rahe", + source: "osm", + sourceId: "w126", + properties: { + "lock:opening_hours": "Mo-Su 07:00-20:00" + } + }); + + expect(properties.openingHours).toBe("Mo-Su 07:00-20:00"); + }); + it("prefers direct contact fields and returns stable null values when details are absent", () => { const properties = normalizeMarineFeatureProperties({ layer: "harbours", @@ -153,6 +313,23 @@ describe("marine feature normalization", () => { }); }); +function featurePool(...results: Array<{ rows: unknown[] }>): FeatureDatabase & { + query: ReturnType; + end: ReturnType; +} { + const query = vi.fn(); + for (const result of results) { + query.mockResolvedValueOnce(result); + } + return { + query, + end: vi.fn().mockResolvedValue(undefined) + } as unknown as FeatureDatabase & { + query: ReturnType; + end: ReturnType; + }; +} + describe("marine contact feature deduplication", () => { it("returns one stable facility feature and reports how many raw objects were merged", () => { const result = deduplicateMarineContactFeatures([ diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index e49c3be..f93c40f 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -4,7 +4,6 @@ import { calculateRouteGuidance, haversineDistanceNm, type BoatProfile, - type VoyageHarbour, type AppConfig, type Coordinate, type MarineForecast, @@ -17,7 +16,6 @@ import { import { createRoute, getConfig, - getMapFeatures, getMarineForecast, getNavigationData, getNearestTide @@ -42,6 +40,7 @@ import { useBoatProfile } from "./hooks/useBoatProfile"; import { useCourseAssistant } from "./hooks/useCourseAssistant"; import { useGeolocation } from "./hooks/useGeolocation"; import { useMarineData } from "./hooks/useMarineData"; +import { useRouteEventFeatures } from "./hooks/useRouteEventFeatures"; import type { OfflineVoyage } from "./lib/offline-route"; import { boatCategoryLabel, toVesselProfile } from "./lib/boat-profile"; import { isOpenRouteWarning } from "./lib/route-warnings"; @@ -51,12 +50,6 @@ import { type UpcomingRouteEvent } from "./routeEvents"; import type { RouteWeatherReport } from "./routeWeatherReport"; -import { - routeFeatureBounds, - routeLocksFromFeatures, - voyageHarboursFromFeatures, - type RouteLock -} from "./voyageHarbours"; type PickMode = "start" | "destination" | "waypoint" | null; @@ -141,16 +134,21 @@ export function App() { }); const [routeOptions, setRouteOptions] = useState([]); const [activeVesselProfile, setActiveVesselProfile] = useState(null); + const routeEventFeatures = useRouteEventFeatures( + route, + activeVesselProfile ?? toVesselProfile(currentBoat) + ); + const { + harbours: routeHarbours, + locks: routeLocks, + bridgeReport: routeBridgeReport + } = routeEventFeatures; const [routeWeatherReport, setRouteWeatherReport] = useState(null); const [routeWeatherLoading, setRouteWeatherLoading] = useState(false); const [routeWeatherError, setRouteWeatherError] = useState(null); const [navigationData, setNavigationData] = useState(null); const [navigationDataLoading, setNavigationDataLoading] = useState(false); const [navigationDataError, setNavigationDataError] = useState(null); - const [routeHarbours, setRouteHarbours] = useState([]); - const [routeLocks, setRouteLocks] = useState([]); - const [routeHarboursLoading, setRouteHarboursLoading] = useState(false); - const [routeHarboursError, setRouteHarboursError] = useState(null); const [routeTides, setRouteTides] = useState(null); const [routeTidesLoading, setRouteTidesLoading] = useState(false); const [routeTidesError, setRouteTidesError] = useState(null); @@ -262,44 +260,6 @@ export function App() { }; }, [route]); - useEffect(() => { - if (!route) { - setRouteHarbours([]); - setRouteLocks([]); - setRouteHarboursLoading(false); - setRouteHarboursError(null); - return; - } - - let active = true; - setRouteHarboursLoading(true); - getMapFeatures({ bbox: routeFeatureBounds(route, 2.5), layers: ["harbours", "locks"] }) - .then((collection) => { - if (!active) { - return; - } - setRouteHarbours(voyageHarboursFromFeatures(collection)); - setRouteLocks(routeLocksFromFeatures(collection, route)); - setRouteHarboursError(null); - }) - .catch((error) => { - if (active) { - setRouteHarbours([]); - setRouteLocks([]); - setRouteHarboursError(error instanceof Error ? error.message : "Häfen entlang der Route nicht erreichbar"); - } - }) - .finally(() => { - if (active) { - setRouteHarboursLoading(false); - } - }); - - return () => { - active = false; - }; - }, [route]); - const routeWarningCount = useMemo( () => route?.warnings.filter(isOpenRouteWarning).length ?? 0, [route] @@ -433,7 +393,7 @@ export function App() { route, harbours: routeHarbours, locks: routeLocks, - bridges: routeWeatherReport?.bridgeReport?.bridges ?? [], + bridges: routeBridgeReport?.bridges ?? [], progressNm: routeProgress.distanceNm, etaBasis: routeEventEtaBasis }) @@ -444,7 +404,7 @@ export function App() { routeHarbours, routeLocks, routeProgress.distanceNm, - routeWeatherReport?.bridgeReport?.bridges + routeBridgeReport?.bridges ] ); const eventWarningCount = useMemo( @@ -485,6 +445,8 @@ export function App() { ) ? "alarm" : "caution" + : routeEventFeatures.error + ? "caution" : route ? "active" : "idle", @@ -511,6 +473,7 @@ export function App() { marineData.loading, marineData.tide?.updatedAt, route, + routeEventFeatures.error, routeEvents, routeWeatherReport?.severity ] @@ -526,7 +489,7 @@ export function App() { import("./routeWeatherReport") .then(({ createRouteWeatherReport }) => - createRouteWeatherReport(result, vesselProfile, getMarineForecast, getMapFeatures) + createRouteWeatherReport(result, vesselProfile, getMarineForecast) ) .then((report) => { if (routeWeatherRequestId.current === reportRequestId) { @@ -812,7 +775,7 @@ export function App() { activeTool === "conditions" ? marineData.loading || routeWeatherLoading || routeTidesLoading : activeTool === "upcoming" - ? routeHarboursLoading || routeWeatherLoading + ? routeEventFeatures.loading : activeTool === "route" ? routeLoading : false; @@ -905,7 +868,7 @@ export function App() { badges={{ boat: boatProfileStored ? null : "!", anchor: anchorAlarm ? "!" : null, - upcoming: eventWarningCount, + upcoming: eventWarningCount || (routeEventFeatures.error ? "!" : null), route: guidanceAlarm ? "!" : routeWarningCount }} /> @@ -991,8 +954,8 @@ export function App() { @@ -1034,9 +997,12 @@ export function App() { navigationDataLoading={navigationDataLoading} navigationDataError={navigationDataError} routeHarbours={routeHarbours} - routeHarboursLoading={routeHarboursLoading} - routeHarboursError={routeHarboursError} + routeHarboursLoading={routeEventFeatures.harboursLoading} + routeHarboursError={routeEventFeatures.errors.harbours} routeLocks={routeLocks} + bridgeReport={routeBridgeReport} + bridgesLoading={routeEventFeatures.bridgesLoading} + bridgesError={routeEventFeatures.errors.bridges} routeTides={routeTides} routeTidesLoading={routeTidesLoading} routeTidesError={routeTidesError} diff --git a/apps/web/src/api.ts b/apps/web/src/api.ts index 2088f2c..a67dfa2 100644 --- a/apps/web/src/api.ts +++ b/apps/web/src/api.ts @@ -9,10 +9,31 @@ import type { } from "@watermaps/shared"; import type { FeatureCollection } from "geojson"; +export type MapFeatureMetadata = { + source?: "postgis" | "demo" | "unavailable" | string; + warning?: string; + deduplication?: { + inputPoiCount: number; + outputPoiCount: number; + mergedObjectCount: number; + }; +}; + +export type MapFeatureCollection = FeatureCollection & { + metadata?: MapFeatureMetadata; +}; + +export class FeatureDataUnavailableError extends Error { + constructor(message: string) { + super(message); + this.name = "FeatureDataUnavailableError"; + } +} + async function getJson(url: string, init?: RequestInit): Promise { const response = await fetch(url, init); if (!response.ok) { - throw new Error(`${response.status} ${response.statusText}`); + throw new Error(await responseErrorMessage(response)); } return (await response.json()) as T; } @@ -84,10 +105,24 @@ export function getMapFeatures(params: { bbox: [number, number, number, number]; layers: string[]; signal?: AbortSignal; -}): Promise { +}): Promise { const search = new URLSearchParams({ bbox: params.bbox.join(","), layers: params.layers.join(",") }); - return getJson(`/api/features?${search.toString()}`, { signal: params.signal }); + return getJson(`/api/features?${search.toString()}`, { + signal: params.signal + }).then(assertFeatureDataAvailable); +} + +export function assertFeatureDataAvailable( + collection: MapFeatureCollection +): MapFeatureCollection { + if (collection.metadata?.source === "unavailable") { + throw new FeatureDataUnavailableError( + collection.metadata.warning?.trim() || + "Die lokale Ereignis-Datenquelle ist nicht konfiguriert." + ); + } + return collection; } diff --git a/apps/web/src/components/RoutePlanner.tsx b/apps/web/src/components/RoutePlanner.tsx index c3aa959..3a6afa4 100644 --- a/apps/web/src/components/RoutePlanner.tsx +++ b/apps/web/src/components/RoutePlanner.tsx @@ -31,7 +31,10 @@ import { type VoyageAmenity, type VoyageHarbour } from "@watermaps/shared"; -import type { RouteWeatherReport } from "../routeWeatherReport"; +import type { + RouteBridgeReport, + RouteWeatherReport +} from "../routeWeatherReport"; import type { OfflineVoyage } from "../lib/offline-route"; import { boatCategoryLabel, @@ -63,6 +66,9 @@ type RoutePlannerProps = { routeHarboursLoading?: boolean; routeHarboursError?: string | null; routeLocks?: RouteLock[]; + bridgeReport?: RouteBridgeReport | null; + bridgesLoading?: boolean; + bridgesError?: string | null; routeTides?: RouteTidePlan | null; routeTidesLoading?: boolean; routeTidesError?: string | null; @@ -120,6 +126,9 @@ export function RoutePlanner({ routeHarboursLoading = false, routeHarboursError = null, routeLocks = [], + bridgeReport = null, + bridgesLoading = false, + bridgesError = null, routeTides = null, routeTidesLoading = false, routeTidesError = null, @@ -739,7 +748,13 @@ export function RoutePlanner({ )} - {operationalPanelsVisible && (weatherLoading || weatherReport || weatherError) && ( + {operationalPanelsVisible && + (weatherLoading || + weatherReport || + weatherError || + bridgesLoading || + bridgeReport || + bridgesError) && (
{weatherLoading &&

Wetterbericht wird geladen

} + {bridgesLoading &&

Brücken werden geladen

} {weatherReport && ( <> @@ -798,35 +814,6 @@ export function RoutePlanner({ {weatherReport.unavailableSamples} Messpunkt nicht erreichbar

)} - {weatherReport.bridgeReport && ( -
-
-
-
- Boot {formatMeters(weatherReport.bridgeReport.requiredAirDraftM)} - Min {formatMeters(weatherReport.bridgeReport.minClearanceM)} - - {weatherReport.bridgeReport.checkedCount}/{weatherReport.bridgeReport.bridges.length} geprüft - -
- {weatherReport.bridgeReport.bridges.length > 0 && ( -
- {weatherReport.bridgeReport.bridges.slice(0, 4).map((bridge) => ( -
- {bridge.name ?? "Brücke"} - {bridge.clearanceLabel ?? "H unbekannt"} - {formatBridgeMargin(bridge.marginM)} -
- ))} - {weatherReport.bridgeReport.bridges.length > 4 && ( -

+{weatherReport.bridgeReport.bridges.length - 4} weitere Brücken

- )} -
- )} -
- )} )} @@ -836,6 +823,43 @@ export function RoutePlanner({ {weatherError}

)} + + {bridgeReport && ( +
+
+
+
+ Boot {formatMeters(bridgeReport.requiredAirDraftM)} + Min {formatMeters(bridgeReport.minClearanceM)} + + {bridgeReport.checkedCount}/{bridgeReport.bridges.length} geprüft + +
+ {bridgeReport.bridges.length > 0 && ( +
+ {bridgeReport.bridges.slice(0, 4).map((bridge) => ( +
+ {bridge.name ?? "Brücke"} + {bridge.clearanceLabel ?? "H unbekannt"} + {formatBridgeMargin(bridge.marginM)} +
+ ))} + {bridgeReport.bridges.length > 4 && ( +

+{bridgeReport.bridges.length - 4} weitere Brücken

+ )} +
+ )} +
+ )} + + {bridgesError && !bridgesLoading && ( +

+

+ )}
)} diff --git a/apps/web/src/components/UpcomingEventsPanel.css b/apps/web/src/components/UpcomingEventsPanel.css index d67a36c..8325de0 100644 --- a/apps/web/src/components/UpcomingEventsPanel.css +++ b/apps/web/src/components/UpcomingEventsPanel.css @@ -244,6 +244,15 @@ font-size: 12px; } +.upcoming-event-row-detail-action { + display: inline-flex; + align-items: center; + gap: 3px; + color: #196f5c; + font-size: 9px; + font-weight: 850; +} + .upcoming-event-contact-actions { display: grid; grid-template-columns: repeat(auto-fit, minmax(100px, 1fr)); diff --git a/apps/web/src/components/UpcomingEventsPanel.tsx b/apps/web/src/components/UpcomingEventsPanel.tsx index 180b226..66f62cf 100644 --- a/apps/web/src/components/UpcomingEventsPanel.tsx +++ b/apps/web/src/components/UpcomingEventsPanel.tsx @@ -4,6 +4,7 @@ import { CalendarClock, Clock3, Globe2, + Info, Landmark, LoaderCircle, LockKeyhole, @@ -308,6 +309,10 @@ function EventRow({ {formatDistance(event.remainingNm)} {formatEta(event)} + ); @@ -456,6 +461,9 @@ function KindSpecificDetails({ event }: { event: UpcomingRouteEvent }) { {bridgeMargin(event.feature.marginM)} + + {event.feature.operator ?? "Nicht hinterlegt"} + ); } @@ -566,14 +574,11 @@ function PanelState({ } function eventContact(event: UpcomingRouteEvent) { - if (event.kind === "harbour" || event.kind === "lock") { - return { - phone: event.feature.phone ?? null, - website: event.feature.website ?? null, - email: event.feature.email ?? null - }; - } - return { phone: null, website: null, email: null }; + return { + phone: event.feature.phone ?? null, + website: event.feature.website ?? null, + email: event.feature.email ?? null + }; } function importantFact(event: UpcomingRouteEvent) { diff --git a/apps/web/src/hooks/useRouteEventFeatures.ts b/apps/web/src/hooks/useRouteEventFeatures.ts new file mode 100644 index 0000000..e3a25f8 --- /dev/null +++ b/apps/web/src/hooks/useRouteEventFeatures.ts @@ -0,0 +1,233 @@ +import { useEffect, useMemo, useState } from "react"; +import type { + RouteResult, + VesselProfile, + VoyageHarbour +} from "@watermaps/shared"; +import { getMapFeatures } from "../api"; +import type { RouteBridgeReport } from "../routeWeatherReport"; +import { + routeFeatureBounds, + routeLocksFromFeatures, + voyageHarboursFromFeatures, + type RouteLock +} from "../voyageHarbours"; + +type FeatureKind = "harbours" | "locks" | "bridges"; + +type FeatureRequestState = Record; +type FeatureErrorState = Record; + +export type RouteEventFeatureState = { + harbours: VoyageHarbour[]; + locks: RouteLock[]; + bridgeReport: RouteBridgeReport | null; + loading: boolean; + errors: FeatureErrorState; + error: string | null; + harboursLoading: boolean; + locksLoading: boolean; + bridgesLoading: boolean; +}; + +const EMPTY_LOADING: FeatureRequestState = { + harbours: false, + locks: false, + bridges: false +}; + +const EMPTY_ERRORS: FeatureErrorState = { + harbours: null, + locks: null, + bridges: null +}; + +/** + * Loads every event category independently. A failed harbour request must not + * hide working lock or bridge data, and bridges are intentionally fetched here + * instead of as a side effect of the weather report. + */ +export function useRouteEventFeatures( + route: RouteResult | null, + vesselProfile: Pick | null +): RouteEventFeatureState { + const [harbours, setHarbours] = useState([]); + const [locks, setLocks] = useState([]); + const [bridgeReport, setBridgeReport] = useState(null); + const [loadingByKind, setLoadingByKind] = + useState(EMPTY_LOADING); + const [errors, setErrors] = useState(EMPTY_ERRORS); + const airDraftM = vesselProfile?.airDraftM; + + useEffect(() => { + if (!route) { + setHarbours([]); + setLoadingByKind((current) => ({ ...current, harbours: false })); + setErrors((current) => ({ ...current, harbours: null })); + return; + } + + let active = true; + const controller = new AbortController(); + setHarbours([]); + setLoadingByKind((current) => ({ ...current, harbours: true })); + setErrors((current) => ({ ...current, harbours: null })); + + void getMapFeatures({ + bbox: routeFeatureBounds(route, 2.5), + layers: ["harbours"], + signal: controller.signal + }) + .then((collection) => { + if (active) { + setHarbours(voyageHarboursFromFeatures(collection)); + } + }) + .catch((error) => { + if (active && !isAbortError(error)) { + setErrors((current) => ({ + ...current, + harbours: featureErrorMessage("Häfen", error) + })); + } + }) + .finally(() => { + if (active) { + setLoadingByKind((current) => ({ ...current, harbours: false })); + } + }); + + return () => { + active = false; + controller.abort(); + }; + }, [route]); + + useEffect(() => { + if (!route) { + setLocks([]); + setLoadingByKind((current) => ({ ...current, locks: false })); + setErrors((current) => ({ ...current, locks: null })); + return; + } + + let active = true; + const controller = new AbortController(); + setLocks([]); + setLoadingByKind((current) => ({ ...current, locks: true })); + setErrors((current) => ({ ...current, locks: null })); + + void getMapFeatures({ + bbox: routeFeatureBounds(route, 0.5), + layers: ["locks"], + signal: controller.signal + }) + .then((collection) => { + if (active) { + setLocks(routeLocksFromFeatures(collection, route)); + } + }) + .catch((error) => { + if (active && !isAbortError(error)) { + setErrors((current) => ({ + ...current, + locks: featureErrorMessage("Schleusen", error) + })); + } + }) + .finally(() => { + if (active) { + setLoadingByKind((current) => ({ ...current, locks: false })); + } + }); + + return () => { + active = false; + controller.abort(); + }; + }, [route]); + + useEffect(() => { + if (!route) { + setBridgeReport(null); + setLoadingByKind((current) => ({ ...current, bridges: false })); + setErrors((current) => ({ ...current, bridges: null })); + return; + } + + let active = true; + const controller = new AbortController(); + setBridgeReport(null); + setLoadingByKind((current) => ({ ...current, bridges: true })); + setErrors((current) => ({ ...current, bridges: null })); + + void import("../routeWeatherReport") + .then(({ createRouteBridgeReport }) => + createRouteBridgeReport( + route, + { airDraftM }, + (params) => + getMapFeatures({ + ...params, + signal: controller.signal + }) + ) + ) + .then((report) => { + if (active) { + setBridgeReport(report); + } + }) + .catch((error) => { + if (active && !isAbortError(error)) { + setErrors((current) => ({ + ...current, + bridges: featureErrorMessage("Brücken", error) + })); + } + }) + .finally(() => { + if (active) { + setLoadingByKind((current) => ({ ...current, bridges: false })); + } + }); + + return () => { + active = false; + controller.abort(); + }; + }, [airDraftM, route]); + + const error = useMemo( + () => + (["harbours", "locks", "bridges"] as const) + .map((kind) => errors[kind]) + .filter((message): message is string => Boolean(message)) + .join(" · ") || null, + [errors] + ); + + return { + harbours, + locks, + bridgeReport, + loading: Object.values(loadingByKind).some(Boolean), + errors, + error, + harboursLoading: loadingByKind.harbours, + locksLoading: loadingByKind.locks, + bridgesLoading: loadingByKind.bridges + }; +} + +function featureErrorMessage(label: string, error: unknown) { + const detail = + error instanceof Error && error.message.trim() + ? error.message.trim() + : "Datenquelle nicht erreichbar."; + return `${label} nicht erreichbar: ${detail}`; +} + +function isAbortError(error: unknown) { + return error instanceof DOMException && error.name === "AbortError"; +} diff --git a/apps/web/src/routeEvents.ts b/apps/web/src/routeEvents.ts index 7a282c0..4c27a9c 100644 --- a/apps/web/src/routeEvents.ts +++ b/apps/web/src/routeEvents.ts @@ -1,4 +1,5 @@ import { + haversineDistanceNm, orderWaypointsAlongRoute, type Coordinate, type RouteResult, @@ -12,11 +13,13 @@ export type RouteEventKind = "harbour" | "lock" | "bridge"; export type RouteEventCorridors = Record; export const DEFAULT_ROUTE_EVENT_CORRIDORS_NM: Readonly = Object.freeze({ - harbour: 1.5, + harbour: 0.5, lock: 0.25, bridge: 0.08 }); +const CROSS_KIND_DUPLICATE_DISTANCE_NM = 0.1; + export type RouteEventEtaSpeedSource = "gps-sog" | "vessel-cruise-speed"; export type RouteEventEtaReferenceSource = "current-time" | "route-departure"; @@ -148,7 +151,7 @@ export function upcomingRouteEvents( input.route ); - return projected.flatMap((projection) => { + const events = projected.flatMap((projection) => { const candidate = candidatesByProjectionId.get(projection.id); if ( !candidate || @@ -179,6 +182,8 @@ export function upcomingRouteEvents( return [{ ...common, kind: candidate.kind, feature: candidate.feature }]; } }); + + return withoutLockHarbourDuplicates(events); } export function nextRouteEventsByKind( @@ -320,3 +325,45 @@ function timestampValue(value: string | number | Date): number | null { : Date.parse(value); return Number.isFinite(timestamp) ? timestamp : null; } + +/** + * Some source objects are classified both as a lock and as a harbour. Preserve + * the operationally more specific lock event only when the normalized names + * match and both source coordinates clearly describe the same place. + */ +function withoutLockHarbourDuplicates( + events: UpcomingRouteEvent[] +): UpcomingRouteEvent[] { + const locksByName = new Map(); + for (const event of events) { + if (event.kind !== "lock") { + continue; + } + const name = normalizedFacilityName(event.name); + if (!name) { + continue; + } + locksByName.set(name, [...(locksByName.get(name) ?? []), event]); + } + + return events.filter((event) => { + if (event.kind !== "harbour") { + return true; + } + const possibleLocks = locksByName.get(normalizedFacilityName(event.name)) ?? []; + return !possibleLocks.some( + (lock) => + haversineDistanceNm(lock.coordinate, event.coordinate) <= + CROSS_KIND_DUPLICATE_DISTANCE_NM + ); + }); +} + +function normalizedFacilityName(value: string) { + return value + .normalize("NFKD") + .replace(/\p{Diacritic}/gu, "") + .toLocaleLowerCase("de-DE") + .replace(/[^\p{Letter}\p{Number}]+/gu, " ") + .trim(); +} diff --git a/apps/web/src/routeWeatherReport.ts b/apps/web/src/routeWeatherReport.ts index 27499cf..bf98c4a 100644 --- a/apps/web/src/routeWeatherReport.ts +++ b/apps/web/src/routeWeatherReport.ts @@ -55,6 +55,10 @@ export type RouteBridgeAssessment = { marginM: number | null; status: RouteBridgeStatus; source: string; + phone?: string | null; + website?: string | null; + email?: string | null; + operator?: string | null; }; export type RouteBridgeReport = { @@ -79,6 +83,9 @@ const SAMPLE_TARGETS: Array<{ label: RouteWeatherSample["label"]; ratio: number const ROUTE_BRIDGE_BBOX_MARGIN_DEG = 0.02; const ROUTE_BRIDGE_MAX_DISTANCE_NM = 0.08; const BRIDGE_TIGHT_MARGIN_M = 0.5; +const SAME_NAMED_BRIDGE_DISTANCE_NM = 0.03; +const UNNAMED_BRIDGE_DISTANCE_NM = 0.005; +const NAMED_TO_UNNAMED_BRIDGE_DISTANCE_NM = 0.01; export async function createRouteWeatherReport( route: RouteResult, @@ -451,7 +458,29 @@ function bridgeAssessmentFromFeature( requiredAirDraftM, marginM, status, - source: stringProperty(properties.source) ?? "OSM/Geofabrik" + source: stringProperty(properties.source) ?? "OSM/Geofabrik", + phone: firstStringProperty(properties, [ + "contact:phone", + "phone", + "telephone", + "contact_phone" + ]), + website: firstStringProperty(properties, [ + "contact:website", + "website", + "url", + "contact_website" + ]), + email: firstStringProperty(properties, [ + "contact:email", + "email", + "contact_email" + ]), + operator: firstStringProperty(properties, [ + "operator", + "operator:name", + "owner" + ]) }; } @@ -572,11 +601,123 @@ function dedupeBridges(bridges: RouteBridgeAssessment[]) { const byId = new Map(); for (const bridge of bridges) { const existing = byId.get(bridge.id); - if (!existing || bridge.distanceNm < existing.distanceNm) { - byId.set(bridge.id, bridge); - } + byId.set(bridge.id, existing ? mergeBridgeAssessments(existing, bridge) : bridge); } - return [...byId.values()]; + + const merged: RouteBridgeAssessment[] = []; + for (const bridge of byId.values()) { + const duplicateIndex = merged.findIndex((candidate) => + bridgeAssessmentsMatch(candidate, bridge) + ); + if (duplicateIndex < 0) { + merged.push(bridge); + continue; + } + merged[duplicateIndex] = mergeBridgeAssessments( + merged[duplicateIndex]!, + bridge + ); + } + return merged; +} + +function bridgeAssessmentsMatch( + left: RouteBridgeAssessment, + right: RouteBridgeAssessment +) { + const distanceNm = haversineDistanceNm(left.coordinate, right.coordinate); + const leftName = normalizedBridgeName(left.name); + const rightName = normalizedBridgeName(right.name); + + if (leftName && rightName) { + return ( + leftName === rightName && + distanceNm <= SAME_NAMED_BRIDGE_DISTANCE_NM + ); + } + if (!leftName && !rightName) { + return distanceNm <= UNNAMED_BRIDGE_DISTANCE_NM; + } + return distanceNm <= NAMED_TO_UNNAMED_BRIDGE_DISTANCE_NM; +} + +function mergeBridgeAssessments( + left: RouteBridgeAssessment, + right: RouteBridgeAssessment +): RouteBridgeAssessment { + const primary = + bridgeInformationScore(right) > bridgeInformationScore(left) ? right : left; + const secondary = primary === left ? right : left; + const clearanceSource = smallestClearanceBridge(left, right); + const clearanceM = clearanceSource?.clearanceM ?? null; + const requiredAirDraftM = + primary.requiredAirDraftM ?? secondary.requiredAirDraftM; + const marginM = + clearanceM !== null && requiredAirDraftM !== null + ? clearanceM - requiredAirDraftM + : null; + const closestToRoute = + left.distanceNm <= right.distanceNm ? left : right; + const name = primary.name ?? secondary.name; + const clearanceLabel = + clearanceSource?.clearanceLabel ?? + (clearanceM !== null ? `H ${formatMeters(clearanceM)}` : null); + + return { + ...primary, + name, + label: [name, clearanceLabel].filter(Boolean).join(" ") || primary.label, + coordinate: closestToRoute.coordinate, + distanceNm: Math.min(left.distanceNm, right.distanceNm), + clearanceM, + clearanceLabel, + requiredAirDraftM, + marginM, + status: bridgeStatus(clearanceM, requiredAirDraftM), + source: unique([left.source, right.source]).join(", "), + phone: primary.phone ?? secondary.phone ?? null, + website: primary.website ?? secondary.website ?? null, + email: primary.email ?? secondary.email ?? null, + operator: primary.operator ?? secondary.operator ?? null + }; +} + +function smallestClearanceBridge( + left: RouteBridgeAssessment, + right: RouteBridgeAssessment +) { + const candidates = [left, right].filter( + (bridge) => + bridge.clearanceM !== null && Number.isFinite(bridge.clearanceM) + ); + return candidates.sort( + (first, second) => first.clearanceM! - second.clearanceM! + )[0] ?? null; +} + +function bridgeInformationScore(bridge: RouteBridgeAssessment) { + return [ + bridge.name, + bridge.clearanceM, + bridge.phone, + bridge.website, + bridge.email, + bridge.operator + ].filter((value) => value !== null && value !== undefined && value !== "") + .length; +} + +function normalizedBridgeName(value: string | null) { + if (!value) { + return ""; + } + const normalized = value + .normalize("NFKD") + .replace(/\p{Diacritic}/gu, "") + .toLocaleLowerCase("de-DE") + .replace(/[^\p{Letter}\p{Number}]+/gu, " ") + .trim(); + return normalized === "brucke" || normalized === "bridge" ? "" : normalized; } function geometryLineStrings(geometry: Geometry): LonLat[][] { @@ -770,6 +911,19 @@ function stringProperty(value: unknown) { return typeof value === "string" && value.trim() ? value.trim() : null; } +function firstStringProperty( + properties: Record, + keys: readonly string[] +) { + for (const key of keys) { + const value = stringProperty(properties[key]); + if (value) { + return value; + } + } + return null; +} + function normalizeMeters(value: unknown) { if (typeof value === "number") { return Number.isFinite(value) ? value : null; diff --git a/apps/web/tests/api-features.test.ts b/apps/web/tests/api-features.test.ts new file mode 100644 index 0000000..5c7f253 --- /dev/null +++ b/apps/web/tests/api-features.test.ts @@ -0,0 +1,106 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + FeatureDataUnavailableError, + getMapFeatures +} from "../src/api"; + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("getMapFeatures", () => { + it("rejects an unavailable feature source instead of treating it as an empty result", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => + new Response( + JSON.stringify({ + type: "FeatureCollection", + features: [], + metadata: { + source: "unavailable", + warning: "Keine lokale Feature-Datenbank konfiguriert." + } + }), + { + status: 200, + headers: { "content-type": "application/json" } + } + ) + ) + ); + + await expect( + getMapFeatures({ + bbox: [7, 53, 8, 54], + layers: ["harbours"] + }) + ).rejects.toEqual( + expect.objectContaining({ + name: "FeatureDataUnavailableError", + message: "Keine lokale Feature-Datenbank konfiguriert." + }) + ); + await expect( + getMapFeatures({ + bbox: [7, 53, 8, 54], + layers: ["locks"] + }) + ).rejects.toBeInstanceOf(FeatureDataUnavailableError); + }); + + it("keeps usable postgis and legacy feature collections intact", async () => { + const responses = [ + { + type: "FeatureCollection", + features: [], + metadata: { source: "postgis" } + }, + { + type: "FeatureCollection", + features: [] + } + ]; + vi.stubGlobal( + "fetch", + vi.fn(async () => + new Response(JSON.stringify(responses.shift()), { + status: 200, + headers: { "content-type": "application/json" } + }) + ) + ); + + await expect( + getMapFeatures({ bbox: [7, 53, 8, 54], layers: ["bridges"] }) + ).resolves.toMatchObject({ metadata: { source: "postgis" } }); + await expect( + getMapFeatures({ bbox: [7, 53, 8, 54], layers: ["bridges"] }) + ).resolves.toMatchObject({ features: [] }); + }); + + it("uses the API error message for an unavailable source", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => + new Response( + JSON.stringify({ + error: "feature_source_unavailable", + message: "Keine lokale Karten- und Ereignisdatenbank konfiguriert." + }), + { + status: 503, + statusText: "Service Unavailable", + headers: { "content-type": "application/json" } + } + ) + ) + ); + + await expect( + getMapFeatures({ bbox: [7, 53, 8, 54], layers: ["locks"] }) + ).rejects.toThrow( + "Keine lokale Karten- und Ereignisdatenbank konfiguriert." + ); + }); +}); diff --git a/apps/web/tests/route-event-features-hook.test.tsx b/apps/web/tests/route-event-features-hook.test.tsx new file mode 100644 index 0000000..122e010 --- /dev/null +++ b/apps/web/tests/route-event-features-hook.test.tsx @@ -0,0 +1,199 @@ +import { cleanup, renderHook, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { RouteResult } from "@watermaps/shared"; +import { useRouteEventFeatures } from "../src/hooks/useRouteEventFeatures"; + +const apiMocks = vi.hoisted(() => ({ + getMapFeatures: vi.fn() +})); + +vi.mock("../src/api", () => ({ + getMapFeatures: apiMocks.getMapFeatures +})); + +beforeEach(() => { + apiMocks.getMapFeatures.mockReset(); +}); + +afterEach(cleanup); + +describe("useRouteEventFeatures", () => { + it("loads harbours, locks and bridges once and independently", async () => { + apiMocks.getMapFeatures.mockImplementation( + async ({ layers }: { layers: string[] }) => + featureCollection(layers[0]!) + ); + + const activeRoute = route("first", 0); + const { result } = renderHook(() => + useRouteEventFeatures(activeRoute, { airDraftM: 3 }) + ); + + await waitFor(() => expect(result.current.loading).toBe(false)); + + expect(apiMocks.getMapFeatures).toHaveBeenCalledTimes(3); + expect( + apiMocks.getMapFeatures.mock.calls.map(([request]) => request.layers) + ).toEqual(expect.arrayContaining([["harbours"], ["locks"], ["bridges"]])); + expect(result.current.harbours.map((harbour) => harbour.name)).toEqual([ + "Testhafen" + ]); + expect(result.current.locks.map((lock) => lock.name)).toEqual([ + "Testschleuse" + ]); + expect(result.current.bridgeReport?.bridges.map((bridge) => bridge.name)).toEqual([ + "Testbrücke" + ]); + expect(result.current.error).toBeNull(); + }); + + it("keeps working categories when one feature request fails", async () => { + apiMocks.getMapFeatures.mockImplementation( + async ({ layers }: { layers: string[] }) => { + if (layers[0] === "harbours") { + throw new Error("Feature-Datenbank fehlt"); + } + return featureCollection(layers[0]!); + } + ); + + const activeRoute = route("partial", 0); + const { result } = renderHook(() => + useRouteEventFeatures(activeRoute, { airDraftM: 3 }) + ); + + await waitFor(() => expect(result.current.loading).toBe(false)); + + expect(result.current.harbours).toEqual([]); + expect(result.current.locks).toHaveLength(1); + expect(result.current.bridgeReport?.bridges).toHaveLength(1); + expect(result.current.errors.harbours).toContain( + "Häfen nicht erreichbar: Feature-Datenbank fehlt" + ); + expect(result.current.errors.locks).toBeNull(); + expect(result.current.errors.bridges).toBeNull(); + }); + + it("reassesses only bridges when the boat height changes", async () => { + apiMocks.getMapFeatures.mockImplementation( + async ({ layers }: { layers: string[] }) => + featureCollection(layers[0]!) + ); + const activeRoute = route("height", 0); + const { result, rerender } = renderHook( + ({ airDraftM }) => + useRouteEventFeatures(activeRoute, { airDraftM }), + { initialProps: { airDraftM: 3 } } + ); + await waitFor(() => expect(result.current.loading).toBe(false)); + apiMocks.getMapFeatures.mockClear(); + + rerender({ airDraftM: 3.5 }); + + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(apiMocks.getMapFeatures).toHaveBeenCalledTimes(1); + expect(apiMocks.getMapFeatures).toHaveBeenCalledWith( + expect.objectContaining({ layers: ["bridges"] }) + ); + expect(result.current.harbours).toHaveLength(1); + expect(result.current.locks).toHaveLength(1); + expect(result.current.bridgeReport?.requiredAirDraftM).toBe(3.5); + }); + + it("aborts stale category requests on a route change and exposes only the new route", async () => { + const staleSignals: AbortSignal[] = []; + const currentSignals: AbortSignal[] = []; + let staleCalls = 0; + apiMocks.getMapFeatures.mockImplementation( + ({ layers, signal }: { layers: string[]; signal: AbortSignal }) => { + if (staleCalls < 3) { + staleCalls += 1; + staleSignals.push(signal); + return new Promise(() => undefined); + } + currentSignals.push(signal); + return Promise.resolve(featureCollection(layers[0]!, 0.5)); + } + ); + const firstRoute = route("first", 0); + const secondRoute = route("second", 0.5); + const { result, rerender, unmount } = renderHook( + ({ activeRoute }) => + useRouteEventFeatures(activeRoute, { airDraftM: 3 }), + { initialProps: { activeRoute: firstRoute } } + ); + + await waitFor(() => expect(apiMocks.getMapFeatures).toHaveBeenCalledTimes(3)); + rerender({ activeRoute: secondRoute }); + + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(apiMocks.getMapFeatures).toHaveBeenCalledTimes(6); + expect(staleSignals).toHaveLength(3); + expect(staleSignals.every((signal) => signal.aborted)).toBe(true); + expect(result.current.harbours[0]?.coordinate.lon).toBeCloseTo(0.7); + expect(result.current.locks[0]?.coordinate.lon).toBeCloseTo(0.8); + expect(result.current.bridgeReport?.bridges[0]?.coordinate.lon).toBeCloseTo(0.9); + + unmount(); + expect(currentSignals).toHaveLength(3); + expect(currentSignals.every((signal) => signal.aborted)).toBe(true); + }); +}); + +function featureCollection(layer: string, offset = 0) { + const fixtures = { + harbours: { + type: "Feature" as const, + id: `harbour-${offset}`, + properties: { layer: "harbours", name: "Testhafen" }, + geometry: { + type: "Point" as const, + coordinates: [0.2 + offset, 0] + } + }, + locks: { + type: "Feature" as const, + id: `lock-${offset}`, + properties: { layer: "locks", name: "Testschleuse" }, + geometry: { + type: "Point" as const, + coordinates: [0.3 + offset, 0] + } + }, + bridges: { + type: "Feature" as const, + id: `bridge-${offset}`, + properties: { + layer: "bridges", + name: "Testbrücke", + clearance_m: 4 + }, + geometry: { + type: "Point" as const, + coordinates: [0.4 + offset, 0] + } + } + }; + return { + type: "FeatureCollection" as const, + features: [fixtures[layer as keyof typeof fixtures]] + }; +} + +function route(id: string, offset: number): RouteResult { + return { + id, + geometry: { + type: "LineString", + coordinates: [ + [offset, 0], + [offset + 1, 0] + ] + }, + distanceNm: 60, + eta: null, + warnings: [], + dataSources: ["test"], + routingMode: "fairway" + }; +} diff --git a/apps/web/tests/route-events.test.ts b/apps/web/tests/route-events.test.ts index ed639f6..4338de8 100644 --- a/apps/web/tests/route-events.test.ts +++ b/apps/web/tests/route-events.test.ts @@ -4,6 +4,7 @@ import type { VoyageHarbour } from "@watermaps/shared"; import { + DEFAULT_ROUTE_EVENT_CORRIDORS_NM, nextRouteEventsByKind, upcomingRouteEvents } from "../src/routeEvents"; @@ -80,6 +81,66 @@ describe("upcomingRouteEvents", () => { expect(events.map((event) => event.id)).toEqual(["detour"]); }); + it("uses a focused half-mile harbour corridor by default", () => { + expect(DEFAULT_ROUTE_EVENT_CORRIDORS_NM.harbour).toBe(0.5); + + const events = upcomingRouteEvents({ + route, + harbours: [ + harbour("near-harbour", 0.4, 0.008), + harbour("unrelated-harbour", 0.5, 0.01) + ] + }); + + expect(events.map((event) => event.id)).toEqual(["near-harbour"]); + }); + + it("prefers a nearby same-named lock over a duplicate harbour classification", () => { + const duplicateHarbour = { + ...harbour("harbour-lock", 0.4, 0.0005), + name: "Nesserländer Schleuse" + }; + const duplicateLock = { + ...lock("lock", 0.4, 0, 0, 0), + name: "Nesserlander Schleuse" + }; + const distinctHarbour = { + ...harbour("real-harbour", 0.6, 0), + name: "Stadthafen" + }; + + const events = upcomingRouteEvents({ + route, + harbours: [duplicateHarbour, distinctHarbour], + locks: [duplicateLock] + }); + + expect(events.map((event) => `${event.kind}:${event.name}`)).toEqual([ + "lock:Nesserlander Schleuse", + "harbour:Stadthafen" + ]); + }); + + it("does not merge same-named facilities that are spatially distinct", () => { + const events = upcomingRouteEvents({ + route, + harbours: [ + { + ...harbour("harbour", 0.4, 0.003), + name: "Kanalschleuse" + } + ], + locks: [ + { + ...lock("lock", 0.4, 0, 0, 0), + name: "Kanalschleuse" + } + ] + }); + + expect(events.map((event) => event.kind).sort()).toEqual(["harbour", "lock"]); + }); + it("calculates ETA and retains both speed and reference-time provenance", () => { const [event] = upcomingRouteEvents({ route, diff --git a/apps/web/tests/route-planner.test.tsx b/apps/web/tests/route-planner.test.tsx index 1fe5d4c..02ea1e8 100644 --- a/apps/web/tests/route-planner.test.tsx +++ b/apps/web/tests/route-planner.test.tsx @@ -528,6 +528,7 @@ describe("RoutePlanner", () => { weatherReport={weatherReportFixture} weatherLoading={false} weatherError={null} + bridgeReport={weatherReportFixture.bridgeReport} loading={false} error={null} pickMode={null} diff --git a/apps/web/tests/route-weather-report.test.ts b/apps/web/tests/route-weather-report.test.ts index 0c1085d..abe2cb6 100644 --- a/apps/web/tests/route-weather-report.test.ts +++ b/apps/web/tests/route-weather-report.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from "vitest"; import type { MarineForecast, RouteResult } from "@watermaps/shared"; -import { createRouteWeatherReport, summarizeRouteWeather } from "../src/routeWeatherReport"; +import { + createRouteBridgeReport, + createRouteWeatherReport, + summarizeRouteWeather +} from "../src/routeWeatherReport"; describe("route weather report", () => { it("samples start, middle and destination forecasts", async () => { @@ -81,7 +85,9 @@ describe("route weather report", () => { clearance_m: 2.7, clearance_label: "H 2.7 m", label: "Niedrige Brücke H 2.7 m", - source: "OSM/Geofabrik" + source: "OSM/Geofabrik", + phone: "+49 491 234", + website: "https://bridge.example" }, geometry: { type: "LineString", @@ -120,6 +126,75 @@ describe("route weather report", () => { expect(report.bridgeReport?.checkedCount).toBe(2); expect(report.bridgeReport?.summary).toContain("Nicht passierbar"); expect(report.bridgeReport?.bridges[0]?.name).toBe("Niedrige Brücke"); + expect(report.bridgeReport?.bridges[0]?.phone).toBe("+49 491 234"); + expect(report.bridgeReport?.bridges[0]?.website).toBe("https://bridge.example"); + }); + + it("deduplicates only conservatively matching bridge ways and keeps the richest safe assessment", async () => { + const route: RouteResult = { + ...routeFixture, + geometry: { + type: "LineString", + coordinates: [[0, 0], [1, 0]] + }, + distanceNm: 60 + }; + const point = ( + id: string, + lon: number, + lat: number, + properties: Record = {} + ) => ({ + type: "Feature" as const, + id, + properties: { layer: "bridges", ...properties }, + geometry: { type: "Point" as const, coordinates: [lon, lat] } + }); + + const report = await createRouteBridgeReport( + route, + { airDraftM: 3 }, + async () => ({ + type: "FeatureCollection", + features: [ + point("named-1", 0.2, 0, { + name: "Am Tonnenhof", + clearance_m: 4, + phone: "+49 491 111" + }), + point("named-2", 0.2, 0.0003, { + name: "Am Tonnenhof", + clearance_m: 3.5, + website: "https://tonnenhof.example" + }), + point("distinct-east", 0.4, 0, { name: "Klappbrücke Ost" }), + point("distinct-west", 0.4, 0.0001, { name: "Klappbrücke West" }), + point("unnamed-tight-1", 0.6, 0), + point("unnamed-tight-2", 0.6, 0.00005), + point("unnamed-separate-1", 0.8, 0), + point("unnamed-separate-2", 0.8, 0.0001), + point("named-with-way", 0.9, 0, { name: "Auricher Straße" }), + point("unnamed-with-name", 0.9, 0.0001) + ] + }) + ); + + expect(report.bridges).toHaveLength(7); + const tonnenhof = report.bridges.find((bridge) => bridge.name === "Am Tonnenhof"); + expect(tonnenhof).toMatchObject({ + clearanceM: 3.5, + clearanceLabel: "H 3.5 m", + label: "Am Tonnenhof H 3.5 m", + marginM: 0.5, + phone: "+49 491 111", + website: "https://tonnenhof.example" + }); + expect( + report.bridges.filter((bridge) => bridge.name?.startsWith("Klappbrücke")) + ).toHaveLength(2); + expect( + report.bridges.filter((bridge) => bridge.name === null) + ).toHaveLength(3); }); it("marks critical weather when wind or wave thresholds are exceeded", () => { diff --git a/apps/web/tests/upcoming-events-panel.test.tsx b/apps/web/tests/upcoming-events-panel.test.tsx index 010dfb0..77b0723 100644 --- a/apps/web/tests/upcoming-events-panel.test.tsx +++ b/apps/web/tests/upcoming-events-panel.test.tsx @@ -26,6 +26,7 @@ describe("UpcomingEventsPanel", () => { expect.stringContaining("Brücke Mitte"), expect.stringContaining("Hafen Weit") ]); + expect(within(list).getAllByText("Infos")).toHaveLength(3); fireEvent.click(screen.getByRole("button", { name: /Brücken/ })); expect(screen.getByRole("button", { name: /Brücken/ })).toHaveAttribute( @@ -81,6 +82,12 @@ describe("UpcomingEventsPanel", () => { expect(within(detail).getByText("3.8 m")).toBeVisible(); const reserve = within(detail).getByText("Reserve").closest("div"); expect(reserve).toHaveTextContent("0.4 m Reserve"); + expect( + within(detail).getByRole("link", { name: "Brücke Mitte anrufen" }) + ).toHaveAttribute("href", "tel:+4949123457"); + expect( + within(detail).getByRole("link", { name: "Website von Brücke Mitte öffnen" }) + ).toHaveAttribute("href", "https://bridge.example/"); expect(within(detail).queryByRole("button", { name: "Auf Karte zeigen" })).not.toBeInTheDocument(); }); @@ -180,7 +187,10 @@ const events: UpcomingRouteEvent[] = [ requiredAirDraftM: 3.8, marginM: 0.4, status: "tight", - source: "Test" + source: "Test", + phone: "+49 49 123457", + website: "bridge.example", + operator: "Brückenamt" } } ]; diff --git a/deploy/.env.production.example b/deploy/.env.production.example index 1cbc5aa..07596a9 100644 --- a/deploy/.env.production.example +++ b/deploy/.env.production.example @@ -17,6 +17,12 @@ WATERMAPS_RUNTIME_DIR=/srv/watermaps-runtime # Verhindert Speicherabbrüche beim vollständigen DE/NL-Indexaufbau auf 4-GB-Servern. WATERMAPS_SWAP_SIZE_GB=4 +# Persistente PostGIS-Datenbank für Häfen, Schleusen, Brücken und Kontaktdaten. +# Vor dem ersten Deployment beispielsweise mit `openssl rand -hex 32` +# erzeugen. Das Secret muss mindestens 24 Zeichen lang sein. +WATERMAPS_POSTGRES_PASSWORD=REPLACE_WITH_RANDOM_32_BYTE_HEX_SECRET +WATERMAPS_POSTGRES_IMAGE=postgis/postgis:16-3.4 + # Gitea Container Registry. Die commitgenauen App-Referenzen werden beim # Deployment separat erzeugt und niemals hier von Hand auf `latest` gesetzt. WATERMAPS_REGISTRY=gitea.incoso.eu @@ -29,6 +35,10 @@ WATERMAPS_CERTBOT_IMAGE=certbot/certbot:v5.7.0 # Niederlande erneut geprüft und der Routingindex neu gebaut werden sollen. WATERMAPS_REBUILD_ROUTE_DATA=false +# Erzwingt unabhängig vom Bereitschaftsmarker einen vollständigen Neuimport +# der Marine-Features aus den vorhandenen Deutschland-/Niederlande-PBFs. +WATERMAPS_REBUILD_MARINE_DATA=false + # Für einen Test gegen Let's Encrypt Staging auf true setzen. # Das damit ausgestellte Zertifikat ist im Browser nicht vertrauenswürdig. WATERMAPS_CERTBOT_STAGING=false diff --git a/deploy/README.md b/deploy/README.md index fc0ef81..7ce1dec 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -1,9 +1,10 @@ # Watermaps-Produktion -Dieser Stack hostet die Watermaps-App und die lokalen Fahrrouten für -Deutschland und die Niederlande. Die sichtbaren Kartenkacheln bleiben externe -Dienste. Öffentlich gebunden werden ausschließlich TCP 80 und 443; die -Watermaps-App ist nur im internen Docker-Netz erreichbar. +Dieser Stack hostet die Watermaps-App, die lokalen Fahrrouten sowie eine +persistente PostGIS-Datenbank für Häfen, Schleusen, Brücken und Kontaktdaten in +Deutschland und den Niederlanden. Die sichtbaren Kartenkacheln bleiben externe +Dienste. Öffentlich gebunden werden ausschließlich TCP 80 und 443; App und +PostGIS sind nur im internen Docker-Netz erreichbar. ## Konfiguration @@ -12,11 +13,19 @@ cp deploy/.env.production.example deploy/.env.production editor deploy/.env.production ``` -Mindestens `WATERMAPS_ACME_EMAIL` muss angepasst werden. Außerdem müssen -`WATERMAPS_REGISTRY` und `WATERMAPS_REGISTRY_OWNER` auf die Gitea Container -Registry zeigen. Der Hetzner-API-Token gehört **nicht** in diese Datei. Er -bleibt lokal in der ignorierten Datei `infra/opentofu/terraform.tfvars` -(alternativ kann der Provider `TF_VAR_hcloud_token` lesen). +Mindestens `WATERMAPS_ACME_EMAIL` und `WATERMAPS_POSTGRES_PASSWORD` müssen +angepasst werden. Für das Datenbankpasswort eignet sich ein URL-unabhängiges +Hex-Secret: + +```bash +openssl rand -hex 32 +``` + +Außerdem müssen `WATERMAPS_REGISTRY` und `WATERMAPS_REGISTRY_OWNER` auf die +Gitea Container Registry zeigen. Der Hetzner-API-Token gehört **nicht** in +diese Datei. Er bleibt lokal in der ignorierten Datei +`infra/opentofu/terraform.tfvars` (alternativ kann der Provider +`TF_VAR_hcloud_token` lesen). Die private SSH-Keydatei wird ebenfalls nicht gespeichert. Sie kann beim Deployment mit `--identity` oder über `WATERMAPS_SSH_KEY` angegeben werden. @@ -53,8 +62,8 @@ werden nur Tests ausgeführt; Registry-Secrets werden dabei nicht verwendet. Nach einem erfolgreichen Image-Build liest das Skript standardmäßig den OpenTofu-Output `server_ipv4`. Es erlaubt ausschließlich einen sauberen, vollständig committeten Git-Stand und überträgt nur den kleinen Ordner -`deploy/`, nicht den Anwendungsquellcode. Die Image-Tags entsprechen exakt -`git rev-parse HEAD`. +`deploy/` sowie das kanonische `database/schema.sql`, nicht den +Anwendungsquellcode. Die Image-Tags entsprechen exakt `git rev-parse HEAD`. Ist die Registry privat, werden einmalig beziehungsweise nach Tokenwechsel lokale Pull-Zugangsdaten mitgegeben: @@ -78,20 +87,36 @@ Anmeldung gültig ist: --identity ~/.ssh/watermaps_hetzner_ed25519 ``` -Das Serverskript lädt App, Routingdaten-Builder, Nginx und Certbot mit -`docker compose pull`. Die beiden commitgenauen Gitea-Tags werden anschließend -in ihre unveränderlichen Registry-Digests aufgelöst. Erst danach werden die -Container mit `--no-build` gestartet. Der Produktionsserver benötigt deshalb -weder Git noch Node/npm oder den Quellcode. +Das Serverskript lädt App, den kombinierten Routing-/Feature-Daten-Builder, +PostGIS, Nginx und Certbot mit `docker compose pull`. Die beiden commitgenauen +Gitea-Tags werden anschließend in ihre unveränderlichen Registry-Digests +aufgelöst. Erst danach werden die Container mit `--no-build` gestartet. Der +Produktionsserver benötigt deshalb weder Git noch Node/npm oder den Quellcode. Der erste Datenaufbau lädt die Geofabrik-Extrakte für Deutschland und die -Niederlande herunter und kann entsprechend der Serverleistung längere Zeit -dauern. Der produktive Index liegt auf dem Server unter: +Niederlande, baut den Fahrroutenindex und importiert daraus die +`marine_features`. Je nach Serverleistung kann dies längere Zeit dauern. +App und Nginx werden beim ersten Release erst gestartet, wenn sowohl der +Routingindex als auch ein nicht leerer, zur PBF-Prüfsumme passender Bestand an +Häfen, Schleusen und Brücken geprüft wurde. Ein HTTP-Healthcheck allein kann +damit keine leere Ereignisdatenbank freigeben. + +Die produktiven Daten liegen auf dem persistenten Servervolume: ```text /srv/watermaps-data/local/germany-netherlands-fairways.json +/srv/watermaps-data/local/.marine-features.ready +/srv/watermaps-data/postgres/ +/srv/watermaps-data/tmp/ ``` +PostGIS veröffentlicht keinen Host-Port. Die App verbindet sich im privaten +Compose-Netz; das Passwort wird separat über `PGPASSWORD` übergeben und muss +nicht URL-kodiert in `DATABASE_URL` dupliziert werden. Auch die temporären, +potenziell mehrere Gigabyte großen Filter- und GeoJSON-Dateien des Imports +liegen unter `tmp/` auf diesem Volume und nicht im Docker-Overlay der +Rootdisk; nach jedem Importlauf werden sie entfernt. + Der Upload wartet zuerst auf SSH und den Abschluss von Cloud-init. Anschließend startet und prüft er `watermaps-volume-setup.service`. Ohne tatsächlich unter `/srv/watermaps-data` eingehängtes Volume wird kein Download gestartet, damit @@ -108,7 +133,10 @@ HTTP-Anfragen erhalten 404. Jedes erfolgreiche Release wird mit Commit-SHA und den aufgelösten Image-Digests unter `/srv/watermaps-runtime/deployments/` gespeichert. Scheitert ein Deployment nach dem Containerwechsel, startet `deploy.sh` automatisch das -vorherige Release und führt die Health- und Routentests erneut aus. +vorherige Release und führt die Health-, Routen- und Featuretests erneut aus. +Ein Rollback wechselt nur die unveränderlichen App-/Builder-Images. Die +persistente PostGIS-Datenbank bleibt erhalten; das Schema und der Import sind +aufwärtskompatibel und idempotent. Das unmittelbar vorherige Release lässt sich auch manuell aktivieren: @@ -153,11 +181,26 @@ HTTPS um. `bootstrap-server.sh` installiert zwei systemd-Timer: - `watermaps-route-update.timer`: täglich neue Deutschland- und - Niederlande-Daten; bei Build- oder Routentestfehler bleibt der vorherige - Index aktiv. + Niederlande-Daten. Der Fahrroutenindex wird atomar aktiviert. Der + Feature-Importer aktualisiert beide Länder per Upsert und entfernt + verschwundene Datensätze erst, nachdem beide Importe vollständig waren; + dabei werden ausschließlich OSM-Zeilen bereinigt. EuRIS- und + Website-Anreicherungen bleiben erhalten. - `watermaps-certbot-renew.timer`: zweimal täglich Certbot-Prüfung mit anschließendem Nginx-Reload. +Ein vollständiger manueller Feature-Neuimport lässt sich über die +Option `--force-marine` erzwingen: + +```bash +/opt/watermaps/deploy/scripts/update-route-data.sh --force-marine +``` + +`WATERMAPS_REBUILD_MARINE_DATA=true` in `.env.production` erzwingt den +Neuimport stattdessen beim nächsten regulären Deployment. Der systemd-Dienst +hat bewusst kein Startzeitlimit, da Download, Indexbau und Import auf kleineren +Servern deutlich länger als 90 Sekunden dauern können. + Status und Logs: ```bash diff --git a/deploy/compose.production.yml b/deploy/compose.production.yml index fc06006..67f4d9b 100644 --- a/deploy/compose.production.yml +++ b/deploy/compose.production.yml @@ -7,7 +7,8 @@ services: NODE_ENV: production HOST: 0.0.0.0 PORT: 5174 - DATABASE_URL: "" + DATABASE_URL: postgresql://seacompass@postgres:5432/seacompass + PGPASSWORD: ${WATERMAPS_POSTGRES_PASSWORD:?WATERMAPS_POSTGRES_PASSWORD fehlt} REDIS_URL: "" WATERMAPS_WEB_DIST_PATH: /app/apps/web/dist WATERMAPS_LOCAL_FAIRWAYS_PATH: /data/germany-netherlands-fairways.json @@ -20,6 +21,9 @@ services: source: ${WATERMAPS_DATA_DIR:-/srv/watermaps-data}/local target: /data read_only: true + depends_on: + postgres: + condition: service_healthy restart: unless-stopped init: true security_opt: @@ -32,7 +36,7 @@ services: - node - -e - >- - fetch('http://127.0.0.1:5174/health') + fetch('http://127.0.0.1:5174/ready') .then(response => { if (!response.ok) process.exit(1) }) .catch(() => process.exit(1)) interval: 15s @@ -40,6 +44,38 @@ services: start_period: 15s retries: 5 + postgres: + image: ${WATERMAPS_POSTGRES_IMAGE:-postgis/postgis:16-3.4} + environment: + POSTGRES_DB: seacompass + POSTGRES_USER: seacompass + POSTGRES_PASSWORD: ${WATERMAPS_POSTGRES_PASSWORD:?WATERMAPS_POSTGRES_PASSWORD fehlt} + expose: + - "5432" + volumes: + - type: bind + source: ${WATERMAPS_DATA_DIR:-/srv/watermaps-data}/postgres + target: /var/lib/postgresql/data + bind: + create_host_path: false + - type: bind + source: ./database/schema.sql + target: /docker-entrypoint-initdb.d/01-schema.sql + read_only: true + restart: unless-stopped + init: true + security_opt: + - no-new-privileges:true + healthcheck: + test: + - CMD-SHELL + - pg_isready --username "$$POSTGRES_USER" --dbname "$$POSTGRES_DB" + interval: 10s + timeout: 5s + start_period: 30s + retries: 10 + stop_grace_period: 60s + nginx: image: ${WATERMAPS_NGINX_IMAGE:-nginx:1.30.4-alpine} depends_on: @@ -92,3 +128,22 @@ services: source: ${WATERMAPS_DATA_DIR:-/srv/watermaps-data} target: /workspace/data restart: "no" + + marine-data: + image: ${WATERMAPS_ROUTE_DATA_IMAGE:?WATERMAPS_ROUTE_DATA_IMAGE fehlt} + profiles: ["maintenance"] + entrypoint: ["/workspace/deploy/scripts/prepare-marine-features.sh"] + environment: + DATABASE_URL: postgresql://seacompass@postgres:5432/seacompass + PGPASSWORD: ${WATERMAPS_POSTGRES_PASSWORD:?WATERMAPS_POSTGRES_PASSWORD fehlt} + TMPDIR: /workspace/data/tmp + WATERMAPS_GEOFABRIK_DIR: /workspace/data/geofabrik + WATERMAPS_MARINE_MARKER_PATH: /workspace/data/local/.marine-features.ready + volumes: + - type: bind + source: ${WATERMAPS_DATA_DIR:-/srv/watermaps-data} + target: /workspace/data + depends_on: + postgres: + condition: service_healthy + restart: "no" diff --git a/deploy/route-data.Dockerfile b/deploy/route-data.Dockerfile index 441b3e3..4f9d1b9 100644 --- a/deploy/route-data.Dockerfile +++ b/deploy/route-data.Dockerfile @@ -1,13 +1,30 @@ -FROM python:3.12-slim +FROM node:22-bookworm-slim RUN apt-get update \ - && apt-get install --yes --no-install-recommends ca-certificates curl coreutils libexpat1 \ - && rm -rf /var/lib/apt/lists/* \ - && python3 -m pip install --no-cache-dir "osmium==4.3.1" + && apt-get install --yes --no-install-recommends \ + ca-certificates \ + coreutils \ + curl \ + libexpat1 \ + osmium-tool \ + postgresql-client \ + python3 \ + python3-pip \ + && rm -rf /var/lib/apt/lists/* WORKDIR /workspace +RUN python3 -m pip install \ + --break-system-packages \ + --disable-pip-version-check \ + --no-cache-dir \ + --target /workspace/.tools/python \ + "osmium==4.3.1" \ + && npm install --no-package-lock --no-save "pg@8.16.3" + COPY scripts ./scripts +COPY database ./database COPY deploy/scripts/prepare-route-data.sh ./deploy/scripts/prepare-route-data.sh +COPY deploy/scripts/prepare-marine-features.sh ./deploy/scripts/prepare-marine-features.sh ENTRYPOINT ["/workspace/deploy/scripts/prepare-route-data.sh"] diff --git a/deploy/scripts/bootstrap-server.sh b/deploy/scripts/bootstrap-server.sh index 02f3af2..6bcf7e8 100755 --- a/deploy/scripts/bootstrap-server.sh +++ b/deploy/scripts/bootstrap-server.sh @@ -87,6 +87,8 @@ install -d -m 0755 \ "$WATERMAPS_DATA_DIR" \ "$WATERMAPS_DATA_DIR/geofabrik" \ "$WATERMAPS_DATA_DIR/local" \ + "$WATERMAPS_DATA_DIR/postgres" \ + "$WATERMAPS_DATA_DIR/tmp" \ "$WATERMAPS_DATA_DIR/certbot" \ "$WATERMAPS_DATA_DIR/certbot/www" \ "$WATERMAPS_DATA_DIR/certbot/letsencrypt" \ @@ -95,6 +97,12 @@ install -d -m 0755 \ "$WATERMAPS_RUNTIME_DIR/nginx/conf.d" \ "$WATERMAPS_RUNTIME_DIR/locks" +# postgis/postgis uses the Debian postgres UID/GID 999. Keeping PGDATA on the +# mounted Hetzner volume makes event data survive image deployments and host +# reboots; the official entrypoint can still repair ownership inside PGDATA. +chown 999:999 "$WATERMAPS_DATA_DIR/postgres" +chmod 0700 "$WATERMAPS_DATA_DIR/postgres" + if [[ ! -f "$WATERMAPS_RUNTIME_DIR/nginx/conf.d/default.conf" ]]; then install -m 0644 \ "$WM_DEPLOY_DIR/nginx/bootstrap.conf" \ diff --git a/deploy/scripts/common.sh b/deploy/scripts/common.sh index b4dde98..12b6fef 100755 --- a/deploy/scripts/common.sh +++ b/deploy/scripts/common.sh @@ -33,6 +33,16 @@ wm_load_env() { wm_require_safe_absolute_dir "$WATERMAPS_DATA_DIR" wm_require_safe_absolute_dir "$WATERMAPS_RUNTIME_DIR" + wm_validate_postgres_password +} + +wm_validate_postgres_password() { + local password="${WATERMAPS_POSTGRES_PASSWORD:-}" + + [[ "${#password}" -ge 24 && + "$password" != *[[:space:]]* && + "$password" != *REPLACE* ]] || + wm_die "WATERMAPS_POSTGRES_PASSWORD muss ein gesetztes Secret mit mindestens 24 Zeichen ohne Leerzeichen sein." } wm_require_safe_absolute_dir() { @@ -162,6 +172,10 @@ wm_route_marker() { printf '%s/local/.germany-netherlands-fairways.ready\n' "$WATERMAPS_DATA_DIR" } +wm_marine_marker() { + printf '%s/local/.marine-features.ready\n' "$WATERMAPS_DATA_DIR" +} + wm_acquire_route_lock() { install -d -m 0755 "$WATERMAPS_RUNTIME_DIR/locks" exec 9>"$WATERMAPS_RUNTIME_DIR/locks/route-update.lock" @@ -288,6 +302,107 @@ wm_route_data_files_ready() { return 0 } +wm_geofabrik_checksum() { + local region="$1" + local checksum_file="$WATERMAPS_DATA_DIR/geofabrik/${region}-latest.osm.pbf.md5" + local checksum + + [[ -s "$checksum_file" ]] || return 1 + checksum="$(awk 'NR == 1 { print tolower($1) }' "$checksum_file")" + [[ "$checksum" =~ ^[0-9a-f]{32}$ ]] || return 1 + printf '%s\n' "$checksum" +} + +wm_postgres_query() { + local query="$1" + + wm_compose exec --no-TTY postgres \ + psql \ + --no-psqlrc \ + --username seacompass \ + --dbname seacompass \ + --tuples-only \ + --no-align \ + --field-separator '|' \ + --set ON_ERROR_STOP=1 \ + --command "$query" +} + +wm_marine_features_ready() { + local marker germany_checksum netherlands_checksum database_counts + local total_count harbour_count lock_count bridge_count + local marker_format marker_source marker_germany marker_netherlands + local marker_total marker_harbours marker_locks marker_bridges + + marker="$(wm_marine_marker)" + if [[ ! -s "$marker" ]]; then + wm_log "Bereitschaftsmarker für Marine-Features fehlt: $marker" + return 1 + fi + + germany_checksum="$(wm_geofabrik_checksum germany 2>/dev/null || true)" + netherlands_checksum="$(wm_geofabrik_checksum netherlands 2>/dev/null || true)" + if [[ -z "$germany_checksum" || -z "$netherlands_checksum" ]]; then + wm_log "Geofabrik-Prüfsummen für den Marine-Feature-Stand fehlen oder sind ungültig." + return 1 + fi + + marker_format="$(wm_marker_value "$marker" format_version 2>/dev/null || true)" + marker_source="$(wm_marker_value "$marker" source 2>/dev/null || true)" + marker_germany="$(wm_marker_value "$marker" germany_md5 2>/dev/null || true)" + marker_netherlands="$(wm_marker_value "$marker" netherlands_md5 2>/dev/null || true)" + marker_total="$(wm_marker_value "$marker" total_osm_features 2>/dev/null || true)" + marker_harbours="$(wm_marker_value "$marker" harbour_count 2>/dev/null || true)" + marker_locks="$(wm_marker_value "$marker" lock_count 2>/dev/null || true)" + marker_bridges="$(wm_marker_value "$marker" bridge_count 2>/dev/null || true)" + + if [[ "$marker_format" != "1" || + "$marker_source" != "germany+netherlands" || + "$marker_germany" != "$germany_checksum" || + "$marker_netherlands" != "$netherlands_checksum" ]]; then + wm_log "Marine-Feature-Marker passt nicht zu den aktuellen Deutschland-/Niederlande-Snapshots." + return 1 + fi + + if ! database_counts="$( + wm_postgres_query " + SELECT + count(*)::bigint, + count(*) FILTER (WHERE layer = 'harbours')::bigint, + count(*) FILTER (WHERE layer = 'locks')::bigint, + count(*) FILTER (WHERE layer = 'bridges')::bigint + FROM marine_features + WHERE source = 'osm'; + " + )"; then + wm_log "Marine-Feature-Tabellen sind in PostGIS nicht abfragbar." + return 1 + fi + database_counts="${database_counts//[[:space:]]/}" + IFS='|' read -r total_count harbour_count lock_count bridge_count <<<"$database_counts" + + for count in "$total_count" "$harbour_count" "$lock_count" "$bridge_count"; do + if [[ ! "$count" =~ ^[1-9][0-9]*$ ]]; then + wm_log "PostGIS enthält keine vollständigen OSM-Hafen-/Schleusen-/Brückendaten." + return 1 + fi + done + + if [[ "$marker_total" != "$total_count" || + "$marker_harbours" != "$harbour_count" || + "$marker_locks" != "$lock_count" || + "$marker_bridges" != "$bridge_count" ]]; then + wm_log "Marine-Feature-Marker stimmt nicht mit den OSM-Zeilen in PostGIS überein." + return 1 + fi + return 0 +} + +wm_assert_marine_features() { + wm_marine_features_ready || + wm_die "Marine-Features sind nicht vollständig in PostGIS bereit." +} + wm_wait_for_health() { local service="$1" local timeout_seconds="${2:-180}" @@ -479,3 +594,44 @@ wm_smoke_test_route() { } ' } + +wm_smoke_test_features() { + wm_compose exec --no-TTY watermaps node --input-type=module --eval ' + const params = new URLSearchParams({ + bbox: "7.118483884871649,53.302596677614666,7.543960668999219,53.50669706666667", + layers: "harbours,locks,bridges" + }); + const response = await fetch(`http://127.0.0.1:5174/api/features?${params}`); + if (!response.ok) { + console.error("Feature-Smoke-Test", response.status, await response.text()); + process.exit(1); + } + + const collection = await response.json(); + const features = Array.isArray(collection.features) ? collection.features : []; + const layers = new Set(features.map((feature) => feature?.properties?.layer)); + const contactFeature = features.find((feature) => + ["harbours", "locks"].includes(feature?.properties?.layer) + && typeof feature?.properties?.phone === "string" + && feature.properties.phone.trim().length > 0 + ); + if ( + collection?.metadata?.source !== "postgis" + || !layers.has("harbours") + || !layers.has("locks") + || !layers.has("bridges") + || !contactFeature + ) { + console.error( + "PostGIS-Feature-Prüfung für die Emden–Aurich-Teststrecke fehlgeschlagen.", + JSON.stringify({ + source: collection?.metadata?.source, + featureCount: features.length, + layers: [...layers], + hasCallableHarbourOrLock: Boolean(contactFeature) + }) + ); + process.exit(1); + } + ' +} diff --git a/deploy/scripts/deploy.sh b/deploy/scripts/deploy.sh index 833ea0e..bb89815 100755 --- a/deploy/scripts/deploy.sh +++ b/deploy/scripts/deploy.sh @@ -69,12 +69,16 @@ wm_finish_deployment() { if [[ -n "$rollback_images_file" && -f "$rollback_images_file" ]]; then wm_log "Deployment fehlgeschlagen; vorheriges Container-Release wird wiederhergestellt." wm_use_images_env "$rollback_images_file" - wm_compose up --detach --no-build --remove-orphans watermaps nginx + wm_compose up --detach --no-build --remove-orphans postgres watermaps nginx + wm_wait_for_health postgres 240 wm_wait_for_health watermaps 240 wm_wait_for_health nginx 120 if wm_route_data_ready; then wm_smoke_test_route fi + if wm_marine_features_ready; then + wm_smoke_test_features + fi else wm_log "Deployment fehlgeschlagen; für das erste Release existiert noch kein Rollback." fi @@ -99,7 +103,7 @@ requested_app_image="$(wm_env_value "$images_env_file" WATERMAPS_APP_IMAGE)" requested_route_data_image="$(wm_env_value "$images_env_file" WATERMAPS_ROUTE_DATA_IMAGE)" wm_log "Container-Images für Commit $revision werden aus den Registries geladen." -wm_compose --profile maintenance pull watermaps nginx certbot route-data +wm_compose --profile maintenance pull watermaps postgres nginx certbot route-data resolved_app_image="$(wm_resolve_image_digest "$requested_app_image")" resolved_route_data_image="$(wm_resolve_image_digest "$requested_route_data_image")" @@ -114,30 +118,42 @@ resolved_images_file="$( chmod 0644 "$resolved_images_file" wm_use_images_env "$resolved_images_file" -wm_log "Commit $revision wird mit unveränderlichen Image-Digests gestartet." -wm_compose up --detach --no-build --remove-orphans watermaps nginx +wm_log "Persistentes PostGIS wird vor App und Datenimport gestartet." +wm_compose up --detach --no-build postgres +wm_wait_for_health postgres 240 -wm_wait_for_health watermaps 240 -wm_wait_for_health nginx 120 - -route_rebuild_required=false +data_update_required=false update_args=() if [[ "${WATERMAPS_REBUILD_ROUTE_DATA:-false}" == "true" ]]; then - route_rebuild_required=true + data_update_required=true update_args+=(--force) elif ! wm_route_data_ready; then - route_rebuild_required=true + data_update_required=true +fi +if [[ "${WATERMAPS_REBUILD_MARINE_DATA:-false}" == "true" ]]; then + data_update_required=true + update_args+=(--force-marine) +elif ! wm_marine_features_ready; then + data_update_required=true fi -if [[ "$route_rebuild_required" == "true" ]]; then - wm_log "Deutschland- und Niederlande-Routingdaten werden sicher vorbereitet." +if [[ "$data_update_required" == "true" ]]; then + wm_log "Deutschland-/Niederlande-Routingdaten und Marine-Features werden sicher vorbereitet." WATERMAPS_ROUTE_LOCK_HELD=true \ "$WM_DEPLOY_DIR/scripts/update-route-data.sh" "${update_args[@]}" fi wm_assert_route_data +wm_assert_marine_features + +wm_log "Commit $revision wird erst mit validierten Routing- und Ereignisdaten gestartet." +wm_compose up --detach --no-build --remove-orphans postgres watermaps nginx + +wm_wait_for_health postgres 240 wm_wait_for_health watermaps 240 +wm_wait_for_health nginx 120 wm_smoke_test_route +wm_smoke_test_features release_images_file="$WATERMAPS_RUNTIME_DIR/deployments/$revision.env" install -m 0644 "$resolved_images_file" "$release_images_file" diff --git a/deploy/scripts/prepare-marine-features.sh b/deploy/scripts/prepare-marine-features.sh new file mode 100755 index 0000000..d9e4e46 --- /dev/null +++ b/deploy/scripts/prepare-marine-features.sh @@ -0,0 +1,158 @@ +#!/usr/bin/env bash + +set -Eeuo pipefail + +cd /workspace + +database_url="${DATABASE_URL:-}" +geofabrik_dir="${WATERMAPS_GEOFABRIK_DIR:-/workspace/data/geofabrik}" +marker_file="${WATERMAPS_MARINE_MARKER_PATH:-/workspace/data/local/.marine-features.ready}" +temporary_root="${TMPDIR:-/tmp}" +pbf_paths=( + "$geofabrik_dir/germany-latest.osm.pbf" + "$geofabrik_dir/netherlands-latest.osm.pbf" +) + +if [[ -z "$database_url" ]]; then + printf 'Fehler: DATABASE_URL ist für den Marine-Feature-Import erforderlich.\n' >&2 + exit 1 +fi + +if [[ ! -x /workspace/scripts/import-geofabrik.sh ]]; then + printf 'Fehler: scripts/import-geofabrik.sh fehlt oder ist nicht ausführbar.\n' >&2 + exit 1 +fi + +mkdir -p "$(dirname "$marker_file")" "$temporary_root" +export TMPDIR="$temporary_root" + +declare -A source_checksums +for pbf_path in "${pbf_paths[@]}"; do + if [[ ! -s "$pbf_path" ]]; then + printf 'Fehler: Geofabrik-Snapshot fehlt oder ist leer: %s\n' "$pbf_path" >&2 + exit 1 + fi + + checksum_file="${pbf_path}.md5" + if [[ ! -s "$checksum_file" ]]; then + printf 'Fehler: Geofabrik-Prüfsumme fehlt: %s\n' "$checksum_file" >&2 + exit 1 + fi + + expected_checksum="$(awk 'NR == 1 { print tolower($1) }' "$checksum_file")" + if [[ ! "$expected_checksum" =~ ^[0-9a-f]{32}$ ]]; then + printf 'Fehler: Ungültige Geofabrik-Prüfsumme in %s\n' "$checksum_file" >&2 + exit 1 + fi + + actual_checksum="$(md5sum "$pbf_path" | awk '{ print $1 }')" + if [[ "$actual_checksum" != "$expected_checksum" ]]; then + printf 'Fehler: Geofabrik-Snapshot stimmt nicht mit seiner Prüfsumme überein: %s\n' "$pbf_path" >&2 + exit 1 + fi + source_checksums["$(basename "$pbf_path" -latest.osm.pbf)"]="$actual_checksum" +done + +# Every imported OSM row receives updated_at=now(). The shared database +# timestamp lets us remove disappeared OSM objects only after both regional +# imports completed and passed the sanity checks. Other sources such as EuRIS +# and facility-website enrichments are deliberately preserved. +import_started_epoch="$( + psql "$database_url" \ + --no-psqlrc \ + --tuples-only \ + --no-align \ + --set ON_ERROR_STOP=1 \ + --command "SELECT extract(epoch FROM clock_timestamp());" +)" +import_started_epoch="${import_started_epoch//[[:space:]]/}" +if [[ ! "$import_started_epoch" =~ ^[0-9]+([.][0-9]+)?$ ]]; then + printf 'Fehler: Datenbank lieferte keinen gültigen Importzeitpunkt.\n' >&2 + exit 1 +fi + +for pbf_path in "${pbf_paths[@]}"; do + printf 'Importiere Marine-Features aus %s\n' "$(basename "$pbf_path")" + DATABASE_URL="$database_url" /workspace/scripts/import-geofabrik.sh "$pbf_path" +done + +fresh_counts="$( + psql "$database_url" \ + --no-psqlrc \ + --tuples-only \ + --no-align \ + --field-separator '|' \ + --set ON_ERROR_STOP=1 \ + --command " + SELECT + count(*)::bigint, + count(*) FILTER (WHERE layer = 'harbours')::bigint, + count(*) FILTER (WHERE layer = 'locks')::bigint, + count(*) FILTER (WHERE layer = 'bridges')::bigint + FROM marine_features + WHERE source = 'osm' + AND updated_at >= to_timestamp($import_started_epoch); + " +)" +fresh_counts="${fresh_counts//[[:space:]]/}" +IFS='|' read -r fresh_total fresh_harbours fresh_locks fresh_bridges <<<"$fresh_counts" +for count in "$fresh_total" "$fresh_harbours" "$fresh_locks" "$fresh_bridges"; do + if [[ ! "$count" =~ ^[1-9][0-9]*$ ]]; then + printf 'Fehler: Der neue OSM-Import enthält keine vollständigen Hafen-/Schleusen-/Brückendaten.\n' >&2 + exit 1 + fi +done + +psql "$database_url" \ + --no-psqlrc \ + --set ON_ERROR_STOP=1 \ + --command " + BEGIN; + DELETE FROM marine_features + WHERE source = 'osm' + AND updated_at < to_timestamp($import_started_epoch); + DELETE FROM marine_fairway_edges + WHERE source = 'osm' + AND updated_at < to_timestamp($import_started_epoch); + COMMIT; + ANALYZE marine_features; + ANALYZE marine_fairway_edges; + " + +final_counts="$( + psql "$database_url" \ + --no-psqlrc \ + --tuples-only \ + --no-align \ + --field-separator '|' \ + --set ON_ERROR_STOP=1 \ + --command " + SELECT + count(*)::bigint, + count(*) FILTER (WHERE layer = 'harbours')::bigint, + count(*) FILTER (WHERE layer = 'locks')::bigint, + count(*) FILTER (WHERE layer = 'bridges')::bigint + FROM marine_features + WHERE source = 'osm'; + " +)" +final_counts="${final_counts//[[:space:]]/}" +IFS='|' read -r total_osm_features harbour_count lock_count bridge_count <<<"$final_counts" + +temporary_marker="$(mktemp "$(dirname "$marker_file")/.marine-features-ready.XXXXXX")" +{ + printf 'format_version=1\n' + printf 'generated_at=%s\n' "$(date --utc +%Y-%m-%dT%H:%M:%SZ)" + printf 'source=germany+netherlands\n' + printf 'germany_md5=%s\n' "${source_checksums[germany]}" + printf 'netherlands_md5=%s\n' "${source_checksums[netherlands]}" + printf 'total_osm_features=%s\n' "$total_osm_features" + printf 'harbour_count=%s\n' "$harbour_count" + printf 'lock_count=%s\n' "$lock_count" + printf 'bridge_count=%s\n' "$bridge_count" +} >"$temporary_marker" +chmod 0644 "$temporary_marker" +mv -f "$temporary_marker" "$marker_file" + +printf 'Marine-Features sind bereit: %s OSM-Objekte (%s Häfen, %s Schleusen, %s Brücken)\n' \ + "$total_osm_features" "$harbour_count" "$lock_count" "$bridge_count" diff --git a/deploy/scripts/tests/route-data-helpers.test.sh b/deploy/scripts/tests/route-data-helpers.test.sh index e573d0e..2e00d70 100755 --- a/deploy/scripts/tests/route-data-helpers.test.sh +++ b/deploy/scripts/tests/route-data-helpers.test.sh @@ -71,4 +71,56 @@ if wm_route_data_ready; then exit 1 fi -printf 'Routingdaten- und Markerprüfungen: OK\n' +mkdir -p "$WATERMAPS_DATA_DIR/geofabrik" +printf '%s germany-latest.osm.pbf\n' \ + 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' \ + >"$WATERMAPS_DATA_DIR/geofabrik/germany-latest.osm.pbf.md5" +printf '%s netherlands-latest.osm.pbf\n' \ + 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' \ + >"$WATERMAPS_DATA_DIR/geofabrik/netherlands-latest.osm.pbf.md5" + +cat >"$(wm_marine_marker)" <<'MARKER' +format_version=1 +generated_at=2026-07-28T00:00:00Z +source=germany+netherlands +germany_md5=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa +netherlands_md5=bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb +total_osm_features=100 +harbour_count=10 +lock_count=5 +bridge_count=20 +MARKER + +wm_postgres_query() { + printf '100|10|5|20\n' +} +wm_marine_features_ready + +wm_postgres_query() { + printf '100|10|0|20\n' +} +if wm_marine_features_ready; then + printf 'Marine-Feature-Marker wurde trotz fehlender Schleusen akzeptiert.\n' >&2 + exit 1 +fi + +wm_postgres_query() { + printf '99|10|5|20\n' +} +if wm_marine_features_ready; then + printf 'Marine-Feature-Marker wurde trotz abweichendem Datenbankbestand akzeptiert.\n' >&2 + exit 1 +fi + +if grep -Eq 'DATABASE_URL:[[:space:]]*""' \ + "$ROOT_DIR/docker-compose.yml" \ + "$ROOT_DIR/deploy/compose.production.yml"; then + printf 'Compose trennt die App weiterhin explizit von PostGIS.\n' >&2 + exit 1 +fi +grep -Fq 'condition: service_healthy' "$ROOT_DIR/deploy/compose.production.yml" +grep -Fq '/ready' "$ROOT_DIR/deploy/compose.production.yml" +grep -Fq 'source: ${WATERMAPS_DATA_DIR:-/srv/watermaps-data}/postgres' \ + "$ROOT_DIR/deploy/compose.production.yml" + +printf 'Routingdaten-, Marine-Feature- und Compose-Prüfungen: OK\n' diff --git a/deploy/scripts/tests/update-route-data.test.sh b/deploy/scripts/tests/update-route-data.test.sh index dd74b2e..0409cfb 100755 --- a/deploy/scripts/tests/update-route-data.test.sh +++ b/deploy/scripts/tests/update-route-data.test.sh @@ -16,6 +16,7 @@ mkdir -p "$FAKE_BIN" "$DATA_DIR/local" "$RUNTIME_DIR" cat >"$ENV_FILE" <"$IMAGES_ENV_FILE" <<'EOF' @@ -48,6 +49,11 @@ if [[ "${1:-}" == "inspect" ]]; then fi arguments=" $* " +if [[ "$arguments" == *" ps --quiet postgres "* ]]; then + printf 'postgres-test-container\n' + exit 0 +fi + if [[ "$arguments" == *" ps --quiet watermaps "* ]]; then printf 'watermaps-test-container\n' exit 0 @@ -55,6 +61,13 @@ fi if [[ "$arguments" == *" run "*" route-data "* || "$arguments" == *" run "*" route-data" ]]; then + mkdir -p "$WATERMAPS_DATA_DIR/geofabrik" + printf '%s germany-latest.osm.pbf\n' \ + 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' \ + >"$WATERMAPS_DATA_DIR/geofabrik/germany-latest.osm.pbf.md5" + printf '%s netherlands-latest.osm.pbf\n' \ + 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' \ + >"$WATERMAPS_DATA_DIR/geofabrik/netherlands-latest.osm.pbf.md5" route_file="$WATERMAPS_DATA_DIR/local/.germany-netherlands-fairways.candidate.json" marker_file="$WATERMAPS_DATA_DIR/local/.germany-netherlands-fairways.candidate.ready" cat >"$route_file" <"$WATERMAPS_DATA_DIR/local/.marine-features.ready" <<'MARKER' +format_version=1 +generated_at=2026-07-28T00:00:00Z +source=germany+netherlands +germany_md5=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa +netherlands_md5=bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb +total_osm_features=100 +harbour_count=10 +lock_count=5 +bridge_count=20 +MARKER + exit 0 +fi + +if [[ "$arguments" == *" exec --no-TTY postgres "* ]]; then + printf '100|10|5|20\n' + exit 0 +fi + if [[ "$arguments" == *" exec --no-TTY watermaps "* ]]; then active_file="$WATERMAPS_DATA_DIR/local/germany-netherlands-fairways.json" active_revision="$(jq --raw-output '.revision // 0' "$active_file" 2>/dev/null || true)" diff --git a/deploy/scripts/update-route-data.sh b/deploy/scripts/update-route-data.sh index 74dda20..566465d 100755 --- a/deploy/scripts/update-route-data.sh +++ b/deploy/scripts/update-route-data.sh @@ -13,11 +13,22 @@ if [[ "$(id -u)" -ne 0 ]]; then fi force_rebuild=false -if [[ "${1:-}" == "--force" ]]; then - force_rebuild=true - shift -fi -[[ "$#" -eq 0 ]] || wm_die "Unbekannte Argumente für update-route-data.sh." +force_marine_import=false +while [[ "$#" -gt 0 ]]; do + case "$1" in + --force) + force_rebuild=true + shift + ;; + --force-marine) + force_marine_import=true + shift + ;; + *) + wm_die "Unbekanntes Argument für update-route-data.sh: $1" + ;; + esac +done install -d -m 0755 "$WATERMAPS_RUNTIME_DIR/locks" "$WATERMAPS_DATA_DIR/local" if [[ "${WATERMAPS_ROUTE_LOCK_HELD:-false}" != "true" ]]; then @@ -26,6 +37,9 @@ if [[ "${WATERMAPS_ROUTE_LOCK_HELD:-false}" != "true" ]]; then fi fi +wm_compose up --detach --no-build postgres +wm_wait_for_health postgres 240 + route_file="$(wm_route_file)" route_marker="$(wm_route_marker)" route_dir="$(dirname "$route_file")" @@ -58,6 +72,16 @@ wm_watermaps_is_running() { [[ -n "$(wm_compose ps --quiet watermaps)" ]] } +wm_update_marine_features() { + if [[ "$force_marine_import" == "true" ]] || ! wm_marine_features_ready; then + wm_log "Häfen, Schleusen und Brücken werden aus den verifizierten Geofabrik-Snapshots importiert." + wm_compose --profile maintenance run --rm marine-data + else + wm_log "Marine-Features in PostGIS sind bereits aktuell." + fi + wm_assert_marine_features +} + wm_restore_previous_route() { rollback_restored_previous=false if [[ -s "$backup_file" && -s "$backup_marker" ]] && @@ -78,8 +102,8 @@ wm_restore_previous_route() { wm_restart_after_restore() { if ! wm_watermaps_is_running; then - wm_log "Watermaps läuft nicht; wiederhergestellte Routingdaten können noch nicht getestet werden." - return 1 + wm_log "Watermaps läuft noch nicht; wiederhergestellte Routingdaten werden beim App-Start getestet." + return 0 fi wm_compose restart watermaps @@ -156,8 +180,10 @@ if ! wm_route_data_files_ready "$candidate_file" "$candidate_marker"; then wm_die "Der neu gebaute Routingindex oder sein Bereitschaftsmarker ist ungültig." fi +wm_update_marine_features + if [[ "$previous_ready" == "true" ]] && cmp --silent "$candidate_file" "$route_file"; then - wm_log "Geofabrik-Snapshots und Routingindex sind unverändert." + wm_log "Geofabrik-Snapshots, Routingindex und Marine-Features sind aktuell." rm -f "$candidate_file" "$candidate_marker" exit 0 fi @@ -186,8 +212,18 @@ wm_write_route_marker "$route_file" "$route_marker" wm_route_data_ready || wm_die "Der aktivierte Routingindex besitzt keinen gültigen Bereitschaftsmarker." -wm_compose restart watermaps -if wm_wait_for_health watermaps 240 && wm_smoke_test_route; then +activation_valid=false +if wm_watermaps_is_running; then + wm_compose restart watermaps + if wm_wait_for_health watermaps 240 && wm_smoke_test_route; then + activation_valid=true + fi +else + wm_log "Watermaps läuft noch nicht; Routing- und Feature-API werden beim App-Start getestet." + activation_valid=true +fi + +if [[ "$activation_valid" == "true" ]]; then rm -f "$transaction_file" transaction_active=false rm -f "$backup_file" "$backup_marker" diff --git a/deploy/scripts/upload-and-deploy.sh b/deploy/scripts/upload-and-deploy.sh index 5d4c9dc..3b41715 100755 --- a/deploy/scripts/upload-and-deploy.sh +++ b/deploy/scripts/upload-and-deploy.sh @@ -124,9 +124,9 @@ printf '[watermaps] Warte auf Cloud-init und das persistente Hetzner-Volume.\n' ssh "${WM_SSH_OPTIONS[@]}" "$WM_SSH_TARGET" \ "cloud-init status --wait && ${remote_prefix}systemctl start watermaps-volume-setup.service && mountpoint --quiet /srv/watermaps-data" -printf '[watermaps] Übertrage Deployment-Dateien nach %s:/opt/watermaps/deploy\n' "$WM_SSH_TARGET" +printf '[watermaps] Übertrage Deployment-Dateien und Datenbankschema nach %s:/opt/watermaps\n' "$WM_SSH_TARGET" ssh "${WM_SSH_OPTIONS[@]}" "$WM_SSH_TARGET" \ - "${remote_prefix}install -d -m 0755 /opt/watermaps /opt/watermaps/deploy" + "${remote_prefix}install -d -m 0755 /opt/watermaps /opt/watermaps/deploy /opt/watermaps/database" rsync \ --archive \ @@ -140,6 +140,14 @@ rsync \ "$WM_DEPLOY_DIR/" \ "$WM_SSH_TARGET:/opt/watermaps/deploy/" +rsync \ + --archive \ + --chmod=F644 \ + --rsync-path="$rsync_path" \ + -e "ssh ${WM_SSH_OPTIONS[*]@Q}" \ + "$WM_LOCAL_ROOT_DIR/database/schema.sql" \ + "$WM_SSH_TARGET:/opt/watermaps/database/schema.sql" + rsync \ --archive \ --chmod=F600 \ diff --git a/deploy/systemd/watermaps-route-update.service b/deploy/systemd/watermaps-route-update.service index 2407b43..ce8f172 100644 --- a/deploy/systemd/watermaps-route-update.service +++ b/deploy/systemd/watermaps-route-update.service @@ -1,5 +1,5 @@ [Unit] -Description=Watermaps Deutschland-/Niederlande-Routingdaten aktualisieren +Description=Watermaps Deutschland-/Niederlande-Routing- und Ereignisdaten aktualisieren Wants=network-online.target After=network-online.target docker.service watermaps-volume-setup.service Requires=docker.service @@ -9,6 +9,7 @@ RequiresMountsFor=/srv/watermaps-data Type=oneshot WorkingDirectory=/opt/watermaps ExecStart=/opt/watermaps/deploy/scripts/update-route-data.sh +TimeoutStartSec=0 Nice=10 IOSchedulingClass=best-effort IOSchedulingPriority=7 diff --git a/deploy/systemd/watermaps-route-update.timer b/deploy/systemd/watermaps-route-update.timer index 7580435..3ff4cb0 100644 --- a/deploy/systemd/watermaps-route-update.timer +++ b/deploy/systemd/watermaps-route-update.timer @@ -1,5 +1,5 @@ [Unit] -Description=Täglich Watermaps-Routingdaten aktualisieren +Description=Täglich Watermaps-Routing- und Ereignisdaten aktualisieren [Timer] OnCalendar=*-*-* 06:15:00 diff --git a/docker-compose.yml b/docker-compose.yml index 114ea70..64384ff 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,7 +10,8 @@ services: NODE_ENV: production HOST: 0.0.0.0 PORT: 5174 - DATABASE_URL: "" + DATABASE_URL: postgresql://seacompass@postgres:5432/seacompass + PGPASSWORD: "${WATERMAPS_POSTGRES_PASSWORD:-seacompass}" REDIS_URL: "" WATERMAPS_WEB_DIST_PATH: /app/apps/web/dist WATERMAPS_LOCAL_FAIRWAYS_PATH: /data/germany-netherlands-fairways.json @@ -23,6 +24,9 @@ services: read_only: true bind: create_host_path: false + depends_on: + postgres: + condition: service_healthy restart: unless-stopped healthcheck: test: @@ -30,7 +34,7 @@ services: "CMD", "node", "-e", - "fetch('http://127.0.0.1:5174/health').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))" + "fetch('http://127.0.0.1:5174/ready').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))" ] interval: 30s timeout: 5s @@ -38,12 +42,11 @@ services: retries: 3 postgres: - profiles: ["postgis"] image: postgis/postgis:16-3.4 environment: POSTGRES_DB: seacompass POSTGRES_USER: seacompass - POSTGRES_PASSWORD: seacompass + POSTGRES_PASSWORD: "${WATERMAPS_POSTGRES_PASSWORD:-seacompass}" ports: - "${WATERMAPS_POSTGRES_PORT:-${SEA_COMPASS_POSTGRES_PORT:-55432}}:5432" volumes: @@ -55,6 +58,28 @@ services: timeout: 5s retries: 5 + marine-data: + profiles: ["postgis"] + build: + context: . + dockerfile: deploy/route-data.Dockerfile + image: watermaps-route-data:local + entrypoint: ["/workspace/deploy/scripts/prepare-marine-features.sh"] + environment: + DATABASE_URL: postgresql://seacompass@postgres:5432/seacompass + PGPASSWORD: "${WATERMAPS_POSTGRES_PASSWORD:-seacompass}" + TMPDIR: /workspace/data/tmp + WATERMAPS_GEOFABRIK_DIR: /workspace/data/geofabrik + WATERMAPS_MARINE_MARKER_PATH: /workspace/data/local/.marine-features.ready + volumes: + - type: bind + source: ./data + target: /workspace/data + depends_on: + postgres: + condition: service_healthy + restart: "no" + redis: profiles: ["postgis"] image: redis:7-alpine diff --git a/infra/opentofu/README.md b/infra/opentofu/README.md index ba9e2a5..c913584 100644 --- a/infra/opentofu/README.md +++ b/infra/opentofu/README.md @@ -2,7 +2,8 @@ Diese OpenTofu-Konfiguration erstellt einen einzelnen Ubuntu-24.04-Server mit fester IPv4-Adresse, vorgeschalteter Hetzner-Firewall und einem persistenten -ext4-Volume für die Routingdaten von Deutschland und den Niederlanden. +ext4-Volume für Routingdaten, Geofabrik-Rohdaten und die PostGIS-Datenbank mit +Häfen, Schleusen, Brücken und Kontaktdaten. Kartenkacheln, DNS-Einträge und konkrete Anwendungsversionen sind bewusst nicht Teil dieses Infrastrukturmoduls. Die Anwendung wird anschließend als commitgenaues Container-Image aus der Gitea Registry deployt. diff --git a/infra/opentofu/cloud-init.yaml.tftpl b/infra/opentofu/cloud-init.yaml.tftpl index f1822a7..b4d2813 100644 --- a/infra/opentofu/cloud-init.yaml.tftpl +++ b/infra/opentofu/cloud-init.yaml.tftpl @@ -102,7 +102,7 @@ write_files: permissions: "0644" content: | [Unit] - Description=Prepare persistent Watermaps routing data volume + Description=Prepare persistent Watermaps routing and event data volume Wants=network-online.target After=network-online.target local-fs.target diff --git a/infra/opentofu/outputs.tf b/infra/opentofu/outputs.tf index 966b2a9..23e21ae 100644 --- a/infra/opentofu/outputs.tf +++ b/infra/opentofu/outputs.tf @@ -23,11 +23,11 @@ output "dns_a_record" { } output "routing_data_mount_path" { - description = "Persistenter Pfad für heruntergeladene PBFs und erzeugte Fahrrouten." + description = "Persistenter Pfad für PBFs, Fahrrouten und PostGIS-Ereignisdaten." value = "/srv/watermaps-data" } output "routing_volume_id" { - description = "Hetzner-ID des persistenten Routingdaten-Volumes." + description = "Hetzner-ID des persistenten Routing- und Ereignisdaten-Volumes." value = hcloud_volume.routing_data.id } diff --git a/infra/opentofu/variables.tf b/infra/opentofu/variables.tf index 4e101d8..64c4772 100644 --- a/infra/opentofu/variables.tf +++ b/infra/opentofu/variables.tf @@ -44,7 +44,7 @@ variable "location" { } variable "server_type" { - description = "Hetzner-Cloud-Servertyp. cx33 ist für den initialen Routingdaten-Import konservativ gewählt." + description = "Hetzner-Cloud-Servertyp. cx33 ist für den initialen Routing- und Ereignisdaten-Import konservativ gewählt." type = string default = "cx33" @@ -101,7 +101,7 @@ variable "deploy_user" { } variable "routing_volume_size_gb" { - description = "Größe des persistenten ext4-Volumes für Deutschland-/Niederlande-PBFs und den Routingindex." + description = "Größe des persistenten ext4-Volumes für Deutschland-/Niederlande-PBFs, Routingindex und PostGIS-Ereignisdaten." type = number default = 40 diff --git a/packages/shared/src/marine-poi-clustering.ts b/packages/shared/src/marine-poi-clustering.ts index 66ef144..d46681f 100644 --- a/packages/shared/src/marine-poi-clustering.ts +++ b/packages/shared/src/marine-poi-clustering.ts @@ -432,6 +432,13 @@ function inferredRole(candidate: MarinePoiCandidate): MarinePoiRole { function officialIds(properties: Record) { const ids = new Map(); for (const key of OFFICIAL_ID_KEYS) { + // On an OSM waterway way, `lock_name` identifies the lock facility while + // `wikidata` commonly still identifies the underlying canal or river. + // Treating that waterway ID as a lock ID would split one physical lock into + // several events. EuRIS/ISRS identifiers remain authoritative. + if (key === "wikidata" && stringValue(properties.lock_name)) { + continue; + } const value = stringValue(properties[key]); if (value) ids.set(key, value.toLocaleUpperCase("en-US")); } diff --git a/packages/shared/tests/marine-poi-clustering.test.ts b/packages/shared/tests/marine-poi-clustering.test.ts index 6c7d96e..a26dbc8 100644 --- a/packages/shared/tests/marine-poi-clustering.test.ts +++ b/packages/shared/tests/marine-poi-clustering.test.ts @@ -92,6 +92,29 @@ describe("marine POI canonicalization", () => { expect(result).toHaveLength(2); }); + it("does not mistake a waterway Wikidata ID for the lock named by lock_name", () => { + const result = canonicalizeMarinePois([ + lock("lock-node", "Kesselschleuse Emden", 7.2, { + wikidata: "Q965772" + }), + lock("canal-way", "Kesselschleuse Emden", 7.2002, { + name: "Fehntjer Tief", + lock_name: "Kesselschleuse Emden", + wikidata: "Q1401323", + phone: "+49 170 8512375" + }) + ]); + + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ + name: "Kesselschleuse Emden", + memberCount: 2, + properties: { + phone: "+49 170 8512375" + } + }); + }); + it("assigns an unnamed gate to exactly the nearest anchor without bridging facilities", () => { const result = canonicalizeMarinePois([ lock("west", "Schleuse West", 7),