/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 !storage/trash/routes/.gitkeep
.env .env
npm-debug.log* npm-debug.log*
nginx/
systemd/

View File

@ -129,13 +129,13 @@ Beispiele verwenden überwiegend Route `1`, POI `7` und Bild `15`.
"lon": 13.407, "lon": 13.407,
"triggerRadiusM": 60, "triggerRadiusM": 60,
"sequence": 2, "sequence": 2,
"audioUrl": "/api/routes/1/audio/7", "audioUrl": "/api/routes/1/pois/7/audio",
"images": [ "images": [
{ {
"id": 15, "id": 15,
"caption": "Blick auf die Baumkrone", "caption": "Blick auf die Baumkrone",
"sequence": 0, "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", "poiTitle": "Die alte Eiche",
"caption": "Blick auf die Baumkrone", "caption": "Blick auf die Baumkrone",
"sequence": 0, "sequence": 0,
"url": "/api/routes/1/pictures/15", "url": "/api/routes/1/pois/7/pictures/15",
"createdAt": "2026-06-16 12:30:00" "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, "routeId": 1,
"poiId": 7, "poiId": 7,
"poiTitle": "Die alte Eiche", "poiTitle": "Die alte Eiche",
"url": "/api/routes/1/audio/7", "url": "/api/routes/1/pois/7/audio",
"updatedAt": "2026-06-16 12:35:00" "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 | | `GET` | `/api/pois/:id` | einzelnen POI lesen |
| `POST` | `/api/routes/:id/pois` | POI-Metadaten anlegen | | `POST` | `/api/routes/:id/pois` | POI-Metadaten anlegen |
| `PUT` | `/api/pois/:id` | POI-Metadaten aktualisieren | | `PUT` | `/api/pois/:id` | POI-Metadaten aktualisieren |
| `GET` | `/api/routes/:id/pictures` | Bilder einer Route auflisten | | `GET` | `/api/routes/:routeId/pois/:poiId/pictures` | Bilder eines POIs auflisten |
| `GET` | `/api/routes/:id/pictures/:pictureId` | Bilddatei ausliefern; optional Metadaten mit `?metadata=true` | | `GET` | `/api/routes/:routeId/pois/:poiId/pictures/:pictureId` | Bilddatei ausliefern; optional Metadaten mit `?metadata=true` |
| `POST` | `/api/routes/:id/pictures` | einzelnes Bild hochladen | | `POST` | `/api/routes/:routeId/pois/:poiId/pictures` | einzelnes Bild für den POI hochladen |
| `PUT` | `/api/routes/:id/pictures/:pictureId` | Bilddatei oder Metadaten aktualisieren | | `PUT` | `/api/routes/:routeId/pois/:poiId/pictures/:pictureId` | Bilddatei oder Metadaten aktualisieren |
| `DELETE` | `/api/routes/:id/pictures/:pictureId` | einzelnes Bild löschen | | `DELETE` | `/api/routes/:routeId/pois/:poiId/pictures/:pictureId` | einzelnes Bild löschen |
| `GET` | `/api/routes/:id/audio` | Audiodateien einer Route auflisten | | `GET` | `/api/routes/:routeId/pois/:poiId/audio` | Audiodatei ausliefern; optional Metadaten mit `?metadata=true` |
| `GET` | `/api/routes/:id/audio/:poiId` | Audiodatei ausliefern; optional Metadaten mit `?metadata=true` | | `POST` | `/api/routes/:routeId/pois/:poiId/audio` | Audiodatei für den POI anlegen |
| `POST` | `/api/routes/:id/audio` | Audiodatei für einen POI anlegen | | `PUT` | `/api/routes/:routeId/pois/:poiId/audio` | Audiodatei des POIs ersetzen |
| `PUT` | `/api/routes/:id/audio/:poiId` | Audiodatei eines POIs ersetzen | | `DELETE` | `/api/routes/:routeId/pois/:poiId/audio` | Audiodatei des POIs löschen |
| `DELETE` | `/api/routes/:id/audio/:poiId` | Audiodatei eines POIs löschen |
## 4. Systemzustand ## 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. ### Gemeinsame Pfadparameter
#### Query-Parameter
| Parameter | Typ | Pflicht | Beschreibung | | 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 ```bash
curl http://127.0.0.1:47145/api/routes/1/pictures curl http://127.0.0.1:47145/api/routes/1/pois/7/pictures
```
Nur Bilder des POIs `7`:
```bash
curl --get http://127.0.0.1:47145/api/routes/1/pictures \
--data-urlencode 'poiId=7'
``` ```
```json ```json
@ -452,28 +446,28 @@ curl --get http://127.0.0.1:47145/api/routes/1/pictures \
"poiTitle": "Die alte Eiche", "poiTitle": "Die alte Eiche",
"caption": "Blick auf die Baumkrone", "caption": "Blick auf die Baumkrone",
"sequence": 0, "sequence": 0,
"url": "/api/routes/1/pictures/15", "url": "/api/routes/1/pois/7/pictures/15",
"createdAt": "2026-06-16 12:30:00" "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. Liefert standardmäßig die Bilddatei direkt aus. Genau dieser Pfad wird im Feld `url` der Bildressource und unter `pois[].images[].url` ausgegeben.
Bild speichern: Bild speichern:
```bash ```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 --output bild-15.jpg
``` ```
Metadaten statt Dateidaten abrufen: Metadaten statt Dateidaten abrufen:
```bash ```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' --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 | | `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` Content-Type: `multipart/form-data`
| Feld | Typ | Pflicht | Standard | Beschreibung | | Feld | Typ | Pflicht | Standard | Beschreibung |
|---|---|---:|---:|---| |---|---|---:|---:|---|
| `picture` | Datei | ja | | JPEG-, PNG- oder WebP-Datei | | `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 | | `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 | | `sequence` | nichtnegative Ganzzahl | nein | nächster freier Wert des POIs | Reihenfolge in der Diashow |
```bash ```bash
curl -X POST http://127.0.0.1:47145/api/routes/1/pictures \ curl -X POST http://127.0.0.1:47145/api/routes/1/pois/7/pictures \
-F 'poiId=7' \
-F 'caption=Blick auf die Baumkrone' \ -F 'caption=Blick auf die Baumkrone' \
-F 'sequence=0' \ -F 'sequence=0' \
-F 'picture=@eiche.jpg;type=image/jpeg' -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 | | Feld | Typ | Pflicht | Beschreibung |
|---|---|---:|---| |---|---|---:|---|
| `picture` | Datei | nein | ersetzt die bisherige Bilddatei | | `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 | | `caption` | Text | nein | neue Bildbeschreibung; leerer Text entfernt die Beschreibung |
| `sequence` | nichtnegative Ganzzahl | nein | neue Position in der Diashow | | `sequence` | nichtnegative Ganzzahl | nein | neue Position in der Diashow |
Nur Metadaten per JSON ändern: Nur Metadaten per JSON ändern:
```bash ```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' \ -H 'Content-Type: application/json' \
-d '{ -d '{
"poiId": 7,
"caption": "Nahaufnahme der Eichenblätter", "caption": "Nahaufnahme der Eichenblätter",
"sequence": 1 "sequence": 1
}' }'
``` ```
Datei und alle Metadaten ersetzen: Datei und Metadaten ersetzen:
```bash ```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 \
-F 'poiId=7' \
-F 'caption=Neue Aufnahme der Eiche' \ -F 'caption=Neue Aufnahme der Eiche' \
-F 'sequence=2' \ -F 'sequence=2' \
-F 'picture=@eiche-neu.webp;type=image/webp' -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. 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. Entfernt Bilddatensatz und Datei.
```bash ```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 ```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` ### Gemeinsame Pfadparameter
Liefert alle vorhandenen Audiodateien der Route. POIs ohne Audio werden nicht ausgegeben.
#### Query-Parameter
| Parameter | Typ | Pflicht | Beschreibung | | 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 ### `GET /api/routes/:routeId/pois/:poiId/audio`
curl http://127.0.0.1:47145/api/routes/1/audio
```
```bash 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.
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.
Audiodatei speichern: Audiodatei speichern:
```bash ```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 --output ansage-7.mp3
``` ```
Metadaten statt Dateidaten abrufen: Metadaten statt Dateidaten abrufen:
```bash ```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' --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 | | `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` Content-Type: `multipart/form-data`
| Feld | Typ | Pflicht | Beschreibung | | Feld | Typ | Pflicht | Beschreibung |
|---|---|---:|---| |---|---|---:|---|
| `audio` | Datei | ja | MP3-, MP4/M4A-, AAC-, Ogg-, WAV- oder WebM-Datei | | `audio` | Datei | ja | MP3-, MP4/M4A-, AAC-, Ogg-, WAV- oder WebM-Datei |
| `poiId` | positive Ganzzahl | ja | POI derselben Route |
```bash ```bash
curl -X POST http://127.0.0.1:47145/api/routes/1/audio \ curl -X POST http://127.0.0.1:47145/api/routes/1/pois/7/audio \
-F 'poiId=7' \
-F 'audio=@ansage.mp3;type=audio/mpeg' -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. 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. Ersetzt die vorhandene Audiodatei des POIs.
@ -647,18 +610,18 @@ Ersetzt die vorhandene Audiodatei des POIs.
| `audio` | Datei | ja | neue Audiodatei | | `audio` | Datei | ja | neue Audiodatei |
```bash ```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' -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. 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`. Entfernt die Audiodatei und setzt `audioUrl` des POIs auf `null`.
```bash ```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 ```json
@ -706,8 +669,8 @@ Die API veröffentlicht keine internen Speicherpfade und keine `/media/...`-Adre
```text ```text
GET /api/routes/1/gpx GET /api/routes/1/gpx
GET /api/routes/1/pictures/15 GET /api/routes/1/pois/7/pictures/15
GET /api/routes/1/audio/7 GET /api/routes/1/pois/7/audio
``` ```
Die Zuordnung lautet: Die Zuordnung lautet:
@ -715,9 +678,9 @@ Die Zuordnung lautet:
| JSON-Feld | Datei-Endpunkt | | JSON-Feld | Datei-Endpunkt |
|---|---| |---|---|
| `route.gpxUrl` | `/api/routes/:id/gpx` | | `route.gpxUrl` | `/api/routes/:id/gpx` |
| `poi.audioUrl` | `/api/routes/:id/audio/:poiId` | | `poi.audioUrl` | `/api/routes/:routeId/pois/:poiId/audio` |
| `poi.images[].url` | `/api/routes/:id/pictures/:pictureId` | | `poi.images[].url` | `/api/routes/:routeId/pois/:poiId/pictures/:pictureId` |
| `picture.url` | `/api/routes/:id/pictures/:pictureId` | | `picture.url` | `/api/routes/:routeId/pois/:poiId/pictures/:pictureId` |
| `audio.url` | `/api/routes/:id/audio/:poiId` | | `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. 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", "name": "wegwichtel-next",
"version": "0.9.0", "version": "0.10.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "wegwichtel-next", "name": "wegwichtel-next",
"version": "0.9.0", "version": "0.10.0",
"hasInstallScript": true, "hasInstallScript": true,
"dependencies": { "dependencies": {
"express": "5.2.1", "express": "5.2.1",

View File

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

View File

@ -7,7 +7,7 @@ import {
} from '../services/routes-service.js'; } from '../services/routes-service.js';
import { import {
listPictures, getPicture, getPictureFile, createPicture, updatePicture, deletePicture, listPictures, getPicture, getPictureFile, createPicture, updatePicture, deletePicture,
listAudio, getAudio, getAudioFile, createAudio, updateAudio, deleteAudio getAudio, getAudioFile, createAudio, updateAudio, deleteAudio
} from '../services/media-service.js'; } from '../services/media-service.js';
const numberOrUndefined = value => { const numberOrUndefined = value => {
@ -90,41 +90,52 @@ export function createApiRouter(db) {
res.json(updatePoi(db, Number(req.params.id), req.body)); res.json(updatePoi(db, Number(req.params.id), req.body));
}); });
api.get('/routes/:id/pictures', (req, res) => { api.get('/routes/:routeId/pois/:poiId/pictures', (req, res) => {
res.json({ pictures: listPictures(db, Number(req.params.id), { poiId: req.query.poiId }) }); res.json({ pictures: listPictures(db, Number(req.params.routeId), Number(req.params.poiId)) });
}); });
api.get('/routes/:id/pictures/:pictureId', (req, res) => { api.get('/routes/:routeId/pois/:poiId/pictures/:pictureId', (req, res) => {
const routeId = Number(req.params.id); const routeId = Number(req.params.routeId);
const poiId = Number(req.params.poiId);
const pictureId = Number(req.params.pictureId); const pictureId = Number(req.params.pictureId);
if (req.query.metadata === 'true') { if (req.query.metadata === 'true') {
res.json(getPicture(db, routeId, pictureId)); res.json(getPicture(db, routeId, poiId, pictureId));
return; return;
} }
sendFileResource(res, getPictureFile(db, routeId, pictureId)); sendFileResource(res, getPictureFile(db, routeId, poiId, pictureId));
}); });
api.post('/routes/:id/pictures', upload.single('picture'), (req, res) => { api.post('/routes/:routeId/pois/:poiId/pictures', upload.single('picture'), (req, res) => {
const picture = createPicture(db, Number(req.params.id), req.body, req.file); 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) res.status(201)
.location(`/api/routes/${req.params.id}/pictures/${picture.id}`) .location(`/api/routes/${routeId}/pois/${poiId}/pictures/${picture.id}`)
.json(picture); .json(picture);
}); });
api.put('/routes/:id/pictures/:pictureId', upload.single('picture'), (req, res) => { api.put('/routes/:routeId/pois/:poiId/pictures/:pictureId', upload.single('picture'), (req, res) => {
res.json(updatePicture(db, Number(req.params.id), Number(req.params.pictureId), req.body, req.file)); 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) => { api.delete('/routes/:routeId/pois/:poiId/pictures/:pictureId', (req, res) => {
res.json(deletePicture(db, Number(req.params.id), Number(req.params.pictureId))); res.json(deletePicture(
db,
Number(req.params.routeId),
Number(req.params.poiId),
Number(req.params.pictureId)
));
}); });
api.get('/routes/:id/audio', (req, res) => { api.get('/routes/:routeId/pois/:poiId/audio', (req, res) => {
res.json({ audio: listAudio(db, Number(req.params.id), { poiId: req.query.poiId }) }); const routeId = Number(req.params.routeId);
});
api.get('/routes/:id/audio/:poiId', (req, res) => {
const routeId = Number(req.params.id);
const poiId = Number(req.params.poiId); const poiId = Number(req.params.poiId);
if (req.query.metadata === 'true') { if (req.query.metadata === 'true') {
res.json(getAudio(db, routeId, poiId)); res.json(getAudio(db, routeId, poiId));
@ -133,19 +144,21 @@ export function createApiRouter(db) {
sendFileResource(res, getAudioFile(db, routeId, poiId)); sendFileResource(res, getAudioFile(db, routeId, poiId));
}); });
api.post('/routes/:id/audio', upload.single('audio'), (req, res) => { api.post('/routes/:routeId/pois/:poiId/audio', upload.single('audio'), (req, res) => {
const audio = createAudio(db, Number(req.params.id), req.body, req.file); const routeId = Number(req.params.routeId);
const poiId = Number(req.params.poiId);
const audio = createAudio(db, routeId, poiId, req.file);
res.status(201) res.status(201)
.location(`/api/routes/${req.params.id}/audio/${audio.poiId}`) .location(`/api/routes/${routeId}/pois/${poiId}/audio`)
.json(audio); .json(audio);
}); });
api.put('/routes/:id/audio/:poiId', upload.single('audio'), (req, res) => { api.put('/routes/:routeId/pois/:poiId/audio', upload.single('audio'), (req, res) => {
res.json(updateAudio(db, Number(req.params.id), Number(req.params.poiId), req.file)); res.json(updateAudio(db, Number(req.params.routeId), Number(req.params.poiId), req.file));
}); });
api.delete('/routes/:id/audio/:poiId', (req, res) => { api.delete('/routes/:routeId/pois/:poiId/audio', (req, res) => {
res.json(deleteAudio(db, Number(req.params.id), Number(req.params.poiId))); res.json(deleteAudio(db, Number(req.params.routeId), Number(req.params.poiId)));
}); });
api.delete('/routes/:id', (req, res) => { 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 AUDIO_EXTENSIONS = new Set(['.mp3', '.mp4', '.m4a', '.aac', '.ogg', '.wav', '.webm']);
const uniqueFilename = original => `${crypto.randomUUID()}${path.extname(original).toLowerCase()}`; const uniqueFilename = original => `${crypto.randomUUID()}${path.extname(original).toLowerCase()}`;
const pictureUrl = (routeId, pictureId) => `/api/routes/${routeId}/pictures/${pictureId}`; const pictureUrl = (routeId, poiId, pictureId) => `/api/routes/${routeId}/pois/${poiId}/pictures/${pictureId}`;
const audioUrl = (routeId, poiId) => `/api/routes/${routeId}/audio/${poiId}`; const audioUrl = (routeId, poiId) => `/api/routes/${routeId}/pois/${poiId}/audio`;
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) { function optionalSequence(value, fallback) {
if (value == null || value === '') return fallback; if (value == null || value === '') return fallback;
@ -44,15 +36,9 @@ function optionalSequence(value, fallback) {
return parsed; 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) { function poiOnRoute(db, routeId, poiId) {
const poi = db.prepare(` 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 FROM pois p
JOIN routes r ON r.id = p.route_id JOIN routes r ON r.id = p.route_id
WHERE p.id = ? AND p.route_id = ? AND r.status = 'active' WHERE p.id = ? AND p.route_id = ? AND r.status = 'active'
@ -85,7 +71,7 @@ function pictureFromRow(row) {
poiTitle: row.poi_title, poiTitle: row.poi_title,
caption: row.caption, caption: row.caption,
sequence: row.sequence, sequence: row.sequence,
url: pictureUrl(row.route_id, row.id), url: pictureUrl(row.route_id, row.poi_id, row.id),
createdAt: row.created_at createdAt: row.created_at
}; };
} }
@ -98,40 +84,42 @@ const pictureSelect = `
JOIN routes r ON r.id = p.route_id JOIN routes r ON r.id = p.route_id
`; `;
export function listPictures(db, routeId, { poiId } = {}) { export function listPictures(db, routeId, poiId) {
activeRoute(db, routeId); poiOnRoute(db, routeId, poiId);
const filterPoiId = poiId == null || poiId === '' ? null : requirePositiveInteger(poiId, 'poiId'); return db.prepare(`${pictureSelect}
const rows = filterPoiId == null WHERE p.route_id = ? AND p.id = ? AND r.status = 'active'
? db.prepare(`${pictureSelect} WHERE p.route_id = ? AND r.status = 'active' ORDER BY p.sequence, p.id, i.sequence, i.id`).all(routeId) ORDER BY i.sequence, i.id`)
: db.prepare(`${pictureSelect} WHERE p.route_id = ? AND p.id = ? AND r.status = 'active' ORDER BY i.sequence, i.id`).all(routeId, filterPoiId); .all(routeId, poiId)
return rows.map(pictureFromRow); .map(pictureFromRow);
} }
export function getPicture(db, routeId, pictureId) { function pictureRow(db, routeId, poiId, pictureId) {
activeRoute(db, routeId); poiOnRoute(db, routeId, poiId);
const row = db.prepare(`${pictureSelect} WHERE p.route_id = ? AND i.id = ? AND r.status = 'active'`).get(routeId, pictureId); const row = db.prepare(`${pictureSelect}
if (!row) throw new HttpError(404, 'Bild auf dieser Strecke nicht gefunden.'); WHERE p.route_id = ? AND p.id = ? AND i.id = ? AND r.status = 'active'`)
return pictureFromRow(row); .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) { export function getPicture(db, routeId, poiId, pictureId) {
activeRoute(db, routeId); return pictureFromRow(pictureRow(db, routeId, poiId, pictureId));
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 getPictureFile(db, routeId, poiId, pictureId) {
const row = pictureRow(db, routeId, poiId, pictureId);
return { return {
absolutePath: resolveStoredFile(row.path), absolutePath: resolveStoredFile(row.path),
filename: path.basename(row.path) filename: path.basename(row.path)
}; };
} }
export function createPicture(db, routeId, fields, file) { export function createPicture(db, routeId, poiId, fields, file) {
let relativePath; let relativePath;
try { try {
activeRoute(db, routeId); poiOnRoute(db, routeId, poiId);
validateUpload(file, 'picture'); validateUpload(file, 'picture');
const poiId = requirePositiveInteger(fields.poiId, 'poiId');
poiOnRoute(db, routeId, poiId);
const sequence = optionalSequence(fields.sequence, const sequence = optionalSequence(fields.sequence,
db.prepare('SELECT COALESCE(MAX(sequence), -1) + 1 AS value FROM poi_images WHERE poi_id = ?').get(poiId).value); 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 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))); relativePath = moveUploadedFile(file, path.join(root, 'images', uniqueFilename(file.originalname)));
const result = db.prepare('INSERT INTO poi_images (poi_id, path, caption, sequence) VALUES (?, ?, ?, ?)') const result = db.prepare('INSERT INTO poi_images (poi_id, path, caption, sequence) VALUES (?, ?, ?, ?)')
.run(poiId, relativePath, caption, sequence); .run(poiId, relativePath, caption, sequence);
return getPicture(db, routeId, Number(result.lastInsertRowid)); return getPicture(db, routeId, poiId, Number(result.lastInsertRowid));
} catch (error) { } catch (error) {
if (relativePath) removeStoredFile(relativePath); if (relativePath) removeStoredFile(relativePath);
else removeUpload(file); 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 newPath;
let oldPath; let oldPath;
try { try {
const existing = getPicture(db, routeId, pictureId); const existing = getPicture(db, routeId, poiId, pictureId);
const row = db.prepare(`${pictureSelect} WHERE p.route_id = ? AND i.id = ? AND r.status = 'active'`).get(routeId, pictureId); const row = pictureRow(db, routeId, poiId, pictureId);
oldPath = row.path; 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 caption = fields.caption == null ? existing.caption : String(fields.caption);
const sequence = optionalSequence(fields.sequence, existing.sequence); 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))); newPath = moveUploadedFile(file, path.join(root, 'images', uniqueFilename(file.originalname)));
} }
db.prepare('UPDATE poi_images SET poi_id = ?, path = ?, caption = ?, sequence = ? WHERE id = ?') db.prepare('UPDATE poi_images SET path = ?, caption = ?, sequence = ? WHERE id = ? AND poi_id = ?')
.run(poiId, newPath, caption, sequence, pictureId); .run(newPath, caption, sequence, pictureId, poiId);
if (file && newPath !== oldPath) removeStoredFile(oldPath); if (file && newPath !== oldPath) removeStoredFile(oldPath);
return getPicture(db, routeId, pictureId); return getPicture(db, routeId, poiId, pictureId);
} catch (error) { } catch (error) {
if (file) { if (file) {
if (newPath && newPath !== oldPath) removeStoredFile(newPath); if (newPath && newPath !== oldPath) removeStoredFile(newPath);
@ -183,56 +167,36 @@ export function updatePicture(db, routeId, pictureId, fields, file) {
} }
} }
export function deletePicture(db, routeId, pictureId) { export function deletePicture(db, routeId, poiId, pictureId) {
const picture = getPicture(db, routeId, pictureId); const picture = getPicture(db, routeId, poiId, pictureId);
const row = db.prepare(`${pictureSelect} WHERE p.route_id = ? AND i.id = ? AND r.status = 'active'`).get(routeId, pictureId); const row = pictureRow(db, routeId, poiId, pictureId);
db.prepare('DELETE FROM poi_images WHERE id = ?').run(pictureId); db.prepare('DELETE FROM poi_images WHERE id = ? AND poi_id = ?').run(pictureId, poiId);
removeStoredFile(row.path); removeStoredFile(row.path);
return { id: picture.id, routeId: picture.routeId, poiId: picture.poiId, deleted: true }; return { id: picture.id, routeId: picture.routeId, poiId: picture.poiId, deleted: true };
} }
function audioFromRow(row) { function audioFromPoi(poi) {
return { return {
routeId: row.route_id, routeId: poi.route_id,
poiId: row.poi_id, poiId: poi.id,
poiTitle: row.poi_title, poiTitle: poi.title,
url: audioUrl(row.route_id, row.poi_id), url: audioUrl(poi.route_id, poi.id),
updatedAt: row.updated_at 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) { export function getAudio(db, routeId, poiId) {
activeRoute(db, routeId); const poi = poiOnRoute(db, routeId, poiId);
const row = db.prepare(`${audioSelect} WHERE p.route_id = ? AND p.id = ? AND p.audio_path IS NOT NULL AND r.status = 'active'`) if (!poi.audio_path) throw new HttpError(404, 'Audiodatei für diesen POI nicht gefunden.');
.get(routeId, poiId); return audioFromPoi(poi);
if (!row) throw new HttpError(404, 'Audiodatei für diesen POI nicht gefunden.');
return audioFromRow(row);
} }
export function getAudioFile(db, routeId, poiId) { export function getAudioFile(db, routeId, poiId) {
activeRoute(db, routeId); const poi = poiOnRoute(db, routeId, poiId);
const row = db.prepare(`${audioSelect} WHERE p.route_id = ? AND p.id = ? AND p.audio_path IS NOT NULL AND r.status = 'active'`) if (!poi.audio_path) throw new HttpError(404, 'Audiodatei für diesen POI nicht gefunden.');
.get(routeId, poiId);
if (!row) throw new HttpError(404, 'Audiodatei für diesen POI nicht gefunden.');
return { return {
absolutePath: resolveStoredFile(row.audio_path), absolutePath: resolveStoredFile(poi.audio_path),
filename: path.basename(row.audio_path) filename: path.basename(poi.audio_path)
}; };
} }
@ -240,7 +204,6 @@ function storeAudio(db, routeId, poiId, file, { requireAbsent, requireExisting }
let relativePath; let relativePath;
let oldPath; let oldPath;
try { try {
activeRoute(db, routeId);
validateUpload(file, 'audio'); validateUpload(file, 'audio');
const poi = poiOnRoute(db, routeId, poiId); const poi = poiOnRoute(db, routeId, poiId);
oldPath = poi.audio_path; oldPath = poi.audio_path;
@ -254,8 +217,8 @@ function storeAudio(db, routeId, poiId, file, { requireAbsent, requireExisting }
const root = ensureRouteDirectories(routeId); const root = ensureRouteDirectories(routeId);
relativePath = moveUploadedFile(file, path.join(root, 'audio', uniqueFilename(file.originalname))); relativePath = moveUploadedFile(file, path.join(root, 'audio', uniqueFilename(file.originalname)));
db.prepare('UPDATE pois SET audio_path = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?') db.prepare('UPDATE pois SET audio_path = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND route_id = ?')
.run(relativePath, poiId); .run(relativePath, poiId, routeId);
if (oldPath && oldPath !== relativePath) removeStoredFile(oldPath); if (oldPath && oldPath !== relativePath) removeStoredFile(oldPath);
return getAudio(db, routeId, poiId); return getAudio(db, routeId, poiId);
@ -266,8 +229,7 @@ function storeAudio(db, routeId, poiId, file, { requireAbsent, requireExisting }
} }
} }
export function createAudio(db, routeId, fields, file) { export function createAudio(db, routeId, poiId, file) {
const poiId = requirePositiveInteger(fields.poiId, 'poiId');
return storeAudio(db, routeId, poiId, file, { requireAbsent: true, requireExisting: false }); 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) { export function deleteAudio(db, routeId, poiId) {
const audio = getAudio(db, routeId, poiId); const audio = getAudio(db, routeId, poiId);
const poi = poiOnRoute(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); removeStoredFile(poi.audio_path);
return { routeId: audio.routeId, poiId: audio.poiId, deleted: true }; 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 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 routeGpxUrl = routeId => `/api/routes/${routeId}/gpx`;
const pictureUrl = (routeId, pictureId) => `/api/routes/${routeId}/pictures/${pictureId}`; const pictureUrl = (routeId, poiId, pictureId) => `/api/routes/${routeId}/pois/${poiId}/pictures/${pictureId}`;
const audioUrl = (routeId, poiId) => `/api/routes/${routeId}/audio/${poiId}`; const audioUrl = (routeId, poiId) => `/api/routes/${routeId}/pois/${poiId}/audio`;
function rowToRoute(row, includeDeleted = false) { function rowToRoute(row, includeDeleted = false) {
if (!row || (!includeDeleted && row.status !== 'active')) return null; if (!row || (!includeDeleted && row.status !== 'active')) return null;
return { return {
@ -146,7 +146,7 @@ export function listPois(db, routeId) {
id: image.id, id: image.id,
caption: image.caption, caption: image.caption,
sequence: image.sequence, 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/append',
'POST /api/routes/:id/pois', 'POST /api/routes/:id/pois',
'PUT /api/pois/:id', 'PUT /api/pois/:id',
'GET /api/routes/:id/pictures', 'GET /api/routes/:routeId/pois/:poiId/pictures',
'GET /api/routes/:id/pictures/:pictureId', 'GET /api/routes/:routeId/pois/:poiId/pictures/:pictureId',
'POST /api/routes/:id/pictures', 'POST /api/routes/:routeId/pois/:poiId/pictures',
'PUT /api/routes/:id/pictures/:pictureId', 'PUT /api/routes/:routeId/pois/:poiId/pictures/:pictureId',
'DELETE /api/routes/:id/pictures/:pictureId', 'DELETE /api/routes/:routeId/pois/:poiId/pictures/:pictureId',
'GET /api/routes/:id/audio', 'GET /api/routes/:routeId/pois/:poiId/audio',
'GET /api/routes/:id/audio/:poiId', 'POST /api/routes/:routeId/pois/:poiId/audio',
'POST /api/routes/:id/audio', 'PUT /api/routes/:routeId/pois/:poiId/audio',
'PUT /api/routes/:id/audio/:poiId', 'DELETE /api/routes/:routeId/pois/:poiId/audio',
'DELETE /api/routes/:id/audio/:poiId',
'DELETE /api/routes/:id', 'DELETE /api/routes/:id',
'POST /api/routes/:id/restore' '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 router = await read('src/routes/api.js');
const routesService = await read('src/services/routes-service.js'); const routesService = await read('src/services/routes-service.js');
const mediaService = await read('src/services/media-service.js'); const mediaService = await read('src/services/media-service.js');
assert.match(router, /post\('\/routes\/:id\/pictures', upload\.single\('picture'\)/); assert.match(router, /post\('\/routes\/:routeId\/pois\/:poiId\/pictures', upload\.single\('picture'\)/);
assert.match(router, /put\('\/routes\/:id\/pictures\/:pictureId', upload\.single\('picture'\)/); assert.match(router, /put\('\/routes\/:routeId\/pois\/:poiId\/pictures\/:pictureId', upload\.single\('picture'\)/);
assert.match(router, /delete\('\/routes\/:id\/pictures\/:pictureId'/); assert.match(router, /delete\('\/routes\/:routeId\/pois\/:poiId\/pictures\/:pictureId'/);
assert.match(router, /post\('\/routes\/:id\/audio', upload\.single\('audio'\)/); assert.match(router, /get\('\/routes\/:routeId\/pois\/:poiId\/audio'/);
assert.match(router, /put\('\/routes\/:id\/audio\/:poiId', upload\.single\('audio'\)/); assert.match(router, /post\('\/routes\/:routeId\/pois\/:poiId\/audio', upload\.single\('audio'\)/);
assert.match(router, /delete\('\/routes\/:id\/audio\/:poiId'/); assert.match(router, /put\('\/routes\/:routeId\/pois\/:poiId\/audio', upload\.single\('audio'\)/);
assert.match(router, /post\('\/routes\/:id\/pois', upload\.none\(\)/); 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.doesNotMatch(routesService, /files\.images|files\.audio/);
assert.match(mediaService, /caption/); assert.match(mediaService, /caption/);
assert.match(mediaService, /removeStoredFile/); assert.match(mediaService, /removeStoredFile/);
assert.doesNotMatch(mediaService, /fields\.poiId/);
assert.match(router, /get\('\/routes\/:id\/gpx'/); assert.match(router, /get\('\/routes\/:id\/gpx'/);
assert.match(router, /req\.query\.metadata === 'true'/); assert.match(router, /req\.query\.metadata === 'true'/);
assert.doesNotMatch(router, /['"`]\/media\//); assert.doesNotMatch(router, /['"`]\/media\//);

View File

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