added image and audio REST endpoints

This commit is contained in:
Florian Zumpe
2026-06-16 23:52:37 +02:00
parent b910bce8df
commit c98f304ffa
13 changed files with 1126 additions and 494 deletions
+24 -39
View File
@@ -1,19 +1,16 @@
import fs from 'node:fs';
import path from 'node:path';
import crypto from 'node:crypto';
import { transaction } from '../database.js';
import { config } from '../config.js';
import { HttpError } from '../middleware/errors.js';
import { parseGpx } from './gpx.js';
import { distanceMeters, routeMetrics } from './geo.js';
import {
ensureRouteDirectories, moveUploadedFile, relativeStoragePath, routeDirectory,
ensureRouteDirectories, moveUploadedFile, routeDirectory,
softDeleteRouteDirectory, restoreRouteDirectory, safeMediaUrl, removeUpload
} from './storage.js';
const slugify = value => value.toLowerCase().normalize('NFKD').replace(/[\u0300-\u036f]/g, '').replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 80) || `route-${Date.now()}`;
const uniqueFilename = original => `${crypto.randomUUID()}${path.extname(original).toLowerCase()}`;
function rowToRoute(row, includeDeleted = false) {
if (!row || (!includeDeleted && row.status !== 'active')) return null;
return {
@@ -132,8 +129,13 @@ export function listPois(db, routeId) {
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: safeMediaUrl(poi.audio_path),
images: imageStatement.all(poi.id).map(image => ({ id: image.id, caption: image.caption, sequence: image.sequence, url: safeMediaUrl(image.path) }))
audioUrl: poi.audio_path ? `/api/routes/${routeId}/audio?poiId=${encodeURIComponent(poi.id)}` : null,
images: imageStatement.all(poi.id).map(image => ({
id: image.id,
caption: image.caption,
sequence: image.sequence,
url: `/api/routes/${routeId}/pictures/${image.id}`
}))
}));
}
@@ -143,49 +145,32 @@ export function getPoi(db, poiId) {
return listPois(db, row.routeId).find(poi => poi.id === Number(poiId));
}
export function createPoi(db, routeId, fields, files = {}) {
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 = Number.parseFloat(fields.lat); const lon = Number.parseFloat(fields.lon);
if (!Number.isFinite(lat) || !Number.isFinite(lon)) throw new HttpError(400, 'Gültige POI-Koordinaten sind erforderlich.');
const lat = Number.parseFloat(fields.lat);
const lon = Number.parseFloat(fields.lon);
if (!Number.isFinite(lat) || !Number.isFinite(lon)) {
throw new HttpError(400, 'Gültige POI-Koordinaten sind erforderlich.');
}
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));
const poiId = Number(result.lastInsertRowid);
const routeRoot = ensureRouteDirectories(routeId);
const audio = files.audio?.[0];
if (audio) {
const relative = moveUploadedFile(audio, path.join(routeRoot, 'audio', uniqueFilename(audio.originalname)));
db.prepare('UPDATE pois SET audio_path = ? WHERE id = ?').run(relative, poiId);
}
const insertImage = db.prepare('INSERT INTO poi_images (poi_id, path, caption, sequence) VALUES (?, ?, ?, ?)');
for (const [index, image] of (files.images || []).entries()) {
const relative = moveUploadedFile(image, path.join(routeRoot, 'images', uniqueFilename(image.originalname)));
insertImage.run(poiId, relative, '', index);
}
return listPois(db, routeId).find(poi => poi.id === poiId);
return listPois(db, routeId).find(poi => poi.id === Number(result.lastInsertRowid));
}
export function updatePoi(db, poiId, fields, files = {}) {
export function updatePoi(db, 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 = ?`).get(poiId);
if (!existing || existing.status !== 'active') throw new HttpError(404, 'POI nicht gefunden.');
const lat = fields.lat == null ? existing.lat : Number.parseFloat(fields.lat);
const lon = fields.lon == null ? existing.lon : Number.parseFloat(fields.lon);
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 (![lat, lon, radius].every(Number.isFinite) || !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,
fields.lat == null ? existing.lat : Number.parseFloat(fields.lat), fields.lon == null ? existing.lon : Number.parseFloat(fields.lon),
fields.triggerRadiusM == null ? existing.trigger_radius_m : Number.parseFloat(fields.triggerRadiusM),
fields.sequence == null ? existing.sequence : Number.parseInt(fields.sequence, 10), poiId);
const root = ensureRouteDirectories(existing.route_id);
const audio = files.audio?.[0];
if (audio) {
const relative = moveUploadedFile(audio, path.join(root, 'audio', uniqueFilename(audio.originalname)));
db.prepare('UPDATE pois SET audio_path = ? WHERE id = ?').run(relative, poiId);
}
const maxSeq = db.prepare('SELECT COALESCE(MAX(sequence), -1) AS value FROM poi_images WHERE poi_id = ?').get(poiId).value;
const insert = db.prepare('INSERT INTO poi_images (poi_id, path, caption, sequence) VALUES (?, ?, ?, ?)');
for (const [index, image] of (files.images || []).entries()) {
const relative = moveUploadedFile(image, path.join(root, 'images', uniqueFilename(image.originalname)));
insert.run(poiId, relative, '', maxSeq + index + 1);
}
.run(fields.title ?? existing.title, fields.description ?? existing.description, lat, lon, radius, sequence, poiId);
return listPois(db, existing.route_id).find(poi => poi.id === Number(poiId));
}