import fs from 'node:fs'; import path from 'node:path'; import { transaction } from '../database.js'; import { config } from '../config.js'; import { HttpError } from '../middleware/errors.js'; import { parseGpx, gpxWithWaypoints } from './gpx.js'; import { distanceMeters } from './geo.js'; import { ensureRouteDirectories, moveUploadedFile, routeDirectory, softDeleteRouteDirectory, restoreRouteDirectory, resolveStoredFile, removeUpload, removeStoredFile } from './storage.js'; const UPDATE_BOUNDS_SQL = `UPDATE routes SET min_lat=?, min_lon=?, max_lat=?, max_lon=?, updated_at=CURRENT_TIMESTAMP WHERE id=?`; function readRouteGpx(row) { const filename = resolveStoredFile(row.gpx_path, { allowTrash: row.status === 'deleted' }); return parseGpx(fs.readFileSync(filename, 'utf8'), null, { allowEmpty: true }); } function trimEdgeHyphens(value) { let start = 0; let end = value.length; while (start < end && value[start] === '-') start += 1; while (end > start && value[end - 1] === '-') end -= 1; return value.slice(start, end); } function slugify(value) { const normalized = String(value).toLowerCase().normalize('NFKD'); const withoutMarks = normalized.replaceAll(/[\u0300-\u036f]/g, ''); const separated = withoutMarks.replaceAll(/[^a-z0-9]+/g, '-'); const trimmed = trimEdgeHyphens(separated); return trimmed.slice(0, 80) || `route-${Date.now()}`; } const routeGpxUrl = routeId => `/api/routes/${routeId}/gpx`; const pictureUrl = (routeId, poiId, pictureId) => `/api/routes/${routeId}/pois/${poiId}/pictures/${pictureId}`; const audioUrl = (routeId, poiId) => `/api/routes/${routeId}/pois/${poiId}/audio`; function rowToRoute(row, includeDeleted = false, parsed = null) { if (!row || (!includeDeleted && row.status !== 'active')) return null; const { metrics } = parsed ?? readRouteGpx(row); return { id: row.id, slug: row.slug, name: row.name, description: row.description, schoolName: row.school_name, status: row.status, activatedAt: row.activated_at, start: metrics.startLat == null ? null : { lat: metrics.startLat, lon: metrics.startLon }, center: metrics.centerLat == null ? null : { lat: metrics.centerLat, lon: metrics.centerLon }, bounds: row.min_lat == null ? null : { minLat: row.min_lat, minLon: row.min_lon, maxLat: row.max_lat, maxLon: row.max_lon }, distanceM: metrics.distanceM, elevationGainM: metrics.elevationGainM, pointCount: metrics.pointCount, gpxUrl: row.gpx_path && row.status === 'active' ? routeGpxUrl(row.id) : null, createdAt: row.created_at, updatedAt: row.updated_at, deletedAt: row.deleted_at }; } function routeComparator(origin) { if (origin) return (left, right) => left.proximityM - right.proximityM; return (left, right) => left.name.localeCompare(right.name, 'de'); } function withinRouteBounds(row, origin, maximumDistance) { if (!origin) return true; if (row.min_lat == null || row.min_lon == null || row.max_lat == null || row.max_lon == null) return false; const nearest = { lat: Math.max(row.min_lat, Math.min(row.max_lat, origin.lat)), lon: Math.max(row.min_lon, Math.min(row.max_lon, origin.lon)) }; return distanceMeters(origin, nearest) <= maximumDistance; } function updateBounds(db, routeId, metrics) { db.prepare(UPDATE_BOUNDS_SQL).run( metrics.minLat, metrics.minLon, metrics.maxLat, metrics.maxLon, routeId ); } function uniqueSlug(db, source) { const base = slugify(source); let candidate = base; let suffix = 2; while (db.prepare('SELECT 1 FROM routes WHERE slug = ?').get(candidate)) { candidate = `${base}-${suffix}`; suffix += 1; } return candidate; } function cleanupFailedRoute(db, routeId, gpxFile) { removeUpload(gpxFile); if (!routeId) return; db.prepare('DELETE FROM routes WHERE id = ?').run(routeId); fs.rmSync(routeDirectory(routeId), { recursive: true, force: true }); } function rewriteMediaPaths(db, routeId, sourcePrefix, targetPrefix) { db.prepare('UPDATE pois SET audio_path = REPLACE(audio_path, ?, ?) WHERE route_id = ? AND audio_path IS NOT NULL') .run(sourcePrefix, targetPrefix, routeId); db.prepare(`UPDATE poi_images SET path = REPLACE(path, ?, ?) WHERE poi_id IN (SELECT id FROM pois WHERE route_id = ?)`) .run(sourcePrefix, targetPrefix, routeId); } export function listRoutes(db, { lat, lon, radiusKm = config.defaultRouteRadiusKm, includeDeleted = false, publishedOnly = false }) { let where = ''; if (!includeDeleted) { where = publishedOnly ? "WHERE status = 'active' AND activated_at IS NOT NULL" : "WHERE status = 'active'"; } const rows = db.prepare(`SELECT * FROM routes ${where} ORDER BY name COLLATE NOCASE`).all(); const origin = Number.isFinite(lat) && Number.isFinite(lon) ? { lat, lon } : null; const maximumDistance = radiusKm * 1000; return rows .filter(row => withinRouteBounds(row, origin, maximumDistance)) .map(row => { const route = rowToRoute(row, includeDeleted); const proximityM = origin && route?.start ? distanceMeters(origin, route.start) : null; return { ...route, proximityM }; }) .filter(route => !origin || (route.proximityM != null && route.proximityM <= maximumDistance)) .sort(routeComparator(origin)); } export function getRoute(db, id, includeDeleted = false) { const row = db.prepare('SELECT * FROM routes WHERE id = ?').get(id); if (!row || (!includeDeleted && row.status !== 'active')) throw new HttpError(404, 'Strecke nicht gefunden.'); const parsed = readRouteGpx(row); const route = rowToRoute(row, includeDeleted, parsed); const { points } = parsed; return { ...route, points, pois: listPois(db, id) }; } export function getRouteGpxFile(db, id) { const route = db.prepare("SELECT id, slug, gpx_path FROM routes WHERE id = ? AND status = 'active'").get(id); if (!route) throw new HttpError(404, 'Strecke nicht gefunden.'); return { absolutePath: resolveStoredFile(route.gpx_path), filename: `${route.slug}.gpx` }; } export function createRoute(db, fields, gpxFile) { if (!gpxFile) throw new HttpError(400, 'Eine GPX-Datei ist erforderlich.'); let routeId; try { const parsed = parseGpx(fs.readFileSync(gpxFile.path, 'utf8')); const name = String(fields.name || parsed.name || '').trim(); if (!name) throw new HttpError(400, 'Ein Streckenname ist erforderlich.'); routeId = transaction(db, () => { const slug = uniqueSlug(db, fields.slug || name); const metrics = parsed.metrics; const result = db.prepare(`INSERT INTO routes (slug, name, description, school_name, gpx_path, min_lat, min_lon, max_lat, max_lon) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`).run( slug, name, fields.description || '', fields.schoolName || '', 'pending', metrics.minLat, metrics.minLon, metrics.maxLat, metrics.maxLon ); const id = Number(result.lastInsertRowid); return id; }); ensureRouteDirectories(routeId); fs.writeFileSync(gpxFile.path, parsed.normalizedXml); const gpxPath = moveUploadedFile(gpxFile, path.join(routeDirectory(routeId), 'route.gpx')); db.prepare('UPDATE routes SET gpx_path = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?') .run(gpxPath, routeId); return getRoute(db, routeId); } catch (error) { cleanupFailedRoute(db, routeId, gpxFile); throw error; } } export function updateRoute(db, id, fields, gpxFile) { const existing = db.prepare("SELECT * FROM routes WHERE id = ? AND status = 'active'").get(id); if (!existing) throw new HttpError(404, 'Strecke nicht gefunden.'); const parsed = gpxFile ? parseGpx(fs.readFileSync(gpxFile.path, 'utf8')) : null; transaction(db, () => { db.prepare(`UPDATE routes SET name = ?, description = ?, school_name = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`).run( fields.name ?? existing.name, fields.description ?? existing.description, fields.schoolName ?? existing.school_name, id ); }); if (gpxFile) { fs.writeFileSync(gpxFile.path, parsed.normalizedXml); moveUploadedFile(gpxFile, path.join(routeDirectory(id), 'route.gpx')); syncRouteWaypoints(db, id); } return getRoute(db, id); } export function appendRoute(db, id, gpxFile) { if (!gpxFile) throw new HttpError(400, 'Eine GPX-Datei ist erforderlich.'); const existing = db.prepare("SELECT * FROM routes WHERE id = ? AND status = 'active'").get(id); if (!existing) throw new HttpError(404, 'Strecke nicht gefunden.'); const parsed = parseGpx(fs.readFileSync(gpxFile.path, 'utf8')); const current = readRouteGpx(existing).points; const allPoints = current.concat(parsed.points).map((point, sequence) => ({ ...point, sequence })); const target = resolveStoredFile(existing.gpx_path); const original = fs.readFileSync(target, 'utf8'); const normalized = parseGpx(original, allPoints); fs.writeFileSync(target, normalized.normalizedXml); updateBounds(db, id, normalized.metrics); removeUpload(gpxFile); return getRoute(db, id); } export function listPois(db, routeId) { const pois = db.prepare('SELECT * FROM pois WHERE route_id = ? ORDER BY sequence, id').all(routeId); const images = db.prepare(`SELECT id, path, caption, sequence FROM poi_images WHERE poi_id = ? ORDER BY sequence, id`); return pois.map(poi => ({ id: poi.id, routeId: poi.route_id, title: poi.title, description: poi.description, lat: poi.lat, lon: poi.lon, triggerRadiusM: poi.trigger_radius_m, sequence: poi.sequence, audioUrl: poi.audio_path ? audioUrl(poi.route_id, poi.id) : null, images: images.all(poi.id).map(image => ({ id: image.id, caption: image.caption, sequence: image.sequence, url: pictureUrl(poi.route_id, poi.id, image.id) })) })); } export function getPoi(db, routeId, poiId) { const row = db.prepare(`SELECT p.id FROM pois p JOIN routes r ON r.id = p.route_id WHERE p.id = ? AND p.route_id = ? AND r.status = 'active'`).get(poiId, routeId); if (!row) throw new HttpError(404, 'POI nicht gefunden.'); return listPois(db, routeId).find(poi => poi.id === Number(poiId)); } function syncRouteWaypoints(db, routeId) { const row = db.prepare('SELECT gpx_path FROM routes WHERE id = ?').get(routeId); const filename = resolveStoredFile(row.gpx_path); const xml = fs.readFileSync(filename, 'utf8'); fs.writeFileSync(filename, gpxWithWaypoints(xml, listPois(db, routeId))); updateBounds(db, routeId, parseGpx(fs.readFileSync(filename, 'utf8'), null, { allowEmpty: true }).metrics); } function poiCoordinates(fields, existing = {}) { const values = ['lat', 'lon'].map(key => Object.hasOwn(fields, key) ? fields[key] : existing[key]); const [lat, lon] = values.map(value => value == null || value === '' ? null : Number.parseFloat(value)); const absent = lat === null && lon === null; if (!absent && (!Number.isFinite(lat) || !Number.isFinite(lon) || Math.abs(lat) > 90 || Math.abs(lon) > 180)) { throw new HttpError(400, 'Gültige POI-Koordinaten sind erforderlich.'); } return { lat, lon }; } export function createPoi(db, routeId, fields) { const route = db.prepare("SELECT id FROM routes WHERE id = ? AND status = 'active'").get(routeId); if (!route) throw new HttpError(404, 'Strecke nicht gefunden.'); const { lat, lon } = poiCoordinates(fields); const result = db.prepare(`INSERT INTO pois (route_id, title, description, lat, lon, trigger_radius_m, sequence) VALUES (?, ?, ?, ?, ?, ?, ?)`).run( routeId, fields.title || 'Unbenannter POI', fields.description || '', lat, lon, Number.parseFloat(fields.triggerRadiusM || config.defaultPoiTriggerMeters), Number.parseInt(fields.sequence || '0', 10) ); syncRouteWaypoints(db, routeId); return listPois(db, routeId).find(poi => poi.id === Number(result.lastInsertRowid)); } export function updatePoi(db, routeId, poiId, fields) { const existing = db.prepare(`SELECT p.*, r.status FROM pois p JOIN routes r ON r.id = p.route_id WHERE p.id = ? AND p.route_id = ?`).get(poiId, routeId); if (existing?.status !== 'active') throw new HttpError(404, 'POI nicht gefunden.'); const { lat, lon } = poiCoordinates(fields, existing); const radius = fields.triggerRadiusM == null ? existing.trigger_radius_m : Number.parseFloat(fields.triggerRadiusM); const sequence = fields.sequence == null ? existing.sequence : Number.parseInt(fields.sequence, 10); if (!Number.isFinite(radius) || !Number.isInteger(sequence) || sequence < 0) { throw new HttpError(400, 'POI-Koordinaten, Auslöseradius oder Reihenfolge sind ungültig.'); } db.prepare(`UPDATE pois SET title=?, description=?, lat=?, lon=?, trigger_radius_m=?, sequence=?, updated_at=CURRENT_TIMESTAMP WHERE id=?`).run( fields.title ?? existing.title, fields.description ?? existing.description, lat, lon, radius, sequence, poiId ); syncRouteWaypoints(db, existing.route_id); return listPois(db, existing.route_id).find(poi => poi.id === Number(poiId)); } export function deletePoi(db, routeId, poiId) { const existing = db.prepare(`SELECT p.id, p.audio_path FROM pois p JOIN routes r ON r.id = p.route_id WHERE p.id = ? AND p.route_id = ? AND r.status = 'active'`).get(poiId, routeId); if (!existing) throw new HttpError(404, 'POI nicht gefunden.'); const images = db.prepare('SELECT path FROM poi_images WHERE poi_id = ?').all(poiId); transaction(db, () => { db.prepare('DELETE FROM poi_images WHERE poi_id = ?').run(poiId); db.prepare('DELETE FROM pois WHERE id = ? AND route_id = ?').run(poiId, routeId); }); syncRouteWaypoints(db, routeId); images.forEach(image => removeStoredFile(image.path)); removeStoredFile(existing.audio_path); return { id: poiId, routeId, deleted: true }; } export function softDeleteRoute(db, id) { const route = db.prepare("SELECT id FROM routes WHERE id = ? AND status = 'active'").get(id); if (!route) throw new HttpError(404, 'Aktive Strecke nicht gefunden.'); const moved = softDeleteRouteDirectory(id); try { transaction(db, () => { db.prepare(`UPDATE routes SET status='deleted', deleted_at=CURRENT_TIMESTAMP, trash_path=?, gpx_path=REPLACE(gpx_path, ?, ?), updated_at=CURRENT_TIMESTAMP WHERE id=?`) .run(moved.targetPrefix, moved.sourcePrefix, moved.targetPrefix, id); rewriteMediaPaths(db, id, moved.sourcePrefix, moved.targetPrefix); }); } catch (error) { fs.renameSync(moved.destination, routeDirectory(id)); throw error; } return rowToRoute(db.prepare('SELECT * FROM routes WHERE id = ?').get(id), true); } export function restoreRoute(db, id) { const route = db.prepare("SELECT trash_path FROM routes WHERE id = ? AND status = 'deleted'").get(id); if (!route) throw new HttpError(404, 'Gelöschte Strecke nicht gefunden.'); const moved = restoreRouteDirectory(id, route.trash_path); try { transaction(db, () => { db.prepare(`UPDATE routes SET status='active', deleted_at=NULL, trash_path=NULL, gpx_path=REPLACE(gpx_path, ?, ?), updated_at=CURRENT_TIMESTAMP WHERE id=?`) .run(moved.sourcePrefix, moved.targetPrefix, id); rewriteMediaPaths(db, id, moved.sourcePrefix, moved.targetPrefix); }); } catch (error) { fs.renameSync(moved.destination, moved.source); throw error; } return getRoute(db, id); }