initial commit
This commit is contained in:
@@ -0,0 +1,29 @@
|
||||
import path from 'node:path';
|
||||
import fs from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const resolveFromRoot = (value, fallback) => path.resolve(projectRoot, value || fallback);
|
||||
|
||||
export const config = Object.freeze({
|
||||
projectRoot,
|
||||
host: process.env.HOST || '127.0.0.1',
|
||||
port: Number.parseInt(process.env.PORT || '47145', 10),
|
||||
instanceName: process.env.INSTANCE_NAME || 'Atlas',
|
||||
dataDir: resolveFromRoot(process.env.DATA_DIR, 'data'),
|
||||
storageDir: resolveFromRoot(process.env.STORAGE_DIR, 'storage'),
|
||||
maxUploadBytes: Number.parseInt(process.env.MAX_UPLOAD_MB || '50', 10) * 1024 * 1024,
|
||||
defaultRouteRadiusKm: Number.parseFloat(process.env.DEFAULT_ROUTE_RADIUS_KM || '25'),
|
||||
defaultPoiTriggerMeters: Number.parseFloat(process.env.DEFAULT_POI_TRIGGER_METERS || '80')
|
||||
});
|
||||
|
||||
export function ensureRuntimeDirectories() {
|
||||
for (const directory of [
|
||||
config.dataDir,
|
||||
path.join(config.storageDir, 'active', 'routes'),
|
||||
path.join(config.storageDir, 'trash', 'routes'),
|
||||
path.join(config.storageDir, 'uploads')
|
||||
]) {
|
||||
fs.mkdirSync(directory, { recursive: true });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { DatabaseSync } from 'node:sqlite';
|
||||
import { config } from './config.js';
|
||||
|
||||
export function openDatabase(filename = path.join(config.dataDir, 'wegwichtel.sqlite')) {
|
||||
const db = new DatabaseSync(filename, {
|
||||
enableForeignKeyConstraints: true,
|
||||
timeout: 5000
|
||||
});
|
||||
db.exec('PRAGMA journal_mode = WAL; PRAGMA busy_timeout = 5000;');
|
||||
const schema = fs.readFileSync(path.join(config.projectRoot, 'database', 'schema.sql'), 'utf8');
|
||||
db.exec(schema);
|
||||
return db;
|
||||
}
|
||||
|
||||
export function transaction(db, callback) {
|
||||
db.exec('BEGIN IMMEDIATE');
|
||||
try {
|
||||
const result = callback();
|
||||
db.exec('COMMIT');
|
||||
return result;
|
||||
} catch (error) {
|
||||
db.exec('ROLLBACK');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
export class HttpError extends Error {
|
||||
constructor(status, message, details = undefined) {
|
||||
super(message);
|
||||
this.name = 'HttpError';
|
||||
this.status = status;
|
||||
this.details = details;
|
||||
}
|
||||
}
|
||||
|
||||
export function notFoundHandler(req, res, next) {
|
||||
next(new HttpError(404, 'Ressource nicht gefunden.'));
|
||||
}
|
||||
|
||||
export function errorHandler(error, req, res, next) {
|
||||
if (res.headersSent) return next(error);
|
||||
let status = Number.isInteger(error.status) ? error.status : 500;
|
||||
if (error.code === 'LIMIT_FILE_SIZE') status = 413;
|
||||
if (error.code === 'LIMIT_UNEXPECTED_FILE') status = 400;
|
||||
if (status >= 500) console.error(error);
|
||||
res.status(status).json({
|
||||
error: error.name || 'Error',
|
||||
message: status >= 500 ? 'Interner Serverfehler.' : error.message,
|
||||
...(error.details ? { details: error.details } : {})
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import path from 'node:path';
|
||||
import crypto from 'node:crypto';
|
||||
import multer from 'multer';
|
||||
import { config } from '../config.js';
|
||||
import { HttpError } from './errors.js';
|
||||
|
||||
const storage = multer.diskStorage({
|
||||
destination: path.join(config.storageDir, 'uploads'),
|
||||
filename(req, file, callback) {
|
||||
const safe = file.originalname.replace(/[^a-zA-Z0-9._-]/g, '_');
|
||||
callback(null, `${Date.now()}-${crypto.randomUUID()}-${safe}`);
|
||||
}
|
||||
});
|
||||
|
||||
function fileFilter(req, file, callback) {
|
||||
const allowed = new Set([
|
||||
'application/gpx+xml', 'application/xml', 'text/xml',
|
||||
'image/jpeg', 'image/png', 'image/webp',
|
||||
'audio/mpeg', 'audio/mp4', 'audio/x-m4a', 'audio/aac', 'audio/ogg', 'audio/wav', 'audio/webm'
|
||||
]);
|
||||
const extension = path.extname(file.originalname).toLowerCase();
|
||||
if (allowed.has(file.mimetype) || extension === '.gpx') return callback(null, true);
|
||||
callback(new HttpError(415, `Dateityp nicht unterstützt: ${file.originalname}`));
|
||||
}
|
||||
|
||||
export const upload = multer({
|
||||
storage,
|
||||
limits: { fileSize: config.maxUploadBytes, files: 25 },
|
||||
fileFilter
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
import { Router } from 'express';
|
||||
import { upload } from '../middleware/upload.js';
|
||||
import { config } from '../config.js';
|
||||
import {
|
||||
listRoutes, getRoute, createRoute, updateRoute, appendRoute,
|
||||
listPois, getPoi, createPoi, updatePoi, softDeleteRoute, restoreRoute
|
||||
} from '../services/routes-service.js';
|
||||
|
||||
const numberOrUndefined = value => {
|
||||
if (value == null || value === '') return undefined;
|
||||
const parsed = Number.parseFloat(value);
|
||||
return Number.isFinite(parsed) ? parsed : undefined;
|
||||
};
|
||||
|
||||
export function createApiRouter(db) {
|
||||
const api = Router();
|
||||
|
||||
api.get('/health', (req, res) => {
|
||||
const sqliteVersion = db.prepare('SELECT sqlite_version() AS version').get().version;
|
||||
res.json({
|
||||
ok: true,
|
||||
service: 'wegwichtel',
|
||||
instance: config.instanceName,
|
||||
socket: `${config.host}:${config.port}`,
|
||||
sqliteVersion,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
});
|
||||
|
||||
api.get('/routes', (req, res) => {
|
||||
res.json({ routes: listRoutes(db, {
|
||||
lat: numberOrUndefined(req.query.lat),
|
||||
lon: numberOrUndefined(req.query.lon),
|
||||
radiusKm: numberOrUndefined(req.query.radiusKm),
|
||||
includeDeleted: req.query.includeDeleted === 'true'
|
||||
}) });
|
||||
});
|
||||
|
||||
api.get('/routes/:id', (req, res) => {
|
||||
res.json(getRoute(db, Number(req.params.id), req.query.includeDeleted === 'true'));
|
||||
});
|
||||
|
||||
api.get('/routes/:id/pois', (req, res) => {
|
||||
getRoute(db, Number(req.params.id));
|
||||
res.json({ pois: listPois(db, Number(req.params.id)) });
|
||||
});
|
||||
|
||||
api.get('/pois/:id', (req, res) => {
|
||||
res.json(getPoi(db, Number(req.params.id)));
|
||||
});
|
||||
|
||||
api.post('/routes', upload.single('gpx'), (req, res) => {
|
||||
const route = createRoute(db, req.body, req.file);
|
||||
res.status(201).location(`/api/routes/${route.id}`).json(route);
|
||||
});
|
||||
|
||||
api.put('/routes/:id', upload.single('gpx'), (req, res) => {
|
||||
res.json(updateRoute(db, Number(req.params.id), req.body, req.file));
|
||||
});
|
||||
|
||||
api.post('/routes/:id/append', upload.single('gpx'), (req, res) => {
|
||||
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);
|
||||
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.delete('/routes/:id', (req, res) => {
|
||||
res.json({ route: softDeleteRoute(db, Number(req.params.id)), softDeleted: true });
|
||||
});
|
||||
|
||||
api.post('/routes/:id/restore', (req, res) => {
|
||||
res.json({ route: restoreRoute(db, Number(req.params.id)), restored: true });
|
||||
});
|
||||
|
||||
return api;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
const EARTH_RADIUS_M = 6_371_000;
|
||||
const radians = degrees => degrees * Math.PI / 180;
|
||||
|
||||
export function distanceMeters(a, b) {
|
||||
const dLat = radians(b.lat - a.lat);
|
||||
const dLon = radians(b.lon - a.lon);
|
||||
const lat1 = radians(a.lat);
|
||||
const lat2 = radians(b.lat);
|
||||
const h = Math.sin(dLat / 2) ** 2 + Math.cos(lat1) * Math.cos(lat2) * Math.sin(dLon / 2) ** 2;
|
||||
return 2 * EARTH_RADIUS_M * Math.asin(Math.sqrt(h));
|
||||
}
|
||||
|
||||
export function routeMetrics(points) {
|
||||
if (!points.length) throw new Error('Die GPX-Datei enthält keine Trackpunkte.');
|
||||
let distance = 0;
|
||||
let gain = 0;
|
||||
for (let i = 1; i < points.length; i += 1) {
|
||||
distance += distanceMeters(points[i - 1], points[i]);
|
||||
const previous = points[i - 1].elevation;
|
||||
const current = points[i].elevation;
|
||||
if (Number.isFinite(previous) && Number.isFinite(current) && current > previous) gain += current - previous;
|
||||
}
|
||||
const lats = points.map(point => point.lat);
|
||||
const lons = points.map(point => point.lon);
|
||||
return {
|
||||
startLat: points[0].lat,
|
||||
startLon: points[0].lon,
|
||||
centerLat: (Math.min(...lats) + Math.max(...lats)) / 2,
|
||||
centerLon: (Math.min(...lons) + Math.max(...lons)) / 2,
|
||||
minLat: Math.min(...lats), minLon: Math.min(...lons),
|
||||
maxLat: Math.max(...lats), maxLon: Math.max(...lons),
|
||||
distanceM: distance,
|
||||
elevationGainM: gain,
|
||||
pointCount: points.length
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { XMLParser } from 'fast-xml-parser';
|
||||
import { HttpError } from '../middleware/errors.js';
|
||||
import { routeMetrics } from './geo.js';
|
||||
|
||||
const parser = new XMLParser({ ignoreAttributes: false, attributeNamePrefix: '@_', trimValues: true });
|
||||
const list = value => value == null ? [] : Array.isArray(value) ? value : [value];
|
||||
|
||||
export function parseGpx(xml) {
|
||||
let parsed;
|
||||
try { parsed = parser.parse(xml); }
|
||||
catch (error) { throw new HttpError(400, 'Die GPX-Datei ist kein gültiges XML.', error.message); }
|
||||
const gpx = parsed?.gpx;
|
||||
if (!gpx) throw new HttpError(400, 'GPX-Wurzelelement fehlt.');
|
||||
const tracks = list(gpx.trk);
|
||||
const points = tracks.flatMap(track => list(track.trkseg)).flatMap(segment => list(segment.trkpt)).map((point, index) => ({
|
||||
sequence: index,
|
||||
lat: Number.parseFloat(point['@_lat']),
|
||||
lon: Number.parseFloat(point['@_lon']),
|
||||
elevation: point.ele == null ? null : Number.parseFloat(point.ele),
|
||||
recordedAt: point.time || null
|
||||
})).filter(point => Number.isFinite(point.lat) && Number.isFinite(point.lon));
|
||||
if (!points.length) throw new HttpError(400, 'Die GPX-Datei enthält keine verwertbaren Trackpunkte.');
|
||||
const name = tracks[0]?.name || gpx.metadata?.name || null;
|
||||
return { name, points, metrics: routeMetrics(points) };
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
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,
|
||||
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 {
|
||||
id: row.id, slug: row.slug, name: row.name, description: row.description,
|
||||
schoolName: row.school_name, status: row.status,
|
||||
start: row.start_lat == null ? null : { lat: row.start_lat, lon: row.start_lon },
|
||||
center: row.center_lat == null ? null : { lat: row.center_lat, lon: row.center_lon },
|
||||
bounds: row.min_lat == null ? null : { minLat: row.min_lat, minLon: row.min_lon, maxLat: row.max_lat, maxLon: row.max_lon },
|
||||
distanceM: row.distance_m, elevationGainM: row.elevation_gain_m, pointCount: row.point_count,
|
||||
gpxUrl: row.gpx_path ? safeMediaUrl(row.gpx_path) : null,
|
||||
createdAt: row.created_at, updatedAt: row.updated_at, deletedAt: row.deleted_at
|
||||
};
|
||||
}
|
||||
|
||||
export function listRoutes(db, { lat, lon, radiusKm = config.defaultRouteRadiusKm, includeDeleted = false }) {
|
||||
const rows = db.prepare(`SELECT * FROM routes ${includeDeleted ? '' : "WHERE status = 'active'"} ORDER BY name COLLATE NOCASE`).all();
|
||||
const origin = Number.isFinite(lat) && Number.isFinite(lon) ? { lat, lon } : null;
|
||||
return rows.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 <= radiusKm * 1000).sort((a, b) => origin ? a.proximityM - b.proximityM : a.name.localeCompare(b.name, 'de'));
|
||||
}
|
||||
|
||||
export function getRoute(db, id, includeDeleted = false) {
|
||||
const route = rowToRoute(db.prepare('SELECT * FROM routes WHERE id = ?').get(id), includeDeleted);
|
||||
if (!route) throw new HttpError(404, 'Strecke nicht gefunden.');
|
||||
const points = db.prepare('SELECT sequence, lat, lon, elevation, recorded_at AS recordedAt FROM route_points WHERE route_id = ? ORDER BY sequence').all(id);
|
||||
const pois = listPois(db, id);
|
||||
return { ...route, points, pois };
|
||||
}
|
||||
|
||||
export function createRoute(db, fields, gpxFile) {
|
||||
if (!gpxFile) throw new HttpError(400, 'Eine GPX-Datei ist erforderlich.');
|
||||
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.');
|
||||
let routeId;
|
||||
let finalGpx;
|
||||
try {
|
||||
routeId = transaction(db, () => {
|
||||
let slug = slugify(fields.slug || name);
|
||||
let suffix = 2;
|
||||
while (db.prepare('SELECT 1 FROM routes WHERE slug = ?').get(slug)) slug = `${slugify(fields.slug || name)}-${suffix++}`;
|
||||
const result = db.prepare(`INSERT INTO routes
|
||||
(slug, name, description, school_name, gpx_path, start_lat, start_lon, center_lat, center_lon, min_lat, min_lon, max_lat, max_lon, distance_m, elevation_gain_m, point_count)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
||||
.run(slug, name, fields.description || '', fields.schoolName || '', 'pending', parsed.metrics.startLat, parsed.metrics.startLon,
|
||||
parsed.metrics.centerLat, parsed.metrics.centerLon, parsed.metrics.minLat, parsed.metrics.minLon, parsed.metrics.maxLat, parsed.metrics.maxLon,
|
||||
parsed.metrics.distanceM, parsed.metrics.elevationGainM, parsed.metrics.pointCount);
|
||||
const id = Number(result.lastInsertRowid);
|
||||
const insertPoint = db.prepare('INSERT INTO route_points (route_id, sequence, lat, lon, elevation, recorded_at) VALUES (?, ?, ?, ?, ?, ?)');
|
||||
for (const point of parsed.points) insertPoint.run(id, point.sequence, point.lat, point.lon, point.elevation, point.recordedAt);
|
||||
return id;
|
||||
});
|
||||
ensureRouteDirectories(routeId);
|
||||
finalGpx = path.join(routeDirectory(routeId), 'route.gpx');
|
||||
const gpxPath = moveUploadedFile(gpxFile, finalGpx);
|
||||
db.prepare("UPDATE routes SET gpx_path = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?").run(gpxPath, routeId);
|
||||
return getRoute(db, routeId);
|
||||
} catch (error) {
|
||||
removeUpload(gpxFile);
|
||||
if (routeId) {
|
||||
db.prepare('DELETE FROM route_points WHERE route_id = ?').run(routeId);
|
||||
db.prepare('DELETE FROM routes WHERE id = ?').run(routeId);
|
||||
fs.rmSync(routeDirectory(routeId), { recursive: true, force: true });
|
||||
}
|
||||
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.');
|
||||
let parsed = null;
|
||||
if (gpxFile) parsed = parseGpx(fs.readFileSync(gpxFile.path, 'utf8'));
|
||||
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 (parsed) {
|
||||
db.prepare('DELETE FROM route_points WHERE route_id = ?').run(id);
|
||||
const insertPoint = db.prepare('INSERT INTO route_points (route_id, sequence, lat, lon, elevation, recorded_at) VALUES (?, ?, ?, ?, ?, ?)');
|
||||
for (const point of parsed.points) insertPoint.run(id, point.sequence, point.lat, point.lon, point.elevation, point.recordedAt);
|
||||
const m = parsed.metrics;
|
||||
db.prepare(`UPDATE routes SET start_lat=?, start_lon=?, center_lat=?, center_lon=?, min_lat=?, min_lon=?, max_lat=?, max_lon=?, distance_m=?, elevation_gain_m=?, point_count=? WHERE id=?`)
|
||||
.run(m.startLat, m.startLon, m.centerLat, m.centerLon, m.minLat, m.minLon, m.maxLat, m.maxLon, m.distanceM, m.elevationGainM, m.pointCount, id);
|
||||
}
|
||||
});
|
||||
if (gpxFile) moveUploadedFile(gpxFile, path.join(routeDirectory(id), 'route.gpx'));
|
||||
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 = db.prepare('SELECT lat, lon, elevation, recorded_at AS recordedAt FROM route_points WHERE route_id = ? ORDER BY sequence').all(id);
|
||||
const all = current.concat(parsed.points).map((point, sequence) => ({ ...point, sequence }));
|
||||
const m = routeMetrics(all);
|
||||
transaction(db, () => {
|
||||
const insert = db.prepare('INSERT INTO route_points (route_id, sequence, lat, lon, elevation, recorded_at) VALUES (?, ?, ?, ?, ?, ?)');
|
||||
for (let i = 0; i < parsed.points.length; i += 1) {
|
||||
const p = parsed.points[i]; insert.run(id, current.length + i, p.lat, p.lon, p.elevation, p.recordedAt);
|
||||
}
|
||||
db.prepare(`UPDATE routes SET start_lat=?, start_lon=?, center_lat=?, center_lon=?, min_lat=?, min_lon=?, max_lat=?, max_lon=?, distance_m=?, elevation_gain_m=?, point_count=?, updated_at=CURRENT_TIMESTAMP WHERE id=?`)
|
||||
.run(m.startLat, m.startLon, m.centerLat, m.centerLon, m.minLat, m.minLon, m.maxLat, m.maxLon, m.distanceM, m.elevationGainM, m.pointCount, id);
|
||||
});
|
||||
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 imageStatement = 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: safeMediaUrl(poi.audio_path),
|
||||
images: imageStatement.all(poi.id).map(image => ({ id: image.id, caption: image.caption, sequence: image.sequence, url: safeMediaUrl(image.path) }))
|
||||
}));
|
||||
}
|
||||
|
||||
export function getPoi(db, poiId) {
|
||||
const row = db.prepare(`SELECT p.route_id AS routeId FROM pois p JOIN routes r ON r.id = p.route_id WHERE p.id = ? AND r.status = 'active'`).get(poiId);
|
||||
if (!row) throw new HttpError(404, 'POI nicht gefunden.');
|
||||
return listPois(db, row.routeId).find(poi => poi.id === Number(poiId));
|
||||
}
|
||||
|
||||
export function createPoi(db, routeId, fields, files = {}) {
|
||||
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 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);
|
||||
}
|
||||
|
||||
export function updatePoi(db, poiId, fields, files = {}) {
|
||||
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.');
|
||||
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);
|
||||
}
|
||||
return listPois(db, existing.route_id).find(poi => poi.id === Number(poiId));
|
||||
}
|
||||
|
||||
export function softDeleteRoute(db, id) {
|
||||
const route = db.prepare("SELECT * 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);
|
||||
db.prepare('UPDATE pois SET audio_path = REPLACE(audio_path, ?, ?) WHERE route_id = ? AND audio_path IS NOT NULL').run(moved.sourcePrefix, moved.targetPrefix, id);
|
||||
db.prepare(`UPDATE poi_images SET path = REPLACE(path, ?, ?) WHERE poi_id IN (SELECT id FROM pois WHERE route_id = ?)`)
|
||||
.run(moved.sourcePrefix, moved.targetPrefix, id);
|
||||
});
|
||||
} 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 * 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);
|
||||
db.prepare('UPDATE pois SET audio_path = REPLACE(audio_path, ?, ?) WHERE route_id = ? AND audio_path IS NOT NULL').run(moved.sourcePrefix, moved.targetPrefix, id);
|
||||
db.prepare(`UPDATE poi_images SET path = REPLACE(path, ?, ?) WHERE poi_id IN (SELECT id FROM pois WHERE route_id = ?)`)
|
||||
.run(moved.sourcePrefix, moved.targetPrefix, id);
|
||||
});
|
||||
} catch (error) {
|
||||
fs.renameSync(moved.destination, moved.source);
|
||||
throw error;
|
||||
}
|
||||
return getRoute(db, id);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { config } from '../config.js';
|
||||
import { HttpError } from '../middleware/errors.js';
|
||||
|
||||
const activeRoot = path.join(config.storageDir, 'active', 'routes');
|
||||
const trashRoot = path.join(config.storageDir, 'trash', 'routes');
|
||||
|
||||
export const routeDirectory = id => path.join(activeRoot, String(id));
|
||||
export const relativeStoragePath = absolute => path.relative(config.storageDir, absolute).split(path.sep).join('/');
|
||||
|
||||
export function ensureRouteDirectories(id) {
|
||||
const root = routeDirectory(id);
|
||||
for (const dir of [root, path.join(root, 'images'), path.join(root, 'audio')]) fs.mkdirSync(dir, { recursive: true });
|
||||
return root;
|
||||
}
|
||||
|
||||
export function moveUploadedFile(uploaded, destination) {
|
||||
fs.mkdirSync(path.dirname(destination), { recursive: true });
|
||||
fs.renameSync(uploaded.path, destination);
|
||||
return relativeStoragePath(destination);
|
||||
}
|
||||
|
||||
export function removeUpload(uploaded) {
|
||||
if (uploaded?.path && fs.existsSync(uploaded.path)) fs.rmSync(uploaded.path, { force: 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.');
|
||||
const destination = path.join(trashRoot, `${id}-${new Date().toISOString().replace(/[:.]/g, '-')}`);
|
||||
fs.renameSync(source, destination);
|
||||
return { sourcePrefix: relativeStoragePath(source), targetPrefix: relativeStoragePath(destination), destination };
|
||||
}
|
||||
|
||||
export function restoreRouteDirectory(id, trashRelativePath) {
|
||||
const source = path.join(config.storageDir, trashRelativePath);
|
||||
const destination = routeDirectory(id);
|
||||
if (!fs.existsSync(source)) throw new HttpError(409, 'Papierkorbverzeichnis der Strecke fehlt.');
|
||||
if (fs.existsSync(destination)) throw new HttpError(409, 'Aktives Streckenverzeichnis existiert bereits.');
|
||||
fs.renameSync(source, destination);
|
||||
return { sourcePrefix: relativeStoragePath(source), targetPrefix: relativeStoragePath(destination), source, destination };
|
||||
}
|
||||
|
||||
export function safeMediaUrl(relativePath) {
|
||||
if (!relativePath || !relativePath.startsWith('active/')) return null;
|
||||
const publicPath = relativePath.slice('active/'.length);
|
||||
return `/media/${publicPath.split('/').map(encodeURIComponent).join('/')}`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user