added image and audio REST endpoints
This commit is contained in:
+78
-4
@@ -5,6 +5,10 @@ import {
|
||||
listRoutes, getRoute, createRoute, updateRoute, appendRoute,
|
||||
listPois, getPoi, createPoi, updatePoi, softDeleteRoute, restoreRoute
|
||||
} from '../services/routes-service.js';
|
||||
import {
|
||||
listPictures, getPictureContent, createPicture, updatePicture, deletePicture,
|
||||
listAudio, getAudioContent, createAudio, updateAudio, deleteAudio
|
||||
} from '../services/media-service.js';
|
||||
|
||||
const numberOrUndefined = value => {
|
||||
if (value == null || value === '') return undefined;
|
||||
@@ -12,6 +16,26 @@ const numberOrUndefined = value => {
|
||||
return Number.isFinite(parsed) ? parsed : undefined;
|
||||
};
|
||||
|
||||
const positiveInteger = (value, name) => {
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
if (!Number.isInteger(parsed) || parsed <= 0) {
|
||||
const error = new Error(`${name} muss eine positive Ganzzahl sein.`);
|
||||
error.status = 400;
|
||||
throw error;
|
||||
}
|
||||
return parsed;
|
||||
};
|
||||
|
||||
function sendMedia(res, resource) {
|
||||
res.set({
|
||||
'Cache-Control': 'private, max-age=60',
|
||||
'Content-Disposition': `inline; filename="${resource.filename.replace(/["\\]/g, '_')}"`,
|
||||
'X-Content-Type-Options': 'nosniff'
|
||||
});
|
||||
res.type(resource.contentType);
|
||||
res.sendFile(resource.absolutePath);
|
||||
}
|
||||
|
||||
export function createApiRouter(db) {
|
||||
const api = Router();
|
||||
|
||||
@@ -61,13 +85,63 @@ export function createApiRouter(db) {
|
||||
res.json(appendRoute(db, Number(req.params.id), req.file));
|
||||
});
|
||||
|
||||
api.post('/routes/:id/pois', upload.fields([{ name: 'audio', maxCount: 1 }, { name: 'images', maxCount: 20 }]), (req, res) => {
|
||||
const poi = createPoi(db, Number(req.params.id), req.body, req.files);
|
||||
api.post('/routes/:id/pois', upload.none(), (req, res) => {
|
||||
const poi = createPoi(db, Number(req.params.id), req.body);
|
||||
res.status(201).location(`/api/pois/${poi.id}`).json(poi);
|
||||
});
|
||||
|
||||
api.put('/pois/:id', upload.fields([{ name: 'audio', maxCount: 1 }, { name: 'images', maxCount: 20 }]), (req, res) => {
|
||||
res.json(updatePoi(db, Number(req.params.id), req.body, req.files));
|
||||
api.put('/pois/:id', upload.none(), (req, res) => {
|
||||
res.json(updatePoi(db, Number(req.params.id), req.body));
|
||||
});
|
||||
|
||||
api.get('/routes/:id/pictures', (req, res) => {
|
||||
res.json({ pictures: listPictures(db, Number(req.params.id), { poiId: req.query.poiId }) });
|
||||
});
|
||||
|
||||
api.get('/routes/:id/pictures/:pictureId', (req, res) => {
|
||||
sendMedia(res, getPictureContent(db, Number(req.params.id), Number(req.params.pictureId)));
|
||||
});
|
||||
|
||||
api.post('/routes/:id/pictures', upload.single('picture'), (req, res) => {
|
||||
const picture = createPicture(db, Number(req.params.id), req.body, req.file);
|
||||
res.status(201)
|
||||
.location(`/api/routes/${req.params.id}/pictures/${picture.id}`)
|
||||
.json(picture);
|
||||
});
|
||||
|
||||
api.put('/routes/:id/pictures/:pictureId', upload.single('picture'), (req, res) => {
|
||||
res.json(updatePicture(db, Number(req.params.id), Number(req.params.pictureId), req.body, req.file));
|
||||
});
|
||||
|
||||
api.delete('/routes/:id/pictures/:pictureId', (req, res) => {
|
||||
res.json(deletePicture(db, Number(req.params.id), Number(req.params.pictureId)));
|
||||
});
|
||||
|
||||
api.get('/routes/:id/audio', (req, res) => {
|
||||
if (req.query.poiId == null || req.query.poiId === '') {
|
||||
res.json({ audio: listAudio(db, Number(req.params.id)) });
|
||||
return;
|
||||
}
|
||||
|
||||
const poiId = positiveInteger(req.query.poiId, 'poiId');
|
||||
sendMedia(res, getAudioContent(db, Number(req.params.id), poiId));
|
||||
});
|
||||
|
||||
api.post('/routes/:id/audio', upload.single('audio'), (req, res) => {
|
||||
const audio = createAudio(db, Number(req.params.id), req.body, req.file);
|
||||
res.status(201)
|
||||
.location(`/api/routes/${req.params.id}/audio?poiId=${encodeURIComponent(audio.poiId)}`)
|
||||
.json(audio);
|
||||
});
|
||||
|
||||
api.put('/routes/:id/audio', upload.single('audio'), (req, res) => {
|
||||
const poiId = positiveInteger(req.query.poiId ?? req.body.poiId, 'poiId');
|
||||
res.json(updateAudio(db, Number(req.params.id), poiId, req.file));
|
||||
});
|
||||
|
||||
api.delete('/routes/:id/audio', (req, res) => {
|
||||
const poiId = positiveInteger(req.query.poiId ?? req.body?.poiId, 'poiId');
|
||||
res.json(deleteAudio(db, Number(req.params.id), poiId));
|
||||
});
|
||||
|
||||
api.delete('/routes/:id', (req, res) => {
|
||||
|
||||
@@ -0,0 +1,301 @@
|
||||
import path from 'node:path';
|
||||
import crypto from 'node:crypto';
|
||||
import { HttpError } from '../middleware/errors.js';
|
||||
import {
|
||||
ensureRouteDirectories,
|
||||
moveUploadedFile,
|
||||
removeStoredFile,
|
||||
removeUpload,
|
||||
resolveStoredFile
|
||||
} from './storage.js';
|
||||
|
||||
const IMAGE_MIME_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp']);
|
||||
const AUDIO_MIME_TYPES = new Set([
|
||||
'audio/mpeg',
|
||||
'audio/mp4',
|
||||
'audio/x-m4a',
|
||||
'audio/aac',
|
||||
'audio/ogg',
|
||||
'audio/wav',
|
||||
'audio/webm'
|
||||
]);
|
||||
|
||||
const IMAGE_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.webp']);
|
||||
const AUDIO_EXTENSIONS = new Set(['.mp3', '.mp4', '.m4a', '.aac', '.ogg', '.wav', '.webm']);
|
||||
|
||||
const uniqueFilename = original => `${crypto.randomUUID()}${path.extname(original).toLowerCase()}`;
|
||||
const pictureApiUrl = (routeId, pictureId) => `/api/routes/${routeId}/pictures/${pictureId}`;
|
||||
const audioApiUrl = (routeId, poiId) => `/api/routes/${routeId}/audio?poiId=${encodeURIComponent(poiId)}`;
|
||||
|
||||
const CONTENT_TYPES = Object.freeze({
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.png': 'image/png',
|
||||
'.webp': 'image/webp',
|
||||
'.mp3': 'audio/mpeg',
|
||||
'.mp4': 'audio/mp4',
|
||||
'.m4a': 'audio/mp4',
|
||||
'.aac': 'audio/aac',
|
||||
'.ogg': 'audio/ogg',
|
||||
'.wav': 'audio/wav',
|
||||
'.webm': 'audio/webm'
|
||||
});
|
||||
|
||||
function contentResource(relativePath) {
|
||||
const absolutePath = resolveStoredFile(relativePath);
|
||||
return {
|
||||
absolutePath,
|
||||
contentType: CONTENT_TYPES[path.extname(absolutePath).toLowerCase()] || 'application/octet-stream',
|
||||
filename: path.basename(absolutePath)
|
||||
};
|
||||
}
|
||||
|
||||
function requirePositiveInteger(value, name) {
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
if (!Number.isInteger(parsed) || parsed <= 0) {
|
||||
throw new HttpError(400, `${name} muss eine positive Ganzzahl sein.`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function optionalSequence(value, fallback) {
|
||||
if (value == null || value === '') return fallback;
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
if (!Number.isInteger(parsed) || parsed < 0) {
|
||||
throw new HttpError(400, 'sequence muss eine nichtnegative Ganzzahl sein.');
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function activeRoute(db, routeId) {
|
||||
const route = db.prepare("SELECT id FROM routes WHERE id = ? AND status = 'active'").get(routeId);
|
||||
if (!route) throw new HttpError(404, 'Strecke nicht gefunden.');
|
||||
return route;
|
||||
}
|
||||
|
||||
function poiOnRoute(db, routeId, poiId) {
|
||||
const poi = db.prepare(`
|
||||
SELECT p.id, p.route_id, p.title, 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 (!poi) throw new HttpError(404, 'POI auf dieser Strecke nicht gefunden.');
|
||||
return poi;
|
||||
}
|
||||
|
||||
function validateUpload(file, kind) {
|
||||
if (!file) throw new HttpError(400, `Eine ${kind === 'picture' ? 'Bilddatei' : 'Audiodatei'} ist erforderlich.`);
|
||||
|
||||
const extension = path.extname(file.originalname).toLowerCase();
|
||||
const valid = kind === 'picture'
|
||||
? IMAGE_MIME_TYPES.has(file.mimetype) || IMAGE_EXTENSIONS.has(extension)
|
||||
: AUDIO_MIME_TYPES.has(file.mimetype) || AUDIO_EXTENSIONS.has(extension);
|
||||
|
||||
if (!valid) {
|
||||
removeUpload(file);
|
||||
throw new HttpError(415, kind === 'picture'
|
||||
? 'Der Upload ist keine unterstützte Bilddatei.'
|
||||
: 'Der Upload ist keine unterstützte Audiodatei.');
|
||||
}
|
||||
}
|
||||
|
||||
function pictureFromRow(row) {
|
||||
return {
|
||||
id: row.id,
|
||||
routeId: row.route_id,
|
||||
poiId: row.poi_id,
|
||||
poiTitle: row.poi_title,
|
||||
caption: row.caption,
|
||||
sequence: row.sequence,
|
||||
url: pictureApiUrl(row.route_id, row.id),
|
||||
createdAt: row.created_at
|
||||
};
|
||||
}
|
||||
|
||||
const pictureSelect = `
|
||||
SELECT i.id, i.poi_id, p.route_id, p.title AS poi_title,
|
||||
i.path, i.caption, i.sequence, i.created_at
|
||||
FROM poi_images i
|
||||
JOIN pois p ON p.id = i.poi_id
|
||||
JOIN routes r ON r.id = p.route_id
|
||||
`;
|
||||
|
||||
export function listPictures(db, routeId, { poiId } = {}) {
|
||||
activeRoute(db, routeId);
|
||||
const filterPoiId = poiId == null || poiId === '' ? null : requirePositiveInteger(poiId, 'poiId');
|
||||
const rows = filterPoiId == null
|
||||
? db.prepare(`${pictureSelect} WHERE p.route_id = ? AND r.status = 'active' ORDER BY p.sequence, p.id, i.sequence, i.id`).all(routeId)
|
||||
: db.prepare(`${pictureSelect} WHERE p.route_id = ? AND p.id = ? AND r.status = 'active' ORDER BY i.sequence, i.id`).all(routeId, filterPoiId);
|
||||
return rows.map(pictureFromRow);
|
||||
}
|
||||
|
||||
export function getPicture(db, routeId, pictureId) {
|
||||
activeRoute(db, routeId);
|
||||
const row = db.prepare(`${pictureSelect} WHERE p.route_id = ? AND i.id = ? AND r.status = 'active'`).get(routeId, pictureId);
|
||||
if (!row) throw new HttpError(404, 'Bild auf dieser Strecke nicht gefunden.');
|
||||
return pictureFromRow(row);
|
||||
}
|
||||
|
||||
export function getPictureContent(db, routeId, pictureId) {
|
||||
activeRoute(db, routeId);
|
||||
const row = db.prepare(`${pictureSelect} WHERE p.route_id = ? AND i.id = ? AND r.status = 'active'`).get(routeId, pictureId);
|
||||
if (!row) throw new HttpError(404, 'Bild auf dieser Strecke nicht gefunden.');
|
||||
return contentResource(row.path);
|
||||
}
|
||||
|
||||
export function createPicture(db, routeId, fields, file) {
|
||||
let relativePath;
|
||||
try {
|
||||
activeRoute(db, routeId);
|
||||
validateUpload(file, 'picture');
|
||||
|
||||
const poiId = requirePositiveInteger(fields.poiId, 'poiId');
|
||||
poiOnRoute(db, routeId, poiId);
|
||||
const sequence = optionalSequence(fields.sequence,
|
||||
db.prepare('SELECT COALESCE(MAX(sequence), -1) + 1 AS value FROM poi_images WHERE poi_id = ?').get(poiId).value);
|
||||
const caption = String(fields.caption ?? '');
|
||||
const root = ensureRouteDirectories(routeId);
|
||||
|
||||
relativePath = moveUploadedFile(file, path.join(root, 'images', uniqueFilename(file.originalname)));
|
||||
const result = db.prepare('INSERT INTO poi_images (poi_id, path, caption, sequence) VALUES (?, ?, ?, ?)')
|
||||
.run(poiId, relativePath, caption, sequence);
|
||||
return getPicture(db, routeId, Number(result.lastInsertRowid));
|
||||
} catch (error) {
|
||||
if (relativePath) removeStoredFile(relativePath);
|
||||
else removeUpload(file);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export function updatePicture(db, routeId, pictureId, fields, file) {
|
||||
let newPath;
|
||||
let oldPath;
|
||||
try {
|
||||
const existing = getPicture(db, routeId, pictureId);
|
||||
const row = db.prepare(`${pictureSelect} WHERE p.route_id = ? AND i.id = ? AND r.status = 'active'`).get(routeId, pictureId);
|
||||
oldPath = row.path;
|
||||
const poiId = fields.poiId == null || fields.poiId === ''
|
||||
? existing.poiId
|
||||
: requirePositiveInteger(fields.poiId, 'poiId');
|
||||
poiOnRoute(db, routeId, poiId);
|
||||
const caption = fields.caption == null ? existing.caption : String(fields.caption);
|
||||
const sequence = optionalSequence(fields.sequence, existing.sequence);
|
||||
|
||||
newPath = row.path;
|
||||
if (file) {
|
||||
validateUpload(file, 'picture');
|
||||
const root = ensureRouteDirectories(routeId);
|
||||
newPath = moveUploadedFile(file, path.join(root, 'images', uniqueFilename(file.originalname)));
|
||||
}
|
||||
|
||||
db.prepare('UPDATE poi_images SET poi_id = ?, path = ?, caption = ?, sequence = ? WHERE id = ?')
|
||||
.run(poiId, newPath, caption, sequence, pictureId);
|
||||
|
||||
if (file && newPath !== oldPath) removeStoredFile(oldPath);
|
||||
return getPicture(db, routeId, pictureId);
|
||||
} catch (error) {
|
||||
if (file) {
|
||||
if (newPath && newPath !== oldPath) removeStoredFile(newPath);
|
||||
else removeUpload(file);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export function deletePicture(db, routeId, pictureId) {
|
||||
const picture = getPicture(db, routeId, pictureId);
|
||||
const row = db.prepare(`${pictureSelect} WHERE p.route_id = ? AND i.id = ? AND r.status = 'active'`).get(routeId, pictureId);
|
||||
db.prepare('DELETE FROM poi_images WHERE id = ?').run(pictureId);
|
||||
removeStoredFile(row.path);
|
||||
return { id: picture.id, routeId: picture.routeId, poiId: picture.poiId, deleted: true };
|
||||
}
|
||||
|
||||
function audioFromRow(row) {
|
||||
return {
|
||||
routeId: row.route_id,
|
||||
poiId: row.poi_id,
|
||||
poiTitle: row.poi_title,
|
||||
url: audioApiUrl(row.route_id, row.poi_id),
|
||||
updatedAt: row.updated_at
|
||||
};
|
||||
}
|
||||
|
||||
const audioSelect = `
|
||||
SELECT p.id AS poi_id, p.route_id, p.title AS poi_title,
|
||||
p.audio_path, p.updated_at
|
||||
FROM pois p
|
||||
JOIN routes r ON r.id = p.route_id
|
||||
`;
|
||||
|
||||
export function listAudio(db, routeId, { poiId } = {}) {
|
||||
activeRoute(db, routeId);
|
||||
const filterPoiId = poiId == null || poiId === '' ? null : requirePositiveInteger(poiId, 'poiId');
|
||||
const rows = filterPoiId == null
|
||||
? db.prepare(`${audioSelect} WHERE p.route_id = ? AND p.audio_path IS NOT NULL AND r.status = 'active' ORDER BY p.sequence, p.id`).all(routeId)
|
||||
: db.prepare(`${audioSelect} WHERE p.route_id = ? AND p.id = ? AND p.audio_path IS NOT NULL AND r.status = 'active'`).all(routeId, filterPoiId);
|
||||
return rows.map(audioFromRow);
|
||||
}
|
||||
|
||||
export function getAudio(db, routeId, poiId) {
|
||||
activeRoute(db, routeId);
|
||||
const row = db.prepare(`${audioSelect} WHERE p.route_id = ? AND p.id = ? AND p.audio_path IS NOT NULL AND r.status = 'active'`)
|
||||
.get(routeId, poiId);
|
||||
if (!row) throw new HttpError(404, 'Audiodatei für diesen POI nicht gefunden.');
|
||||
return audioFromRow(row);
|
||||
}
|
||||
|
||||
export function getAudioContent(db, routeId, poiId) {
|
||||
activeRoute(db, routeId);
|
||||
const row = db.prepare(`${audioSelect} WHERE p.route_id = ? AND p.id = ? AND p.audio_path IS NOT NULL AND r.status = 'active'`)
|
||||
.get(routeId, poiId);
|
||||
if (!row) throw new HttpError(404, 'Audiodatei für diesen POI nicht gefunden.');
|
||||
return contentResource(row.audio_path);
|
||||
}
|
||||
|
||||
function storeAudio(db, routeId, poiId, file, { requireAbsent, requireExisting }) {
|
||||
let relativePath;
|
||||
let oldPath;
|
||||
try {
|
||||
activeRoute(db, routeId);
|
||||
validateUpload(file, 'audio');
|
||||
const poi = poiOnRoute(db, routeId, poiId);
|
||||
oldPath = poi.audio_path;
|
||||
|
||||
if (requireAbsent && poi.audio_path) {
|
||||
throw new HttpError(409, 'Für diesen POI ist bereits eine Audiodatei hinterlegt. Verwende PUT zum Ersetzen.');
|
||||
}
|
||||
if (requireExisting && !poi.audio_path) {
|
||||
throw new HttpError(404, 'Für diesen POI ist noch keine Audiodatei hinterlegt. Verwende POST zum Anlegen.');
|
||||
}
|
||||
|
||||
const root = ensureRouteDirectories(routeId);
|
||||
relativePath = moveUploadedFile(file, path.join(root, 'audio', uniqueFilename(file.originalname)));
|
||||
db.prepare('UPDATE pois SET audio_path = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?')
|
||||
.run(relativePath, poiId);
|
||||
|
||||
if (oldPath && oldPath !== relativePath) removeStoredFile(oldPath);
|
||||
return getAudio(db, routeId, poiId);
|
||||
} catch (error) {
|
||||
if (relativePath && relativePath !== oldPath) removeStoredFile(relativePath);
|
||||
else removeUpload(file);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export function createAudio(db, routeId, fields, file) {
|
||||
const poiId = requirePositiveInteger(fields.poiId, 'poiId');
|
||||
return storeAudio(db, routeId, poiId, file, { requireAbsent: true, requireExisting: false });
|
||||
}
|
||||
|
||||
export function updateAudio(db, routeId, poiId, file) {
|
||||
return storeAudio(db, routeId, poiId, file, { requireAbsent: false, requireExisting: true });
|
||||
}
|
||||
|
||||
export function deleteAudio(db, routeId, poiId) {
|
||||
const audio = getAudio(db, routeId, poiId);
|
||||
const poi = poiOnRoute(db, routeId, poiId);
|
||||
db.prepare('UPDATE pois SET audio_path = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(poiId);
|
||||
removeStoredFile(poi.audio_path);
|
||||
return { routeId: audio.routeId, poiId: audio.poiId, deleted: true };
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
@@ -25,6 +25,34 @@ export function removeUpload(uploaded) {
|
||||
if (uploaded?.path && fs.existsSync(uploaded.path)) fs.rmSync(uploaded.path, { force: true });
|
||||
}
|
||||
|
||||
export function resolveStoredFile(relativePath) {
|
||||
if (!relativePath || !relativePath.startsWith('active/')) {
|
||||
throw new HttpError(404, 'Aktive Mediendatei nicht gefunden.');
|
||||
}
|
||||
|
||||
const storageRoot = path.resolve(config.storageDir);
|
||||
const target = path.resolve(storageRoot, relativePath);
|
||||
if (target !== storageRoot && !target.startsWith(`${storageRoot}${path.sep}`)) {
|
||||
throw new HttpError(500, 'Ungültiger interner Medienpfad.');
|
||||
}
|
||||
if (!fs.existsSync(target) || !fs.statSync(target).isFile()) {
|
||||
throw new HttpError(404, 'Mediendatei nicht gefunden.');
|
||||
}
|
||||
return target;
|
||||
}
|
||||
|
||||
export function removeStoredFile(relativePath) {
|
||||
if (!relativePath) return false;
|
||||
const storageRoot = path.resolve(config.storageDir);
|
||||
const target = path.resolve(storageRoot, relativePath);
|
||||
if (target !== storageRoot && !target.startsWith(`${storageRoot}${path.sep}`)) {
|
||||
throw new HttpError(500, 'Ungültiger interner Medienpfad.');
|
||||
}
|
||||
if (!fs.existsSync(target)) return false;
|
||||
fs.rmSync(target, { force: true });
|
||||
return true;
|
||||
}
|
||||
|
||||
export function softDeleteRouteDirectory(id) {
|
||||
const source = routeDirectory(id);
|
||||
if (!fs.existsSync(source)) throw new HttpError(409, 'Streckenverzeichnis fehlt; Löschmarkierung wurde nicht ausgeführt.');
|
||||
|
||||
Reference in New Issue
Block a user