/pictures und /audio endpoints moved under /poi

This commit is contained in:
Florian Zumpe 2026-06-17 01:14:19 +02:00
parent 833b5ad3a4
commit 5c85a0194b
9 changed files with 225 additions and 267 deletions

3
.gitignore vendored
View File

@ -7,6 +7,3 @@ storage/trash/routes/*
!storage/trash/routes/.gitkeep
.env
npm-debug.log*
nginx/
systemd/

View File

@ -129,13 +129,13 @@ Beispiele verwenden überwiegend Route `1`, POI `7` und Bild `15`.
"lon": 13.407,
"triggerRadiusM": 60,
"sequence": 2,
"audioUrl": "/api/routes/1/audio/7",
"audioUrl": "/api/routes/1/pois/7/audio",
"images": [
{
"id": 15,
"caption": "Blick auf die Baumkrone",
"sequence": 0,
"url": "/api/routes/1/pictures/15"
"url": "/api/routes/1/pois/7/pictures/15"
}
]
}
@ -151,7 +151,7 @@ Beispiele verwenden überwiegend Route `1`, POI `7` und Bild `15`.
"poiTitle": "Die alte Eiche",
"caption": "Blick auf die Baumkrone",
"sequence": 0,
"url": "/api/routes/1/pictures/15",
"url": "/api/routes/1/pois/7/pictures/15",
"createdAt": "2026-06-16 12:30:00"
}
```
@ -165,7 +165,7 @@ Pro POI kann höchstens eine Audiodatei existieren. Deshalb wird die Audioressou
"routeId": 1,
"poiId": 7,
"poiTitle": "Die alte Eiche",
"url": "/api/routes/1/audio/7",
"url": "/api/routes/1/pois/7/audio",
"updatedAt": "2026-06-16 12:35:00"
}
```
@ -187,16 +187,15 @@ Pro POI kann höchstens eine Audiodatei existieren. Deshalb wird die Audioressou
| `GET` | `/api/pois/:id` | einzelnen POI lesen |
| `POST` | `/api/routes/:id/pois` | POI-Metadaten anlegen |
| `PUT` | `/api/pois/:id` | POI-Metadaten aktualisieren |
| `GET` | `/api/routes/:id/pictures` | Bilder einer Route auflisten |
| `GET` | `/api/routes/:id/pictures/:pictureId` | Bilddatei ausliefern; optional Metadaten mit `?metadata=true` |
| `POST` | `/api/routes/:id/pictures` | einzelnes Bild hochladen |
| `PUT` | `/api/routes/:id/pictures/:pictureId` | Bilddatei oder Metadaten aktualisieren |
| `DELETE` | `/api/routes/:id/pictures/:pictureId` | einzelnes Bild löschen |
| `GET` | `/api/routes/:id/audio` | Audiodateien einer Route auflisten |
| `GET` | `/api/routes/:id/audio/:poiId` | Audiodatei ausliefern; optional Metadaten mit `?metadata=true` |
| `POST` | `/api/routes/:id/audio` | Audiodatei für einen POI anlegen |
| `PUT` | `/api/routes/:id/audio/:poiId` | Audiodatei eines POIs ersetzen |
| `DELETE` | `/api/routes/:id/audio/:poiId` | Audiodatei eines POIs löschen |
| `GET` | `/api/routes/:routeId/pois/:poiId/pictures` | Bilder eines POIs auflisten |
| `GET` | `/api/routes/:routeId/pois/:poiId/pictures/:pictureId` | Bilddatei ausliefern; optional Metadaten mit `?metadata=true` |
| `POST` | `/api/routes/:routeId/pois/:poiId/pictures` | einzelnes Bild für den POI hochladen |
| `PUT` | `/api/routes/:routeId/pois/:poiId/pictures/:pictureId` | Bilddatei oder Metadaten aktualisieren |
| `DELETE` | `/api/routes/:routeId/pois/:poiId/pictures/:pictureId` | einzelnes Bild löschen |
| `GET` | `/api/routes/:routeId/pois/:poiId/audio` | Audiodatei ausliefern; optional Metadaten mit `?metadata=true` |
| `POST` | `/api/routes/:routeId/pois/:poiId/audio` | Audiodatei für den POI anlegen |
| `PUT` | `/api/routes/:routeId/pois/:poiId/audio` | Audiodatei des POIs ersetzen |
| `DELETE` | `/api/routes/:routeId/pois/:poiId/audio` | Audiodatei des POIs löschen |
## 4. Systemzustand
@ -417,29 +416,24 @@ curl -X PUT http://127.0.0.1:47145/api/pois/7 \
}'
```
## 8. Bilder einzeln verwalten
## 8. Bilder eines POIs einzeln verwalten
### `GET /api/routes/:id/pictures`
Die Route und der POI sind Bestandteil jedes Bildpfades. Dadurch ist die Zuordnung eindeutig und beim Upload muss keine zusätzliche `poiId` übergeben werden.
Liefert alle Bilder der Route. Optional kann auf einen POI eingeschränkt werden.
#### Query-Parameter
### Gemeinsame Pfadparameter
| Parameter | Typ | Pflicht | Beschreibung |
|---|---|---:|---|
| `poiId` | positive Ganzzahl | nein | liefert nur Bilder dieses POIs |
| `routeId` | positive Ganzzahl | ja | ID der aktiven Route |
| `poiId` | positive Ganzzahl | ja | ID eines POIs, der zu dieser Route gehört |
| `pictureId` | positive Ganzzahl | nur bei Einzelressourcen | ID des Bildes, das zu diesem POI gehört |
Alle Bilder der Route:
### `GET /api/routes/:routeId/pois/:poiId/pictures`
Liefert alle Bilder des angegebenen POIs in Diashow-Reihenfolge.
```bash
curl http://127.0.0.1:47145/api/routes/1/pictures
```
Nur Bilder des POIs `7`:
```bash
curl --get http://127.0.0.1:47145/api/routes/1/pictures \
--data-urlencode 'poiId=7'
curl http://127.0.0.1:47145/api/routes/1/pois/7/pictures
```
```json
@ -452,28 +446,28 @@ curl --get http://127.0.0.1:47145/api/routes/1/pictures \
"poiTitle": "Die alte Eiche",
"caption": "Blick auf die Baumkrone",
"sequence": 0,
"url": "/api/routes/1/pictures/15",
"url": "/api/routes/1/pois/7/pictures/15",
"createdAt": "2026-06-16 12:30:00"
}
]
}
```
### `GET /api/routes/:id/pictures/:pictureId`
### `GET /api/routes/:routeId/pois/:poiId/pictures/:pictureId`
Liefert standardmäßig die Bilddatei direkt aus. Genau dieser Pfad wird im Feld `url` der Bildressource und unter `pois[].images[].url` ausgegeben.
Bild speichern:
```bash
curl http://127.0.0.1:47145/api/routes/1/pictures/15 \
curl http://127.0.0.1:47145/api/routes/1/pois/7/pictures/15 \
--output bild-15.jpg
```
Metadaten statt Dateidaten abrufen:
```bash
curl --get http://127.0.0.1:47145/api/routes/1/pictures/15 \
curl --get http://127.0.0.1:47145/api/routes/1/pois/7/pictures/15 \
--data-urlencode 'metadata=true'
```
@ -481,57 +475,54 @@ curl --get http://127.0.0.1:47145/api/routes/1/pictures/15 \
|---|---|---:|---:|---|
| `metadata` | Boolean | nein | `false` | bei `true` JSON-Metadaten statt der Bilddatei liefern |
### `POST /api/routes/:id/pictures`
Die API antwortet mit `404`, wenn Route, POI oder Bild nicht zusammengehören.
Lädt genau ein Bild hoch und ordnet es einem POI derselben Route zu.
### `POST /api/routes/:routeId/pois/:poiId/pictures`
Lädt genau ein Bild für den im Pfad angegebenen POI hoch.
Content-Type: `multipart/form-data`
| Feld | Typ | Pflicht | Standard | Beschreibung |
|---|---|---:|---:|---|
| `picture` | Datei | ja | | JPEG-, PNG- oder WebP-Datei |
| `poiId` | positive Ganzzahl | ja | | Ziel-POI derselben Route |
| `caption` | Text | nein | leer | sichtbare Bildbeschreibung und Grundlage für den Alternativtext |
| `sequence` | nichtnegative Ganzzahl | nein | nächster freier Wert des POIs | Reihenfolge in der Diashow |
```bash
curl -X POST http://127.0.0.1:47145/api/routes/1/pictures \
-F 'poiId=7' \
curl -X POST http://127.0.0.1:47145/api/routes/1/pois/7/pictures \
-F 'caption=Blick auf die Baumkrone' \
-F 'sequence=0' \
-F 'picture=@eiche.jpg;type=image/jpeg'
```
Erfolg: `201 Created` und `Location: /api/routes/1/pictures/<pictureId>`.
Erfolg: `201 Created` und `Location: /api/routes/1/pois/7/pictures/<pictureId>`.
### `PUT /api/routes/:id/pictures/:pictureId`
### `PUT /api/routes/:routeId/pois/:poiId/pictures/:pictureId`
Aktualisiert Metadaten und kann optional die Datei ersetzen. Nicht übergebene Metadaten bleiben erhalten.
Aktualisiert Metadaten und kann optional die Datei ersetzen. Nicht übergebene Metadaten bleiben erhalten. Ein Bild kann über diesen Endpunkt nicht einem anderen POI zugeordnet werden; dafür muss es beim bisherigen POI gelöscht und beim Ziel-POI neu angelegt werden.
| Feld | Typ | Pflicht | Beschreibung |
|---|---|---:|---|
| `picture` | Datei | nein | ersetzt die bisherige Bilddatei |
| `poiId` | positive Ganzzahl | nein | ordnet das Bild einem anderen POI derselben Route zu |
| `caption` | Text | nein | neue Bildbeschreibung; leerer Text entfernt die Beschreibung |
| `sequence` | nichtnegative Ganzzahl | nein | neue Position in der Diashow |
Nur Metadaten per JSON ändern:
```bash
curl -X PUT http://127.0.0.1:47145/api/routes/1/pictures/15 \
curl -X PUT http://127.0.0.1:47145/api/routes/1/pois/7/pictures/15 \
-H 'Content-Type: application/json' \
-d '{
"poiId": 7,
"caption": "Nahaufnahme der Eichenblätter",
"sequence": 1
}'
```
Datei und alle Metadaten ersetzen:
Datei und Metadaten ersetzen:
```bash
curl -X PUT http://127.0.0.1:47145/api/routes/1/pictures/15 \
-F 'poiId=7' \
curl -X PUT http://127.0.0.1:47145/api/routes/1/pois/7/pictures/15 \
-F 'caption=Neue Aufnahme der Eiche' \
-F 'sequence=2' \
-F 'picture=@eiche-neu.webp;type=image/webp'
@ -539,12 +530,12 @@ curl -X PUT http://127.0.0.1:47145/api/routes/1/pictures/15 \
Wird eine Datei ersetzt, entfernt der Server die bisherige Datei nach erfolgreicher Datenbankaktualisierung.
### `DELETE /api/routes/:id/pictures/:pictureId`
### `DELETE /api/routes/:routeId/pois/:poiId/pictures/:pictureId`
Entfernt Bilddatensatz und Datei.
```bash
curl -X DELETE http://127.0.0.1:47145/api/routes/1/pictures/15
curl -X DELETE http://127.0.0.1:47145/api/routes/1/pois/7/pictures/15
```
```json
@ -556,58 +547,32 @@ curl -X DELETE http://127.0.0.1:47145/api/routes/1/pictures/15
}
```
## 9. Audiodateien einzeln verwalten
## 9. Audiodatei eines POIs verwalten
Pro POI ist höchstens eine Audiodatei vorgesehen. Deshalb bildet die `poiId` den Schlüssel der Audioressource.
Pro POI ist höchstens eine Audiodatei vorgesehen. Daher ist `/audio` selbst die Einzelressource; eine zusätzliche Audio-ID ist nicht erforderlich.
### `GET /api/routes/:id/audio`
Liefert alle vorhandenen Audiodateien der Route. POIs ohne Audio werden nicht ausgegeben.
#### Query-Parameter
### Gemeinsame Pfadparameter
| Parameter | Typ | Pflicht | Beschreibung |
|---|---|---:|---|
| `poiId` | positive Ganzzahl | nein | schränkt die Liste auf einen POI ein |
| `routeId` | positive Ganzzahl | ja | ID der aktiven Route |
| `poiId` | positive Ganzzahl | ja | ID eines POIs, der zu dieser Route gehört |
```bash
curl http://127.0.0.1:47145/api/routes/1/audio
```
### `GET /api/routes/:routeId/pois/:poiId/audio`
```bash
curl --get http://127.0.0.1:47145/api/routes/1/audio \
--data-urlencode 'poiId=7'
```
```json
{
"audio": [
{
"routeId": 1,
"poiId": 7,
"poiTitle": "Die alte Eiche",
"url": "/api/routes/1/audio/7",
"updatedAt": "2026-06-16 12:35:00"
}
]
}
```
### `GET /api/routes/:id/audio/:poiId`
Liefert standardmäßig die Audiodatei des POIs direkt aus. Genau dieser Pfad wird in `audioUrl` und im Feld `url` einer Audioressource ausgegeben. Der Endpunkt unterstützt HTTP-Range-Anfragen, damit Browser innerhalb einer Audiodatei springen können.
Liefert standardmäßig die Audiodatei des POIs direkt aus. Genau dieser Pfad wird in `audioUrl` und im Feld `url` der Audioressource ausgegeben. Der Endpunkt unterstützt HTTP-Range-Anfragen, damit Browser innerhalb der Audiodatei springen können.
Audiodatei speichern:
```bash
curl http://127.0.0.1:47145/api/routes/1/audio/7 \
curl http://127.0.0.1:47145/api/routes/1/pois/7/audio \
--output ansage-7.mp3
```
Metadaten statt Dateidaten abrufen:
```bash
curl --get http://127.0.0.1:47145/api/routes/1/audio/7 \
curl --get http://127.0.0.1:47145/api/routes/1/pois/7/audio \
--data-urlencode 'metadata=true'
```
@ -615,30 +580,28 @@ curl --get http://127.0.0.1:47145/api/routes/1/audio/7 \
|---|---|---:|---:|---|
| `metadata` | Boolean | nein | `false` | bei `true` JSON-Metadaten statt der Audiodatei liefern |
Antwortet mit `404`, wenn der POI keine Audiodatei besitzt.
Antwortet mit `404`, wenn Route und POI nicht zusammengehören oder der POI keine Audiodatei besitzt.
### `POST /api/routes/:id/audio`
### `POST /api/routes/:routeId/pois/:poiId/audio`
Legt die Audiodatei eines POIs an.
Legt die Audiodatei des im Pfad angegebenen POIs an.
Content-Type: `multipart/form-data`
| Feld | Typ | Pflicht | Beschreibung |
|---|---|---:|---|
| `audio` | Datei | ja | MP3-, MP4/M4A-, AAC-, Ogg-, WAV- oder WebM-Datei |
| `poiId` | positive Ganzzahl | ja | POI derselben Route |
```bash
curl -X POST http://127.0.0.1:47145/api/routes/1/audio \
-F 'poiId=7' \
curl -X POST http://127.0.0.1:47145/api/routes/1/pois/7/audio \
-F 'audio=@ansage.mp3;type=audio/mpeg'
```
Erfolg: `201 Created` und `Location: /api/routes/1/audio/7`.
Erfolg: `201 Created` und `Location: /api/routes/1/pois/7/audio`.
Existiert bereits eine Audiodatei, antwortet der Server mit `409`. Zum Ersetzen ist `PUT` zu verwenden.
### `PUT /api/routes/:id/audio/:poiId`
### `PUT /api/routes/:routeId/pois/:poiId/audio`
Ersetzt die vorhandene Audiodatei des POIs.
@ -647,18 +610,18 @@ Ersetzt die vorhandene Audiodatei des POIs.
| `audio` | Datei | ja | neue Audiodatei |
```bash
curl -X PUT http://127.0.0.1:47145/api/routes/1/audio/7 \
curl -X PUT http://127.0.0.1:47145/api/routes/1/pois/7/audio \
-F 'audio=@ansage-neu.ogg;type=audio/ogg'
```
Der Server entfernt die bisherige Datei nach erfolgreicher Aktualisierung. Besitzt der POI noch keine Audiodatei, antwortet der Server mit `404`; zum erstmaligen Anlegen ist `POST` zu verwenden.
### `DELETE /api/routes/:id/audio/:poiId`
### `DELETE /api/routes/:routeId/pois/:poiId/audio`
Entfernt die Audiodatei und setzt `audioUrl` des POIs auf `null`.
```bash
curl -X DELETE http://127.0.0.1:47145/api/routes/1/audio/7
curl -X DELETE http://127.0.0.1:47145/api/routes/1/pois/7/audio
```
```json
@ -706,8 +669,8 @@ Die API veröffentlicht keine internen Speicherpfade und keine `/media/...`-Adre
```text
GET /api/routes/1/gpx
GET /api/routes/1/pictures/15
GET /api/routes/1/audio/7
GET /api/routes/1/pois/7/pictures/15
GET /api/routes/1/pois/7/audio
```
Die Zuordnung lautet:
@ -715,9 +678,9 @@ Die Zuordnung lautet:
| JSON-Feld | Datei-Endpunkt |
|---|---|
| `route.gpxUrl` | `/api/routes/:id/gpx` |
| `poi.audioUrl` | `/api/routes/:id/audio/:poiId` |
| `poi.images[].url` | `/api/routes/:id/pictures/:pictureId` |
| `picture.url` | `/api/routes/:id/pictures/:pictureId` |
| `audio.url` | `/api/routes/:id/audio/:poiId` |
| `poi.audioUrl` | `/api/routes/:routeId/pois/:poiId/audio` |
| `poi.images[].url` | `/api/routes/:routeId/pois/:poiId/pictures/:pictureId` |
| `picture.url` | `/api/routes/:routeId/pois/:poiId/pictures/:pictureId` |
| `audio.url` | `/api/routes/:routeId/pois/:poiId/audio` |
Die Dateinamen und relativen Pfade unter `storage/` bleiben ausschließlich interne Implementierungsdetails. Dateien gelöschter Routen liegen im Papierkorb und sind über keinen Datei-Endpunkt erreichbar.

4
package-lock.json generated
View File

@ -1,12 +1,12 @@
{
"name": "wegwichtel-next",
"version": "0.9.0",
"version": "0.10.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "wegwichtel-next",
"version": "0.9.0",
"version": "0.10.0",
"hasInstallScript": true,
"dependencies": {
"express": "5.2.1",

View File

@ -1,6 +1,6 @@
{
"name": "wegwichtel-next",
"version": "0.9.0",
"version": "0.10.0",
"private": true,
"description": "GPS-gestützte Lern- und Wanderwege mit GPX, POIs, Bildern und Audioansagen.",
"type": "module",

View File

@ -7,7 +7,7 @@ import {
} from '../services/routes-service.js';
import {
listPictures, getPicture, getPictureFile, createPicture, updatePicture, deletePicture,
listAudio, getAudio, getAudioFile, createAudio, updateAudio, deleteAudio
getAudio, getAudioFile, createAudio, updateAudio, deleteAudio
} from '../services/media-service.js';
const numberOrUndefined = value => {
@ -90,41 +90,52 @@ export function createApiRouter(db) {
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/:routeId/pois/:poiId/pictures', (req, res) => {
res.json({ pictures: listPictures(db, Number(req.params.routeId), Number(req.params.poiId)) });
});
api.get('/routes/:id/pictures/:pictureId', (req, res) => {
const routeId = Number(req.params.id);
api.get('/routes/:routeId/pois/:poiId/pictures/:pictureId', (req, res) => {
const routeId = Number(req.params.routeId);
const poiId = Number(req.params.poiId);
const pictureId = Number(req.params.pictureId);
if (req.query.metadata === 'true') {
res.json(getPicture(db, routeId, pictureId));
res.json(getPicture(db, routeId, poiId, pictureId));
return;
}
sendFileResource(res, getPictureFile(db, routeId, pictureId));
sendFileResource(res, getPictureFile(db, routeId, poiId, pictureId));
});
api.post('/routes/:id/pictures', upload.single('picture'), (req, res) => {
const picture = createPicture(db, Number(req.params.id), req.body, req.file);
api.post('/routes/:routeId/pois/:poiId/pictures', upload.single('picture'), (req, res) => {
const routeId = Number(req.params.routeId);
const poiId = Number(req.params.poiId);
const picture = createPicture(db, routeId, poiId, req.body, req.file);
res.status(201)
.location(`/api/routes/${req.params.id}/pictures/${picture.id}`)
.location(`/api/routes/${routeId}/pois/${poiId}/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.put('/routes/:routeId/pois/:poiId/pictures/:pictureId', upload.single('picture'), (req, res) => {
res.json(updatePicture(
db,
Number(req.params.routeId),
Number(req.params.poiId),
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.delete('/routes/:routeId/pois/:poiId/pictures/:pictureId', (req, res) => {
res.json(deletePicture(
db,
Number(req.params.routeId),
Number(req.params.poiId),
Number(req.params.pictureId)
));
});
api.get('/routes/:id/audio', (req, res) => {
res.json({ audio: listAudio(db, Number(req.params.id), { poiId: req.query.poiId }) });
});
api.get('/routes/:id/audio/:poiId', (req, res) => {
const routeId = Number(req.params.id);
api.get('/routes/:routeId/pois/:poiId/audio', (req, res) => {
const routeId = Number(req.params.routeId);
const poiId = Number(req.params.poiId);
if (req.query.metadata === 'true') {
res.json(getAudio(db, routeId, poiId));
@ -133,19 +144,21 @@ export function createApiRouter(db) {
sendFileResource(res, getAudioFile(db, routeId, poiId));
});
api.post('/routes/:id/audio', upload.single('audio'), (req, res) => {
const audio = createAudio(db, Number(req.params.id), req.body, req.file);
api.post('/routes/:routeId/pois/:poiId/audio', upload.single('audio'), (req, res) => {
const routeId = Number(req.params.routeId);
const poiId = Number(req.params.poiId);
const audio = createAudio(db, routeId, poiId, req.file);
res.status(201)
.location(`/api/routes/${req.params.id}/audio/${audio.poiId}`)
.location(`/api/routes/${routeId}/pois/${poiId}/audio`)
.json(audio);
});
api.put('/routes/:id/audio/:poiId', upload.single('audio'), (req, res) => {
res.json(updateAudio(db, Number(req.params.id), Number(req.params.poiId), req.file));
api.put('/routes/:routeId/pois/:poiId/audio', upload.single('audio'), (req, res) => {
res.json(updateAudio(db, Number(req.params.routeId), Number(req.params.poiId), req.file));
});
api.delete('/routes/:id/audio/:poiId', (req, res) => {
res.json(deleteAudio(db, Number(req.params.id), Number(req.params.poiId)));
api.delete('/routes/:routeId/pois/:poiId/audio', (req, res) => {
res.json(deleteAudio(db, Number(req.params.routeId), Number(req.params.poiId)));
});
api.delete('/routes/:id', (req, res) => {

View File

@ -24,16 +24,8 @@ 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 pictureUrl = (routeId, pictureId) => `/api/routes/${routeId}/pictures/${pictureId}`;
const audioUrl = (routeId, poiId) => `/api/routes/${routeId}/audio/${poiId}`;
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;
}
const pictureUrl = (routeId, poiId, pictureId) => `/api/routes/${routeId}/pois/${poiId}/pictures/${pictureId}`;
const audioUrl = (routeId, poiId) => `/api/routes/${routeId}/pois/${poiId}/audio`;
function optionalSequence(value, fallback) {
if (value == null || value === '') return fallback;
@ -44,15 +36,9 @@ function optionalSequence(value, fallback) {
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
SELECT p.id, p.route_id, p.title, p.audio_path, p.updated_at
FROM pois p
JOIN routes r ON r.id = p.route_id
WHERE p.id = ? AND p.route_id = ? AND r.status = 'active'
@ -85,7 +71,7 @@ function pictureFromRow(row) {
poiTitle: row.poi_title,
caption: row.caption,
sequence: row.sequence,
url: pictureUrl(row.route_id, row.id),
url: pictureUrl(row.route_id, row.poi_id, row.id),
createdAt: row.created_at
};
}
@ -98,40 +84,42 @@ const pictureSelect = `
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 listPictures(db, routeId, poiId) {
poiOnRoute(db, routeId, poiId);
return db.prepare(`${pictureSelect}
WHERE p.route_id = ? AND p.id = ? AND r.status = 'active'
ORDER BY i.sequence, i.id`)
.all(routeId, poiId)
.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);
function pictureRow(db, routeId, poiId, pictureId) {
poiOnRoute(db, routeId, poiId);
const row = db.prepare(`${pictureSelect}
WHERE p.route_id = ? AND p.id = ? AND i.id = ? AND r.status = 'active'`)
.get(routeId, poiId, pictureId);
if (!row) throw new HttpError(404, 'Bild für diesen POI nicht gefunden.');
return row;
}
export function getPictureFile(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.');
export function getPicture(db, routeId, poiId, pictureId) {
return pictureFromRow(pictureRow(db, routeId, poiId, pictureId));
}
export function getPictureFile(db, routeId, poiId, pictureId) {
const row = pictureRow(db, routeId, poiId, pictureId);
return {
absolutePath: resolveStoredFile(row.path),
filename: path.basename(row.path)
};
}
export function createPicture(db, routeId, fields, file) {
export function createPicture(db, routeId, poiId, fields, file) {
let relativePath;
try {
activeRoute(db, routeId);
poiOnRoute(db, routeId, poiId);
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 ?? '');
@ -140,7 +128,7 @@ export function createPicture(db, routeId, fields, file) {
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));
return getPicture(db, routeId, poiId, Number(result.lastInsertRowid));
} catch (error) {
if (relativePath) removeStoredFile(relativePath);
else removeUpload(file);
@ -148,17 +136,13 @@ export function createPicture(db, routeId, fields, file) {
}
}
export function updatePicture(db, routeId, pictureId, fields, file) {
export function updatePicture(db, routeId, poiId, 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);
const existing = getPicture(db, routeId, poiId, pictureId);
const row = pictureRow(db, routeId, poiId, 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);
@ -169,11 +153,11 @@ export function updatePicture(db, routeId, pictureId, fields, file) {
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);
db.prepare('UPDATE poi_images SET path = ?, caption = ?, sequence = ? WHERE id = ? AND poi_id = ?')
.run(newPath, caption, sequence, pictureId, poiId);
if (file && newPath !== oldPath) removeStoredFile(oldPath);
return getPicture(db, routeId, pictureId);
return getPicture(db, routeId, poiId, pictureId);
} catch (error) {
if (file) {
if (newPath && newPath !== oldPath) removeStoredFile(newPath);
@ -183,56 +167,36 @@ export function updatePicture(db, routeId, pictureId, fields, file) {
}
}
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);
export function deletePicture(db, routeId, poiId, pictureId) {
const picture = getPicture(db, routeId, poiId, pictureId);
const row = pictureRow(db, routeId, poiId, pictureId);
db.prepare('DELETE FROM poi_images WHERE id = ? AND poi_id = ?').run(pictureId, poiId);
removeStoredFile(row.path);
return { id: picture.id, routeId: picture.routeId, poiId: picture.poiId, deleted: true };
}
function audioFromRow(row) {
function audioFromPoi(poi) {
return {
routeId: row.route_id,
poiId: row.poi_id,
poiTitle: row.poi_title,
url: audioUrl(row.route_id, row.poi_id),
updatedAt: row.updated_at
routeId: poi.route_id,
poiId: poi.id,
poiTitle: poi.title,
url: audioUrl(poi.route_id, poi.id),
updatedAt: poi.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);
const poi = poiOnRoute(db, routeId, poiId);
if (!poi.audio_path) throw new HttpError(404, 'Audiodatei für diesen POI nicht gefunden.');
return audioFromPoi(poi);
}
export function getAudioFile(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.');
const poi = poiOnRoute(db, routeId, poiId);
if (!poi.audio_path) throw new HttpError(404, 'Audiodatei für diesen POI nicht gefunden.');
return {
absolutePath: resolveStoredFile(row.audio_path),
filename: path.basename(row.audio_path)
absolutePath: resolveStoredFile(poi.audio_path),
filename: path.basename(poi.audio_path)
};
}
@ -240,7 +204,6 @@ 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;
@ -254,8 +217,8 @@ function storeAudio(db, routeId, poiId, file, { requireAbsent, requireExisting }
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);
db.prepare('UPDATE pois SET audio_path = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND route_id = ?')
.run(relativePath, poiId, routeId);
if (oldPath && oldPath !== relativePath) removeStoredFile(oldPath);
return getAudio(db, routeId, poiId);
@ -266,8 +229,7 @@ function storeAudio(db, routeId, poiId, file, { requireAbsent, requireExisting }
}
}
export function createAudio(db, routeId, fields, file) {
const poiId = requirePositiveInteger(fields.poiId, 'poiId');
export function createAudio(db, routeId, poiId, file) {
return storeAudio(db, routeId, poiId, file, { requireAbsent: true, requireExisting: false });
}
@ -278,7 +240,8 @@ export function updateAudio(db, routeId, poiId, file) {
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);
db.prepare('UPDATE pois SET audio_path = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND route_id = ?')
.run(poiId, routeId);
removeStoredFile(poi.audio_path);
return { routeId: audio.routeId, poiId: audio.poiId, deleted: true };
}

View File

@ -12,8 +12,8 @@ import {
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 routeGpxUrl = routeId => `/api/routes/${routeId}/gpx`;
const pictureUrl = (routeId, pictureId) => `/api/routes/${routeId}/pictures/${pictureId}`;
const audioUrl = (routeId, poiId) => `/api/routes/${routeId}/audio/${poiId}`;
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) {
if (!row || (!includeDeleted && row.status !== 'active')) return null;
return {
@ -146,7 +146,7 @@ export function listPois(db, routeId) {
id: image.id,
caption: image.caption,
sequence: image.sequence,
url: pictureUrl(poi.route_id, image.id)
url: pictureUrl(poi.route_id, poi.id, image.id)
}))
}));
}

View File

@ -26,16 +26,15 @@ test('complete REST API documentation is kept outside the README', async () => {
'POST /api/routes/:id/append',
'POST /api/routes/:id/pois',
'PUT /api/pois/:id',
'GET /api/routes/:id/pictures',
'GET /api/routes/:id/pictures/:pictureId',
'POST /api/routes/:id/pictures',
'PUT /api/routes/:id/pictures/:pictureId',
'DELETE /api/routes/:id/pictures/:pictureId',
'GET /api/routes/:id/audio',
'GET /api/routes/:id/audio/:poiId',
'POST /api/routes/:id/audio',
'PUT /api/routes/:id/audio/:poiId',
'DELETE /api/routes/:id/audio/:poiId',
'GET /api/routes/:routeId/pois/:poiId/pictures',
'GET /api/routes/:routeId/pois/:poiId/pictures/:pictureId',
'POST /api/routes/:routeId/pois/:poiId/pictures',
'PUT /api/routes/:routeId/pois/:poiId/pictures/:pictureId',
'DELETE /api/routes/:routeId/pois/:poiId/pictures/:pictureId',
'GET /api/routes/:routeId/pois/:poiId/audio',
'POST /api/routes/:routeId/pois/:poiId/audio',
'PUT /api/routes/:routeId/pois/:poiId/audio',
'DELETE /api/routes/:routeId/pois/:poiId/audio',
'DELETE /api/routes/:id',
'POST /api/routes/:id/restore'
]) {
@ -51,21 +50,24 @@ test('complete REST API documentation is kept outside the README', async () => {
}
});
test('POI media uploads use dedicated single-resource endpoints', async () => {
test('POI media uploads use POI-scoped single-resource endpoints', async () => {
const router = await read('src/routes/api.js');
const routesService = await read('src/services/routes-service.js');
const mediaService = await read('src/services/media-service.js');
assert.match(router, /post\('\/routes\/:id\/pictures', upload\.single\('picture'\)/);
assert.match(router, /put\('\/routes\/:id\/pictures\/:pictureId', upload\.single\('picture'\)/);
assert.match(router, /delete\('\/routes\/:id\/pictures\/:pictureId'/);
assert.match(router, /post\('\/routes\/:id\/audio', upload\.single\('audio'\)/);
assert.match(router, /put\('\/routes\/:id\/audio\/:poiId', upload\.single\('audio'\)/);
assert.match(router, /delete\('\/routes\/:id\/audio\/:poiId'/);
assert.match(router, /post\('\/routes\/:id\/pois', upload\.none\(\)/);
assert.match(router, /post\('\/routes\/:routeId\/pois\/:poiId\/pictures', upload\.single\('picture'\)/);
assert.match(router, /put\('\/routes\/:routeId\/pois\/:poiId\/pictures\/:pictureId', upload\.single\('picture'\)/);
assert.match(router, /delete\('\/routes\/:routeId\/pois\/:poiId\/pictures\/:pictureId'/);
assert.match(router, /get\('\/routes\/:routeId\/pois\/:poiId\/audio'/);
assert.match(router, /post\('\/routes\/:routeId\/pois\/:poiId\/audio', upload\.single\('audio'\)/);
assert.match(router, /put\('\/routes\/:routeId\/pois\/:poiId\/audio', upload\.single\('audio'\)/);
assert.match(router, /delete\('\/routes\/:routeId\/pois\/:poiId\/audio'/);
assert.doesNotMatch(router, /\/routes\/:id\/(?:pictures|audio)/);
assert.match(router, /post\('\/routes\/:id\/pois', upload\.none\(\),/);
assert.doesNotMatch(routesService, /files\.images|files\.audio/);
assert.match(mediaService, /caption/);
assert.match(mediaService, /removeStoredFile/);
assert.doesNotMatch(mediaService, /fields\.poiId/);
assert.match(router, /get\('\/routes\/:id\/gpx'/);
assert.match(router, /req\.query\.metadata === 'true'/);
assert.doesNotMatch(router, /['"`]\/media\//);

View File

@ -102,32 +102,50 @@ test('pictures and audio are managed as individual REST resources', { timeout: 3
})
}, 201);
const { body: otherPoi } = await requestJson(`${baseUrl}/api/routes/${route.id}/pois`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: 'Andere Teststation',
lat: route.start.lat + 0.0001,
lon: route.start.lon + 0.0001,
triggerRadiusM: 40,
sequence: 1
})
}, 201);
const pictureForm = new FormData();
pictureForm.append('poiId', String(poi.id));
pictureForm.append('poiId', String(otherPoi.id));
pictureForm.append('caption', 'Erste Bildbeschreibung');
pictureForm.append('sequence', '0');
pictureForm.append('picture', new Blob(['picture-one'], { type: 'image/png' }), 'bild.png');
const { response: pictureResponse, body: picture } = await requestJson(
`${baseUrl}/api/routes/${route.id}/pictures`,
`${baseUrl}/api/routes/${route.id}/pois/${poi.id}/pictures`,
{ method: 'POST', body: pictureForm },
201
);
assert.equal(pictureResponse.headers.get('location'), `/api/routes/${route.id}/pictures/${picture.id}`);
assert.equal(pictureResponse.headers.get('location'), `/api/routes/${route.id}/pois/${poi.id}/pictures/${picture.id}`);
assert.equal(picture.poiId, poi.id);
assert.equal(picture.caption, 'Erste Bildbeschreibung');
assert.equal(picture.url, `/api/routes/${route.id}/pictures/${picture.id}`);
assert.equal(picture.url, `/api/routes/${route.id}/pois/${poi.id}/pictures/${picture.id}`);
const { response: pictureFileResponse, body: pictureFile } = await requestBuffer(`${baseUrl}${picture.url}`);
assert.match(pictureFileResponse.headers.get('content-type') || '', /image\/png/);
assert.equal(pictureFile.toString(), 'picture-one');
const { body: pictureMetadata } = await requestJson(`${baseUrl}${picture.url}?metadata=true`);
assert.equal(pictureMetadata.caption, 'Erste Bildbeschreibung');
assert.equal(pictureMetadata.poiId, poi.id);
await requestJson(
`${baseUrl}/api/routes/${route.id}/pois/${otherPoi.id}/pictures/${picture.id}?metadata=true`,
{},
404
);
const { body: pictureList } = await requestJson(`${baseUrl}/api/routes/${route.id}/pictures?poiId=${poi.id}`);
const { body: pictureList } = await requestJson(`${baseUrl}/api/routes/${route.id}/pois/${poi.id}/pictures`);
assert.equal(pictureList.pictures.length, 1);
assert.equal(pictureList.pictures[0].id, picture.id);
const { body: updatedPicture } = await requestJson(
`${baseUrl}/api/routes/${route.id}/pictures/${picture.id}`,
`${baseUrl}/api/routes/${route.id}/pois/${poi.id}/pictures/${picture.id}`,
{
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
@ -138,16 +156,15 @@ test('pictures and audio are managed as individual REST resources', { timeout: 3
assert.equal(updatedPicture.sequence, 2);
const audioForm = new FormData();
audioForm.append('poiId', String(poi.id));
audioForm.append('audio', new Blob(['audio-one'], { type: 'audio/mpeg' }), 'ansage.mp3');
const { response: audioResponse, body: audio } = await requestJson(
`${baseUrl}/api/routes/${route.id}/audio`,
`${baseUrl}/api/routes/${route.id}/pois/${poi.id}/audio`,
{ method: 'POST', body: audioForm },
201
);
assert.equal(audioResponse.headers.get('location'), `/api/routes/${route.id}/audio/${poi.id}`);
assert.equal(audioResponse.headers.get('location'), `/api/routes/${route.id}/pois/${poi.id}/audio`);
assert.equal(audio.poiId, poi.id);
assert.equal(audio.url, `/api/routes/${route.id}/audio/${poi.id}`);
assert.equal(audio.url, `/api/routes/${route.id}/pois/${poi.id}/audio`);
const { response: audioFileResponse, body: audioFile } = await requestBuffer(`${baseUrl}${audio.url}`);
assert.match(audioFileResponse.headers.get('content-type') || '', /audio\/mpeg/);
assert.equal(audioFile.toString(), 'audio-one');
@ -155,9 +172,8 @@ test('pictures and audio are managed as individual REST resources', { timeout: 3
assert.equal(audioMetadata.poiId, poi.id);
const duplicateAudio = new FormData();
duplicateAudio.append('poiId', String(poi.id));
duplicateAudio.append('audio', new Blob(['duplicate'], { type: 'audio/mpeg' }), 'doppelt.mp3');
await requestJson(`${baseUrl}/api/routes/${route.id}/audio`, {
await requestJson(`${baseUrl}/api/routes/${route.id}/pois/${poi.id}/audio`, {
method: 'POST',
body: duplicateAudio
}, 409);
@ -165,10 +181,10 @@ test('pictures and audio are managed as individual REST resources', { timeout: 3
const replacementAudio = new FormData();
replacementAudio.append('audio', new Blob(['audio-two'], { type: 'audio/ogg' }), 'ansage-neu.ogg');
const { body: replacedAudio } = await requestJson(
`${baseUrl}/api/routes/${route.id}/audio/${poi.id}`,
`${baseUrl}/api/routes/${route.id}/pois/${poi.id}/audio`,
{ method: 'PUT', body: replacementAudio }
);
assert.equal(replacedAudio.url, `/api/routes/${route.id}/audio/${poi.id}`);
assert.equal(replacedAudio.url, `/api/routes/${route.id}/pois/${poi.id}/audio`);
const { response: replacedAudioResponse, body: replacedAudioFile } = await requestBuffer(`${baseUrl}${replacedAudio.url}`);
assert.match(replacedAudioResponse.headers.get('content-type') || '', /audio\/ogg/);
assert.equal(replacedAudioFile.toString(), 'audio-two');
@ -176,24 +192,28 @@ test('pictures and audio are managed as individual REST resources', { timeout: 3
const { body: routeWithMedia } = await requestJson(`${baseUrl}/api/routes/${route.id}`);
assert.equal(routeWithMedia.pois[0].images[0].caption, 'Aktualisierte Bildbeschreibung');
assert.equal(routeWithMedia.gpxUrl, `/api/routes/${route.id}/gpx`);
assert.equal(routeWithMedia.pois[0].audioUrl, `/api/routes/${route.id}/audio/${poi.id}`);
assert.equal(routeWithMedia.pois[0].images[0].url, `/api/routes/${route.id}/pictures/${picture.id}`);
assert.equal(routeWithMedia.pois[0].audioUrl, `/api/routes/${route.id}/pois/${poi.id}/audio`);
assert.equal(routeWithMedia.pois[0].images[0].url, `/api/routes/${route.id}/pois/${poi.id}/pictures/${picture.id}`);
assert.doesNotMatch(JSON.stringify(routeWithMedia), /\/media\//);
const legacyMediaResponse = await fetch(`${baseUrl}/media/routes/${route.id}/route.gpx`);
assert.equal(legacyMediaResponse.status, 404);
const legacyPictureEndpoint = await fetch(`${baseUrl}/api/routes/${route.id}/pictures`);
assert.equal(legacyPictureEndpoint.status, 404);
const legacyAudioEndpoint = await fetch(`${baseUrl}/api/routes/${route.id}/audio`);
assert.equal(legacyAudioEndpoint.status, 404);
const { body: deletedAudio } = await requestJson(
`${baseUrl}/api/routes/${route.id}/audio/${poi.id}`,
`${baseUrl}/api/routes/${route.id}/pois/${poi.id}/audio`,
{ method: 'DELETE' }
);
assert.equal(deletedAudio.deleted, true);
const { body: deletedPicture } = await requestJson(
`${baseUrl}/api/routes/${route.id}/pictures/${picture.id}`,
`${baseUrl}/api/routes/${route.id}/pois/${poi.id}/pictures/${picture.id}`,
{ method: 'DELETE' }
);
assert.equal(deletedPicture.deleted, true);
await requestJson(`${baseUrl}/api/routes/${route.id}/pictures/${picture.id}`, {}, 404);
await requestJson(`${baseUrl}/api/routes/${route.id}/audio/${poi.id}`, {}, 404);
await requestJson(`${baseUrl}/api/routes/${route.id}/pois/${poi.id}/pictures/${picture.id}`, {}, 404);
await requestJson(`${baseUrl}/api/routes/${route.id}/pois/${poi.id}/audio`, {}, 404);
});