update editor and add tests

This commit is contained in:
Florian Zumpe
2026-09-26 20:40:05 +02:00
parent bdc0aabc07
commit 96b379d8d6
14 changed files with 2099 additions and 587 deletions
+3 -1
View File
@@ -51,11 +51,13 @@ export function createApiRouter(db) {
});
api.get('/routes', (req, res) => {
const includeDeleted = req.query.includeDeleted === 'true';
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'
includeDeleted,
publishedOnly: req.query.activatedOnly === 'true'
}) });
});
+9 -26
View File
@@ -24,10 +24,8 @@ const UPDATE_METRICS_SQL = `UPDATE routes SET
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);
}
@@ -45,7 +43,6 @@ const audioUrl = (routeId, poiId) => `/api/routes/${routeId}/pois/${poiId}/audio
function rowToRoute(row, includeDeleted = false) {
if (!row || (!includeDeleted && row.status !== 'active')) return null;
return {
id: row.id,
slug: row.slug,
@@ -53,6 +50,7 @@ function rowToRoute(row, includeDeleted = false) {
description: row.description,
schoolName: row.school_name,
status: row.status,
activatedAt: row.activated_at,
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
@@ -101,19 +99,16 @@ 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 route_points WHERE route_id = ?').run(routeId);
db.prepare('DELETE FROM routes WHERE id = ?').run(routeId);
fs.rmSync(routeDirectory(routeId), { recursive: true, force: true });
@@ -131,13 +126,18 @@ export function listRoutes(db, {
lat,
lon,
radiusKm = config.defaultRouteRadiusKm,
includeDeleted = false
includeDeleted = false,
publishedOnly = false
}) {
const where = includeDeleted ? '' : "WHERE status = 'active'";
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
.map(row => {
const route = rowToRoute(row, includeDeleted);
@@ -161,7 +161,6 @@ export function getRoute(db, id, includeDeleted = false) {
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`
@@ -170,13 +169,11 @@ export function getRouteGpxFile(db, id) {
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;
@@ -205,7 +202,6 @@ export function createRoute(db, fields, gpxFile) {
insertRoutePoints(db, id, parsed.points);
return id;
});
ensureRouteDirectories(routeId);
const gpxPath = moveUploadedFile(gpxFile, path.join(routeDirectory(routeId), 'route.gpx'));
db.prepare('UPDATE routes SET gpx_path = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?')
@@ -220,7 +216,6 @@ export function createRoute(db, fields, gpxFile) {
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
@@ -230,7 +225,6 @@ export function updateRoute(db, id, fields, gpxFile) {
fields.schoolName ?? existing.school_name,
id
);
if (!parsed) return;
db.prepare('DELETE FROM route_points WHERE route_id = ?').run(id);
insertRoutePoints(db, id, parsed.points);
@@ -243,7 +237,6 @@ export function updateRoute(db, id, fields, gpxFile) {
export function appendRoute(db, id, gpxFile) {
if (!gpxFile) throw new HttpError(400, 'Eine GPX-Datei ist erforderlich.');
const existing = db.prepare("SELECT id FROM routes WHERE id = ? AND status = 'active'").get(id);
if (!existing) throw new HttpError(404, 'Strecke nicht gefunden.');
@@ -251,7 +244,6 @@ export function appendRoute(db, id, gpxFile) {
const current = db.prepare(`SELECT lat, lon, elevation, recorded_at AS recordedAt
FROM route_points WHERE route_id = ? ORDER BY sequence`).all(id);
const allPoints = current.concat(parsed.points).map((point, sequence) => ({ ...point, sequence }));
transaction(db, () => {
insertRoutePoints(db, id, parsed.points, current.length);
updateMetrics(db, id, routeMetrics(allPoints));
@@ -265,7 +257,6 @@ 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,
@@ -301,7 +292,6 @@ export function createPoi(db, routeId, fields) {
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,
@@ -319,7 +309,6 @@ 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 = 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
@@ -329,7 +318,6 @@ export function updatePoi(db, routeId, poiId, fields) {
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,
@@ -347,7 +335,6 @@ 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);
@@ -362,7 +349,6 @@ export function deletePoi(db, routeId, poiId) {
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, () => {
@@ -375,14 +361,12 @@ export function softDeleteRoute(db, id) {
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, () => {
@@ -395,6 +379,5 @@ export function restoreRoute(db, id) {
fs.renameSync(moved.destination, moved.source);
throw error;
}
return getRoute(db, id);
}