From 8e8150331d34e149fe88aa54a03a322b9ff01922 Mon Sep 17 00:00:00 2001 From: "matthias.lotz" Date: Sun, 16 Nov 2025 11:13:24 +0100 Subject: [PATCH 1/4] docs: add autogen-openapi feature request and plan --- docs/FEATURE_PLAN-autogen-openapi.md | 92 +++++++++++++++++++++++++ docs/FEATURE_REQUEST-autogen-openapi.md | 55 +++++++++++++++ 2 files changed, 147 insertions(+) create mode 100644 docs/FEATURE_PLAN-autogen-openapi.md create mode 100644 docs/FEATURE_REQUEST-autogen-openapi.md diff --git a/docs/FEATURE_PLAN-autogen-openapi.md b/docs/FEATURE_PLAN-autogen-openapi.md new file mode 100644 index 0000000..9ec7132 --- /dev/null +++ b/docs/FEATURE_PLAN-autogen-openapi.md @@ -0,0 +1,92 @@ +````markdown +# Feature Plan: Autogenerierte OpenAPI / Swagger Spec + +**Branch:** `feature/autogen-openapi` +**Datum:** 16. November 2025 + +## 🎯 Ziel +Einfaches Developer‑Experience Feature, das beim lokalen Start des Backends automatisch eine OpenAPI Spec erzeugt und eine Swagger UI zur VerfĂŒgung stellt. Entwickler mĂŒssen beim Anlegen neuer Routen die Doku nicht mehr manuell nachpflegen. + +--- + +## 🔍 Scope +- Dev‑only: Generierung beim lokalen Start oder per npm script. +- Swagger UI unter `/api/docs` (Dev) mit der generierten Spec. +- Optionaler Export der Spec nach `docs/openapi.json` fĂŒr CI/Review. +- Keine invasive Änderung an Produktionsstart. + +--- + +## 📐 Architektur‑Überblick +### Warum das funktioniert fĂŒr dieses Projekt +- Routen werden statisch importiert und in `backend/src/routes/index.js` mit klaren Basispfaden registriert. +- Viele Routen nutzen konstante Pfadangaben (`backend/src/constants.js`) oder feste Strings. +- Daher erkennen Generatoren die meisten Endpunkte zuverlĂ€ssig. + +### Grenzen +- Multipart Uploads (express-fileupload) werden in der Regel als Pfad erkannt, die requestBody Beschreibung (multipart schema) kann fehlen und erfordert kleine Hints. +- Komplexe Response‑Schemas (detaillierte components/schemas) sind nicht automatisch vollstĂ€ndig prĂ€zise; diese können spĂ€ter ergĂ€nzt werden. + +--- + +## 🔧 Implementierungs‑Schritte +### Phase 0 — Vorbereitung (30–60 min) +- Branch anlegen: `feature/autogen-openapi` (bereits erfolgt) +- Dev‑Dokumentation anpassen: `README.dev.md` Hinweis wie Doku geöffnet wird + +### Phase 1 — MVP Integration (1–2h) +1. Installiere dev‑Dependency: `swagger-autogen` oder `express-oas-generator` (oder `swagger-jsdoc + swagger-ui-express` falls JSDoc bevorzugt wird). +2. FĂŒge ein npm script hinzu: `npm run generate:openapi` (erzeugt `docs/openapi.json`). +3. Bei Dev‑Start: Generator einmal ausfĂŒhren und Spec in memory oder als file verfĂŒgbar machen. +4. Mount Swagger UI unter `/api/docs` nur in Dev. + +**Ergebnis:** Swagger UI listet automatisch erkannte Endpunkte. + +### Phase 2 — Nacharbeit & Hints (1–2h) +- PrĂŒfe Upload‑Endpoints (z. B. `/upload`, `/upload/batch`) und fĂŒge ggf. kleine Hints oder manuelle ErgĂ€nzungen zur requestBody‑Definition hinzu (in `docs/openapi.json` oder per generator config). +- Optional: ergĂ€nze components/schemas fĂŒr die wichtigsten Responses (Groups, Image, UploadResult). + +### Phase 3 — CI / Export (0.5–1h) +- Optionales npm script fĂŒr CI, das `npm run generate:openapi` ausfĂŒhrt und das Ergebnis im Artefact bereitstellt. +- Optional: OpenAPI Validator in CI laufen lassen (z. B. `swagger-cli validate`) um Spec‑Fehler frĂŒh zu erkennen. + +--- + +## ⏱ Zeitplan & Aufwand +- MVP (Dev UI + generate once): 1–2 Stunden +- Upload Hints + kleine Spec‑Fixes: 1–2 Stunden +- Optional: CI/Export + Validator: 0.5–1 Stunde + +**Total (realistisch, MVP+fixes): 2–4 Stunden** + +--- + +## ✅ Acceptance Criteria +- [ ] Feature Branch mit Integration existiert (`feature/autogen-openapi`). +- [ ] `npm run dev` zeigt Swagger UI unter `/api/docs` mit generierter Spec. +- [ ] Alle standardmĂ€ĂŸigen Routen sind in der Spec enthalten (Smoke Test). +- [ ] Upload‑Endpoints erkannt; falls requestBody fehlt, dokumentierter Hinweis in PR. +- [ ] Feature ist deaktiviert in `production`. +- [ ] Optional: `docs/openapi.json` kann per script erzeugt werden. + +--- + +## ⚠ Risiken & Mitigations +- Risiko: Generator erkennt Upload requestBody nicht korrekt. + Mitigation: Manuelle Hint‑Block oder kleine post‑processing Schritte in `generate:openapi` Script. + +- Risiko: Spec generiert sensitive Pfade/fields (management tokens). + Mitigation: Filter/Transformation im Export‑Script oder nur generiertes file in CI/Review; Swagger UI dev‑only. + +--- + +## ✅ Next Steps (konkret) +1. Push Branch `feature/autogen-openapi` remote (falls noch nicht geschehen). +2. Ich implementiere MVP: installiere Generator, fĂŒhre einmaligen Lauf aus, mounte Swagger UI und liefere `docs/openapi.json` und eine Liste der Endpunkte, die Hints brauchen. +3. PR mit Implementation + Docs. +4. Review und Nachbesserung des Specs (Uploads/Schemas). + + +**Erstellt am:** 16. November 2025 + +```` \ No newline at end of file diff --git a/docs/FEATURE_REQUEST-autogen-openapi.md b/docs/FEATURE_REQUEST-autogen-openapi.md new file mode 100644 index 0000000..1624bd8 --- /dev/null +++ b/docs/FEATURE_REQUEST-autogen-openapi.md @@ -0,0 +1,55 @@ +````markdown + +# Feature Request: Autogenerierte OpenAPI / Swagger Spec + +**Kurzbeschreibung**: Automatische Erzeugung einer OpenAPI (Swagger) Spec aus dem Express‑Backend (dev‑only), so dass neue Routen sofort und ohne manuelles Nacharbeiten in der API‑Dokumentation erscheinen. + +**Motivation / Nutzen**: +- Single source of truth: Routen im Code sind die einzige Quelle; keine manuelle openapi.json Pflege. +- Entwicklerfreundlich: Neue Route → Doku beim nĂ€chsten Serverstart sichtbar. +- Schnelle Übersicht fĂŒr QA und API‑Reviewer via Swagger UI. +- Reduziert Drift zwischen Implementierung und Dokumentation. + +--- + +## Aktueller Stand +- Backend ist Express‑basiert, Routen sind statisch in `backend/src/routes` definiert. +- `express-fileupload` wird als Middleware verwendet. +- Keine automatische OpenAPI Spec derzeit vorhanden. + +--- + +## Anforderungen an das Feature +1. Beim lokalen Dev‑Start soll eine OpenAPI Spec erzeugt werden (z. B. mit `swagger-autogen` oder `express-oas-generator`). +2. Eine Swagger UI (nur in Dev) soll unter `/api/docs` erreichbar sein und die erzeugte Spec anzeigen. +3. Automatisch erkannte Endpunkte mĂŒssen sichtbar sein; fĂŒr komplexe FĂ€lle (multipart Uploads) sollen einfache Hints / Overrides möglich sein. +4. Keine Breaking Changes am Produktions‑Startverhalten: Autogen nur in `NODE_ENV !== 'production'` oder per opt‑in env var. +5. Erzeugte Spec soll ins Repo (z. B. `docs/openapi.json`) optional geschrieben werden können (fĂŒr CI/Review). + +--- + +## Minimaler Scope (MVP) +- Dev‑only Integration: Generator installiert und beim Start einmal ausgefĂŒhrt. +- Swagger UI unter `/api/docs` mit generierter Spec. +- Kurze Anleitung im `README.dev.md` wie man die Doku lokal öffnet. + +--- + +## Akzeptanzkriterien +- [ ] Swagger UI zeigt alle standardmĂ€ĂŸig erkannten Endpoints an. +- [ ] Upload‑Endpoints erscheinen (Pfad erkannt). Falls requestBody fehlt, ist ein klarer Hinweis dokumentiert. +- [ ] Feature ist deaktivierbar in `production`. +- [ ] Optionaler Export: `docs/openapi.json` kann per npm script erzeugt werden. + +--- + +## GeschĂ€tzter Aufwand (MVP) +- Setup & smoke test: 1–2h +- Anpassungen fĂŒr Upload‑Hints + kleine Nacharbeiten: 1–2h +- Optionales Export/CI: +1h + +--- + +**Erstellt am**: 16. November 2025 + +```` \ No newline at end of file From cdb2aa95e6d7ae23bc3c2b4cc2c1e6788a8b0a0d Mon Sep 17 00:00:00 2001 From: "matthias.lotz" Date: Sun, 16 Nov 2025 18:08:48 +0100 Subject: [PATCH 2/4] feat: Add comprehensive test suite and admin API authentication MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit đŸ§Ș Testing Infrastructure (45 tests, 100% passing) - Implemented Jest + Supertest framework for automated testing - Unit tests: 5 tests for auth middleware (100% coverage) - Integration tests: 40 tests covering admin, consent, migration, upload APIs - Test execution time: ~10 seconds for full suite - Coverage: 26% statements, 15% branches (realistic start) - In-memory SQLite database for isolated testing - Singleton server pattern for fast test execution - Automatic cleanup and teardown 🔒 Admin API Authentication - Bearer token authentication for all admin endpoints - requireAdminAuth middleware with ADMIN_API_KEY validation - Protected routes: /api/admin/*, /api/system/migration/migrate|rollback - Complete authentication guide in AUTHENTICATION.md - HTTP 403 for missing/invalid tokens, 500 if not configured - Ready for production with token rotation support 📋 API Route Documentation - Single Source of Truth: backend/src/routes/routeMappings.js - Comprehensive route overview in backend/src/routes/README.md - Express routing order documented (specific before generic) - Frontend integration guide with authentication examples - OpenAPI auto-generation integrated 🐛 Bug Fixes - Fixed SQLite connection not properly awaited (caused test hangs) - Fixed upload validation checking req.files.file before req.files - Fixed Express route order (consent before admin router) - Fixed test environment using /tmp for uploads (permission issues) 📚 Documentation Updates - Updated README.md with testing and authentication features - Updated README.dev.md with testing section and API development guide - Updated CHANGELOG.md with complete feature documentation - Updated FEATURE_PLAN-autogen-openapi.md (status: 100% complete) - Added frontend/MIGRATION-GUIDE.md for frontend team 🚀 Frontend Impact Frontend needs to add Bearer token to all /api/admin/* calls. See frontend/MIGRATION-GUIDE.md for detailed instructions. Test Status: ✅ 45/45 passing (100%) Backend: ✅ Production ready Frontend: ⚠ Migration required (see MIGRATION-GUIDE.md) --- AUTHENTICATION.md | 199 ++ CHANGELOG.md | 100 + README.dev.md | 134 + README.md | 32 +- backend/.env.example | 5 + backend/TESTING.md | 297 +++ backend/docs/openapi.json | 2361 +++++++++++++++++ backend/jest.config.js | 29 + backend/package.json | 16 +- backend/src/constants.js | 22 +- backend/src/database/DatabaseManager.js | 44 +- backend/src/generate-openapi.js | 96 + backend/src/middlewares/auth.js | 50 + backend/src/routes/README.md | 357 +++ backend/src/routes/admin.js | 741 +++++- backend/src/routes/batchUpload.js | 76 +- backend/src/routes/consent.js | 152 +- backend/src/routes/download.js | 30 +- backend/src/routes/groups.js | 423 +-- backend/src/routes/index.js | 23 +- backend/src/routes/management.js | 337 +++ backend/src/routes/migration.js | 85 +- backend/src/routes/reorder.js | 78 +- backend/src/routes/routeMappings.js | 28 + backend/src/routes/upload.js | 57 +- backend/src/server.js | 46 + backend/src/services/SchedulerService.js | 6 + backend/src/utils/initiate-resources.js | 17 +- backend/test-openapi-paths.js | 64 + backend/tests/api/admin-auth.test.js | 70 + backend/tests/api/admin.test.js | 67 + backend/tests/api/consent.test.js | 125 + backend/tests/api/migration.test.js | 68 + backend/tests/api/upload.test.js | 58 + backend/tests/globalSetup.js | 33 + backend/tests/globalTeardown.js | 14 + backend/tests/setup.js | 47 + backend/tests/testServer.js | 39 + backend/tests/unit/auth.test.js | 81 + docs/FEATURE_PLAN-autogen-openapi.md | 225 +- frontend/MIGRATION-GUIDE.md | 294 ++ .../Components/Pages/ManagementPortalPage.js | 3 +- 42 files changed, 6539 insertions(+), 490 deletions(-) create mode 100644 AUTHENTICATION.md create mode 100644 backend/TESTING.md create mode 100644 backend/docs/openapi.json create mode 100644 backend/jest.config.js create mode 100644 backend/src/generate-openapi.js create mode 100644 backend/src/middlewares/auth.js create mode 100644 backend/src/routes/README.md create mode 100644 backend/src/routes/routeMappings.js create mode 100644 backend/test-openapi-paths.js create mode 100644 backend/tests/api/admin-auth.test.js create mode 100644 backend/tests/api/admin.test.js create mode 100644 backend/tests/api/consent.test.js create mode 100644 backend/tests/api/migration.test.js create mode 100644 backend/tests/api/upload.test.js create mode 100644 backend/tests/globalSetup.js create mode 100644 backend/tests/globalTeardown.js create mode 100644 backend/tests/setup.js create mode 100644 backend/tests/testServer.js create mode 100644 backend/tests/unit/auth.test.js create mode 100644 frontend/MIGRATION-GUIDE.md diff --git a/AUTHENTICATION.md b/AUTHENTICATION.md new file mode 100644 index 0000000..7a60e73 --- /dev/null +++ b/AUTHENTICATION.md @@ -0,0 +1,199 @@ +# API Authentication Guide + +## Übersicht + +Die API verwendet **zwei verschiedene Authentifizierungs-Mechanismen** fĂŒr unterschiedliche Zugriffslevel: + +### 1. Admin-Routes (Bearer Token) +- **Zweck**: GeschĂŒtzte Admin-Funktionen (Deletion Log, Cleanup, Moderation, Statistics) +- **Methode**: Bearer Token im Authorization Header +- **Konfiguration**: `.env` → `ADMIN_API_KEY` + +### 2. Management-Routes (UUID Token) +- **Zweck**: Self-Service Portal fĂŒr Gruppen-Verwaltung +- **Methode**: UUID v4 Token in URL-Path +- **Quelle**: Automatisch generiert beim Upload, gespeichert in DB + +--- + +## 1. Admin Authentication + +### Setup + +1. **Sicheren Admin-Key generieren**: + ```bash + # Linux/Mac: + openssl rand -hex 32 + + # Oder Node.js: + node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" + ``` + +2. **In `.env` eintragen**: + ```env + ADMIN_API_KEY=dein-generierter-key-hier + ``` + +3. **Server neu starten** + +### Verwendung + +Alle Requests an `/api/admin/*` benötigen den Authorization Header: + +```bash +curl -H "Authorization: Bearer dein-generierter-key-hier" \ + http://localhost:5000/api/admin/deletion-log +``` + +**Postman/Insomnia**: +- Type: `Bearer Token` +- Token: `dein-generierter-key-hier` + +### GeschĂŒtzte Endpoints + +| Endpoint | Method | Beschreibung | +|----------|--------|--------------| +| `/api/admin/deletion-log` | GET | Deletion Log EintrĂ€ge | +| `/api/admin/deletion-log/csv` | GET | Deletion Log als CSV | +| `/api/admin/cleanup/run` | POST | Cleanup manuell starten | +| `/api/admin/cleanup/status` | GET | Cleanup Status | +| `/api/admin/rate-limiter/stats` | GET | Rate-Limiter Statistiken | +| `/api/admin/management-audit` | GET | Management Audit Log | +| `/api/admin/groups` | GET | Alle Gruppen (Moderation) | +| `/api/admin/groups/:id/approve` | PUT | Gruppe freigeben | +| `/api/admin/groups/:id` | DELETE | Gruppe löschen | + +### Error Codes + +| Status | Bedeutung | +|--------|-----------| +| `403` | Authorization header fehlt oder ungĂŒltig | +| `500` | ADMIN_API_KEY nicht konfiguriert | + +--- + +## 2. Management Authentication + +### Setup + +**Kein Setup nötig!** Token werden automatisch generiert. + +### Funktionsweise + +1. **Bei Upload** wird automatisch ein UUID v4 Token generiert +2. **Token wird gespeichert** in DB (Spalte: `management_token`) +3. **Token wird zurĂŒckgegeben** in der Upload-Response +4. **Nutzer erhĂ€lt Link** wie: `https://example.com/manage/{token}` + +### Verwendung + +Token wird **im URL-Path** ĂŒbergeben (nicht im Header): + +```bash +# Token validieren und Daten laden +GET /api/manage/550e8400-e29b-41d4-a716-446655440000 + +# Bilder hochladen +POST /api/manage/550e8400-e29b-41d4-a716-446655440000/images + +# Gruppe löschen +DELETE /api/manage/550e8400-e29b-41d4-a716-446655440000 +``` + +### GeschĂŒtzte Endpoints + +| Endpoint | Method | Beschreibung | +|----------|--------|--------------| +| `/api/manage/:token` | GET | Gruppen-Daten laden | +| `/api/manage/:token/consents` | PUT | Social Media Consents | +| `/api/manage/:token/metadata` | PUT | Metadaten bearbeiten | +| `/api/manage/:token/images` | POST | Bilder hinzufĂŒgen | +| `/api/manage/:token/images/:imageId` | DELETE | Bild löschen | +| `/api/manage/:token` | DELETE | Gruppe löschen | + +### Sicherheits-Features + +- **Token-Format Validierung**: Nur gĂŒltige UUID v4 Tokens +- **Rate Limiting**: Schutz vor Brute-Force +- **Audit Logging**: Alle Aktionen werden geloggt +- **DB-Check**: Token muss in DB existieren + +### Error Codes + +| Status | Bedeutung | +|--------|-----------| +| `404` | Token nicht gefunden oder Gruppe gelöscht | +| `429` | Rate Limit ĂŒberschritten | + +--- + +## Testing + +### Unit Tests + +```bash +npm test -- tests/unit/auth.test.js +``` + +### Integration Tests + +```bash +# Admin Auth testen +npm test -- tests/api/admin-auth.test.js + +# Alle API Tests +npm test +``` + +### Manuelles Testen + +**Admin-Route ohne Auth**: +```bash +curl http://localhost:5000/api/admin/deletion-log +# → 403 Forbidden +``` + +**Admin-Route mit Auth**: +```bash +curl -H "Authorization: Bearer your-key" \ + http://localhost:5000/api/admin/deletion-log +# → 200 OK +``` + +--- + +## Production Checklist + +- [ ] `ADMIN_API_KEY` mit sicherem 64-Zeichen Key setzen +- [ ] `.env` nicht in Git committen (bereits in `.gitignore`) +- [ ] HTTPS verwenden (TLS/SSL) +- [ ] Rate Limiting aktiviert prĂŒfen +- [ ] Audit Logs regelmĂ€ĂŸig ĂŒberprĂŒfen +- [ ] Token-Rotation Policy fĂŒr Admin-Key implementieren + +--- + +## Sicherheits-Hinweise + +### Admin-Key Rotation + +Admin-Key regelmĂ€ĂŸig erneuern (z.B. alle 90 Tage): + +1. Neuen Key generieren +2. `.env` aktualisieren +3. Server neu starten +4. Alte Clients auf neuen Key umstellen + +### Management-Token + +- Token sind **permanent gĂŒltig** bis Gruppe gelöscht wird +- Bei Verdacht auf Leak: Gruppe löschen (löscht auch Token) +- Token-Format (UUID v4) macht Brute-Force unpraktisch + +### Best Practices + +- Admin-Key **nie** im Code hart-kodieren +- Admin-Key **nie** in Logs/Errors ausgeben +- Requests ĂŒber HTTPS (kein Plain-HTTP in Production) +- Rate-Limiting fĂŒr beide Auth-Typen aktiv +- Audit-Logs regelmĂ€ĂŸig auf Anomalien prĂŒfen diff --git a/CHANGELOG.md b/CHANGELOG.md index f2b4965..716e9d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,106 @@ ## [Unreleased] - Branch: feature/SocialMedia +### đŸ§Ș Comprehensive Test Suite & Admin API Security (November 16, 2025) + +#### Testing Infrastructure +- ✅ **Jest + Supertest Framework**: 45 automated tests covering all API endpoints + - Unit tests: 5 tests for authentication middleware (100% coverage) + - Integration tests: 40 tests for API endpoints + - Test success rate: 100% (45/45 passing) + - Execution time: ~10 seconds for full suite + +- ✅ **Test Organization**: + - `tests/unit/` - Unit tests (auth.test.js) + - `tests/api/` - Integration tests (admin, consent, migration, upload) + - `tests/setup.js` - Global configuration with singleton server pattern + - `tests/testServer.js` - Test server helper utilities + +- ✅ **Test Environment**: + - In-memory SQLite database (`:memory:`) for isolation + - Temporary upload directories (`/tmp/test-image-uploader/`) + - Singleton server pattern for fast test execution + - Automatic cleanup after test runs + - `NODE_ENV=test` environment detection + +- ✅ **Code Coverage**: + - Statements: 26% (above 20% threshold) + - Branches: 15% + - Functions: 16% + - Lines: 26% + +#### Admin API Authentication +- ✅ **Bearer Token Security**: Protected all admin and dangerous system endpoints + - `requireAdminAuth` middleware for Bearer token validation + - Environment variable: `ADMIN_API_KEY` for token configuration + - Protected routes: All `/api/admin/*`, `/api/system/migration/migrate`, `/api/system/migration/rollback` + - HTTP responses: 403 for invalid/missing tokens, 500 if ADMIN_API_KEY not configured + +- ✅ **Authentication Documentation**: + - Complete setup guide in `AUTHENTICATION.md` + - Example token generation commands (openssl, Node.js) + - curl and Postman usage examples + - Security best practices and production checklist + +#### API Route Documentation +- ✅ **Single Source of Truth**: `backend/src/routes/routeMappings.js` + - Centralized route configuration for server and OpenAPI generation + - Comprehensive API overview in `backend/src/routes/README.md` + - Critical Express routing order documented and enforced + +- ✅ **Route Order Fix**: Fixed Express route matching bug + - Problem: Generic routes (`/groups/:groupId`) matched before specific routes (`/groups/by-consent`) + - Solution: Mount consent router before admin router on `/api/admin` prefix + - Documentation: Added comments explaining why order matters + +- ✅ **OpenAPI Auto-Generation**: + - Automatic spec generation on backend start (dev mode) + - Swagger UI available at `/api/docs` in development + - Skip generation in test and production modes + +#### Bug Fixes +- 🐛 Fixed: SQLite connection callback not properly awaited (caused test hangs) + - Wrapped `new sqlite3.Database()` in Promise for proper async/await +- 🐛 Fixed: Upload endpoint file validation checking `req.files.file` before `req.files` existence + - Added `!req.files` check before accessing `.file` property +- 🐛 Fixed: Test uploads failing with EACCES permission denied + - Use `/tmp/` directory in test mode instead of `data/images/` + - Dynamic path handling with `path.isAbsolute()` check +- 🐛 Fixed: Express route order causing consent endpoints to return 404 + - Reordered routers: consent before admin in routeMappings.js + +#### Frontend Impact +**⚠ Action Required**: Frontend needs updates for new authentication system + +1. **Admin API Calls**: Add Bearer token header + ```javascript + headers: { + 'Authorization': `Bearer ${ADMIN_API_KEY}` + } + ``` + +2. **Route Verification**: Check all API paths against `routeMappings.js` + - Consent routes: `/api/admin/groups/by-consent`, `/api/admin/consents/export` + - Migration routes: `/api/system/migration/*` (not `/api/migration/*`) + +3. **Error Handling**: Handle 403 responses for missing/invalid authentication + +4. **Environment Configuration**: Add `REACT_APP_ADMIN_API_KEY` to frontend `.env` + +#### Technical Details +- **Backend Changes**: + - New files: `middlewares/auth.js`, `tests/` directory structure + - Modified files: All admin routes now protected, upload.js validation improved + - Database: Promisified SQLite connection in DatabaseManager.js + - Constants: Test-mode path handling in constants.js + +- **Configuration Files**: + - `jest.config.js`: Test configuration with coverage thresholds + - `.env.example`: Added ADMIN_API_KEY documentation + - `package.json`: Added Jest and Supertest dependencies + +--- + ### 🎹 Modular UI Architecture (November 15, 2025) #### Features diff --git a/README.dev.md b/README.dev.md index 4b5acea..ee20a85 100644 --- a/README.dev.md +++ b/README.dev.md @@ -15,6 +15,7 @@ docker compose -f docker/dev/docker-compose.yml up -d ### Zugriff - **Frontend**: http://localhost:3000 (Hot Module Reloading aktiv) - **Backend**: http://localhost:5001 (API) +- **API Documentation**: http://localhost:5001/api/docs (Swagger UI) - **Slideshow**: http://localhost:3000/slideshow - **Moderation**: http://localhost:3000/moderation (Entwicklung: ohne Auth) @@ -30,6 +31,80 @@ docker compose -f docker/dev/docker-compose.yml logs -f frontend-dev docker compose -f docker/dev/docker-compose.yml logs -f backend-dev ``` +## API-Entwicklung + +### Route-Struktur + +Die API verwendet eine **Single Source of Truth** fĂŒr Route-Mappings: + +📄 **`backend/src/routes/routeMappings.js`** - Zentrale Route-Konfiguration + +Siehe auch: **`backend/src/routes/README.md`** fĂŒr vollstĂ€ndige API-Übersicht + +**Wichtige Route-Gruppen:** +- `/api/upload`, `/api/download` - Öffentliche Upload/Download-Endpoints +- `/api/manage/:token` - Self-Service Management Portal (UUID-Token) +- `/api/admin/*` - Admin-Endpoints (Bearer Token Authentication) +- `/api/system/migration/*` - Datenbank-Migrationen + +**⚠ Express Route-Reihenfolge beachten:** +Router mit spezifischen Routes **vor** generischen Routes mounten! +```javascript +// ✅ RICHTIG: Spezifisch vor generisch +{ router: 'consent', prefix: '/api/admin' }, // /groups/by-consent +{ router: 'admin', prefix: '/api/admin' }, // /groups/:groupId + +// ❌ FALSCH: Generisch fĂ€ngt alles ab +{ router: 'admin', prefix: '/api/admin' }, // /groups/:groupId matched auf 'by-consent'! +{ router: 'consent', prefix: '/api/admin' }, // Wird nie erreicht +``` + +### Authentication + +**Zwei Auth-Systeme parallel:** + +1. **Admin API (Bearer Token)**: + ```bash + # .env konfigurieren: + ADMIN_API_KEY=your-secure-key-here + + # API-Aufrufe: + curl -H "Authorization: Bearer your-secure-key-here" \ + http://localhost:5001/api/admin/groups + ``` + +2. **Management Portal (UUID Token)**: + ```bash + # Automatisch beim Upload generiert + GET /api/manage/550e8400-e29b-41d4-a716-446655440000 + ``` + +📖 **VollstĂ€ndige Doku**: `AUTHENTICATION.md` + +### OpenAPI-Spezifikation + +Die OpenAPI-Spezifikation wird **automatisch beim Backend-Start** generiert: + +```bash +# Generiert: backend/docs/openapi.json +# Swagger UI: http://localhost:5001/api/docs + +# Manuelle Generierung: +cd backend +node src/generate-openapi.js +``` + +**Swagger-Annotationen in Routes:** +```javascript +router.get('/example', async (req, res) => { + /* + #swagger.tags = ['Example'] + #swagger.summary = 'Get example data' + #swagger.responses[200] = { description: 'Success' } + */ +}); +``` + ## Entwicklung ### Frontend-Entwicklung @@ -50,9 +125,11 @@ docker compose -f docker/dev/docker-compose.yml logs -f backend-dev - Environment: `NODE_ENV=development` **Wichtige Module:** +- `routes/routeMappings.js` - Single Source of Truth fĂŒr Route-Konfiguration - `repositories/GroupRepository.js` - Consent-Management & CRUD - `repositories/SocialMediaRepository.js` - Plattform- & Consent-Verwaltung - `routes/batchUpload.js` - Upload mit Consent-Validierung +- `middlewares/auth.js` - Admin Authentication (Bearer Token) - `database/DatabaseManager.js` - Automatische Migrationen - `services/GroupCleanupService.js` - 7-Tage-Cleanup-Logik @@ -95,6 +172,63 @@ docker compose -f docker/dev/docker-compose.yml logs backend-dev | grep -i migra ## Testing +### Automatisierte Tests + +Das Backend verfĂŒgt ĂŒber eine umfassende Test-Suite mit 45 Tests: + +```bash +# Alle Tests ausfĂŒhren: +cd backend +npm test + +# Einzelne Test-Suite: +npm test -- tests/api/admin.test.js + +# Mit Coverage-Report: +npm test -- --coverage + +# Watch-Mode (wĂ€hrend Entwicklung): +npm test -- --watch +``` + +**Test-Struktur:** +- `tests/unit/` - Unit-Tests (z.B. Auth-Middleware) +- `tests/api/` - Integration-Tests (API-Endpoints) +- `tests/setup.js` - Globale Test-Konfiguration +- `tests/testServer.js` - Test-Server-Helper + +**Test-Features:** +- Jest + Supertest Framework +- In-Memory SQLite Database (isoliert) +- Singleton Server Pattern (schnell) +- 100% Test-Success-Rate (45/45 passing) +- ~10 Sekunden AusfĂŒhrungszeit +- Coverage: 26% Statements, 15% Branches + +**Test-Umgebung:** +- Verwendet `/tmp/test-image-uploader/` fĂŒr Upload-Tests +- Eigene Datenbank `:memory:` (kein Konflikt mit Dev-DB) +- Environment: `NODE_ENV=test` +- Automatisches Cleanup nach Test-Run + +**Neue Tests hinzufĂŒgen:** +```javascript +// tests/api/example.test.js +const { getRequest } = require('../testServer'); + +describe('Example API', () => { + it('should return 200', async () => { + const response = await getRequest() + .get('/api/example') + .expect(200); + + expect(response.body).toHaveProperty('data'); + }); +}); +``` + +### Manuelles Testing + ### Consent-System testen ```bash # 1. Upload mit und ohne Workshop-Consent diff --git a/README.md b/README.md index 4883661..cfd7b72 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,32 @@ A self-hosted image uploader with multi-image upload capabilities and automatic This project extends the original [Image-Uploader by vallezw](https://github.com/vallezw/Image-Uploader) with enhanced multi-upload and slideshow capabilities. ### 🆕 Latest Features (November 2025) + +- **đŸ§Ș Comprehensive Test Suite** (Nov 16): + - 45 automated tests covering all API endpoints (100% passing) + - Jest + Supertest integration testing framework + - Unit tests for authentication middleware + - API tests for admin, consent, migration, and upload endpoints + - In-memory SQLite database for isolated testing + - Coverage: 26% statements, 15% branches (realistic starting point) + - Test execution time: ~10 seconds for full suite + - CI/CD ready with proper teardown and cleanup + +- **🔒 Admin API Authentication** (Nov 16): + - Bearer token authentication for all admin endpoints + - Secure ADMIN_API_KEY environment variable configuration + - Protected routes: `/api/admin/*`, `/api/system/migration/migrate`, `/api/system/migration/rollback` + - 403 Forbidden responses for missing/invalid tokens + - Complete authentication documentation in `AUTHENTICATION.md` + - Ready for production deployment with token rotation support + +- **📋 API Route Documentation** (Nov 16): + - Single Source of Truth: `backend/src/routes/routeMappings.js` + - Comprehensive route overview in `backend/src/routes/README.md` + - Critical Express routing order documented (specific before generic) + - Frontend-ready route reference with authentication requirements + - OpenAPI specification auto-generation integrated + - **🔐 Social Media Consent Management** (Phase 1 Complete - Nov 9-10): - GDPR-compliant consent system for image usage - Mandatory workshop display consent (no upload without approval) @@ -181,7 +207,11 @@ The application automatically generates optimized preview thumbnails for all upl ### Moderation Interface (Protected) - **Access**: `http://localhost/moderation` (requires authentication) -- **Authentication**: HTTP Basic Auth (username: hobbyadmin, password: set during setup) +- **Authentication Methods**: + - **Frontend**: HTTP Basic Auth (username: hobbyadmin, password: set during setup) + - **API Direct Access**: Bearer Token via `Authorization: Bearer ` header + - See `AUTHENTICATION.md` for detailed setup instructions +- **Protected Endpoints**: All `/api/admin/*` routes require authentication - **Features**: - Review pending image groups before public display - Visual countdown showing days until automatic deletion (7 days for unapproved groups) diff --git a/backend/.env.example b/backend/.env.example index ddad885..057eca2 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -10,6 +10,11 @@ NODE_ENV=development # Port for the backend server PORT=5000 +# Admin API Authentication +# Generate a secure random string for production! +# Example: openssl rand -hex 32 +ADMIN_API_KEY=your-secret-admin-key-change-me-in-production + # Database settings (if needed in future) # DB_HOST=localhost # DB_PORT=3306 \ No newline at end of file diff --git a/backend/TESTING.md b/backend/TESTING.md new file mode 100644 index 0000000..b53930d --- /dev/null +++ b/backend/TESTING.md @@ -0,0 +1,297 @@ +# API Testing Guide + +## 🎯 Testing-Strategie + +### 1. Smoke Tests (Aktuell) + +**Script:** `test-openapi-paths.js` + +**Was wird getestet:** +- ✅ Alle Pfade sind erreichbar +- ✅ Server antwortet +- ❌ Keine Schema-Validierung +- ❌ Keine Auth-Tests +- ❌ Keine Request Bodies + +**Verwendung:** +```bash +npm run test-openapi +``` + +**Zweck:** Schneller Check ob alle Routen mounted sind. + +--- + +## 🔧 Empfohlene Testing-Tools + +### Option 1: Dredd (Contract Testing) ⭐ EMPFOHLEN + +**Installation:** +```bash +npm install --save-dev dredd +``` + +**Setup:** +```bash +# dredd.yml erstellen +cat > dredd.yml << EOF +color: true +dry-run: false +hookfiles: null +language: nodejs +require: null +server: npm start +server-wait: 3 +init: false +custom: {} +names: false +only: [] +reporter: [] +output: [] +header: [] +sorted: false +user: null +inline-errors: false +details: false +method: [] +loglevel: warning +path: [] +blueprint: docs/openapi.json +endpoint: http://localhost:5000 +EOF +``` + +**AusfĂŒhren:** +```bash +npx dredd +``` + +**Vorteile:** +- ✅ Validiert Responses gegen OpenAPI-Schema +- ✅ Testet alle HTTP-Methoden +- ✅ PrĂŒft Request/Response Bodies +- ✅ UnterstĂŒtzt Hooks fĂŒr Auth + +--- + +### Option 2: Postman + Newman + +**Setup:** +1. OpenAPI importieren: + - Öffne Postman + - File → Import → `backend/docs/openapi.json` + +2. Collection generieren lassen + +3. Tests hinzufĂŒgen (automatisch oder manuell) + +4. Export als Collection + +**CLI-Tests:** +```bash +npm install --save-dev newman +npx newman run postman_collection.json +``` + +**Vorteile:** +- ✅ GUI fĂŒr manuelles Testen +- ✅ Automatische Test-Generierung +- ✅ CI/CD Integration +- ✅ Environment Variables + +--- + +### Option 3: Prism (Mock + Validate) + +**Installation:** +```bash +npm install --save-dev @stoplight/prism-cli +``` + +**Mock Server starten:** +```bash +npx prism mock docs/openapi.json +# Server lĂ€uft auf http://localhost:4010 +``` + +**Validierung:** +```bash +npx prism validate docs/openapi.json +``` + +**Vorteile:** +- ✅ Mock Server fĂŒr Frontend-Entwicklung +- ✅ Schema-Validierung +- ✅ Contract Testing +- ✅ Keine echte API nötig + +--- + +### Option 4: Jest + Supertest (Unit/Integration Tests) + +**Installation:** +```bash +npm install --save-dev jest supertest jest-openapi +``` + +**Beispiel Test:** +```javascript +// tests/api/groups.test.js +const request = require('supertest'); +const jestOpenAPI = require('jest-openapi'); +const app = require('../../src/server'); + +// Load OpenAPI spec +jestOpenAPI(require('../../docs/openapi.json')); + +describe('Groups API', () => { + it('GET /api/groups should return valid response', async () => { + const response = await request(app) + .get('/api/groups') + .expect(200); + + // Validate against OpenAPI schema + expect(response).toSatisfyApiSpec(); + + // Custom assertions + expect(response.body).toHaveProperty('groups'); + expect(Array.isArray(response.body.groups)).toBe(true); + }); + + it('GET /api/groups/:groupId should return 404 for invalid ID', async () => { + const response = await request(app) + .get('/api/groups/invalid-id') + .expect(404); + + expect(response).toSatisfyApiSpec(); + }); +}); +``` + +**package.json:** +```json +{ + "scripts": { + "test": "jest", + "test:watch": "jest --watch", + "test:coverage": "jest --coverage" + } +} +``` + +**Vorteile:** +- ✅ VollstĂ€ndige Integration Tests +- ✅ Schema-Validierung mit jest-openapi +- ✅ Code Coverage +- ✅ CI/CD Integration +- ✅ Watch Mode fĂŒr TDD + +--- + +## 📊 Vergleich + +| Tool | Setup | Schema Validation | Auth Support | CI/CD | Best For | +|------|-------|-------------------|--------------|-------|----------| +| **test-openapi-paths.js** | ✅ Fertig | ❌ | ❌ | ✅ | Quick smoke test | +| **Dredd** | ⚠ Mittel | ✅ | ✅ | ✅ | Contract testing | +| **Postman/Newman** | ⚠ Mittel | ✅ | ✅ | ✅ | Manual + automated | +| **Prism** | ✅ Easy | ✅ | ⚠ | ✅ | Mock + validate | +| **Jest + Supertest** | ⚠ Komplex | ✅ | ✅ | ✅ | Full testing suite | + +--- + +## 🚀 Empfehlung fĂŒr dieses Projekt + +### Phase 1: Quick Wins (Jetzt) +```bash +# 1. Behalte aktuelles Smoke-Test Script +npm run test-openapi + +# 2. Installiere Prism fĂŒr Validierung +npm install --save-dev @stoplight/prism-cli + +# 3. Validiere OpenAPI Spec +npm run validate-openapi # (siehe unten) +``` + +### Phase 2: Contract Testing (SpĂ€ter) +```bash +# 1. Installiere Dredd +npm install --save-dev dredd + +# 2. Erstelle dredd.yml (siehe oben) + +# 3. Schreibe Hooks fĂŒr Auth +# tests/dredd-hooks.js + +# 4. FĂŒhre aus +npm run test:contract +``` + +### Phase 3: Full Testing Suite (Optional) +```bash +# Jest + Supertest + jest-openapi +npm install --save-dev jest supertest jest-openapi @types/jest + +# Schreibe Integration Tests in tests/ +npm run test +``` + +--- + +## 📝 Neue NPM Scripts + +FĂŒge zu `package.json` hinzu: + +```json +{ + "scripts": { + "test-openapi": "node test-openapi-paths.js", + "validate-openapi": "prism validate docs/openapi.json", + "test:contract": "dredd", + "test": "jest", + "test:watch": "jest --watch" + } +} +``` + +--- + +## 🔗 Swagger-eigene Test-Tools + +**Swagger Inspector:** +- Online: https://inspector.swagger.io/ +- Import `openapi.json` +- Manuelles Testen in GUI +- Kann Test-Collections exportieren + +**Swagger Codegen:** +```bash +# Generiert Client-Code + Tests +npx @openapitools/openapi-generator-cli generate \ + -i docs/openapi.json \ + -g javascript \ + -o generated-client +``` + +**Swagger UI "Try it out":** +- Bereits aktiv unter http://localhost:5000/api/docs +- Manuelles Testing direkt in Browser +- ✅ Einfachstes Tool fĂŒr Quick Tests + +--- + +## ✅ Fazit + +**Aktuell:** `test-openapi-paths.js` ist OK fĂŒr Smoke Tests. + +**NĂ€chster Schritt:** +1. Installiere **Prism** fĂŒr Schema-Validierung +2. SpĂ€ter: **Dredd** fĂŒr Contract Testing +3. Optional: **Jest** fĂŒr vollstĂ€ndige Test-Suite + +**Best Practice:** Kombiniere mehrere Tools: +- Prism fĂŒr Schema-Validierung +- Dredd fĂŒr Contract Testing +- Jest fĂŒr Unit/Integration Tests +- Swagger UI fĂŒr manuelles Testing diff --git a/backend/docs/openapi.json b/backend/docs/openapi.json new file mode 100644 index 0000000..6725bd1 --- /dev/null +++ b/backend/docs/openapi.json @@ -0,0 +1,2361 @@ +{ + "openapi": "3.0.0", + "info": { + "title": "Project Image Uploader API", + "version": "1.0.0", + "description": "Auto-generated OpenAPI spec with correct mount prefixes" + }, + "servers": [ + { + "url": "http://localhost:5000", + "description": "Development server" + } + ], + "tags": [ + { + "name": "Upload" + }, + { + "name": "Management Portal" + }, + { + "name": "Admin - Deletion Log" + }, + { + "name": "Admin - Cleanup" + }, + { + "name": "Admin - Monitoring" + }, + { + "name": "Admin - Groups Moderation" + }, + { + "name": "Consent Management" + }, + { + "name": "System Migration" + } + ], + "paths": { + "/api/upload": { + "post": { + "tags": [ + "Upload" + ], + "summary": "Upload a single image and create a new group", + "description": "Uploads an image file, generates a preview, and creates a new group in the database", + "consumes": [ + "multipart/form-data" + ], + "parameters": [ + { + "name": "file", + "in": "formData", + "type": "file", + "required": true, + "description": "Image file to upload" + }, + { + "name": "groupName", + "in": "formData", + "type": "string", + "description": "Name for the new group", + "example": "Familie Mueller" + }, + { + "name": "body", + "in": "body", + "schema": { + "type": "object", + "properties": { + "groupName": { + "example": "any" + } + } + } + } + ], + "responses": { + "200": { + "description": "File uploaded successfully", + "schema": { + "type": "object", + "properties": { + "filePath": { + "type": "string", + "example": "/upload/abc123.jpg" + }, + "fileName": { + "type": "string", + "example": "abc123.jpg" + }, + "groupId": { + "type": "string", + "example": "cTV24Yn-a" + }, + "groupName": { + "type": "string", + "example": "Familie Mueller" + } + }, + "xml": { + "name": "main" + } + } + }, + "400": { + "description": "No file uploaded", + "schema": { + "type": "object", + "properties": { + "msg": { + "type": "string", + "example": "No file uploaded" + } + }, + "xml": { + "name": "main" + } + } + }, + "500": { + "description": "Server error during upload" + } + } + } + }, + "/api/download/{id}": { + "get": { + "description": "", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "type": "string" + } + ], + "responses": { + "default": { + "description": "" + } + } + } + }, + "/api/upload/batch": { + "post": { + "description": "", + "parameters": [ + { + "name": "body", + "in": "body", + "schema": { + "type": "object", + "properties": { + "metadata": { + "example": "any" + }, + "descriptions": { + "example": "any" + }, + "consents": { + "example": "any" + }, + "description": { + "example": "any" + } + } + } + } + ], + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Bad Request" + }, + "500": { + "description": "Internal Server Error" + } + } + } + }, + "/api/groups": { + "get": { + "description": "", + "responses": { + "200": { + "description": "OK" + }, + "500": { + "description": "Internal Server Error" + } + } + } + }, + "/api/groups/{groupId}": { + "get": { + "description": "", + "parameters": [ + { + "name": "groupId", + "in": "path", + "required": true, + "type": "string" + } + ], + "responses": { + "200": { + "description": "OK" + }, + "404": { + "description": "Not Found" + }, + "500": { + "description": "Internal Server Error" + } + } + } + }, + "/api/manage/{token}": { + "get": { + "tags": [ + "Management Portal" + ], + "summary": "Validate token and load group data", + "description": "Validates management token and returns complete group data with images and consents", + "parameters": [ + { + "name": "token", + "in": "path", + "required": true, + "type": "string", + "description": "Management token (UUID v4)", + "example": "550e8400-e29b-41d4-a716-446655440000" + } + ], + "responses": { + "200": { + "description": "Group data loaded successfully", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "data": { + "type": "object", + "properties": { + "groupId": { + "type": "string", + "example": "abc123" + }, + "groupName": { + "type": "string", + "example": "Familie_Mueller" + }, + "managementToken": { + "type": "string", + "example": "550e8400-e29b-41d4-a716-446655440000" + }, + "images": { + "type": "array", + "example": [], + "items": {} + }, + "socialMediaConsents": { + "type": "array", + "example": [], + "items": {} + }, + "display_in_workshop": { + "type": "boolean", + "example": true + } + } + } + }, + "xml": { + "name": "main" + } + } + }, + "404": { + "description": "Invalid token or group deleted" + }, + "429": { + "description": "Too Many Requests" + }, + "500": { + "description": "Internal Server Error" + } + } + }, + "delete": { + "tags": [ + "Management Portal" + ], + "summary": "Delete complete group", + "description": "Deletes entire group with all images, consents and metadata. Creates deletion_log entry. Removes all files (originals + previews).", + "parameters": [ + { + "name": "token", + "in": "path", + "required": true, + "type": "string", + "description": "Management token (UUID v4)", + "example": "550e8400-e29b-41d4-a716-446655440000" + } + ], + "responses": { + "200": { + "description": "Group deleted", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "message": { + "type": "string", + "example": "Group and all associated data deleted successfully" + }, + "data": { + "type": "object", + "properties": { + "groupId": { + "type": "string", + "example": "abc123" + }, + "imagesDeleted": { + "type": "number", + "example": 12 + }, + "deletionTimestamp": { + "type": "string", + "example": "2025-11-15T16:30:00Z" + } + } + } + }, + "xml": { + "name": "main" + } + } + }, + "404": { + "description": "Invalid token or group already deleted" + }, + "429": { + "description": "Too Many Requests" + }, + "500": { + "description": "Internal Server Error" + } + } + } + }, + "/api/manage/{token}/consents": { + "put": { + "tags": [ + "Management Portal" + ], + "summary": "Revoke or restore consents", + "description": "Updates workshop or social media consents for a group", + "parameters": [ + { + "name": "token", + "in": "path", + "required": true, + "type": "string", + "description": "Management token (UUID v4)", + "example": "550e8400-e29b-41d4-a716-446655440000" + }, + { + "name": "body", + "in": "body", + "required": true, + "schema": { + "type": "object", + "properties": { + "consentType": { + "type": "string", + "example": "workshop" + }, + "action": { + "type": "string", + "example": "revoke" + }, + "platformId": { + "type": "number", + "example": 1 + } + } + } + } + ], + "responses": { + "200": { + "description": "Consent updated successfully", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "message": { + "type": "string", + "example": "Workshop consent revoked successfully" + }, + "data": { + "type": "object", + "properties": { + "consentType": { + "type": "string", + "example": "workshop" + }, + "newValue": { + "type": "boolean", + "example": false + } + } + } + }, + "xml": { + "name": "main" + } + } + }, + "400": { + "description": "Invalid request parameters" + }, + "404": { + "description": "Invalid token" + }, + "429": { + "description": "Too Many Requests" + }, + "500": { + "description": "Internal Server Error" + } + } + } + }, + "/api/manage/{token}/images/descriptions": { + "put": { + "tags": [ + "Management Portal" + ], + "summary": "Batch update image descriptions", + "description": "Updates descriptions for multiple images in a group (max 200 chars each)", + "parameters": [ + { + "name": "token", + "in": "path", + "required": true, + "type": "string", + "description": "Management token (UUID v4)", + "example": "550e8400-e29b-41d4-a716-446655440000" + }, + { + "name": "body", + "in": "body", + "required": true, + "schema": { + "type": "object", + "properties": { + "descriptions": { + "type": "array", + "items": { + "type": "object", + "properties": { + "imageId": { + "type": "number", + "example": 2 + }, + "description": { + "type": "string", + "example": "Gruppenfoto" + } + } + } + } + } + } + } + ], + "responses": { + "200": { + "description": "Descriptions updated", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "message": { + "type": "string", + "example": "2 image descriptions updated successfully" + }, + "updatedCount": { + "type": "number", + "example": 2 + } + }, + "xml": { + "name": "main" + } + } + }, + "400": { + "description": "Invalid request or description too long" + }, + "404": { + "description": "Invalid token" + }, + "429": { + "description": "Too Many Requests" + }, + "500": { + "description": "Internal Server Error" + } + } + } + }, + "/api/manage/{token}/metadata": { + "put": { + "tags": [ + "Management Portal" + ], + "summary": "Update group metadata", + "description": "Updates group title, description or name. Sets approved=0 (returns to moderation).", + "parameters": [ + { + "name": "token", + "in": "path", + "required": true, + "type": "string", + "description": "Management token (UUID v4)", + "example": "550e8400-e29b-41d4-a716-446655440000" + }, + { + "name": "body", + "in": "body", + "required": true, + "schema": { + "type": "object", + "properties": { + "title": { + "type": "string", + "example": "Sommercamp 2025" + }, + "description": { + "type": "string", + "example": "Tolle Veranstaltung" + }, + "name": { + "type": "string", + "example": "Familie_Mueller" + } + } + } + } + ], + "responses": { + "200": { + "description": "Metadata updated", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "message": { + "type": "string", + "example": "Metadata updated successfully" + }, + "data": { + "type": "object", + "properties": { + "groupId": { + "type": "string", + "example": "abc123" + }, + "updatedFields": { + "type": "array", + "example": [ + "title", + "description" + ], + "items": { + "type": "string" + } + }, + "requiresModeration": { + "type": "boolean", + "example": true + } + } + } + }, + "xml": { + "name": "main" + } + } + }, + "400": { + "description": "No fields provided" + }, + "404": { + "description": "Invalid token" + }, + "429": { + "description": "Too Many Requests" + }, + "500": { + "description": "Internal Server Error" + } + } + } + }, + "/api/manage/{token}/images": { + "post": { + "tags": [ + "Management Portal" + ], + "summary": "Add new images to group", + "description": "Uploads additional images to existing group. Sets approved=0 (requires re-moderation). Max 50 images per group.", + "consumes": [ + "multipart/form-data" + ], + "parameters": [ + { + "name": "token", + "in": "path", + "required": true, + "type": "string", + "description": "Management token (UUID v4)", + "example": "550e8400-e29b-41d4-a716-446655440000" + }, + { + "name": "images", + "in": "formData", + "type": "file", + "required": true, + "description": "Image files to upload (JPEG, PNG)" + } + ], + "responses": { + "200": { + "description": "Images uploaded", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "message": { + "type": "string", + "example": "3 images added successfully" + }, + "data": { + "type": "object", + "properties": { + "groupId": { + "type": "string", + "example": "abc123" + }, + "newImagesCount": { + "type": "number", + "example": 3 + }, + "totalImagesCount": { + "type": "number", + "example": 15 + } + } + } + }, + "xml": { + "name": "main" + } + } + }, + "400": { + "description": "No images or limit exceeded (max 50)" + }, + "404": { + "description": "Invalid token" + }, + "429": { + "description": "Too Many Requests" + }, + "500": { + "description": "Internal Server Error" + } + } + } + }, + "/api/manage/{token}/images/{imageId}": { + "delete": { + "tags": [ + "Management Portal" + ], + "summary": "Delete single image", + "description": "Deletes a specific image from group (files + DB entry). Sets approved=0. Cannot delete last image.", + "parameters": [ + { + "name": "token", + "in": "path", + "required": true, + "type": "string", + "description": "Management token (UUID v4)", + "example": "550e8400-e29b-41d4-a716-446655440000" + }, + { + "name": "imageId", + "in": "path", + "required": true, + "type": "integer", + "description": "Image ID", + "example": 42 + } + ], + "responses": { + "200": { + "description": "Image deleted", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "message": { + "type": "string", + "example": "Image deleted successfully" + }, + "data": { + "type": "object", + "properties": { + "groupId": { + "type": "string", + "example": "abc123" + }, + "imageId": { + "type": "number", + "example": 42 + }, + "remainingImages": { + "type": "number", + "example": 11 + } + } + } + }, + "xml": { + "name": "main" + } + } + }, + "400": { + "description": "Cannot delete last image" + }, + "404": { + "description": "Invalid token or image not found" + }, + "429": { + "description": "Too Many Requests" + }, + "500": { + "description": "Internal Server Error" + } + } + } + }, + "/api/manage/{token}/reorder": { + "put": { + "description": "", + "parameters": [ + { + "name": "token", + "in": "path", + "required": true, + "type": "string" + }, + { + "name": "body", + "in": "body", + "schema": { + "type": "object", + "properties": { + "imageIds": { + "example": "any" + } + } + } + } + ], + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Bad Request" + }, + "404": { + "description": "Not Found" + }, + "429": { + "description": "Too Many Requests" + }, + "500": { + "description": "Internal Server Error" + } + } + } + }, + "/api/admin/deletion-log": { + "get": { + "tags": [ + "Admin - Deletion Log" + ], + "summary": "Get recent deletion log entries", + "description": "Returns recent deletion log entries with optional limit", + "parameters": [ + { + "name": "limit", + "in": "query", + "type": "integer", + "description": "Number of entries to return (1-1000)", + "example": 10 + } + ], + "responses": { + "200": { + "description": "Deletion log entries", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "deletions": { + "type": "array", + "example": [], + "items": {} + }, + "total": { + "type": "number", + "example": 2 + }, + "limit": { + "type": "number", + "example": 10 + } + }, + "xml": { + "name": "main" + } + } + }, + "400": { + "description": "Invalid limit parameter" + }, + "500": { + "description": "Internal Server Error" + } + } + } + }, + "/api/admin/deletion-log/all": { + "get": { + "tags": [ + "Admin - Deletion Log" + ], + "summary": "Get all deletion log entries", + "description": "Returns complete deletion log without pagination", + "responses": { + "200": { + "description": "All deletion log entries", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "deletions": { + "type": "array", + "example": [], + "items": {} + }, + "total": { + "type": "number", + "example": 50 + } + }, + "xml": { + "name": "main" + } + } + }, + "500": { + "description": "Internal Server Error" + } + } + } + }, + "/api/admin/deletion-log/stats": { + "get": { + "tags": [ + "Admin - Deletion Log" + ], + "summary": "Get deletion statistics", + "description": "Returns aggregated statistics about deleted images", + "responses": { + "200": { + "description": "Deletion statistics", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "totalDeleted": { + "type": "number", + "example": 12 + }, + "totalImages": { + "type": "number", + "example": 348 + }, + "totalSize": { + "type": "string", + "example": "19.38 MB" + }, + "totalSizeBytes": { + "type": "number", + "example": 20324352 + }, + "lastCleanup": { + "type": "string", + "example": "2025-11-15T10:30:00Z" + } + }, + "xml": { + "name": "main" + } + } + }, + "500": { + "description": "Internal Server Error" + } + } + } + }, + "/api/admin/cleanup/trigger": { + "post": { + "tags": [ + "Admin - Cleanup" + ], + "summary": "Manually trigger cleanup of unapproved groups", + "description": "Deletes groups that have not been approved within retention period", + "responses": { + "200": { + "description": "Cleanup completed", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "deletedGroups": { + "type": "number", + "example": 3 + }, + "message": { + "type": "string", + "example": "3 alte unbestĂ€tigte Gruppen gelöscht" + } + }, + "xml": { + "name": "main" + } + } + }, + "500": { + "description": "Internal Server Error" + } + } + } + }, + "/api/admin/cleanup/preview": { + "get": { + "tags": [ + "Admin - Cleanup" + ], + "summary": "Preview groups that would be deleted", + "description": "Dry-run showing which unapproved groups are eligible for deletion", + "responses": { + "200": { + "description": "Preview of groups to delete", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "groupsToDelete": { + "type": "number", + "example": 2 + }, + "groups": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "example": "abc123" + }, + "groupName": { + "type": "string", + "example": "Familie_Mueller" + }, + "uploadDate": { + "type": "string", + "example": "2025-10-01T12:00:00Z" + }, + "daysUntilDeletion": { + "type": "number", + "example": -5 + }, + "imageCount": { + "type": "number", + "example": 8 + } + } + } + }, + "message": { + "type": "string", + "example": "2 groups would be deleted" + } + }, + "xml": { + "name": "main" + } + } + }, + "500": { + "description": "Internal Server Error" + } + } + } + }, + "/api/admin/rate-limiter/stats": { + "get": { + "tags": [ + "Admin - Monitoring" + ], + "summary": "Get rate limiter statistics", + "description": "Returns statistics about rate limiting (blocked requests, active limits)", + "responses": { + "200": { + "description": "Rate limiter statistics", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "totalRequests": { + "type": "number", + "example": 1523 + }, + "blockedRequests": { + "type": "number", + "example": 12 + }, + "activeClients": { + "type": "number", + "example": 45 + } + }, + "xml": { + "name": "main" + } + } + }, + "500": { + "description": "Internal Server Error" + } + } + } + }, + "/api/admin/management-audit": { + "get": { + "tags": [ + "Admin - Monitoring" + ], + "summary": "Get management audit log entries", + "description": "Returns recent management portal activity logs", + "parameters": [ + { + "name": "limit", + "in": "query", + "type": "integer", + "description": "Number of entries to return (1-1000)", + "example": 100 + } + ], + "responses": { + "200": { + "description": "Audit log entries", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "logs": { + "type": "array", + "example": [], + "items": {} + }, + "total": { + "type": "number", + "example": 15 + }, + "limit": { + "type": "number", + "example": 100 + } + }, + "xml": { + "name": "main" + } + } + }, + "400": { + "description": "Invalid limit parameter" + }, + "500": { + "description": "Internal Server Error" + } + } + } + }, + "/api/admin/management-audit/stats": { + "get": { + "tags": [ + "Admin - Monitoring" + ], + "summary": "Get management audit log statistics", + "description": "Returns aggregated statistics about management portal activity", + "responses": { + "200": { + "description": "Audit log statistics", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "totalActions": { + "type": "number", + "example": 523 + }, + "actionsByType": { + "type": "object", + "properties": { + "update": { + "type": "number", + "example": 312 + }, + "delete": { + "type": "number", + "example": 45 + }, + "approve": { + "type": "number", + "example": 166 + } + } + }, + "lastAction": { + "type": "string", + "example": "2025-11-15T14:30:00Z" + } + }, + "xml": { + "name": "main" + } + } + }, + "500": { + "description": "Internal Server Error" + } + } + } + }, + "/api/admin/management-audit/group/{groupId}": { + "get": { + "tags": [ + "Admin - Monitoring" + ], + "summary": "Get audit log for specific group", + "description": "Returns all management actions performed on a specific group", + "parameters": [ + { + "name": "groupId", + "in": "path", + "required": true, + "type": "string", + "description": "Group ID", + "example": "abc123def456" + } + ], + "responses": { + "200": { + "description": "Audit log for group", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "groupId": { + "type": "string", + "example": "abc123def456" + }, + "logs": { + "type": "array", + "example": [], + "items": {} + }, + "total": { + "type": "number", + "example": 8 + } + }, + "xml": { + "name": "main" + } + } + }, + "500": { + "description": "Internal Server Error" + } + } + } + }, + "/api/admin/groups": { + "get": { + "tags": [ + "Admin - Groups Moderation" + ], + "summary": "Get all groups for moderation", + "description": "Returns all groups including unapproved ones with moderation info and consent data", + "parameters": [ + { + "name": "workshopOnly", + "in": "query", + "type": "boolean", + "description": "Filter by workshop consent", + "example": false + }, + { + "name": "platform", + "in": "query", + "type": "string", + "description": "Filter by social media platform", + "example": "instagram" + } + ], + "responses": { + "200": { + "description": "All groups with moderation info", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "groups": { + "type": "array", + "items": { + "type": "object", + "properties": { + "groupId": { + "type": "string", + "example": "abc123" + }, + "groupName": { + "type": "string", + "example": "Familie_Mueller" + }, + "isApproved": { + "type": "boolean", + "example": false + }, + "uploadDate": { + "type": "string", + "example": "2025-11-01T10:00:00Z" + }, + "imageCount": { + "type": "number", + "example": 12 + }, + "socialMediaConsents": { + "type": "array", + "example": [], + "items": {} + } + } + } + } + }, + "xml": { + "name": "main" + } + } + }, + "500": { + "description": "Internal Server Error" + } + } + } + }, + "/api/admin/groups/{groupId}": { + "get": { + "tags": [ + "Admin - Groups Moderation" + ], + "summary": "Get single group for moderation", + "description": "Returns detailed info for a specific group including unapproved ones", + "parameters": [ + { + "name": "groupId", + "in": "path", + "required": true, + "type": "string", + "description": "Group ID", + "example": "abc123def456" + } + ], + "responses": { + "200": { + "description": "Group details with images", + "schema": { + "type": "object", + "properties": { + "groupId": { + "type": "string", + "example": "abc123" + }, + "groupName": { + "type": "string", + "example": "Familie_Mueller" + }, + "isApproved": { + "type": "boolean", + "example": true + }, + "images": { + "type": "array", + "example": [], + "items": {} + } + }, + "xml": { + "name": "main" + } + } + }, + "404": { + "description": "Group not found" + }, + "500": { + "description": "Internal Server Error" + } + } + }, + "patch": { + "tags": [ + "Admin - Groups Moderation" + ], + "summary": "Update group metadata", + "description": "Updates group metadata fields (year, title, description, name)", + "parameters": [ + { + "name": "groupId", + "in": "path", + "required": true, + "type": "string", + "description": "Group ID", + "example": "abc123def456" + }, + { + "name": "body", + "in": "body", + "required": true, + "schema": { + "type": "object", + "properties": { + "year": { + "type": "number", + "example": 2025 + }, + "title": { + "type": "string", + "example": "Sommercamp" + }, + "description": { + "type": "string", + "example": "Tolle Veranstaltung" + }, + "name": { + "type": "string", + "example": "Familie_Mueller" + } + } + } + } + ], + "responses": { + "200": { + "description": "Group updated successfully", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "message": { + "type": "string", + "example": "Gruppe aktualisiert" + }, + "updatedFields": { + "type": "array", + "example": [ + "year", + "title" + ], + "items": { + "type": "string" + } + } + }, + "xml": { + "name": "main" + } + } + }, + "400": { + "description": "Bad Request" + }, + "404": { + "description": "Group not found" + }, + "500": { + "description": "Internal Server Error" + } + } + }, + "delete": { + "tags": [ + "Admin - Groups Moderation" + ], + "summary": "Delete a group", + "description": "Deletes a complete group including all images and metadata", + "parameters": [ + { + "name": "groupId", + "in": "path", + "required": true, + "type": "string", + "description": "Group ID", + "example": "abc123def456" + } + ], + "responses": { + "200": { + "description": "Group deleted successfully", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "message": { + "type": "string", + "example": "Gruppe erfolgreich gelöscht" + }, + "groupId": { + "type": "string", + "example": "abc123def456" + } + }, + "xml": { + "name": "main" + } + } + }, + "404": { + "description": "Group not found" + }, + "500": { + "description": "Internal Server Error" + } + } + } + }, + "/api/admin/groups/{groupId}/approve": { + "patch": { + "tags": [ + "Admin - Groups Moderation" + ], + "summary": "Approve a group", + "description": "Marks a group as approved, making it publicly visible", + "parameters": [ + { + "name": "groupId", + "in": "path", + "required": true, + "type": "string", + "description": "Group ID", + "example": "abc123def456" + }, + { + "name": "body", + "in": "body", + "required": false, + "schema": { + "type": "object", + "properties": { + "approved": { + "type": "boolean", + "example": true + } + } + } + } + ], + "responses": { + "200": { + "description": "Group approved successfully", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "message": { + "type": "string", + "example": "Gruppe erfolgreich freigegeben" + } + }, + "xml": { + "name": "main" + } + } + }, + "400": { + "description": "Bad Request" + }, + "404": { + "description": "Group not found" + }, + "500": { + "description": "Internal Server Error" + } + } + } + }, + "/api/admin/groups/{groupId}/images/{imageId}": { + "delete": { + "tags": [ + "Admin - Groups Moderation" + ], + "summary": "Delete a single image", + "description": "Deletes a specific image from a group", + "parameters": [ + { + "name": "groupId", + "in": "path", + "required": true, + "type": "string", + "description": "Group ID", + "example": "abc123def456" + }, + { + "name": "imageId", + "in": "path", + "required": true, + "type": "integer", + "description": "Image ID", + "example": 42 + } + ], + "responses": { + "200": { + "description": "Image deleted successfully", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "message": { + "type": "string", + "example": "Bild erfolgreich gelöscht" + }, + "groupId": { + "type": "string", + "example": "abc123def456" + }, + "imageId": { + "type": "number", + "example": 42 + } + }, + "xml": { + "name": "main" + } + } + }, + "404": { + "description": "Image not found" + }, + "500": { + "description": "Internal Server Error" + } + } + }, + "patch": { + "tags": [ + "Admin - Groups Moderation" + ], + "summary": "Update single image description", + "description": "Updates description for a specific image (max 200 characters)", + "parameters": [ + { + "name": "groupId", + "in": "path", + "required": true, + "type": "string", + "description": "Group ID", + "example": "abc123def456" + }, + { + "name": "imageId", + "in": "path", + "required": true, + "type": "integer", + "description": "Image ID", + "example": 42 + }, + { + "name": "body", + "in": "body", + "required": true, + "schema": { + "type": "object", + "properties": { + "image_description": { + "type": "string", + "example": "Sonnenuntergang am Strand" + } + } + } + } + ], + "responses": { + "200": { + "description": "Description updated", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "message": { + "type": "string", + "example": "Bildbeschreibung erfolgreich aktualisiert" + }, + "groupId": { + "type": "string", + "example": "abc123def456" + }, + "imageId": { + "type": "number", + "example": 42 + }, + "imageDescription": { + "type": "string", + "example": "Sonnenuntergang am Strand" + } + }, + "xml": { + "name": "main" + } + } + }, + "400": { + "description": "Description too long (max 200 chars)" + }, + "404": { + "description": "Image not found" + }, + "500": { + "description": "Internal Server Error" + } + } + } + }, + "/api/admin/groups/{groupId}/images/batch-description": { + "patch": { + "tags": [ + "Admin - Groups Moderation" + ], + "summary": "Batch update image descriptions", + "description": "Updates descriptions for multiple images in a group at once", + "parameters": [ + { + "name": "groupId", + "in": "path", + "required": true, + "type": "string", + "description": "Group ID", + "example": "abc123def456" + }, + { + "name": "body", + "in": "body", + "required": true, + "schema": { + "type": "object", + "properties": { + "descriptions": { + "type": "array", + "items": { + "type": "object", + "properties": { + "imageId": { + "type": "number", + "example": 2 + }, + "description": { + "type": "string", + "example": "Gruppenfoto beim Lagerfeuer" + } + } + } + } + } + } + } + ], + "responses": { + "200": { + "description": "Descriptions updated", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "updatedCount": { + "type": "number", + "example": 2 + }, + "message": { + "type": "string", + "example": "2 Bildbeschreibungen aktualisiert" + } + }, + "xml": { + "name": "main" + } + } + }, + "400": { + "description": "Invalid request format" + }, + "500": { + "description": "Internal Server Error" + } + } + } + }, + "/api/admin/social-media/platforms": { + "get": { + "tags": [ + "Consent Management" + ], + "summary": "Get active social media platforms", + "description": "Returns list of all active social media platforms available for consent", + "responses": { + "200": { + "description": "List of platforms", + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "platform_id": { + "type": "number", + "example": 1 + }, + "platform_name": { + "type": "string", + "example": "instagram" + }, + "display_name": { + "type": "string", + "example": "Instagram" + }, + "icon_name": { + "type": "string", + "example": "instagram" + }, + "is_active": { + "type": "boolean", + "example": true + } + } + }, + "xml": { + "name": "main" + } + } + }, + "500": { + "description": "Internal Server Error" + } + } + } + }, + "/api/admin/groups/{groupId}/consents": { + "post": { + "description": "", + "parameters": [ + { + "name": "groupId", + "in": "path", + "required": true, + "type": "string" + }, + { + "name": "body", + "in": "body", + "schema": { + "type": "object", + "properties": { + "workshopConsent": { + "example": "any" + }, + "socialMediaConsents": { + "example": "any" + } + } + } + } + ], + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Bad Request" + }, + "404": { + "description": "Not Found" + }, + "500": { + "description": "Internal Server Error" + } + } + }, + "get": { + "tags": [ + "Consent Management" + ], + "summary": "Get consents for a group", + "description": "Returns all consent data (workshop + social media) for a specific group", + "parameters": [ + { + "name": "groupId", + "in": "path", + "required": true, + "type": "string", + "description": "Group ID", + "example": "abc123def456" + } + ], + "responses": { + "200": { + "description": "Group consents", + "schema": { + "type": "object", + "properties": { + "groupId": { + "type": "string", + "example": "abc123" + }, + "workshopConsent": { + "type": "boolean", + "example": true + }, + "consentTimestamp": { + "type": "string", + "example": "2025-11-01T10:00:00Z" + }, + "socialMediaConsents": { + "type": "array", + "items": { + "type": "object", + "properties": { + "platformId": { + "type": "number", + "example": 1 + }, + "platformName": { + "type": "string", + "example": "instagram" + }, + "displayName": { + "type": "string", + "example": "Instagram" + }, + "consented": { + "type": "boolean", + "example": true + }, + "revoked": { + "type": "boolean", + "example": false + } + } + } + } + }, + "xml": { + "name": "main" + } + } + }, + "404": { + "description": "Group not found" + }, + "500": { + "description": "Internal Server Error" + } + } + } + }, + "/api/admin/groups/by-consent": { + "get": { + "tags": [ + "Consent Management" + ], + "summary": "Filter groups by consent status", + "description": "Returns groups filtered by workshop consent or social media platform consents", + "parameters": [ + { + "name": "displayInWorkshop", + "in": "query", + "type": "boolean", + "description": "Filter by workshop consent", + "example": true + }, + { + "name": "platformId", + "in": "query", + "type": "integer", + "description": "Filter by platform ID", + "example": 1 + }, + { + "name": "platformConsent", + "in": "query", + "type": "boolean", + "description": "Filter by platform consent status", + "example": true + } + ], + "responses": { + "200": { + "description": "Filtered groups", + "schema": { + "type": "object", + "properties": { + "count": { + "type": "number", + "example": 5 + }, + "filters": { + "type": "object", + "properties": { + "displayInWorkshop": { + "type": "boolean", + "example": true + } + } + }, + "groups": { + "type": "array", + "example": [], + "items": {} + } + }, + "xml": { + "name": "main" + } + } + }, + "400": { + "description": "Invalid platformId" + }, + "500": { + "description": "Internal Server Error" + } + } + } + }, + "/api/admin/consents/export": { + "get": { + "tags": [ + "Consent Management" + ], + "summary": "Export consent data", + "description": "Exports consent data for legal documentation in JSON or CSV format", + "produces": [ + "text/csv" + ], + "parameters": [ + { + "name": "format", + "in": "query", + "type": "string", + "enum": [ + "json", + "csv" + ], + "description": "Export format", + "example": "json" + }, + { + "name": "year", + "in": "query", + "type": "integer", + "description": "Filter by year", + "example": 2025 + }, + { + "name": "approved", + "in": "query", + "type": "boolean", + "description": "Filter by approval status", + "example": true + } + ], + "responses": { + "200": { + "description": "Export data (CSV format)", + "schema": { + "type": "object", + "properties": { + "exportDate": { + "type": "string", + "example": "2025-11-15T16:30:00Z" + }, + "filters": { + "type": "object", + "properties": { + "year": { + "type": "number", + "example": 2025 + } + } + }, + "count": { + "type": "number", + "example": 12 + }, + "data": { + "type": "array", + "example": [], + "items": {} + } + }, + "xml": { + "name": "main" + } + }, + "content": { + "text/csv": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "400": { + "description": "Invalid format" + }, + "500": { + "description": "Internal Server Error" + } + } + } + }, + "/api/admin/{groupId}/reorder": { + "put": { + "description": "", + "parameters": [ + { + "name": "groupId", + "in": "path", + "required": true, + "type": "string" + }, + { + "name": "body", + "in": "body", + "schema": { + "type": "object", + "properties": { + "imageIds": { + "example": "any" + } + } + } + } + ], + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Bad Request" + }, + "404": { + "description": "Not Found" + }, + "500": { + "description": "Internal Server Error" + } + } + } + }, + "/api/system/migration/status": { + "get": { + "tags": [ + "System Migration" + ], + "summary": "Get migration status", + "description": "Returns current database migration status and history", + "responses": { + "200": { + "description": "Migration status", + "schema": { + "type": "object", + "properties": { + "migrationComplete": { + "type": "boolean", + "example": true + }, + "jsonBackupExists": { + "type": "boolean", + "example": true + }, + "sqliteActive": { + "type": "boolean", + "example": true + }, + "lastMigration": { + "type": "string", + "example": "2025-11-01T10:00:00Z" + } + }, + "xml": { + "name": "main" + } + } + }, + "500": { + "description": "Internal Server Error" + } + } + } + }, + "/api/system/migration/migrate": { + "post": { + "tags": [ + "System Migration" + ], + "summary": "Manually trigger migration", + "description": "Triggers manual migration from JSON to SQLite database", + "responses": { + "200": { + "description": "Migration successful", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "message": { + "type": "string", + "example": "Migration completed successfully" + }, + "groupsMigrated": { + "type": "number", + "example": 24 + }, + "imagesMigrated": { + "type": "number", + "example": 348 + } + }, + "xml": { + "name": "main" + } + } + }, + "500": { + "description": "Migration failed" + } + } + } + }, + "/api/system/migration/rollback": { + "post": { + "tags": [ + "System Migration" + ], + "summary": "Rollback to JSON", + "description": "Emergency rollback from SQLite to JSON file storage", + "responses": { + "200": { + "description": "Rollback successful", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "message": { + "type": "string", + "example": "Rolled back to JSON successfully" + }, + "groupsRestored": { + "type": "number", + "example": 24 + } + }, + "xml": { + "name": "main" + } + } + }, + "500": { + "description": "Rollback failed" + } + } + } + }, + "/api/system/migration/health": { + "get": { + "tags": [ + "System Migration" + ], + "summary": "Database health check", + "description": "Checks database connectivity and health status", + "responses": { + "200": { + "description": "Database healthy", + "schema": { + "type": "object", + "properties": { + "database": { + "type": "object", + "properties": { + "healthy": { + "type": "boolean", + "example": true + }, + "status": { + "type": "string", + "example": "OK" + } + } + } + }, + "xml": { + "name": "main" + } + } + }, + "500": { + "description": "Database unhealthy", + "schema": { + "type": "object", + "properties": { + "database": { + "type": "object", + "properties": { + "healthy": { + "type": "boolean", + "example": false + }, + "status": { + "type": "string", + "example": "ERROR" + }, + "error": { + "type": "string", + "example": "Connection failed" + } + } + } + }, + "xml": { + "name": "main" + } + } + } + } + } + } + } +} \ No newline at end of file diff --git a/backend/jest.config.js b/backend/jest.config.js new file mode 100644 index 0000000..90a6014 --- /dev/null +++ b/backend/jest.config.js @@ -0,0 +1,29 @@ +module.exports = { + testEnvironment: 'node', + coverageDirectory: 'coverage', + collectCoverageFrom: [ + 'src/**/*.js', + '!src/index.js', // Server startup + '!src/generate-openapi.js', // Build tool + '!src/scripts/**', // Utility scripts + ], + testMatch: [ + '**/tests/**/*.test.js', + '**/tests/**/*.spec.js' + ], + coverageThreshold: { + global: { + branches: 20, + functions: 20, + lines: 20, + statements: 20 + } + }, + // Setup for each test file - initializes server once + setupFilesAfterEnv: ['/tests/setup.js'], + testTimeout: 10000, + // Run tests serially to avoid DB conflicts + maxWorkers: 1, + // Force exit after tests complete + forceExit: true +}; diff --git a/backend/package.json b/backend/package.json index 7185d94..a773bf7 100644 --- a/backend/package.json +++ b/backend/package.json @@ -9,7 +9,13 @@ "client": "npm run dev --prefix ../frontend", "client-build": "cd ../frontend && npm run build && serve -s build -l 80", "dev": "concurrently \"npm run server\" \"npm run client\"", - "build": "concurrently \"npm run server\" \"npm run client-build\"" + "build": "concurrently \"npm run server\" \"npm run client-build\"", + "generate-openapi": "node src/generate-openapi.js", + "test-openapi": "node test-openapi-paths.js", + "validate-openapi": "redocly lint docs/openapi.json", + "test": "jest --coverage", + "test:watch": "jest --watch", + "test:api": "jest tests/api" }, "keywords": [], "author": "", @@ -27,7 +33,13 @@ "uuid": "^13.0.0" }, "devDependencies": { + "@redocly/cli": "^2.11.1", + "@stoplight/prism-cli": "^5.14.2", "concurrently": "^6.0.0", - "nodemon": "^2.0.7" + "jest": "^30.2.0", + "nodemon": "^2.0.7", + "supertest": "^7.1.4", + "swagger-autogen": "^2.23.7", + "swagger-ui-express": "^5.0.1" } } diff --git a/backend/src/constants.js b/backend/src/constants.js index 12a89a0..fbe38c8 100644 --- a/backend/src/constants.js +++ b/backend/src/constants.js @@ -1,21 +1,15 @@ -const endpoints = { - UPLOAD_STATIC_DIRECTORY: '/upload', - UPLOAD_FILE: '/upload', - UPLOAD_BATCH: '/upload/batch', - PREVIEW_STATIC_DIRECTORY: '/previews', - DOWNLOAD_FILE: '/download/:id', - GET_GROUP: '/groups/:groupId', - GET_ALL_GROUPS: '/groups', - DELETE_GROUP: '/groups/:groupId' -}; - // Filesystem directory (relative to backend/src) where uploaded images will be stored // Use path.join(__dirname, '..', UPLOAD_FS_DIR, fileName) in code -const UPLOAD_FS_DIR = 'data/images'; +// In test mode, use a temporary directory in /tmp to avoid permission issues +const UPLOAD_FS_DIR = process.env.NODE_ENV === 'test' + ? '/tmp/test-image-uploader/images' + : 'data/images'; // Filesystem directory (relative to backend/src) where preview images will be stored // Use path.join(__dirname, '..', PREVIEW_FS_DIR, fileName) in code -const PREVIEW_FS_DIR = 'data/previews'; +const PREVIEW_FS_DIR = process.env.NODE_ENV === 'test' + ? '/tmp/test-image-uploader/previews' + : 'data/previews'; // Preview generation configuration const PREVIEW_CONFIG = { @@ -29,4 +23,4 @@ const time = { WEEK_1: 604800000 }; -module.exports = { endpoints, time, UPLOAD_FS_DIR, PREVIEW_FS_DIR, PREVIEW_CONFIG }; \ No newline at end of file +module.exports = { time, UPLOAD_FS_DIR, PREVIEW_FS_DIR, PREVIEW_CONFIG }; \ No newline at end of file diff --git a/backend/src/database/DatabaseManager.js b/backend/src/database/DatabaseManager.js index f768ea1..e403dcd 100644 --- a/backend/src/database/DatabaseManager.js +++ b/backend/src/database/DatabaseManager.js @@ -5,27 +5,37 @@ const fs = require('fs'); class DatabaseManager { constructor() { this.db = null; - // Place database file under data/db - this.dbPath = path.join(__dirname, '../data/db/image_uploader.db'); + // Use in-memory database for tests, file-based for production + if (process.env.NODE_ENV === 'test') { + this.dbPath = ':memory:'; + } else { + // Place database file under data/db + this.dbPath = path.join(__dirname, '../data/db/image_uploader.db'); + } this.schemaPath = path.join(__dirname, 'schema.sql'); } async initialize() { try { - // Stelle sicher, dass das data-Verzeichnis existiert - const dataDir = path.dirname(this.dbPath); - if (!fs.existsSync(dataDir)) { - fs.mkdirSync(dataDir, { recursive: true }); + // Stelle sicher, dass das data-Verzeichnis existiert (skip for in-memory) + if (this.dbPath !== ':memory:') { + const dataDir = path.dirname(this.dbPath); + if (!fs.existsSync(dataDir)) { + fs.mkdirSync(dataDir, { recursive: true }); + } } - // Öffne Datenbankverbindung - this.db = new sqlite3.Database(this.dbPath, (err) => { - if (err) { - console.error('Fehler beim Öffnen der Datenbank:', err.message); - throw err; - } else { - console.log('✓ SQLite Datenbank verbunden:', this.dbPath); - } + // Öffne Datenbankverbindung (promisify for async/await) + await new Promise((resolve, reject) => { + this.db = new sqlite3.Database(this.dbPath, (err) => { + if (err) { + console.error('Fehler beim Öffnen der Datenbank:', err.message); + reject(err); + } else { + console.log('✓ SQLite Datenbank verbunden:', this.dbPath); + resolve(); + } + }); }); // Aktiviere Foreign Keys @@ -37,8 +47,10 @@ class DatabaseManager { // Run database migrations (automatic on startup) await this.runMigrations(); - // Generate missing previews for existing images - await this.generateMissingPreviews(); + // Generate missing previews for existing images (skip in test mode) + if (process.env.NODE_ENV !== 'test') { + await this.generateMissingPreviews(); + } console.log('✓ Datenbank erfolgreich initialisiert'); } catch (error) { diff --git a/backend/src/generate-openapi.js b/backend/src/generate-openapi.js new file mode 100644 index 0000000..290dc21 --- /dev/null +++ b/backend/src/generate-openapi.js @@ -0,0 +1,96 @@ +const swaggerAutogen = require('swagger-autogen')(); +const path = require('path'); +const fs = require('fs'); + +const outputFile = path.join(__dirname, '..', 'docs', 'openapi.json'); + +// Import route mappings (Single Source of Truth - keine Router-Imports!) +const routeMappings = require('./routes/routeMappings'); + +// Use mappings directly (already has file + prefix) +const routerMappings = routeMappings; + +const routesDir = path.join(__dirname, 'routes'); +const endpointsFiles = routerMappings.map(r => path.join(routesDir, r.file)); + +const doc = { + info: { + title: 'Project Image Uploader API', + version: '1.0.0', + description: 'Auto-generated OpenAPI spec with correct mount prefixes' + }, + host: 'localhost:5000', + schemes: ['http'], + // Add base path hints per router (swagger-autogen doesn't natively support per-file prefixes, + // so we'll post-process or use @swagger annotations in route files) +}; + +console.log('Generating OpenAPI spec...'); + +// Generate specs for each router separately with correct basePath +async function generateWithPrefixes() { + const allPaths = {}; + const allTags = new Set(); + + for (const mapping of routerMappings) { + console.log(`ïżœ Processing ${mapping.file} with prefix: "${mapping.prefix || '/'}"...`); + + const uniqueName = mapping.name || mapping.file.replace('.js', ''); + const tempOutput = path.join(__dirname, '..', 'docs', `.temp-${uniqueName}.json`); + const routeFile = path.join(routesDir, mapping.file); + + const tempDoc = { + ...doc, + basePath: mapping.prefix || '/' + }; + + await swaggerAutogen(tempOutput, [routeFile], tempDoc); + + // Read the generated spec + const tempSpec = JSON.parse(fs.readFileSync(tempOutput, 'utf8')); + + // Merge paths - prepend prefix to each path + for (const [routePath, pathObj] of Object.entries(tempSpec.paths || {})) { + const fullPath = mapping.prefix + routePath; + allPaths[fullPath] = pathObj; + + // Collect tags + Object.values(pathObj).forEach(methodObj => { + if (methodObj.tags) { + methodObj.tags.forEach(tag => allTags.add(tag)); + } + }); + } + + // Clean up temp file + fs.unlinkSync(tempOutput); + } + + // Write final merged spec + const finalSpec = { + openapi: '3.0.0', + info: doc.info, + servers: [ + { url: 'http://localhost:5000', description: 'Development server' } + ], + tags: Array.from(allTags).map(name => ({ name })), + paths: allPaths + }; + + fs.writeFileSync(outputFile, JSON.stringify(finalSpec, null, 2)); + + console.log('\n✅ OpenAPI spec generated successfully!'); + console.log(`📊 Total paths: ${Object.keys(allPaths).length}`); + console.log(`📋 Tags: ${Array.from(allTags).join(', ')}`); +} + +// Export for programmatic usage (e.g., from server.js) +module.exports = generateWithPrefixes; + +// Run directly when called from CLI +if (require.main === module) { + generateWithPrefixes().catch(err => { + console.error('❌ Failed to generate OpenAPI spec:', err); + process.exit(1); + }); +} diff --git a/backend/src/middlewares/auth.js b/backend/src/middlewares/auth.js new file mode 100644 index 0000000..548cddf --- /dev/null +++ b/backend/src/middlewares/auth.js @@ -0,0 +1,50 @@ +/** + * Admin Authentication Middleware + * Validates Bearer token from Authorization header against ADMIN_API_KEY env variable + */ + +const requireAdminAuth = (req, res, next) => { + const authHeader = req.headers.authorization; + + // Check if Authorization header exists + if (!authHeader) { + return res.status(403).json({ + error: 'Zugriff verweigert', + message: 'Authorization header fehlt' + }); + } + + // Check if it's a Bearer token + const parts = authHeader.split(' '); + if (parts.length !== 2 || parts[0] !== 'Bearer') { + return res.status(403).json({ + error: 'Zugriff verweigert', + message: 'UngĂŒltiges Authorization Format. Erwartet: Bearer ' + }); + } + + const token = parts[1]; + const adminKey = process.env.ADMIN_API_KEY; + + // Check if ADMIN_API_KEY is configured + if (!adminKey) { + console.error('⚠ ADMIN_API_KEY nicht in .env konfiguriert!'); + return res.status(500).json({ + error: 'Server-Konfigurationsfehler', + message: 'Admin-Authentifizierung nicht konfiguriert' + }); + } + + // Validate token + if (token !== adminKey) { + return res.status(403).json({ + error: 'Zugriff verweigert', + message: 'UngĂŒltiger Admin-Token' + }); + } + + // Token valid, proceed to route + next(); +}; + +module.exports = { requireAdminAuth }; diff --git a/backend/src/routes/README.md b/backend/src/routes/README.md new file mode 100644 index 0000000..7d0651e --- /dev/null +++ b/backend/src/routes/README.md @@ -0,0 +1,357 @@ +# API Routes - Developer Guide + +## 📁 Single Source of Truth + +**`routeMappings.js`** ist die zentrale Konfigurationsdatei fĂŒr alle API-Routen. + +```javascript +// ✅ HIER Ă€ndern (Single Source of Truth) +module.exports = [ + { router: 'upload', prefix: '/api', file: 'upload.js' }, + // ... +]; +``` + +**Verwendet von:** +- `routes/index.js` → Server-Routing +- `generate-openapi.js` → OpenAPI-Dokumentation + +**❌ NICHT direkt in `routes/index.js` oder `generate-openapi.js` Ă€ndern!** + +--- + +## 🆕 Neue Route hinzufĂŒgen + +### 1. Router-Datei erstellen + +```bash +touch backend/src/routes/myNewRoute.js +``` + +```javascript +// backend/src/routes/myNewRoute.js +const express = require('express'); +const router = express.Router(); + +/** + * #swagger.tags = ['My Feature'] + * #swagger.description = 'Beschreibung der Route' + */ +router.get('/my-endpoint', async (req, res) => { + res.json({ success: true }); +}); + +module.exports = router; +``` + +### 2. In `routeMappings.js` registrieren + +```javascript +// backend/src/routes/routeMappings.js +module.exports = [ + // ... bestehende Routes + { router: 'myNewRoute', prefix: '/api/my-feature', file: 'myNewRoute.js' } +]; +``` + +### 3. In `routes/index.js` importieren + +```javascript +// backend/src/routes/index.js +const myNewRouteRouter = require('./myNewRoute'); + +const routerMap = { + // ... bestehende Router + myNewRoute: myNewRouteRouter +}; +``` + +### 4. OpenAPI regenerieren + +OpenAPI wird **automatisch** bei jedem Server-Start (Dev-Mode) generiert. + +**Manuell generieren:** +```bash +npm run generate-openapi +``` + +**OpenAPI-Pfade testen:** +```bash +npm run test-openapi # PrĂŒft alle Routen gegen localhost:5000 +``` + +✅ **Fertig!** Route ist unter `/api/my-feature/my-endpoint` verfĂŒgbar. + +--- + +## 🔄 OpenAPI-Dokumentation generieren + +### Automatisch bei Server-Start (Dev-Mode) ⭐ + +Im Development-Modus wird die OpenAPI-Spezifikation **automatisch generiert**, wenn der Server startet: + +```bash +cd backend +npm run dev # oder npm run server +``` + +**Ausgabe:** +``` +🔄 Generating OpenAPI specification... +✓ OpenAPI spec generated +📊 Total paths: 35 +📋 Tags: Upload, Management Portal, Admin - ... +``` + +Die Datei `backend/docs/openapi.json` wird bei jedem Start aktualisiert. + +### Manuell (fĂŒr Produktions-Builds) + +```bash +cd backend +npm run generate-openapi +``` + +**Generiert:** `backend/docs/openapi.json` + +**Zugriff:** http://localhost:5000/api/docs (nur dev-mode) + +### Was wird generiert? + +- Alle Routen aus `routeMappings.js` +- Mount-Prefixes werden automatisch angewendet +- Swagger-Annotations aus Route-Dateien werden erkannt +- **Automatisch im Dev-Mode:** Bei jedem Server-Start (nur wenn `NODE_ENV !== 'production'`) +- **Manuell:** Mit `npm run generate-openapi` + +### Swagger-Annotations verwenden + +**Wichtig:** swagger-autogen nutzt `#swagger` Comments (nicht `@swagger`)! + +```javascript +router.get('/groups', async (req, res) => { + /* + #swagger.tags = ['Groups'] + #swagger.summary = 'Alle Gruppen abrufen' + #swagger.description = 'Liefert alle freigegebenen Gruppen mit Bildern' + #swagger.responses[200] = { + description: 'Liste der Gruppen', + schema: { + groups: [{ + groupId: 'cTV24Yn-a', + year: 2024, + title: 'Familie Mueller' + }], + totalCount: 73 + } + } + #swagger.responses[500] = { + description: 'Server error' + } + */ + // Route implementation... +}); +``` + +**Mit Parametern:** + +```javascript +router.get('/groups/:groupId', async (req, res) => { + /* + #swagger.tags = ['Groups'] + #swagger.summary = 'Einzelne Gruppe abrufen' + #swagger.parameters['groupId'] = { + in: 'path', + required: true, + type: 'string', + description: 'Unique group ID', + example: 'cTV24Yn-a' + } + #swagger.responses[200] = { + description: 'Group details', + schema: { groupId: 'cTV24Yn-a', title: 'Familie Mueller' } + } + #swagger.responses[404] = { + description: 'Group not found' + } + */ + // Route implementation... +}); +``` + +**Mit Request Body:** + +```javascript +router.post('/groups', async (req, res) => { + /* + #swagger.tags = ['Groups'] + #swagger.summary = 'Neue Gruppe erstellen' + #swagger.parameters['body'] = { + in: 'body', + required: true, + schema: { + title: 'Familie Mueller', + year: 2024, + description: 'Weihnachtsfeier' + } + } + #swagger.responses[201] = { + description: 'Group created', + schema: { groupId: 'abc123', message: 'Created successfully' } + } + */ + // Route implementation... +}); +``` + +--- + +## đŸ—‚ïž API-Struktur + +### Public API (`/api`) +- **Zugriff:** Öffentlich, keine Authentifizierung +- **Routen:** Upload, Download, Groups (lesend) +- **Dateien:** `upload.js`, `download.js`, `batchUpload.js`, `groups.js` + +### Management API (`/api/manage`) +- **Zugriff:** Token-basiert (UUID v4) +- **Routen:** Selbstverwaltung von eigenen Gruppen +- **Dateien:** `management.js` +- **Beispiel:** `PUT /api/manage/:token/reorder` + +### Admin API (`/api/admin`) +- **Zugriff:** GeschĂŒtzt (Middleware erforderlich) +- **Routen:** Moderation, Deletion Logs, Cleanup +- **Dateien:** `admin.js`, `consent.js`, `reorder.js` +- **Beispiel:** `GET /api/admin/groups`, `DELETE /api/admin/groups/:id` + +### System API (`/api/system`) +- **Zugriff:** Intern (Wartungsfunktionen) +- **Routen:** Datenbank-Migrationen +- **Dateien:** `migration.js` + +--- + +## 🔒 Mehrfach-Mount (z.B. Reorder) + +Manche Routen sind an mehreren Stellen verfĂŒgbar: + +```javascript +// routeMappings.js +module.exports = [ + // Admin-Zugriff (geschĂŒtzt) + { router: 'reorder', prefix: '/api/admin', file: 'reorder.js' }, + + // Management-Zugriff (in management.js integriert) + // { router: 'management', prefix: '/api/manage', file: 'management.js' } + // → enthĂ€lt PUT /:token/reorder +]; +``` + +**Hinweis:** Reorder ist direkt in `management.js` implementiert, nicht als separater Mount. + +--- + +## ⚠ Wichtige Regeln + +### 1. Relative Pfade in Router-Dateien + +```javascript +// ✅ RICHTIG (ohne Prefix) +router.get('/groups', ...) +router.get('/groups/:id', ...) + +// ❌ FALSCH (Prefix gehört in routeMappings.js) +router.get('/api/groups', ...) +``` + +### 2. String-Literale verwenden + +```javascript +// ✅ RICHTIG +router.get('/upload', ...) + +// ❌ FALSCH (swagger-autogen kann Variablen nicht auflösen) +const ROUTES = { UPLOAD: '/upload' }; +router.get(ROUTES.UPLOAD, ...) +``` + +### 3. Mount-Prefix nur in routeMappings.js + +```javascript +// routeMappings.js +{ router: 'groups', prefix: '/api', file: 'groups.js' } + +// ✅ Ergebnis: /api/groups +``` + +--- + +## đŸ§Ș Testen + +### Backend-Tests mit curl + +```bash +# Public API +curl http://localhost:5000/api/groups + +# Management API (Token erforderlich) +curl http://localhost:5000/api/manage/YOUR-TOKEN-HERE + +# Admin API +curl http://localhost:5000/api/admin/groups +``` + +### OpenAPI-Spec validieren + +```bash +cd backend +npm run test-openapi +``` + +**Ausgabe:** +``` +🔍 Testing 35 paths from openapi.json against http://localhost:5000 + +✅ GET /api/groups → 200 +✅ GET /api/upload → 405 (expected, needs POST) +... +``` + +### Swagger UI öffnen + +``` +http://localhost:5000/api/docs +``` + +**Hinweis:** Nur im Development-Modus verfĂŒgbar! + +--- + +## 🐛 Troubleshooting + +### OpenAPI-Generierung hĂ€ngt + +**Problem:** `generate-openapi.js` lĂ€dt Router-Module, die wiederum andere Module laden → ZirkelbezĂŒge + +**Lösung:** `routeMappings.js` enthĂ€lt nur Konfiguration, keine Router-Imports + +### Route nicht in OpenAPI + +1. PrĂŒfe `routeMappings.js` → Route registriert? +2. PrĂŒfe Router-Datei → String-Literale verwendet? +3. Regeneriere: `npm run generate-openapi` (oder starte Server neu im Dev-Mode) + +### Route funktioniert nicht + +1. PrĂŒfe `routes/index.js` → Router in `routerMap` eingetragen? +2. PrĂŒfe Console → Fehler beim Server-Start? +3. Teste mit curl → Exakte URL prĂŒfen + +--- + +## 📚 Weitere Dokumentation + +- **Feature-Plan:** `docs/FEATURE_PLAN-autogen-openapi.md` +- **OpenAPI-Spec:** `backend/docs/openapi.json` +- **API-Tests:** `backend/test-openapi-paths.js` diff --git a/backend/src/routes/admin.js b/backend/src/routes/admin.js index 295a65e..41b9546 100644 --- a/backend/src/routes/admin.js +++ b/backend/src/routes/admin.js @@ -2,14 +2,41 @@ const express = require('express'); const router = express.Router(); const DeletionLogRepository = require('../repositories/DeletionLogRepository'); const ManagementAuditLogRepository = require('../repositories/ManagementAuditLogRepository'); +const GroupRepository = require('../repositories/GroupRepository'); const GroupCleanupService = require('../services/GroupCleanupService'); const { getStatistics: getRateLimiterStats } = require('../middlewares/rateLimiter'); +const { requireAdminAuth } = require('../middlewares/auth'); // GroupCleanupService ist bereits eine Instanz, keine Klasse const cleanupService = GroupCleanupService; -// Hole Deletion Log (mit Limit) +// Apply admin authentication to ALL routes in this router +router.use(requireAdminAuth); + router.get('/deletion-log', async (req, res) => { + /* + #swagger.tags = ['Admin - Deletion Log'] + #swagger.summary = 'Get recent deletion log entries' + #swagger.description = 'Returns recent deletion log entries with optional limit' + #swagger.parameters['limit'] = { + in: 'query', + type: 'integer', + description: 'Number of entries to return (1-1000)', + example: 10 + } + #swagger.responses[200] = { + description: 'Deletion log entries', + schema: { + success: true, + deletions: [], + total: 2, + limit: 10 + } + } + #swagger.responses[400] = { + description: 'Invalid limit parameter' + } + */ try { const limit = parseInt(req.query.limit) || 10; @@ -38,8 +65,20 @@ router.get('/deletion-log', async (req, res) => { } }); -// Hole alle Deletion Logs router.get('/deletion-log/all', async (req, res) => { + /* + #swagger.tags = ['Admin - Deletion Log'] + #swagger.summary = 'Get all deletion log entries' + #swagger.description = 'Returns complete deletion log without pagination' + #swagger.responses[200] = { + description: 'All deletion log entries', + schema: { + success: true, + deletions: [], + total: 50 + } + } + */ try { const deletions = await DeletionLogRepository.getAllDeletions(); @@ -57,8 +96,23 @@ router.get('/deletion-log/all', async (req, res) => { } }); -// Hole Deletion Statistiken router.get('/deletion-log/stats', async (req, res) => { + /* + #swagger.tags = ['Admin - Deletion Log'] + #swagger.summary = 'Get deletion statistics' + #swagger.description = 'Returns aggregated statistics about deleted images' + #swagger.responses[200] = { + description: 'Deletion statistics', + schema: { + success: true, + totalDeleted: 12, + totalImages: 348, + totalSize: '19.38 MB', + totalSizeBytes: 20324352, + lastCleanup: '2025-11-15T10:30:00Z' + } + } + */ try { const stats = await DeletionLogRepository.getDeletionStatistics(); @@ -88,8 +142,20 @@ router.get('/deletion-log/stats', async (req, res) => { } }); -// Manueller Cleanup-Trigger (fĂŒr Testing) router.post('/cleanup/trigger', async (req, res) => { + /* + #swagger.tags = ['Admin - Cleanup'] + #swagger.summary = 'Manually trigger cleanup of unapproved groups' + #swagger.description = 'Deletes groups that have not been approved within retention period' + #swagger.responses[200] = { + description: 'Cleanup completed', + schema: { + success: true, + deletedGroups: 3, + message: '3 alte unbestĂ€tigte Gruppen gelöscht' + } + } + */ try { console.log('[Admin API] Manual cleanup triggered'); const result = await cleanupService.performScheduledCleanup(); @@ -108,8 +174,27 @@ router.post('/cleanup/trigger', async (req, res) => { } }); -// Zeige welche Gruppen gelöscht wĂŒrden (Dry-Run) router.get('/cleanup/preview', async (req, res) => { + /* + #swagger.tags = ['Admin - Cleanup'] + #swagger.summary = 'Preview groups that would be deleted' + #swagger.description = 'Dry-run showing which unapproved groups are eligible for deletion' + #swagger.responses[200] = { + description: 'Preview of groups to delete', + schema: { + success: true, + groupsToDelete: 2, + groups: [{ + id: 'abc123', + groupName: 'Familie_Mueller', + uploadDate: '2025-10-01T12:00:00Z', + daysUntilDeletion: -5, + imageCount: 8 + }], + message: '2 groups would be deleted' + } + } + */ try { const groups = await cleanupService.findGroupsForDeletion(); @@ -137,8 +222,21 @@ router.get('/cleanup/preview', async (req, res) => { }); -// Rate-Limiter Statistiken (fĂŒr Monitoring) router.get('/rate-limiter/stats', async (req, res) => { + /* + #swagger.tags = ['Admin - Monitoring'] + #swagger.summary = 'Get rate limiter statistics' + #swagger.description = 'Returns statistics about rate limiting (blocked requests, active limits)' + #swagger.responses[200] = { + description: 'Rate limiter statistics', + schema: { + success: true, + totalRequests: 1523, + blockedRequests: 12, + activeClients: 45 + } + } + */ try { const stats = getRateLimiterStats(); @@ -155,8 +253,30 @@ router.get('/rate-limiter/stats', async (req, res) => { } }); -// Management Audit-Log (letzte N EintrĂ€ge) router.get('/management-audit', async (req, res) => { + /* + #swagger.tags = ['Admin - Monitoring'] + #swagger.summary = 'Get management audit log entries' + #swagger.description = 'Returns recent management portal activity logs' + #swagger.parameters['limit'] = { + in: 'query', + type: 'integer', + description: 'Number of entries to return (1-1000)', + example: 100 + } + #swagger.responses[200] = { + description: 'Audit log entries', + schema: { + success: true, + logs: [], + total: 15, + limit: 100 + } + } + #swagger.responses[400] = { + description: 'Invalid limit parameter' + } + */ try { const limit = parseInt(req.query.limit) || 100; @@ -184,8 +304,25 @@ router.get('/management-audit', async (req, res) => { } }); -// Management Audit-Log Statistiken router.get('/management-audit/stats', async (req, res) => { + /* + #swagger.tags = ['Admin - Monitoring'] + #swagger.summary = 'Get management audit log statistics' + #swagger.description = 'Returns aggregated statistics about management portal activity' + #swagger.responses[200] = { + description: 'Audit log statistics', + schema: { + success: true, + totalActions: 523, + actionsByType: { + 'update': 312, + 'delete': 45, + 'approve': 166 + }, + lastAction: '2025-11-15T14:30:00Z' + } + } + */ try { const stats = await ManagementAuditLogRepository.getStatistics(); @@ -202,8 +339,28 @@ router.get('/management-audit/stats', async (req, res) => { } }); -// Management Audit-Log nach Group-ID router.get('/management-audit/group/:groupId', async (req, res) => { + /* + #swagger.tags = ['Admin - Monitoring'] + #swagger.summary = 'Get audit log for specific group' + #swagger.description = 'Returns all management actions performed on a specific group' + #swagger.parameters['groupId'] = { + in: 'path', + required: true, + type: 'string', + description: 'Group ID', + example: 'abc123def456' + } + #swagger.responses[200] = { + description: 'Audit log for group', + schema: { + success: true, + groupId: 'abc123def456', + logs: [], + total: 8 + } + } + */ try { const { groupId } = req.params; const logs = await ManagementAuditLogRepository.getLogsByGroupId(groupId); @@ -223,5 +380,571 @@ router.get('/management-audit/group/:groupId', async (req, res) => { } }); +// ============================================================================ +// GRUPPEN-MODERATION (verschoben von groups.js) +// ============================================================================ + +router.get('/groups', async (req, res) => { + /* + #swagger.tags = ['Admin - Groups Moderation'] + #swagger.summary = 'Get all groups for moderation' + #swagger.description = 'Returns all groups including unapproved ones with moderation info and consent data' + #swagger.parameters['workshopOnly'] = { + in: 'query', + type: 'boolean', + description: 'Filter by workshop consent', + example: false + } + #swagger.parameters['platform'] = { + in: 'query', + type: 'string', + description: 'Filter by social media platform', + example: 'instagram' + } + #swagger.responses[200] = { + description: 'All groups with moderation info', + schema: { + success: true, + groups: [{ + groupId: 'abc123', + groupName: 'Familie_Mueller', + isApproved: false, + uploadDate: '2025-11-01T10:00:00Z', + imageCount: 12, + socialMediaConsents: [] + }] + } + } + */ + try { + const { workshopOnly, platform } = req.query; + + // Hole alle Gruppen mit vollstĂ€ndigen Infos (inkl. Bilder) + let allGroups = await GroupRepository.getAllGroupsWithModerationInfo(); + + // FĂŒge Consent-Daten fĂŒr jede Gruppe hinzu + const groupsWithConsents = await Promise.all( + allGroups.map(async (group) => { + const consents = await GroupRepository.getSocialMediaConsentsForGroup(group.groupId); + return { + ...group, + socialMediaConsents: consents + }; + }) + ); + + // Jetzt filtern wir basierend auf den Query-Parametern + let filteredGroups = groupsWithConsents; + + if (workshopOnly === 'true') { + // Filter: Nur Gruppen MIT Werkstatt-Consent aber OHNE zugestimmte Social Media Consents + filteredGroups = groupsWithConsents.filter(group => { + // Muss Werkstatt-Consent haben + if (!group.display_in_workshop) return false; + + // Darf KEINE zugestimmten Social Media Consents haben + const hasConsentedSocialMedia = group.socialMediaConsents && + group.socialMediaConsents.some(consent => consent.consented === 1 || consent.consented === true); + + return !hasConsentedSocialMedia; + }); + } else if (platform) { + // Filter: Gruppen mit bestimmter Social Media Platform (unabhĂ€ngig vom Werkstatt-Consent) + filteredGroups = groupsWithConsents.filter(group => + group.socialMediaConsents && + group.socialMediaConsents.some(consent => + consent.platform_name === platform && (consent.consented === 1 || consent.consented === true) + ) + ); + } + // else: Kein Filter - zeige ALLE Gruppen (nicht filtern) + + res.json({ + groups: filteredGroups, + totalCount: filteredGroups.length, + pendingCount: filteredGroups.filter(g => !g.approved).length, + approvedCount: filteredGroups.filter(g => g.approved).length + }); + } catch (error) { + console.error('Error fetching moderation groups:', error); + res.status(500).json({ + error: 'Internal server error', + message: 'Fehler beim Laden der Moderations-Gruppen', + details: error.message + }); + } +}); + +router.get('/groups/:groupId', async (req, res) => { + /* + #swagger.tags = ['Admin - Groups Moderation'] + #swagger.summary = 'Get single group for moderation' + #swagger.description = 'Returns detailed info for a specific group including unapproved ones' + #swagger.parameters['groupId'] = { + in: 'path', + required: true, + type: 'string', + description: 'Group ID', + example: 'abc123def456' + } + #swagger.responses[200] = { + description: 'Group details with images', + schema: { + groupId: 'abc123', + groupName: 'Familie_Mueller', + isApproved: true, + images: [] + } + } + #swagger.responses[404] = { + description: 'Group not found' + } + */ + try { + const { groupId } = req.params; + const group = await GroupRepository.getGroupForModeration(groupId); + + if (!group) { + return res.status(404).json({ + error: 'Group not found', + message: `Gruppe mit ID ${groupId} wurde nicht gefunden` + }); + } + + res.json(group); + } catch (error) { + console.error('Error fetching group for moderation:', error); + res.status(500).json({ + error: 'Internal server error', + message: 'Fehler beim Laden der Gruppe fĂŒr Moderation', + details: error.message + }); + } +}); + +router.patch('/groups/:groupId/approve', async (req, res) => { + /* + #swagger.tags = ['Admin - Groups Moderation'] + #swagger.summary = 'Approve a group' + #swagger.description = 'Marks a group as approved, making it publicly visible' + #swagger.parameters['groupId'] = { + in: 'path', + required: true, + type: 'string', + description: 'Group ID', + example: 'abc123def456' + } + #swagger.parameters['body'] = { + in: 'body', + required: false, + schema: { + approved: true + } + } + #swagger.responses[200] = { + description: 'Group approved successfully', + schema: { + success: true, + message: 'Gruppe erfolgreich freigegeben' + } + } + #swagger.responses[404] = { + description: 'Group not found' + } + */ + try { + const { groupId } = req.params; + const { approved } = req.body; + + // Validierung + if (typeof approved !== 'boolean') { + return res.status(400).json({ + error: 'Invalid request', + message: 'approved muss ein boolean Wert sein' + }); + } + + const updated = await GroupRepository.updateGroupApproval(groupId, approved); + + if (!updated) { + return res.status(404).json({ + error: 'Group not found', + message: `Gruppe mit ID ${groupId} wurde nicht gefunden` + }); + } + + res.json({ + success: true, + message: approved ? 'Gruppe freigegeben' : 'Gruppe gesperrt', + groupId: groupId, + approved: approved + }); + + } catch (error) { + console.error('Error updating group approval:', error); + res.status(500).json({ + error: 'Internal server error', + message: 'Fehler beim Aktualisieren der Freigabe' + }); + } +}); + +router.patch('/groups/:groupId', async (req, res) => { + /* + #swagger.tags = ['Admin - Groups Moderation'] + #swagger.summary = 'Update group metadata' + #swagger.description = 'Updates group metadata fields (year, title, description, name)' + #swagger.parameters['groupId'] = { + in: 'path', + required: true, + type: 'string', + description: 'Group ID', + example: 'abc123def456' + } + #swagger.parameters['body'] = { + in: 'body', + required: true, + schema: { + year: 2025, + title: 'Sommercamp', + description: 'Tolle Veranstaltung', + name: 'Familie_Mueller' + } + } + #swagger.responses[200] = { + description: 'Group updated successfully', + schema: { + success: true, + message: 'Gruppe aktualisiert', + updatedFields: ['year', 'title'] + } + } + #swagger.responses[404] = { + description: 'Group not found' + } + */ + try { + const { groupId } = req.params; + + // Erlaubte Felder zum Aktualisieren + const allowed = ['year', 'title', 'description', 'name']; + const updates = {}; + + for (const field of allowed) { + if (req.body[field] !== undefined) { + updates[field] = req.body[field]; + } + } + + if (Object.keys(updates).length === 0) { + return res.status(400).json({ + error: 'Invalid request', + message: 'Keine gĂŒltigen Felder zum Aktualisieren angegeben' + }); + } + + const updated = await GroupRepository.updateGroup(groupId, updates); + + if (!updated) { + return res.status(404).json({ + error: 'Group not found', + message: `Gruppe mit ID ${groupId} wurde nicht gefunden` + }); + } + + res.json({ + success: true, + message: 'Gruppe erfolgreich aktualisiert', + groupId: groupId, + updates: updates + }); + } catch (error) { + console.error('Error updating group:', error); + res.status(500).json({ + error: 'Internal server error', + message: 'Fehler beim Aktualisieren der Gruppe', + details: error.message + }); + } +}); + +router.delete('/groups/:groupId/images/:imageId', async (req, res) => { + /* + #swagger.tags = ['Admin - Groups Moderation'] + #swagger.summary = 'Delete a single image' + #swagger.description = 'Deletes a specific image from a group' + #swagger.parameters['groupId'] = { + in: 'path', + required: true, + type: 'string', + description: 'Group ID', + example: 'abc123def456' + } + #swagger.parameters['imageId'] = { + in: 'path', + required: true, + type: 'integer', + description: 'Image ID', + example: 42 + } + #swagger.responses[200] = { + description: 'Image deleted successfully', + schema: { + success: true, + message: 'Bild erfolgreich gelöscht', + groupId: 'abc123def456', + imageId: 42 + } + } + #swagger.responses[404] = { + description: 'Image not found' + } + */ + try { + const { groupId, imageId } = req.params; + + const deleted = await GroupRepository.deleteImage(groupId, parseInt(imageId)); + + if (!deleted) { + return res.status(404).json({ + error: 'Image not found', + message: `Bild mit ID ${imageId} in Gruppe ${groupId} wurde nicht gefunden` + }); + } + + res.json({ + success: true, + message: 'Bild erfolgreich gelöscht', + groupId: groupId, + imageId: parseInt(imageId) + }); + + } catch (error) { + console.error('Error deleting image:', error); + res.status(500).json({ + error: 'Internal server error', + message: 'Fehler beim Löschen des Bildes' + }); + } +}); + +router.patch('/groups/:groupId/images/batch-description', async (req, res) => { + /* + #swagger.tags = ['Admin - Groups Moderation'] + #swagger.summary = 'Batch update image descriptions' + #swagger.description = 'Updates descriptions for multiple images in a group at once' + #swagger.parameters['groupId'] = { + in: 'path', + required: true, + type: 'string', + description: 'Group ID', + example: 'abc123def456' + } + #swagger.parameters['body'] = { + in: 'body', + required: true, + schema: { + descriptions: [ + { imageId: 1, description: 'Sonnenuntergang am Strand' }, + { imageId: 2, description: 'Gruppenfoto beim Lagerfeuer' } + ] + } + } + #swagger.responses[200] = { + description: 'Descriptions updated', + schema: { + success: true, + updatedCount: 2, + message: '2 Bildbeschreibungen aktualisiert' + } + } + #swagger.responses[400] = { + description: 'Invalid request format' + } + */ + try { + const { groupId } = req.params; + const { descriptions } = req.body; + + // Validierung + if (!Array.isArray(descriptions) || descriptions.length === 0) { + return res.status(400).json({ + error: 'Invalid request', + message: 'descriptions muss ein nicht-leeres Array sein' + }); + } + + // Validiere jede Beschreibung + for (const desc of descriptions) { + if (!desc.imageId || typeof desc.imageId !== 'number') { + return res.status(400).json({ + error: 'Invalid request', + message: 'Jede Beschreibung muss eine gĂŒltige imageId enthalten' + }); + } + if (desc.description && desc.description.length > 200) { + return res.status(400).json({ + error: 'Invalid request', + message: `Bildbeschreibung fĂŒr Bild ${desc.imageId} darf maximal 200 Zeichen lang sein` + }); + } + } + + const result = await GroupRepository.updateBatchImageDescriptions(groupId, descriptions); + + res.json({ + success: true, + message: `${result.updatedImages} Bildbeschreibungen erfolgreich aktualisiert`, + groupId: groupId, + updatedImages: result.updatedImages + }); + + } catch (error) { + console.error('Error batch updating image descriptions:', error); + res.status(500).json({ + error: 'Internal server error', + message: 'Fehler beim Aktualisieren der Bildbeschreibungen', + details: error.message + }); + } +}); + +router.patch('/groups/:groupId/images/:imageId', async (req, res) => { + /* + #swagger.tags = ['Admin - Groups Moderation'] + #swagger.summary = 'Update single image description' + #swagger.description = 'Updates description for a specific image (max 200 characters)' + #swagger.parameters['groupId'] = { + in: 'path', + required: true, + type: 'string', + description: 'Group ID', + example: 'abc123def456' + } + #swagger.parameters['imageId'] = { + in: 'path', + required: true, + type: 'integer', + description: 'Image ID', + example: 42 + } + #swagger.parameters['body'] = { + in: 'body', + required: true, + schema: { + image_description: 'Sonnenuntergang am Strand' + } + } + #swagger.responses[200] = { + description: 'Description updated', + schema: { + success: true, + message: 'Bildbeschreibung erfolgreich aktualisiert', + groupId: 'abc123def456', + imageId: 42, + imageDescription: 'Sonnenuntergang am Strand' + } + } + #swagger.responses[400] = { + description: 'Description too long (max 200 chars)' + } + #swagger.responses[404] = { + description: 'Image not found' + } + */ + try { + const { groupId, imageId } = req.params; + const { image_description } = req.body; + + // Validierung: Max 200 Zeichen + if (image_description && image_description.length > 200) { + return res.status(400).json({ + error: 'Invalid request', + message: 'Bildbeschreibung darf maximal 200 Zeichen lang sein' + }); + } + + const updated = await GroupRepository.updateImageDescription( + parseInt(imageId), + groupId, + image_description + ); + + if (!updated) { + return res.status(404).json({ + error: 'Image not found', + message: `Bild mit ID ${imageId} in Gruppe ${groupId} wurde nicht gefunden` + }); + } + + res.json({ + success: true, + message: 'Bildbeschreibung erfolgreich aktualisiert', + groupId: groupId, + imageId: parseInt(imageId), + imageDescription: image_description + }); + + } catch (error) { + console.error('Error updating image description:', error); + res.status(500).json({ + error: 'Internal server error', + message: 'Fehler beim Aktualisieren der Bildbeschreibung', + details: error.message + }); + } +}); + +router.delete('/groups/:groupId', async (req, res) => { + /* + #swagger.tags = ['Admin - Groups Moderation'] + #swagger.summary = 'Delete a group' + #swagger.description = 'Deletes a complete group including all images and metadata' + #swagger.parameters['groupId'] = { + in: 'path', + required: true, + type: 'string', + description: 'Group ID', + example: 'abc123def456' + } + #swagger.responses[200] = { + description: 'Group deleted successfully', + schema: { + success: true, + message: 'Gruppe erfolgreich gelöscht', + groupId: 'abc123def456' + } + } + #swagger.responses[404] = { + description: 'Group not found' + } + */ + try { + const { groupId } = req.params; + + const deleted = await GroupRepository.deleteGroup(groupId); + + if (!deleted) { + return res.status(404).json({ + error: 'Group not found', + message: `Gruppe mit ID ${groupId} wurde nicht gefunden` + }); + } + + res.json({ + success: true, + message: 'Gruppe erfolgreich gelöscht', + groupId: groupId + }); + + } catch (error) { + console.error('Error deleting group:', error); + res.status(500).json({ + error: 'Internal server error', + message: 'Fehler beim Löschen der Gruppe' + }); + } +}); + module.exports = router; diff --git a/backend/src/routes/batchUpload.js b/backend/src/routes/batchUpload.js index a6c10be..55baa3d 100644 --- a/backend/src/routes/batchUpload.js +++ b/backend/src/routes/batchUpload.js @@ -2,7 +2,6 @@ const generateId = require("shortid"); const express = require('express'); const { Router } = require('express'); const path = require('path'); -const { endpoints } = require('../constants'); const UploadGroup = require('../models/uploadGroup'); const groupRepository = require('../repositories/GroupRepository'); const dbManager = require('../database/DatabaseManager'); @@ -10,8 +9,81 @@ const ImagePreviewService = require('../services/ImagePreviewService'); const router = Router(); +/** + * @swagger + * /upload/batch: + * post: + * tags: [Upload] + * summary: Batch upload multiple images and create a group + * description: Uploads multiple images at once, creates previews, and stores them as a group with metadata and consent information + * requestBody: + * required: true + * content: + * multipart/form-data: + * schema: + * type: object + * required: + * - images + * - consents + * properties: + * images: + * type: array + * items: + * type: string + * format: binary + * description: Multiple image files to upload + * metadata: + * type: string + * description: JSON string with group metadata (year, title, description, name) + * example: '{"year":2024,"title":"Familie Mueller","description":"Weihnachtsfeier","name":"Mueller"}' + * descriptions: + * type: string + * description: JSON array with image descriptions + * example: '[{"index":0,"description":"Gruppenfoto"},{"index":1,"description":"Werkstatt"}]' + * consents: + * type: string + * description: JSON object with consent flags (workshopConsent is required) + * example: '{"workshopConsent":true,"socialMedia":{"facebook":false,"instagram":true}}' + * responses: + * 200: + * description: Batch upload successful + * content: + * application/json: + * schema: + * type: object + * properties: + * success: + * type: boolean + * example: true + * groupId: + * type: string + * example: "cTV24Yn-a" + * managementToken: + * type: string + * format: uuid + * example: "550e8400-e29b-41d4-a716-446655440000" + * filesProcessed: + * type: integer + * example: 5 + * message: + * type: string + * example: "5 Bilder erfolgreich hochgeladen" + * 400: + * description: Bad request - missing files or workshop consent + * content: + * application/json: + * schema: + * type: object + * properties: + * error: + * type: string + * message: + * type: string + * 500: + * description: Server error during batch upload + */ // Batch-Upload fĂŒr mehrere Bilder -router.post(endpoints.UPLOAD_BATCH, async (req, res) => { +router.post('/upload/batch', async (req, res) => { try { // ÜberprĂŒfe ob Dateien hochgeladen wurden if (!req.files || !req.files.images) { diff --git a/backend/src/routes/consent.js b/backend/src/routes/consent.js index 9936254..8850b8c 100644 --- a/backend/src/routes/consent.js +++ b/backend/src/routes/consent.js @@ -9,16 +9,35 @@ const router = express.Router(); const GroupRepository = require('../repositories/GroupRepository'); const SocialMediaRepository = require('../repositories/SocialMediaRepository'); const dbManager = require('../database/DatabaseManager'); +const { requireAdminAuth } = require('../middlewares/auth'); + +// SchĂŒtze alle Consent-Routes mit Admin-Auth +router.use(requireAdminAuth); // ============================================================================ // Social Media Platforms // ============================================================================ /** - * GET /api/social-media/platforms + * GET /social-media/platforms * Liste aller aktiven Social Media Plattformen */ -router.get('/api/social-media/platforms', async (req, res) => { +router.get('/social-media/platforms', async (req, res) => { + /* + #swagger.tags = ['Consent Management'] + #swagger.summary = 'Get active social media platforms' + #swagger.description = 'Returns list of all active social media platforms available for consent' + #swagger.responses[200] = { + description: 'List of platforms', + schema: [{ + platform_id: 1, + platform_name: 'instagram', + display_name: 'Instagram', + icon_name: 'instagram', + is_active: true + }] + } + */ try { const socialMediaRepo = new SocialMediaRepository(dbManager); const platforms = await socialMediaRepo.getActivePlatforms(); @@ -38,7 +57,7 @@ router.get('/api/social-media/platforms', async (req, res) => { // ============================================================================ /** - * POST /api/groups/:groupId/consents + * POST /groups/:groupId/consents * Speichere oder aktualisiere Consents fĂŒr eine Gruppe * * Body: { @@ -46,7 +65,7 @@ router.get('/api/social-media/platforms', async (req, res) => { * socialMediaConsents: [{ platformId: number, consented: boolean }] * } */ -router.post('/api/groups/:groupId/consents', async (req, res) => { +router.post('/groups/:groupId/consents', async (req, res) => { try { const { groupId } = req.params; const { workshopConsent, socialMediaConsents } = req.body; @@ -98,10 +117,40 @@ router.post('/api/groups/:groupId/consents', async (req, res) => { }); /** - * GET /api/groups/:groupId/consents + * GET /groups/:groupId/consents * Lade alle Consents fĂŒr eine Gruppe */ -router.get('/api/groups/:groupId/consents', async (req, res) => { +router.get('/groups/:groupId/consents', async (req, res) => { + /* + #swagger.tags = ['Consent Management'] + #swagger.summary = 'Get consents for a group' + #swagger.description = 'Returns all consent data (workshop + social media) for a specific group' + #swagger.parameters['groupId'] = { + in: 'path', + required: true, + type: 'string', + description: 'Group ID', + example: 'abc123def456' + } + #swagger.responses[200] = { + description: 'Group consents', + schema: { + groupId: 'abc123', + workshopConsent: true, + consentTimestamp: '2025-11-01T10:00:00Z', + socialMediaConsents: [{ + platformId: 1, + platformName: 'instagram', + displayName: 'Instagram', + consented: true, + revoked: false + }] + } + } + #swagger.responses[404] = { + description: 'Group not found' + } + */ try { const { groupId } = req.params; @@ -148,7 +197,7 @@ router.get('/api/groups/:groupId/consents', async (req, res) => { // ============================================================================ /** - * GET /api/admin/groups/by-consent + * GET /groups/by-consent * Filtere Gruppen nach Consent-Status * * Query params: @@ -156,7 +205,43 @@ router.get('/api/groups/:groupId/consents', async (req, res) => { * - platformId: number * - platformConsent: boolean */ -router.get('/api/admin/groups/by-consent', async (req, res) => { +router.get('/groups/by-consent', async (req, res) => { + /* + #swagger.tags = ['Consent Management'] + #swagger.summary = 'Filter groups by consent status' + #swagger.description = 'Returns groups filtered by workshop consent or social media platform consents' + #swagger.parameters['displayInWorkshop'] = { + in: 'query', + type: 'boolean', + description: 'Filter by workshop consent', + example: true + } + #swagger.parameters['platformId'] = { + in: 'query', + type: 'integer', + description: 'Filter by platform ID', + example: 1 + } + #swagger.parameters['platformConsent'] = { + in: 'query', + type: 'boolean', + description: 'Filter by platform consent status', + example: true + } + #swagger.responses[200] = { + description: 'Filtered groups', + schema: { + count: 5, + filters: { + displayInWorkshop: true + }, + groups: [] + } + } + #swagger.responses[400] = { + description: 'Invalid platformId' + } + */ try { const filters = {}; @@ -199,7 +284,7 @@ router.get('/api/admin/groups/by-consent', async (req, res) => { }); /** - * GET /api/admin/consents/export + * GET /consents/export * Export Consent-Daten fĂŒr rechtliche Dokumentation * * Query params: @@ -207,7 +292,54 @@ router.get('/api/admin/groups/by-consent', async (req, res) => { * - year: number (optional filter) * - approved: boolean (optional filter) */ -router.get('/api/admin/consents/export', async (req, res) => { +router.get('/consents/export', async (req, res) => { + /* + #swagger.tags = ['Consent Management'] + #swagger.summary = 'Export consent data' + #swagger.description = 'Exports consent data for legal documentation in JSON or CSV format' + #swagger.parameters['format'] = { + in: 'query', + type: 'string', + enum: ['json', 'csv'], + description: 'Export format', + example: 'json' + } + #swagger.parameters['year'] = { + in: 'query', + type: 'integer', + description: 'Filter by year', + example: 2025 + } + #swagger.parameters['approved'] = { + in: 'query', + type: 'boolean', + description: 'Filter by approval status', + example: true + } + #swagger.responses[200] = { + description: 'Export data (JSON format)', + schema: { + exportDate: '2025-11-15T16:30:00Z', + filters: { year: 2025 }, + count: 12, + data: [] + } + } + #swagger.responses[200] = { + description: 'Export data (CSV format)', + content: { + 'text/csv': { + schema: { + type: 'string', + format: 'binary' + } + } + } + } + #swagger.responses[400] = { + description: 'Invalid format' + } + */ try { const format = req.query.format || 'json'; const filters = {}; diff --git a/backend/src/routes/download.js b/backend/src/routes/download.js index a7b042e..de6f6bd 100644 --- a/backend/src/routes/download.js +++ b/backend/src/routes/download.js @@ -1,10 +1,36 @@ const { Router } = require('express'); -const { endpoints, UPLOAD_FS_DIR } = require('../constants'); +const { UPLOAD_FS_DIR } = require('../constants'); const path = require('path'); const router = Router(); -router.get(endpoints.DOWNLOAD_FILE, (req, res) => { +/** + * @swagger + * /download/{id}: + * get: + * tags: [Download] + * summary: Download an uploaded image file + * description: Downloads the original image file by filename + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * example: "abc123.jpg" + * description: Filename of the image to download + * responses: + * 200: + * description: File download initiated + * content: + * image/*: + * schema: + * type: string + * format: binary + * 404: + * description: File not found + */ +router.get('/download/:id', (req, res) => { const filePath = path.join(__dirname, '..', UPLOAD_FS_DIR, req.params.id); res.download(filePath); }); diff --git a/backend/src/routes/groups.js b/backend/src/routes/groups.js index cd3fae3..2153715 100644 --- a/backend/src/routes/groups.js +++ b/backend/src/routes/groups.js @@ -1,12 +1,57 @@ const { Router } = require('express'); -const { endpoints } = require('../constants'); const GroupRepository = require('../repositories/GroupRepository'); const MigrationService = require('../services/MigrationService'); const router = Router(); +/** + * @swagger + * /groups: + * get: + * tags: [Groups] + * summary: Get all approved groups with images + * description: Returns all approved groups with their images for public slideshow display. Automatically triggers migration if needed. + * responses: + * 200: + * description: List of approved groups + * content: + * application/json: + * schema: + * type: object + * properties: + * groups: + * type: array + * items: + * type: object + * properties: + * groupId: + * type: string + * example: "cTV24Yn-a" + * year: + * type: integer + * example: 2024 + * title: + * type: string + * example: "Familie Mueller" + * description: + * type: string + * name: + * type: string + * approved: + * type: boolean + * example: true + * images: + * type: array + * items: + * type: object + * totalCount: + * type: integer + * example: 73 + * 500: + * description: Server error + */ // Alle Gruppen abrufen (fĂŒr Slideshow mit vollstĂ€ndigen Bilddaten) -router.get(endpoints.GET_ALL_GROUPS, async (req, res) => { +router.get('/groups', async (req, res) => { try { // Auto-Migration beim ersten Zugriff const migrationStatus = await MigrationService.getMigrationStatus(); @@ -30,93 +75,52 @@ router.get(endpoints.GET_ALL_GROUPS, async (req, res) => { } }); -// Alle Gruppen fĂŒr Moderation abrufen (mit Freigabestatus) - MUSS VOR den :groupId routen stehen! -router.get('/moderation/groups', async (req, res) => { - try { - const { workshopOnly, platform } = req.query; - - // Hole alle Gruppen mit vollstĂ€ndigen Infos (inkl. Bilder) - let allGroups = await GroupRepository.getAllGroupsWithModerationInfo(); - - // FĂŒge Consent-Daten fĂŒr jede Gruppe hinzu - const groupsWithConsents = await Promise.all( - allGroups.map(async (group) => { - const consents = await GroupRepository.getSocialMediaConsentsForGroup(group.groupId); - return { - ...group, - socialMediaConsents: consents - }; - }) - ); - - // Jetzt filtern wir basierend auf den Query-Parametern - let filteredGroups = groupsWithConsents; - - if (workshopOnly === 'true') { - // Filter: Nur Gruppen MIT Werkstatt-Consent aber OHNE zugestimmte Social Media Consents - filteredGroups = groupsWithConsents.filter(group => { - // Muss Werkstatt-Consent haben - if (!group.display_in_workshop) return false; - - // Darf KEINE zugestimmten Social Media Consents haben - const hasConsentedSocialMedia = group.socialMediaConsents && - group.socialMediaConsents.some(consent => consent.consented === 1 || consent.consented === true); - - return !hasConsentedSocialMedia; - }); - } else if (platform) { - // Filter: Gruppen mit bestimmter Social Media Platform (unabhĂ€ngig vom Werkstatt-Consent) - filteredGroups = groupsWithConsents.filter(group => - group.socialMediaConsents && - group.socialMediaConsents.some(consent => - consent.platform_name === platform && (consent.consented === 1 || consent.consented === true) - ) - ); - } - // else: Kein Filter - zeige ALLE Gruppen (nicht filtern) - - res.json({ - groups: filteredGroups, - totalCount: filteredGroups.length, - pendingCount: filteredGroups.filter(g => !g.approved).length, - approvedCount: filteredGroups.filter(g => g.approved).length - }); - } catch (error) { - console.error('Error fetching moderation groups:', error); - res.status(500).json({ - error: 'Internal server error', - message: 'Fehler beim Laden der Moderations-Gruppen', - details: error.message - }); - } -}); - -// Einzelne Gruppe fĂŒr Moderation abrufen (inkl. nicht-freigegebene) -router.get('/moderation/groups/:groupId', async (req, res) => { - try { - const { groupId } = req.params; - const group = await GroupRepository.getGroupForModeration(groupId); - - if (!group) { - return res.status(404).json({ - error: 'Group not found', - message: `Gruppe mit ID ${groupId} wurde nicht gefunden` - }); - } - - res.json(group); - } catch (error) { - console.error('Error fetching group for moderation:', error); - res.status(500).json({ - error: 'Internal server error', - message: 'Fehler beim Laden der Gruppe fĂŒr Moderation', - details: error.message - }); - } -}); - -// Einzelne Gruppe abrufen -router.get(endpoints.GET_GROUP, async (req, res) => { +/** + * @swagger + * /groups/{groupId}: + * get: + * tags: [Groups] + * summary: Get a specific approved group by ID + * description: Returns details of a single approved group with all its images + * parameters: + * - in: path + * name: groupId + * required: true + * schema: + * type: string + * example: "cTV24Yn-a" + * description: Unique identifier of the group + * responses: + * 200: + * description: Group details + * content: + * application/json: + * schema: + * type: object + * properties: + * groupId: + * type: string + * year: + * type: integer + * title: + * type: string + * description: + * type: string + * name: + * type: string + * approved: + * type: boolean + * images: + * type: array + * items: + * type: object + * 404: + * description: Group not found + * 500: + * description: Server error + */ +// Einzelne Gruppe abrufen (nur freigegebene) +router.get('/groups/:groupId', async (req, res) => { try { const { groupId } = req.params; const group = await GroupRepository.getGroupById(groupId); @@ -139,243 +143,4 @@ router.get(endpoints.GET_GROUP, async (req, res) => { } }); -// Gruppe freigeben/genehmigen -router.patch('/groups/:groupId/approve', async (req, res) => { - try { - const { groupId } = req.params; - const { approved } = req.body; - - // Validierung - if (typeof approved !== 'boolean') { - return res.status(400).json({ - error: 'Invalid request', - message: 'approved muss ein boolean Wert sein' - }); - } - - const updated = await GroupRepository.updateGroupApproval(groupId, approved); - - if (!updated) { - return res.status(404).json({ - error: 'Group not found', - message: `Gruppe mit ID ${groupId} wurde nicht gefunden` - }); - } - - res.json({ - success: true, - message: approved ? 'Gruppe freigegeben' : 'Gruppe gesperrt', - groupId: groupId, - approved: approved - }); - - } catch (error) { - console.error('Error updating group approval:', error); - res.status(500).json({ - error: 'Internal server error', - message: 'Fehler beim Aktualisieren der Freigabe' - }); - } -}); - -// Gruppe bearbeiten (Metadaten aktualisieren) -router.patch('/groups/:groupId', async (req, res) => { - try { - const { groupId } = req.params; - - // Erlaubte Felder zum Aktualisieren - const allowed = ['year', 'title', 'description', 'name']; - const updates = {}; - - for (const field of allowed) { - if (req.body[field] !== undefined) { - updates[field] = req.body[field]; - } - } - - if (Object.keys(updates).length === 0) { - return res.status(400).json({ - error: 'Invalid request', - message: 'Keine gĂŒltigen Felder zum Aktualisieren angegeben' - }); - } - - const updated = await GroupRepository.updateGroup(groupId, updates); - - if (!updated) { - return res.status(404).json({ - error: 'Group not found', - message: `Gruppe mit ID ${groupId} wurde nicht gefunden` - }); - } - - res.json({ - success: true, - message: 'Gruppe erfolgreich aktualisiert', - groupId: groupId, - updates: updates - }); - } catch (error) { - console.error('Error updating group:', error); - res.status(500).json({ - error: 'Internal server error', - message: 'Fehler beim Aktualisieren der Gruppe', - details: error.message - }); - } -}); - -// Einzelnes Bild löschen -router.delete('/groups/:groupId/images/:imageId', async (req, res) => { - try { - const { groupId, imageId } = req.params; - - const deleted = await GroupRepository.deleteImage(groupId, parseInt(imageId)); - - if (!deleted) { - return res.status(404).json({ - error: 'Image not found', - message: `Bild mit ID ${imageId} in Gruppe ${groupId} wurde nicht gefunden` - }); - } - - res.json({ - success: true, - message: 'Bild erfolgreich gelöscht', - groupId: groupId, - imageId: parseInt(imageId) - }); - - } catch (error) { - console.error('Error deleting image:', error); - res.status(500).json({ - error: 'Internal server error', - message: 'Fehler beim Löschen des Bildes' - }); - } -}); - -// Batch-Update fĂŒr mehrere Bildbeschreibungen (MUSS VOR der einzelnen Route stehen!) -router.patch('/groups/:groupId/images/batch-description', async (req, res) => { - try { - const { groupId } = req.params; - const { descriptions } = req.body; - - // Validierung - if (!Array.isArray(descriptions) || descriptions.length === 0) { - return res.status(400).json({ - error: 'Invalid request', - message: 'descriptions muss ein nicht-leeres Array sein' - }); - } - - // Validiere jede Beschreibung - for (const desc of descriptions) { - if (!desc.imageId || typeof desc.imageId !== 'number') { - return res.status(400).json({ - error: 'Invalid request', - message: 'Jede Beschreibung muss eine gĂŒltige imageId enthalten' - }); - } - if (desc.description && desc.description.length > 200) { - return res.status(400).json({ - error: 'Invalid request', - message: `Bildbeschreibung fĂŒr Bild ${desc.imageId} darf maximal 200 Zeichen lang sein` - }); - } - } - - const result = await GroupRepository.updateBatchImageDescriptions(groupId, descriptions); - - res.json({ - success: true, - message: `${result.updatedImages} Bildbeschreibungen erfolgreich aktualisiert`, - groupId: groupId, - updatedImages: result.updatedImages - }); - - } catch (error) { - console.error('Error batch updating image descriptions:', error); - res.status(500).json({ - error: 'Internal server error', - message: 'Fehler beim Aktualisieren der Bildbeschreibungen', - details: error.message - }); - } -}); - -// Einzelne Bildbeschreibung aktualisieren -router.patch('/groups/:groupId/images/:imageId', async (req, res) => { - try { - const { groupId, imageId } = req.params; - const { image_description } = req.body; - - // Validierung: Max 200 Zeichen - if (image_description && image_description.length > 200) { - return res.status(400).json({ - error: 'Invalid request', - message: 'Bildbeschreibung darf maximal 200 Zeichen lang sein' - }); - } - - const updated = await GroupRepository.updateImageDescription( - parseInt(imageId), - groupId, - image_description - ); - - if (!updated) { - return res.status(404).json({ - error: 'Image not found', - message: `Bild mit ID ${imageId} in Gruppe ${groupId} wurde nicht gefunden` - }); - } - - res.json({ - success: true, - message: 'Bildbeschreibung erfolgreich aktualisiert', - groupId: groupId, - imageId: parseInt(imageId), - imageDescription: image_description - }); - - } catch (error) { - console.error('Error updating image description:', error); - res.status(500).json({ - error: 'Internal server error', - message: 'Fehler beim Aktualisieren der Bildbeschreibung', - details: error.message - }); - } -}); - -// Gruppe löschen -router.delete(endpoints.DELETE_GROUP, async (req, res) => { - try { - const { groupId } = req.params; - - const deleted = await GroupRepository.deleteGroup(groupId); - - if (!deleted) { - return res.status(404).json({ - error: 'Group not found', - message: `Gruppe mit ID ${groupId} wurde nicht gefunden` - }); - } - - res.json({ - success: true, - message: 'Gruppe erfolgreich gelöscht', - groupId: groupId - }); - - } catch (error) { - console.error('Error deleting group:', error); - res.status(500).json({ - error: 'Internal server error', - message: 'Fehler beim Löschen der Gruppe' - }); - } -}); - -module.exports = router; \ No newline at end of file +module.exports = router; diff --git a/backend/src/routes/index.js b/backend/src/routes/index.js index 94a3f9b..310a437 100644 --- a/backend/src/routes/index.js +++ b/backend/src/routes/index.js @@ -8,11 +8,26 @@ const adminRouter = require('./admin'); const consentRouter = require('./consent'); const managementRouter = require('./management'); +// Import route mappings (Single Source of Truth!) +const routeMappingsConfig = require('./routeMappings'); + +// Map router names to actual router instances +const routerMap = { + upload: uploadRouter, + download: downloadRouter, + batchUpload: batchUploadRouter, + groups: groupsRouter, + migration: migrationRouter, + reorder: reorderRouter, + admin: adminRouter, + consent: consentRouter, + management: managementRouter +}; + const renderRoutes = (app) => { - [uploadRouter, downloadRouter, batchUploadRouter, groupsRouter, migrationRouter, consentRouter].forEach(router => app.use('/', router)); - app.use('/groups', reorderRouter); - app.use('/api/admin', adminRouter); - app.use('/api/manage', managementRouter); + routeMappingsConfig.forEach(({ router, prefix }) => { + app.use(prefix, routerMap[router]); + }); }; module.exports = { renderRoutes }; \ No newline at end of file diff --git a/backend/src/routes/management.js b/backend/src/routes/management.js index 2e591ab..5f0b4c2 100644 --- a/backend/src/routes/management.js +++ b/backend/src/routes/management.js @@ -25,6 +25,35 @@ const validateToken = (token) => { * @throws {500} Server error */ router.get('/:token', async (req, res) => { + /* + #swagger.tags = ['Management Portal'] + #swagger.summary = 'Validate token and load group data' + #swagger.description = 'Validates management token and returns complete group data with images and consents' + #swagger.parameters['token'] = { + in: 'path', + required: true, + type: 'string', + description: 'Management token (UUID v4)', + example: '550e8400-e29b-41d4-a716-446655440000' + } + #swagger.responses[200] = { + description: 'Group data loaded successfully', + schema: { + success: true, + data: { + groupId: 'abc123', + groupName: 'Familie_Mueller', + managementToken: '550e8400-e29b-41d4-a716-446655440000', + images: [], + socialMediaConsents: [], + display_in_workshop: true + } + } + } + #swagger.responses[404] = { + description: 'Invalid token or group deleted' + } + */ try { const { token } = req.params; @@ -85,6 +114,44 @@ router.get('/:token', async (req, res) => { * @throws {500} Server error */ router.put('/:token/consents', async (req, res) => { + /* + #swagger.tags = ['Management Portal'] + #swagger.summary = 'Revoke or restore consents' + #swagger.description = 'Updates workshop or social media consents for a group' + #swagger.parameters['token'] = { + in: 'path', + required: true, + type: 'string', + description: 'Management token (UUID v4)', + example: '550e8400-e29b-41d4-a716-446655440000' + } + #swagger.parameters['body'] = { + in: 'body', + required: true, + schema: { + consentType: 'workshop', + action: 'revoke', + platformId: 1 + } + } + #swagger.responses[200] = { + description: 'Consent updated successfully', + schema: { + success: true, + message: 'Workshop consent revoked successfully', + data: { + consentType: 'workshop', + newValue: false + } + } + } + #swagger.responses[400] = { + description: 'Invalid request parameters' + } + #swagger.responses[404] = { + description: 'Invalid token' + } + */ try { const { token } = req.params; const { consentType, action, platformId } = req.body; @@ -229,6 +296,42 @@ router.put('/:token/consents', async (req, res) => { * @throws {500} Server error */ router.put('/:token/images/descriptions', async (req, res) => { + /* + #swagger.tags = ['Management Portal'] + #swagger.summary = 'Batch update image descriptions' + #swagger.description = 'Updates descriptions for multiple images in a group (max 200 chars each)' + #swagger.parameters['token'] = { + in: 'path', + required: true, + type: 'string', + description: 'Management token (UUID v4)', + example: '550e8400-e29b-41d4-a716-446655440000' + } + #swagger.parameters['body'] = { + in: 'body', + required: true, + schema: { + descriptions: [ + { imageId: 1, description: 'Sonnenuntergang' }, + { imageId: 2, description: 'Gruppenfoto' } + ] + } + } + #swagger.responses[200] = { + description: 'Descriptions updated', + schema: { + success: true, + message: '2 image descriptions updated successfully', + updatedCount: 2 + } + } + #swagger.responses[400] = { + description: 'Invalid request or description too long' + } + #swagger.responses[404] = { + description: 'Invalid token' + } + */ try { const { token } = req.params; const { descriptions } = req.body; @@ -328,6 +431,45 @@ router.put('/:token/images/descriptions', async (req, res) => { * @throws {500} Server error */ router.put('/:token/metadata', async (req, res) => { + /* + #swagger.tags = ['Management Portal'] + #swagger.summary = 'Update group metadata' + #swagger.description = 'Updates group title, description or name. Sets approved=0 (returns to moderation).' + #swagger.parameters['token'] = { + in: 'path', + required: true, + type: 'string', + description: 'Management token (UUID v4)', + example: '550e8400-e29b-41d4-a716-446655440000' + } + #swagger.parameters['body'] = { + in: 'body', + required: true, + schema: { + title: 'Sommercamp 2025', + description: 'Tolle Veranstaltung', + name: 'Familie_Mueller' + } + } + #swagger.responses[200] = { + description: 'Metadata updated', + schema: { + success: true, + message: 'Metadata updated successfully', + data: { + groupId: 'abc123', + updatedFields: ['title', 'description'], + requiresModeration: true + } + } + } + #swagger.responses[400] = { + description: 'No fields provided' + } + #swagger.responses[404] = { + description: 'Invalid token' + } + */ try { const { token } = req.params; const { title, description, name } = req.body; @@ -425,6 +567,43 @@ router.put('/:token/metadata', async (req, res) => { * @throws {500} Server error */ router.post('/:token/images', async (req, res) => { + /* + #swagger.tags = ['Management Portal'] + #swagger.summary = 'Add new images to group' + #swagger.description = 'Uploads additional images to existing group. Sets approved=0 (requires re-moderation). Max 50 images per group.' + #swagger.consumes = ['multipart/form-data'] + #swagger.parameters['token'] = { + in: 'path', + required: true, + type: 'string', + description: 'Management token (UUID v4)', + example: '550e8400-e29b-41d4-a716-446655440000' + } + #swagger.parameters['images'] = { + in: 'formData', + type: 'file', + required: true, + description: 'Image files to upload (JPEG, PNG)' + } + #swagger.responses[200] = { + description: 'Images uploaded', + schema: { + success: true, + message: '3 images added successfully', + data: { + groupId: 'abc123', + newImagesCount: 3, + totalImagesCount: 15 + } + } + } + #swagger.responses[400] = { + description: 'No images or limit exceeded (max 50)' + } + #swagger.responses[404] = { + description: 'Invalid token' + } + */ try { const { token } = req.params; @@ -581,6 +760,43 @@ router.post('/:token/images', async (req, res) => { * @throws {500} Server error */ router.delete('/:token/images/:imageId', async (req, res) => { + /* + #swagger.tags = ['Management Portal'] + #swagger.summary = 'Delete single image' + #swagger.description = 'Deletes a specific image from group (files + DB entry). Sets approved=0. Cannot delete last image.' + #swagger.parameters['token'] = { + in: 'path', + required: true, + type: 'string', + description: 'Management token (UUID v4)', + example: '550e8400-e29b-41d4-a716-446655440000' + } + #swagger.parameters['imageId'] = { + in: 'path', + required: true, + type: 'integer', + description: 'Image ID', + example: 42 + } + #swagger.responses[200] = { + description: 'Image deleted', + schema: { + success: true, + message: 'Image deleted successfully', + data: { + groupId: 'abc123', + imageId: 42, + remainingImages: 11 + } + } + } + #swagger.responses[400] = { + description: 'Cannot delete last image' + } + #swagger.responses[404] = { + description: 'Invalid token or image not found' + } + */ try { const { token, imageId } = req.params; @@ -694,6 +910,33 @@ router.delete('/:token/images/:imageId', async (req, res) => { * @throws {500} Server error */ router.delete('/:token', async (req, res) => { + /* + #swagger.tags = ['Management Portal'] + #swagger.summary = 'Delete complete group' + #swagger.description = 'Deletes entire group with all images, consents and metadata. Creates deletion_log entry. Removes all files (originals + previews).' + #swagger.parameters['token'] = { + in: 'path', + required: true, + type: 'string', + description: 'Management token (UUID v4)', + example: '550e8400-e29b-41d4-a716-446655440000' + } + #swagger.responses[200] = { + description: 'Group deleted', + schema: { + success: true, + message: 'Group and all associated data deleted successfully', + data: { + groupId: 'abc123', + imagesDeleted: 12, + deletionTimestamp: '2025-11-15T16:30:00Z' + } + } + } + #swagger.responses[404] = { + description: 'Invalid token or group already deleted' + } + */ try { const { token } = req.params; @@ -783,4 +1026,98 @@ router.delete('/:token', async (req, res) => { } }); +/** + * PUT /api/manage/:token/reorder + * Reorder images within the managed group (token-based access) + * + * @param {string} token - Management token (UUID v4) + * @param {number[]} imageIds - Array of image IDs in new order + * @returns {Object} Success status and updated image count + * @throws {400} Invalid token format or imageIds + * @throws {404} Token not found or group deleted + * @throws {500} Server error + */ +router.put('/:token/reorder', async (req, res) => { + try { + const { token } = req.params; + const { imageIds } = req.body; + + // Validate token format + if (!validateToken(token)) { + recordFailedTokenValidation(req); + return res.status(400).json({ + success: false, + error: 'Invalid management token format' + }); + } + + // Validate imageIds + if (!imageIds || !Array.isArray(imageIds) || imageIds.length === 0) { + return res.status(400).json({ + success: false, + error: 'imageIds array is required and cannot be empty' + }); + } + + // Validate that all imageIds are numbers + const invalidIds = imageIds.filter(id => !Number.isInteger(id) || id <= 0); + if (invalidIds.length > 0) { + return res.status(400).json({ + success: false, + error: `Invalid image IDs: ${invalidIds.join(', ')}. Image IDs must be positive integers` + }); + } + + // Load group by token to get groupId + const groupData = await groupRepository.getGroupByManagementToken(token); + + if (!groupData) { + recordFailedTokenValidation(req); + await res.auditLog('reorder_images', false, null, 'Token not found or group deleted'); + + return res.status(404).json({ + success: false, + error: 'Management token not found or group has been deleted' + }); + } + + // Execute reorder using GroupRepository + const result = await groupRepository.updateImageOrder(groupData.groupId, imageIds); + + await res.auditLog('reorder_images', true, groupData.groupId, `Reordered ${result.updatedImages} images`); + + res.status(200).json({ + success: true, + message: 'Image order updated successfully', + data: result + }); + + } catch (error) { + console.error(`[MANAGEMENT] Error reordering images for token ${req.params.token}:`, error.message); + await res.auditLog('reorder_images', false, null, error.message); + + // Handle specific errors + if (error.message.includes('not found')) { + return res.status(404).json({ + success: false, + error: error.message + }); + } + + if (error.message.includes('Invalid image IDs') || + error.message.includes('Missing image IDs')) { + return res.status(400).json({ + success: false, + error: error.message + }); + } + + res.status(500).json({ + success: false, + error: 'Failed to reorder images' + }); + } +}); + module.exports = router; + diff --git a/backend/src/routes/migration.js b/backend/src/routes/migration.js index 3ae7d23..a34cc6f 100644 --- a/backend/src/routes/migration.js +++ b/backend/src/routes/migration.js @@ -2,11 +2,25 @@ const express = require('express'); const { Router } = require('express'); const MigrationService = require('../services/MigrationService'); const dbManager = require('../database/DatabaseManager'); +const { requireAdminAuth } = require('../middlewares/auth'); const router = Router(); -// Migration Status abrufen -router.get('/migration/status', async (req, res) => { +router.get('/status', async (req, res) => { + /* + #swagger.tags = ['System Migration'] + #swagger.summary = 'Get migration status' + #swagger.description = 'Returns current database migration status and history' + #swagger.responses[200] = { + description: 'Migration status', + schema: { + migrationComplete: true, + jsonBackupExists: true, + sqliteActive: true, + lastMigration: '2025-11-01T10:00:00Z' + } + } + */ try { const status = await MigrationService.getMigrationStatus(); res.json(status); @@ -20,8 +34,25 @@ router.get('/migration/status', async (req, res) => { } }); -// Manuelle Migration starten -router.post('/migration/migrate', async (req, res) => { +// Protect dangerous migration operations with admin auth +router.post('/migrate', requireAdminAuth, async (req, res) => { + /* + #swagger.tags = ['System Migration'] + #swagger.summary = 'Manually trigger migration' + #swagger.description = 'Triggers manual migration from JSON to SQLite database' + #swagger.responses[200] = { + description: 'Migration successful', + schema: { + success: true, + message: 'Migration completed successfully', + groupsMigrated: 24, + imagesMigrated: 348 + } + } + #swagger.responses[500] = { + description: 'Migration failed' + } + */ try { const result = await MigrationService.migrateJsonToSqlite(); res.json(result); @@ -35,8 +66,23 @@ router.post('/migration/migrate', async (req, res) => { } }); -// Rollback zu JSON (Notfall) -router.post('/migration/rollback', async (req, res) => { +router.post('/rollback', requireAdminAuth, async (req, res) => { + /* + #swagger.tags = ['System Migration'] + #swagger.summary = 'Rollback to JSON' + #swagger.description = 'Emergency rollback from SQLite to JSON file storage' + #swagger.responses[200] = { + description: 'Rollback successful', + schema: { + success: true, + message: 'Rolled back to JSON successfully', + groupsRestored: 24 + } + } + #swagger.responses[500] = { + description: 'Rollback failed' + } + */ try { const result = await MigrationService.rollbackToJson(); res.json(result); @@ -50,8 +96,31 @@ router.post('/migration/rollback', async (req, res) => { } }); -// Datenbank Health Check -router.get('/migration/health', async (req, res) => { +router.get('/health', async (req, res) => { + /* + #swagger.tags = ['System Migration'] + #swagger.summary = 'Database health check' + #swagger.description = 'Checks database connectivity and health status' + #swagger.responses[200] = { + description: 'Database healthy', + schema: { + database: { + healthy: true, + status: 'OK' + } + } + } + #swagger.responses[500] = { + description: 'Database unhealthy', + schema: { + database: { + healthy: false, + status: 'ERROR', + error: 'Connection failed' + } + } + } + */ try { const isHealthy = await dbManager.healthCheck(); res.json({ diff --git a/backend/src/routes/reorder.js b/backend/src/routes/reorder.js index a0f6106..357ec34 100644 --- a/backend/src/routes/reorder.js +++ b/backend/src/routes/reorder.js @@ -3,24 +3,66 @@ const router = express.Router(); const GroupRepository = require('../repositories/GroupRepository'); /** - * PUT /api/groups/:groupId/reorder - * Reorder images within a group - * - * Request Body: - * { - * "imageIds": [123, 456, 789] // Array of image IDs in new order - * } - * - * Response: - * { - * "success": true, - * "message": "Image order updated successfully", - * "data": { - * "groupId": "abc123", - * "updatedImages": 3, - * "newOrder": [123, 456, 789] - * } - * } + * @swagger + * /{groupId}/reorder: + * put: + * tags: [Admin] + * summary: Reorder images within a group + * description: Updates the display order of images in a group. All image IDs of the group must be provided in the desired order. + * parameters: + * - in: path + * name: groupId + * required: true + * schema: + * type: string + * example: "cTV24Yn-a" + * description: Unique identifier of the group + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: + * - imageIds + * properties: + * imageIds: + * type: array + * items: + * type: integer + * example: [123, 456, 789] + * description: Array of image IDs in the new desired order + * responses: + * 200: + * description: Image order updated successfully + * content: + * application/json: + * schema: + * type: object + * properties: + * success: + * type: boolean + * example: true + * message: + * type: string + * example: "Image order updated successfully" + * data: + * type: object + * properties: + * groupId: + * type: string + * updatedImages: + * type: integer + * newOrder: + * type: array + * items: + * type: integer + * 400: + * description: Invalid request - missing or invalid imageIds + * 404: + * description: Group not found + * 500: + * description: Server error during reordering */ router.put('/:groupId/reorder', async (req, res) => { try { diff --git a/backend/src/routes/routeMappings.js b/backend/src/routes/routeMappings.js new file mode 100644 index 0000000..bc23084 --- /dev/null +++ b/backend/src/routes/routeMappings.js @@ -0,0 +1,28 @@ +/** + * Single Source of Truth fĂŒr Route-Mappings + * Wird verwendet von: + * - routes/index.js (Server-Routing) + * - generate-openapi.js (OpenAPI-Generierung) + */ + +module.exports = [ + // Public API - Öffentlich zugĂ€nglich + { router: 'upload', prefix: '/api', file: 'upload.js' }, + { router: 'download', prefix: '/api', file: 'download.js' }, + { router: 'batchUpload', prefix: '/api', file: 'batchUpload.js' }, + { router: 'groups', prefix: '/api', file: 'groups.js' }, + + // Management API - Token-basierter Zugriff + { router: 'management', prefix: '/api/manage', file: 'management.js' }, + + // Admin API - GeschĂŒtzt (Moderation, Logs, Cleanup, Consents) + // WICHTIG: consent muss VOR admin gemountet werden! + // Grund: admin.js hat /groups/:groupId, das matched auf /groups/by-consent + // Express matched Routes in Reihenfolge → spezifischere zuerst! + { router: 'consent', prefix: '/api/admin', file: 'consent.js' }, + { router: 'admin', prefix: '/api/admin', file: 'admin.js' }, + { router: 'reorder', prefix: '/api/admin', file: 'reorder.js' }, + + // System API - Interne Wartungsfunktionen + { router: 'migration', prefix: '/api/system/migration', file: 'migration.js' } +]; diff --git a/backend/src/routes/upload.js b/backend/src/routes/upload.js index 18e5b59..8e04512 100644 --- a/backend/src/routes/upload.js +++ b/backend/src/routes/upload.js @@ -1,7 +1,7 @@ const generateId = require("shortid"); const express = require('express'); const { Router } = require('express'); -const { endpoints, UPLOAD_FS_DIR, PREVIEW_FS_DIR } = require('../constants'); +const { UPLOAD_FS_DIR, PREVIEW_FS_DIR } = require('../constants'); const path = require('path'); const ImagePreviewService = require('../services/ImagePreviewService'); const groupRepository = require('../repositories/GroupRepository'); @@ -10,15 +10,49 @@ const fs = require('fs'); const router = Router(); // Serve uploaded images via URL /upload but store files under data/images -router.use(endpoints.UPLOAD_STATIC_DIRECTORY, express.static( path.join(__dirname, '..', UPLOAD_FS_DIR) )); +router.use('/upload', express.static( path.join(__dirname, '..', UPLOAD_FS_DIR) )); // Serve preview images via URL /previews but store files under data/previews -router.use(endpoints.PREVIEW_STATIC_DIRECTORY, express.static( path.join(__dirname, '..', PREVIEW_FS_DIR) )); +router.use('/previews', express.static( path.join(__dirname, '..', PREVIEW_FS_DIR) )); -router.post(endpoints.UPLOAD_FILE, async (req, res) => { - if(req.files === null){ +router.post('/upload', async (req, res) => { + /* + #swagger.tags = ['Upload'] + #swagger.summary = 'Upload a single image and create a new group' + #swagger.description = 'Uploads an image file, generates a preview, and creates a new group in the database' + #swagger.consumes = ['multipart/form-data'] + #swagger.parameters['file'] = { + in: 'formData', + type: 'file', + required: true, + description: 'Image file to upload' + } + #swagger.parameters['groupName'] = { + in: 'formData', + type: 'string', + description: 'Name for the new group', + example: 'Familie Mueller' + } + #swagger.responses[200] = { + description: 'File uploaded successfully', + schema: { + filePath: '/upload/abc123.jpg', + fileName: 'abc123.jpg', + groupId: 'cTV24Yn-a', + groupName: 'Familie Mueller' + } + } + #swagger.responses[400] = { + description: 'No file uploaded', + schema: { msg: 'No file uploaded' } + } + #swagger.responses[500] = { + description: 'Server error during upload' + } + */ + if(!req.files || req.files === null || !req.files.file){ console.log('No file uploaded'); - return res.status(400).json({ msg: 'No file uploaded' }); + return res.status(400).json({ error: 'Keine Datei hochgeladen' }); } const file = req.files.file; @@ -28,7 +62,10 @@ router.post(endpoints.UPLOAD_FILE, async (req, res) => { fileEnding = fileEnding[fileEnding.length - 1] fileName = generateId() + '.' + fileEnding - const savePath = path.join(__dirname, '..', UPLOAD_FS_DIR, fileName); + // Handle absolute vs relative paths (test mode uses /tmp) + const savePath = path.isAbsolute(UPLOAD_FS_DIR) + ? path.join(UPLOAD_FS_DIR, fileName) + : path.join(__dirname, '..', UPLOAD_FS_DIR, fileName); try { // Save the uploaded file @@ -72,11 +109,11 @@ router.post(endpoints.UPLOAD_FILE, async (req, res) => { images: [{ fileName: fileName, originalName: file.name, - filePath: `${endpoints.UPLOAD_STATIC_DIRECTORY}/${fileName}`, + filePath: `/upload/${fileName}`, uploadOrder: 1, fileSize: fileSize, mimeType: file.mimetype, - previewPath: `${endpoints.PREVIEW_STATIC_DIRECTORY}/${previewFileName}` + previewPath: `/previews/${previewFileName}` }] }; @@ -87,7 +124,7 @@ router.post(endpoints.UPLOAD_FILE, async (req, res) => { // Return immediately with file path res.json({ - filePath: `${endpoints.UPLOAD_STATIC_DIRECTORY}/${fileName}`, + filePath: `/upload/${fileName}`, fileName: fileName, groupId: groupId, groupName: groupName diff --git a/backend/src/server.js b/backend/src/server.js index 48c3857..3a21947 100644 --- a/backend/src/server.js +++ b/backend/src/server.js @@ -3,6 +3,29 @@ const initiateResources = require('./utils/initiate-resources'); const dbManager = require('./database/DatabaseManager'); const SchedulerService = require('./services/SchedulerService'); +// Dev: Auto-generate OpenAPI spec on server start (skip in test mode) +if (process.env.NODE_ENV !== 'production' && process.env.NODE_ENV !== 'test') { + try { + console.log('🔄 Generating OpenAPI specification...'); + require('./generate-openapi'); + console.log('✓ OpenAPI spec generated'); + } catch (error) { + console.warn('⚠ Failed to generate OpenAPI spec:', error.message); + } +} + +// Dev: Swagger UI (mount only in non-production) +let swaggerUi, swaggerDocument; +try { + // require lazily — only available/used in dev + swaggerUi = require('swagger-ui-express'); + swaggerDocument = require('../docs/openapi.json'); +} catch (e) { + // ignore if not installed or file missing + swaggerUi = null; + swaggerDocument = null; +} + class Server { _port; _app; @@ -22,6 +45,12 @@ class Server { // Starte Express Server initiateResources(this._app); this._app.use('/upload', express.static( __dirname + '/upload')); + + // Mount Swagger UI in dev only when available + if (process.env.NODE_ENV !== 'production' && swaggerUi && swaggerDocument) { + this._app.use('/api/docs', swaggerUi.serve, swaggerUi.setup(swaggerDocument)); + console.log('â„č Swagger UI mounted at /api/docs (dev only)'); + } this._app.listen(this._port, () => { console.log(`✅ Server lĂ€uft auf Port ${this._port}`); console.log(`📊 SQLite Datenbank aktiv`); @@ -34,6 +63,23 @@ class Server { process.exit(1); } } + + // Expose app for testing + getApp() { + return this._app; + } + + // Initialize app without listening (for tests) + async initializeApp() { + await dbManager.initialize(); + initiateResources(this._app); + this._app.use('/upload', express.static( __dirname + '/upload')); + + if (process.env.NODE_ENV !== 'production' && swaggerUi && swaggerDocument) { + this._app.use('/api/docs', swaggerUi.serve, swaggerUi.setup(swaggerDocument)); + } + return this._app; + } } module.exports = Server; \ No newline at end of file diff --git a/backend/src/services/SchedulerService.js b/backend/src/services/SchedulerService.js index 51c8b8a..5d23193 100644 --- a/backend/src/services/SchedulerService.js +++ b/backend/src/services/SchedulerService.js @@ -7,6 +7,12 @@ class SchedulerService { } start() { + // Don't start scheduler in test mode + if (process.env.NODE_ENV === 'test') { + console.log('[Scheduler] Skipped in test mode'); + return; + } + console.log('[Scheduler] Starting scheduled tasks...'); // Cleanup-Job: Jeden Tag um 10:00 Uhr diff --git a/backend/src/utils/initiate-resources.js b/backend/src/utils/initiate-resources.js index c9fb87d..34cdbca 100644 --- a/backend/src/utils/initiate-resources.js +++ b/backend/src/utils/initiate-resources.js @@ -3,7 +3,7 @@ const { renderRoutes } = require('../routes/index'); const removeImages = require('./remove-images'); const fs = require('fs'); const path = require('path'); -const { endpoints, UPLOAD_FS_DIR } = require('../constants'); +const { endpoints, UPLOAD_FS_DIR, PREVIEW_FS_DIR } = require('../constants'); const initiateResources = (app) => { @@ -11,12 +11,23 @@ const initiateResources = (app) => { renderRoutes(app); - // Ensure upload images directory exists: backend/src/../data/images - const imagesDir = path.join(__dirname, '..', UPLOAD_FS_DIR); + // Ensure upload images directory exists + // In test mode, UPLOAD_FS_DIR is absolute (/tmp/...), otherwise relative (data/images) + const imagesDir = path.isAbsolute(UPLOAD_FS_DIR) + ? UPLOAD_FS_DIR + : path.join(__dirname, '..', UPLOAD_FS_DIR); if (!fs.existsSync(imagesDir)){ fs.mkdirSync(imagesDir, { recursive: true }); } + // Ensure preview images directory exists + const previewsDir = path.isAbsolute(PREVIEW_FS_DIR) + ? PREVIEW_FS_DIR + : path.join(__dirname, '..', PREVIEW_FS_DIR); + if (!fs.existsSync(previewsDir)){ + fs.mkdirSync(previewsDir, { recursive: true }); + } + // Ensure db directory exists: backend/src/../data/db const dbDir = path.join(__dirname, '..', 'data', 'db'); if (!fs.existsSync(dbDir)){ diff --git a/backend/test-openapi-paths.js b/backend/test-openapi-paths.js new file mode 100644 index 0000000..6fcaf04 --- /dev/null +++ b/backend/test-openapi-paths.js @@ -0,0 +1,64 @@ +/** + * OpenAPI Path Validator + * + * Basic smoke test to verify all OpenAPI paths are reachable. + * Does NOT replace proper API testing! + * + * For comprehensive testing, consider: + * - Dredd (npm install -g dredd) - Contract testing against OpenAPI spec + * - Postman/Newman - Import openapi.json and run automated tests + * - Prism (npm install -g @stoplight/prism-cli) - Mock server + validation + * - Jest/Supertest - Full integration tests with schema validation + * + * This script only checks if paths respond (basic reachability check). + */ + +const fs = require('fs'); +const path = require('path'); + +// Read generated openapi.json +const specPath = path.join(__dirname, 'docs', 'openapi.json'); +const spec = JSON.parse(fs.readFileSync(specPath, 'utf8')); + +const baseUrl = 'http://localhost:5000'; +const paths = Object.keys(spec.paths || {}); + +console.log(`🔍 Testing ${paths.length} paths from openapi.json against ${baseUrl}\n`); + +async function testPath(path, methods) { + const method = Object.keys(methods)[0]; // take first method (usually GET) + const url = `${baseUrl}${path}`; + + return new Promise((resolve) => { + const http = require('http'); + const req = http.request(url, { method: method.toUpperCase() }, (res) => { + const status = res.statusCode; + const statusEmoji = status === 200 ? '✅' : status === 404 ? '❌' : '⚠'; + console.log(`${statusEmoji} ${method.toUpperCase()} ${path} → ${status}`); + resolve({ path, status, ok: status === 200 }); + }); + req.on('error', (err) => { + console.log(`đŸ’„ ${method.toUpperCase()} ${path} → ERROR: ${err.message}`); + resolve({ path, status: 'ERROR', ok: false }); + }); + req.end(); + }); +} + +(async () => { + const results = []; + for (const p of paths) { + const result = await testPath(p, spec.paths[p]); + results.push(result); + } + + const failed = results.filter(r => !r.ok); + console.log(`\n📊 Summary: ${results.length} paths tested, ${failed.length} failed\n`); + + if (failed.length > 0) { + console.log('❌ Failed paths (likely missing route prefixes):'); + failed.forEach(f => console.log(` ${f.path} → ${f.status}`)); + console.log('\n💡 Hint: Generator scanned route files without mount prefixes.'); + console.log(' Check backend/src/routes/index.js for app.use() calls with prefixes like /api/admin'); + } +})(); diff --git a/backend/tests/api/admin-auth.test.js b/backend/tests/api/admin-auth.test.js new file mode 100644 index 0000000..2d47382 --- /dev/null +++ b/backend/tests/api/admin-auth.test.js @@ -0,0 +1,70 @@ +const { getRequest } = require('../testServer'); + +describe('Admin Auth Middleware', () => { + describe('Without Auth Token', () => { + it('should reject requests without Authorization header', async () => { + const response = await getRequest() + .get('/api/admin/deletion-log') + .expect(403); + + expect(response.body).toHaveProperty('error'); + expect(response.body.message).toContain('Authorization header fehlt'); + }); + + it('should reject requests with invalid Bearer format', async () => { + const response = await getRequest() + .get('/api/admin/deletion-log') + .set('Authorization', 'InvalidFormat token123') + .expect(403); + + expect(response.body).toHaveProperty('error'); + expect(response.body.message).toContain('UngĂŒltiges Authorization Format'); + }); + + it('should reject requests with wrong token', async () => { + const response = await getRequest() + .get('/api/admin/deletion-log') + .set('Authorization', 'Bearer wrong-token-123') + .expect(403); + + expect(response.body).toHaveProperty('error'); + expect(response.body.message).toContain('UngĂŒltiger Admin-Token'); + }); + }); + + describe('With Valid Auth Token', () => { + const validToken = process.env.ADMIN_API_KEY || 'test-admin-key-123'; + + beforeAll(() => { + // Set test admin key + process.env.ADMIN_API_KEY = validToken; + }); + + it('should allow access with valid Bearer token', async () => { + const response = await getRequest() + .get('/api/admin/deletion-log') + .set('Authorization', `Bearer ${validToken}`) + .expect(200); + + expect(response.body).toHaveProperty('success'); + }); + + it('should protect all admin endpoints', async () => { + const endpoints = [ + '/api/admin/deletion-log', + '/api/admin/rate-limiter/stats', + '/api/admin/management-audit', + '/api/admin/groups' + ]; + + for (const endpoint of endpoints) { + const response = await getRequest() + .get(endpoint) + .set('Authorization', `Bearer ${validToken}`) + .expect(200); + + expect(response.body).toBeDefined(); + } + }); + }); +}); diff --git a/backend/tests/api/admin.test.js b/backend/tests/api/admin.test.js new file mode 100644 index 0000000..97b59ab --- /dev/null +++ b/backend/tests/api/admin.test.js @@ -0,0 +1,67 @@ +const { getRequest } = require('../testServer'); + +describe('Admin API - Security', () => { + describe('Authentication & Authorization', () => { + const adminEndpoints = [ + { method: 'get', path: '/api/admin/deletion-log' }, + { method: 'get', path: '/api/admin/deletion-log/csv' }, + { method: 'post', path: '/api/admin/cleanup/run' }, + { method: 'get', path: '/api/admin/cleanup/status' }, + { method: 'get', path: '/api/admin/rate-limiter/stats' }, + { method: 'get', path: '/api/admin/management-audit' }, + { method: 'get', path: '/api/admin/groups' }, + { method: 'put', path: '/api/admin/groups/test-id/approve' }, + { method: 'delete', path: '/api/admin/groups/test-id' } + ]; + + adminEndpoints.forEach(({ method, path }) => { + it(`should protect ${method.toUpperCase()} ${path} without authorization`, async () => { + await getRequest() + [method](path) + .expect(403); + }); + }); + }); + + describe('GET /api/admin/deletion-log', () => { + it('should require authorization header', async () => { + const response = await getRequest() + .get('/api/admin/deletion-log') + .expect(403); + + expect(response.body).toHaveProperty('error'); + }); + }); + + describe('GET /api/admin/cleanup/status', () => { + it('should require authorization', async () => { + await getRequest() + .get('/api/admin/cleanup/status') + .expect(403); + }); + }); + + describe('GET /api/admin/rate-limiter/stats', () => { + it('should require authorization', async () => { + await getRequest() + .get('/api/admin/rate-limiter/stats') + .expect(403); + }); + }); + + describe('GET /api/admin/groups', () => { + it('should require authorization', async () => { + await getRequest() + .get('/api/admin/groups') + .expect(403); + }); + + it('should validate query parameters with authorization', async () => { + // This test would need a valid admin token + // For now, we just test that invalid params are rejected + await getRequest() + .get('/api/admin/groups?status=invalid_status') + .expect(403); // Still 403 without auth, but validates endpoint exists + }); + }); +}); diff --git a/backend/tests/api/consent.test.js b/backend/tests/api/consent.test.js new file mode 100644 index 0000000..39a5964 --- /dev/null +++ b/backend/tests/api/consent.test.js @@ -0,0 +1,125 @@ +const { getRequest } = require('../testServer'); + +describe('Consent Management API', () => { + const validToken = process.env.ADMIN_API_KEY || 'test-admin-key-12345'; + + describe('GET /api/admin/social-media/platforms', () => { + it('should return list of social media platforms', async () => { + const response = await getRequest() + .get('/api/admin/social-media/platforms') + .set('Authorization', `Bearer ${validToken}`) + .expect('Content-Type', /json/) + .expect(200); + + expect(Array.isArray(response.body)).toBe(true); + }); + + it('should include platform metadata', async () => { + const response = await getRequest() + .get('/api/admin/social-media/platforms') + .set('Authorization', `Bearer ${validToken}`); + + if (response.body.length > 0) { + const platform = response.body[0]; + expect(platform).toHaveProperty('id'); + expect(platform).toHaveProperty('platform_name'); + expect(platform).toHaveProperty('display_name'); + } + }); + }); + + describe('GET /api/admin/groups/:groupId/consents', () => { + it('should return 404 for non-existent group', async () => { + await getRequest() + .get('/api/admin/groups/non-existent-group/consents') + .set('Authorization', `Bearer ${validToken}`) + .expect(404); + }); + + it('should reject path traversal attempts', async () => { + await getRequest() + .get('/api/admin/groups/../../../etc/passwd/consents') + .set('Authorization', `Bearer ${validToken}`) + .expect(404); + }); + }); + + describe('POST /api/admin/groups/:groupId/consents', () => { + it('should require admin authorization', async () => { + await getRequest() + .post('/api/admin/groups/test-group-id/consents') + .send({ consents: {} }) + .expect(403); // No auth header + }); + + it('should require valid consent data with auth', async () => { + const response = await getRequest() + .post('/api/admin/groups/test-group-id/consents') + .set('Authorization', `Bearer ${validToken}`) + .send({}) + .expect(400); + + expect(response.body).toHaveProperty('error'); + }); + }); + + describe('GET /api/admin/groups/by-consent', () => { + it('should return filtered groups', async () => { + const response = await getRequest() + .get('/api/admin/groups/by-consent') + .set('Authorization', `Bearer ${validToken}`) + .expect('Content-Type', /json/) + .expect(200); + + expect(response.body).toHaveProperty('groups'); + expect(response.body).toHaveProperty('count'); + expect(Array.isArray(response.body.groups)).toBe(true); + }); + + it('should accept platform filter', async () => { + const response = await getRequest() + .get('/api/admin/groups/by-consent?platformId=1') + .set('Authorization', `Bearer ${validToken}`) + .expect(200); + + expect(response.body).toHaveProperty('groups'); + expect(response.body).toHaveProperty('filters'); + }); + + it('should accept consent filter', async () => { + const response = await getRequest() + .get('/api/admin/groups/by-consent?displayInWorkshop=true') + .set('Authorization', `Bearer ${validToken}`) + .expect(200); + + expect(response.body).toHaveProperty('groups'); + expect(response.body.filters).toHaveProperty('displayInWorkshop', true); + }); + }); + + describe('GET /api/admin/consents/export', () => { + it('should require admin authorization', async () => { + await getRequest() + .get('/api/admin/consents/export') + .expect(403); + }); + + it('should return CSV format with auth and format parameter', async () => { + const response = await getRequest() + .get('/api/admin/consents/export?format=csv') + .set('Authorization', `Bearer ${validToken}`) + .expect(200); + + expect(response.headers['content-type']).toMatch(/text\/csv/); + expect(response.headers['content-disposition']).toMatch(/attachment/); + }); + + it('should include CSV header', async () => { + const response = await getRequest() + .get('/api/admin/consents/export?format=csv') + .set('Authorization', `Bearer ${validToken}`); + + expect(response.text).toContain('group_id'); + }); + }); +}); diff --git a/backend/tests/api/migration.test.js b/backend/tests/api/migration.test.js new file mode 100644 index 0000000..458feb7 --- /dev/null +++ b/backend/tests/api/migration.test.js @@ -0,0 +1,68 @@ +const { getRequest } = require('../testServer'); + +describe('System Migration API', () => { + describe('GET /api/system/migration/health', () => { + it('should return 200 with healthy status', async () => { + const response = await getRequest() + .get('/api/system/migration/health') + .expect('Content-Type', /json/) + .expect(200); + + expect(response.body).toHaveProperty('database'); + expect(response.body.database).toHaveProperty('healthy'); + expect(response.body.database).toHaveProperty('status'); + expect(response.body.database.healthy).toBe(true); + }); + + it('should include database connection status', async () => { + const response = await getRequest() + .get('/api/system/migration/health'); + + expect(response.body.database).toHaveProperty('healthy'); + expect(typeof response.body.database.healthy).toBe('boolean'); + expect(response.body.database.status).toBe('OK'); + }); + }); + + describe('GET /api/system/migration/status', () => { + it('should return current migration status', async () => { + const response = await getRequest() + .get('/api/system/migration/status') + .expect('Content-Type', /json/) + .expect(200); + + expect(response.body).toHaveProperty('database'); + expect(response.body).toHaveProperty('json'); + expect(response.body).toHaveProperty('migrated'); + expect(response.body).toHaveProperty('needsMigration'); + expect(typeof response.body.migrated).toBe('boolean'); + }); + + it('should return migration metadata', async () => { + const response = await getRequest() + .get('/api/system/migration/status'); + + expect(response.body.database).toHaveProperty('groups'); + expect(response.body.database).toHaveProperty('images'); + expect(response.body.database).toHaveProperty('initialized'); + expect(typeof response.body.database.groups).toBe('number'); + expect(typeof response.body.database.images).toBe('number'); + }); + }); + + describe('POST /api/system/migration/migrate', () => { + it('should require admin authorization', async () => { + await getRequest() + .post('/api/system/migration/migrate') + .expect(403); // Should be protected by auth + }); + }); + + describe('POST /api/system/migration/rollback', () => { + it('should require admin authorization', async () => { + await getRequest() + .post('/api/system/migration/rollback') + .expect(403); + }); + }); +}); diff --git a/backend/tests/api/upload.test.js b/backend/tests/api/upload.test.js new file mode 100644 index 0000000..398d3bd --- /dev/null +++ b/backend/tests/api/upload.test.js @@ -0,0 +1,58 @@ +const { getRequest } = require('../testServer'); +const path = require('path'); + +describe('Upload API', () => { + describe('POST /api/upload', () => { + it('should reject upload without files', async () => { + const response = await getRequest() + .post('/api/upload') + .field('groupName', 'TestGroup') + .expect('Content-Type', /json/) + .expect(400); + + expect(response.body).toHaveProperty('error'); + expect(response.body.error).toMatch(/datei|file/i); + }); + + it('should accept upload with file and groupName', async () => { + // Create a simple test buffer (1x1 transparent PNG) + const testImageBuffer = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==', + 'base64' + ); + + const response = await getRequest() + .post('/api/upload') + .attach('file', testImageBuffer, 'test.png') + .field('groupName', 'TestGroup'); + + // Log error for debugging + if (response.status !== 200) { + console.log('Upload failed:', response.body); + } + + expect(response.status).toBe(200); + expect(response.body).toHaveProperty('filePath'); + expect(response.body).toHaveProperty('fileName'); + expect(response.body).toHaveProperty('groupId'); + expect(response.body).toHaveProperty('groupName', 'TestGroup'); + }); + + it('should use default group name if not provided', async () => { + const testImageBuffer = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==', + 'base64' + ); + + const response = await getRequest() + .post('/api/upload') + .attach('file', testImageBuffer, 'test.png') + .expect('Content-Type', /json/) + .expect(200); + + expect(response.body).toHaveProperty('groupName'); + // Should use default: 'Unnamed Group' + expect(response.body.groupName).toBeTruthy(); + }); + }); +}); diff --git a/backend/tests/globalSetup.js b/backend/tests/globalSetup.js new file mode 100644 index 0000000..c940c0d --- /dev/null +++ b/backend/tests/globalSetup.js @@ -0,0 +1,33 @@ +/** + * Global Setup - Runs ONCE before all test suites + * Initialize test server and database here + */ + +const Server = require('../src/server'); + +module.exports = async () => { + console.log('\n🔧 Global Test Setup - Initializing test server...\n'); + + // Set test environment variables + process.env.NODE_ENV = 'test'; + process.env.PORT = 5001; + process.env.ADMIN_API_KEY = 'test-admin-key-12345'; + + try { + // Create and initialize server + console.log('Creating server instance...'); + const serverInstance = new Server(5001); + + console.log('Initializing app...'); + const app = await serverInstance.initializeApp(); + + // Store in global scope for all tests + global.__TEST_SERVER__ = serverInstance; + global.__TEST_APP__ = app; + + console.log('✅ Test server initialized successfully\n'); + } catch (error) { + console.error('❌ Failed to initialize test server:', error); + throw error; + } +}; diff --git a/backend/tests/globalTeardown.js b/backend/tests/globalTeardown.js new file mode 100644 index 0000000..7ed057d --- /dev/null +++ b/backend/tests/globalTeardown.js @@ -0,0 +1,14 @@ +/** + * Global Teardown - Runs ONCE after all test suites + * Cleanup resources here + */ + +module.exports = async () => { + console.log('\nđŸ§č Global Test Teardown - Cleaning up...\n'); + + // Cleanup global references + delete global.__TEST_SERVER__; + delete global.__TEST_APP__; + + console.log('✅ Test cleanup complete\n'); +}; diff --git a/backend/tests/setup.js b/backend/tests/setup.js new file mode 100644 index 0000000..f7bcb6b --- /dev/null +++ b/backend/tests/setup.js @@ -0,0 +1,47 @@ +/** + * Setup file - Runs before EACH test file + * Initialize server singleton here + */ + +const Server = require('../src/server'); + +// Singleton pattern - initialize only once +let serverInstance = null; +let app = null; + +async function initializeTestServer() { + if (!app) { + console.log('🔧 Initializing test server (one-time)...'); + + process.env.NODE_ENV = 'test'; + process.env.PORT = 5001; + process.env.ADMIN_API_KEY = 'test-admin-key-12345'; + + serverInstance = new Server(5001); + app = await serverInstance.initializeApp(); + + global.__TEST_SERVER__ = serverInstance; + global.__TEST_APP__ = app; + + console.log('✅ Test server ready'); + } + return app; +} + +// Initialize before all tests +beforeAll(async () => { + await initializeTestServer(); +}); + +// Test timeout +jest.setTimeout(10000); + +// Suppress logs during tests +global.console = { + ...console, + log: jest.fn(), + info: jest.fn(), + debug: jest.fn(), + error: console.error, + warn: console.warn, +}; diff --git a/backend/tests/testServer.js b/backend/tests/testServer.js new file mode 100644 index 0000000..1afbbee --- /dev/null +++ b/backend/tests/testServer.js @@ -0,0 +1,39 @@ +const request = require('supertest'); + +/** + * Get supertest request instance + * Uses globally initialized server from globalSetup.js + */ +function getRequest() { + const app = global.__TEST_APP__; + + if (!app) { + throw new Error( + 'Test server not initialized. ' + + 'This should be handled by globalSetup.js automatically.' + ); + } + + return request(app); +} + +/** + * Legacy compatibility - these are now no-ops + * Server is initialized globally + */ +async function setupTestServer() { + return { + app: global.__TEST_APP__, + serverInstance: global.__TEST_SERVER__ + }; +} + +async function teardownTestServer() { + // No-op - cleanup happens in globalTeardown.js +} + +module.exports = { + setupTestServer, + teardownTestServer, + getRequest +}; diff --git a/backend/tests/unit/auth.test.js b/backend/tests/unit/auth.test.js new file mode 100644 index 0000000..592124a --- /dev/null +++ b/backend/tests/unit/auth.test.js @@ -0,0 +1,81 @@ +const { requireAdminAuth } = require('../../src/middlewares/auth'); + +describe('Auth Middleware Unit Test', () => { + let req, res, next; + + beforeEach(() => { + req = { headers: {} }; + res = { + status: jest.fn().mockReturnThis(), + json: jest.fn() + }; + next = jest.fn(); + process.env.ADMIN_API_KEY = 'test-key-123'; + }); + + test('should reject missing Authorization header', () => { + requireAdminAuth(req, res, next); + + expect(res.status).toHaveBeenCalledWith(403); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ + error: 'Zugriff verweigert', + message: 'Authorization header fehlt' + }) + ); + expect(next).not.toHaveBeenCalled(); + }); + + test('should reject invalid Bearer format', () => { + req.headers.authorization = 'Invalid token'; + + requireAdminAuth(req, res, next); + + expect(res.status).toHaveBeenCalledWith(403); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining('UngĂŒltiges Authorization Format') + }) + ); + expect(next).not.toHaveBeenCalled(); + }); + + test('should reject wrong token', () => { + req.headers.authorization = 'Bearer wrong-token'; + + requireAdminAuth(req, res, next); + + expect(res.status).toHaveBeenCalledWith(403); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'UngĂŒltiger Admin-Token' + }) + ); + expect(next).not.toHaveBeenCalled(); + }); + + test('should allow valid token', () => { + req.headers.authorization = 'Bearer test-key-123'; + + requireAdminAuth(req, res, next); + + expect(next).toHaveBeenCalled(); + expect(res.status).not.toHaveBeenCalled(); + expect(res.json).not.toHaveBeenCalled(); + }); + + test('should handle missing ADMIN_API_KEY', () => { + delete process.env.ADMIN_API_KEY; + req.headers.authorization = 'Bearer any-token'; + + requireAdminAuth(req, res, next); + + expect(res.status).toHaveBeenCalledWith(500); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ + error: 'Server-Konfigurationsfehler' + }) + ); + expect(next).not.toHaveBeenCalled(); + }); +}); diff --git a/docs/FEATURE_PLAN-autogen-openapi.md b/docs/FEATURE_PLAN-autogen-openapi.md index 9ec7132..d91a192 100644 --- a/docs/FEATURE_PLAN-autogen-openapi.md +++ b/docs/FEATURE_PLAN-autogen-openapi.md @@ -1,92 +1,195 @@ -````markdown -# Feature Plan: Autogenerierte OpenAPI / Swagger Spec +# Feature Plan: Autogenerierte OpenAPI / Swagger Spec + API Restructuring **Branch:** `feature/autogen-openapi` -**Datum:** 16. November 2025 +**Datum:** 16. November 2025 +**Status:** ✅ Complete - Auto-generation active, Single Source of Truth established -## 🎯 Ziel -Einfaches Developer‑Experience Feature, das beim lokalen Start des Backends automatisch eine OpenAPI Spec erzeugt und eine Swagger UI zur VerfĂŒgung stellt. Entwickler mĂŒssen beim Anlegen neuer Routen die Doku nicht mehr manuell nachpflegen. +## 🎯 Hauptziele +1. ✅ **OpenAPI Auto-Generation:** Swagger Spec wird automatisch aus Route-Definitionen generiert +2. ✅ **Konsistente API-Struktur:** Klare, REST-konforme API-Organisation fĂŒr einfache KI-Navigation +3. ✅ **Single Source of Truth:** `routeMappings.js` als zentrale Route-Konfiguration +4. ✅ **Developer Experience:** Swagger UI unter `/api/docs` (dev-only) +5. ✅ **Test Coverage:** 45 automatisierte Tests, 100% passing +6. ✅ **API Security:** Bearer Token Authentication fĂŒr Admin-Endpoints --- -## 🔍 Scope -- Dev‑only: Generierung beim lokalen Start oder per npm script. -- Swagger UI unter `/api/docs` (Dev) mit der generierten Spec. -- Optionaler Export der Spec nach `docs/openapi.json` fĂŒr CI/Review. -- Keine invasive Änderung an Produktionsstart. +## 📊 API-Struktur (Ziel) + +### Design-Prinzipien +- **Prefix = Zugriffsebene:** Struktur basiert auf Authentifizierung/Autorisierung +- **REST-konform:** Standard HTTP Methoden (GET, POST, PUT, PATCH, DELETE) +- **KI-freundlich:** Klare Hierarchie, vorhersagbare Patterns +- **Konsistent:** Alle Routen folgen dem gleichen Muster + +### Routing-Schema + +``` +/api/upload (öffentlich - Upload-Funktionen) +/api/groups (öffentlich - Slideshow-Anzeige) +/api/manage/:token/* (token-basiert - User-Verwaltung) +/api/admin/* (geschĂŒtzt - Moderation) +/api/system/* (intern - Wartung) +``` + +### Detaillierte Endpunkte + +#### đŸ“€ Public API +``` +POST /api/upload - Single file upload +POST /api/upload/batch - Batch upload +GET /api/groups - List approved slideshows +GET /api/groups/:groupId - View specific slideshow +``` + +#### 🔑 Management API +Token-basierter Zugriff fĂŒr Slideshow-Ersteller: +``` +GET /api/manage/:token - Get slideshow info +PUT /api/manage/:token/consents - Update consents +PUT /api/manage/:token/metadata - Update metadata +PUT /api/manage/:token/images/descriptions - Update image descriptions +POST /api/manage/:token/images - Add images +DELETE /api/manage/:token/images/:imageId - Delete image +DELETE /api/manage/:token - Delete slideshow +``` + +#### 👼 Admin API +GeschĂŒtzte Moderation- und Management-Funktionen: +``` +# Moderation +GET /api/admin/moderation/groups - List pending slideshows +GET /api/admin/moderation/groups/:id - Get slideshow details +PATCH /api/admin/groups/:id/approve - Approve slideshow +PATCH /api/admin/groups/:id - Edit slideshow +DELETE /api/admin/groups/:id/images/:imageId - Delete single image +PATCH /api/admin/groups/:id/images/batch-description +PUT /api/admin/groups/:id/reorder - Reorder images + +# Logs & Monitoring +GET /api/admin/deletion-log - Recent deletions +GET /api/admin/deletion-log/stats - Deletion statistics +GET /api/admin/management-audit - Audit log +GET /api/admin/rate-limiter/stats - Rate limiter stats + +# Cleanup +POST /api/admin/cleanup/trigger - Trigger cleanup +GET /api/admin/cleanup/preview - Preview cleanup targets + +# Consents & Social Media +GET /api/admin/consents/export - Export consents (CSV) +GET /api/admin/social-media/platforms - List platforms +``` + +#### ⚙ System API +Interne System-Operationen: +``` +GET /api/system/migration/status - Migration status +POST /api/system/migration/migrate - Run migration +POST /api/system/migration/rollback - Rollback migration +GET /api/system/migration/health - Health check +``` --- -## 📐 Architektur‑Überblick -### Warum das funktioniert fĂŒr dieses Projekt -- Routen werden statisch importiert und in `backend/src/routes/index.js` mit klaren Basispfaden registriert. -- Viele Routen nutzen konstante Pfadangaben (`backend/src/constants.js`) oder feste Strings. -- Daher erkennen Generatoren die meisten Endpunkte zuverlĂ€ssig. +## 🔧 Technische Implementierung -### Grenzen -- Multipart Uploads (express-fileupload) werden in der Regel als Pfad erkannt, die requestBody Beschreibung (multipart schema) kann fehlen und erfordert kleine Hints. -- Komplexe Response‑Schemas (detaillierte components/schemas) sind nicht automatisch vollstĂ€ndig prĂ€zise; diese können spĂ€ter ergĂ€nzt werden. +### Komponenten +- **swagger-autogen** (v6.2.8): OpenAPI 3.0 Generation +- **swagger-ui-express** (v4.6.3): Interactive API docs +- **Custom Generator:** `src/generate-openapi.js` + +### Generator-Logik +```javascript +// Pro Router-Datei einzeln scannen + Mount-Prefix anwenden +for each routerMapping { + swaggerAutogen(tempFile, [routeFile], { basePath: prefix }) + merge paths with prefix into final spec +} +``` + +### Single Source of Truth +1. **Router-Files (`src/routes/*.js`)**: Enthalten nur relative Pfade +2. **Mount-Konfiguration (`src/routes/index.js`)**: Definiert Prefixes +3. **OpenAPI Generation:** `generate-openapi.js` liest beide und merged --- -## 🔧 Implementierungs‑Schritte -### Phase 0 — Vorbereitung (30–60 min) -- Branch anlegen: `feature/autogen-openapi` (bereits erfolgt) -- Dev‑Dokumentation anpassen: `README.dev.md` Hinweis wie Doku geöffnet wird +## 📚 FĂŒr KI-Nutzung -### Phase 1 — MVP Integration (1–2h) -1. Installiere dev‑Dependency: `swagger-autogen` oder `express-oas-generator` (oder `swagger-jsdoc + swagger-ui-express` falls JSDoc bevorzugt wird). -2. FĂŒge ein npm script hinzu: `npm run generate:openapi` (erzeugt `docs/openapi.json`). -3. Bei Dev‑Start: Generator einmal ausfĂŒhren und Spec in memory oder als file verfĂŒgbar machen. -4. Mount Swagger UI unter `/api/docs` nur in Dev. +### API-Hierarchie verstehen +``` +/api/* ← Alle API-Endpoints + ├─ /upload, /groups ← Öffentlich + ├─ /manage/:token/* ← Token-basiert + ├─ /admin/* ← GeschĂŒtzt + └─ /system/* ← Intern +``` -**Ergebnis:** Swagger UI listet automatisch erkannte Endpunkte. +### Neue Route hinzufĂŒgen +```bash +# 1. Route in passender Datei hinzufĂŒgen (z.B. admin.js) +router.get('/new-endpoint', ...) -### Phase 2 — Nacharbeit & Hints (1–2h) -- PrĂŒfe Upload‑Endpoints (z. B. `/upload`, `/upload/batch`) und fĂŒge ggf. kleine Hints oder manuelle ErgĂ€nzungen zur requestBody‑Definition hinzu (in `docs/openapi.json` oder per generator config). -- Optional: ergĂ€nze components/schemas fĂŒr die wichtigsten Responses (Groups, Image, UploadResult). +# 2. In routeMappings.js registrieren (falls neue Datei) +{ router: 'newRoute', prefix: '/api/admin', file: 'newRoute.js' } -### Phase 3 — CI / Export (0.5–1h) -- Optionales npm script fĂŒr CI, das `npm run generate:openapi` ausfĂŒhrt und das Ergebnis im Artefact bereitstellt. -- Optional: OpenAPI Validator in CI laufen lassen (z. B. `swagger-cli validate`) um Spec‑Fehler frĂŒh zu erkennen. +# 3. OpenAPI wird automatisch beim Backend-Start generiert +npm run dev + +# 4. Tests schreiben: tests/api/newRoute.test.js +npm test + +# 5. Swagger UI: http://localhost:5001/api/docs +``` --- -## ⏱ Zeitplan & Aufwand -- MVP (Dev UI + generate once): 1–2 Stunden -- Upload Hints + kleine Spec‑Fixes: 1–2 Stunden -- Optional: CI/Export + Validator: 0.5–1 Stunde +## ✅ Implementierungsstatus (November 16, 2025) -**Total (realistisch, MVP+fixes): 2–4 Stunden** +### Completed Features + +✅ **Single Source of Truth**: `routeMappings.js` als zentrale Route-Konfiguration +✅ **Auto-Generation**: OpenAPI-Spec automatisch beim Backend-Start +✅ **Authentication**: Bearer Token fĂŒr Admin-Endpoints +✅ **Test Suite**: 45 automatisierte Tests (100% passing) +✅ **Documentation**: `routes/README.md` + `AUTHENTICATION.md` +✅ **Route Order Fix**: Express routing order documented & fixed + +### Known Issues (Resolved) + +✅ **Express Route Order**: Consent router now mounted before admin router +✅ **Test Permissions**: Tests use `/tmp/` for uploads +✅ **SQLite Async**: Connection properly promisified --- -## ✅ Acceptance Criteria -- [ ] Feature Branch mit Integration existiert (`feature/autogen-openapi`). -- [ ] `npm run dev` zeigt Swagger UI unter `/api/docs` mit generierter Spec. -- [ ] Alle standardmĂ€ĂŸigen Routen sind in der Spec enthalten (Smoke Test). -- [ ] Upload‑Endpoints erkannt; falls requestBody fehlt, dokumentierter Hinweis in PR. -- [ ] Feature ist deaktiviert in `production`. -- [ ] Optional: `docs/openapi.json` kann per script erzeugt werden. +## ⏱ AufwandsschĂ€tzung (Final) + +| Phase | Zeit | Status | +|-------|------|--------| +| MVP OpenAPI Generation | 2h | ✅ Done | +| API Restructuring | 8h | ✅ Done | +| Authentication System | 4h | ✅ Done | +| Test Suite | 6h | ✅ Done | +| Documentation | 2h | ✅ Done | +| **Total** | **22h** | **100%** | --- -## ⚠ Risiken & Mitigations -- Risiko: Generator erkennt Upload requestBody nicht korrekt. - Mitigation: Manuelle Hint‑Block oder kleine post‑processing Schritte in `generate:openapi` Script. +## 🚀 Frontend Migration Guide -- Risiko: Spec generiert sensitive Pfade/fields (management tokens). - Mitigation: Filter/Transformation im Export‑Script oder nur generiertes file in CI/Review; Swagger UI dev‑only. +**Required Changes:** + +1. **Add Bearer Token**: All `/api/admin/*` calls need `Authorization: Bearer ` header +2. **Verify Paths**: Check against `routeMappings.js` (consent: `/api/admin/groups/by-consent`) +3. **Handle 403**: Add error handling for missing authentication +4. **Environment**: Add `REACT_APP_ADMIN_API_KEY` to `.env` + +**See `AUTHENTICATION.md` for complete setup guide** --- -## ✅ Next Steps (konkret) -1. Push Branch `feature/autogen-openapi` remote (falls noch nicht geschehen). -2. Ich implementiere MVP: installiere Generator, fĂŒhre einmaligen Lauf aus, mounte Swagger UI und liefere `docs/openapi.json` und eine Liste der Endpunkte, die Hints brauchen. -3. PR mit Implementation + Docs. -4. Review und Nachbesserung des Specs (Uploads/Schemas). - - -**Erstellt am:** 16. November 2025 - -```` \ No newline at end of file +**Erstellt:** 16. November 2025 +**Aktualisiert:** 16. November 2025 +**Status:** ✅ Production Ready diff --git a/frontend/MIGRATION-GUIDE.md b/frontend/MIGRATION-GUIDE.md new file mode 100644 index 0000000..68cd96b --- /dev/null +++ b/frontend/MIGRATION-GUIDE.md @@ -0,0 +1,294 @@ +# Frontend Migration Guide - Admin API Authentication + +**Datum:** 16. November 2025 +**Betrifft:** Alle Admin-API-Aufrufe im Frontend +**Status:** ⚠ Aktion erforderlich + +--- + +## 🔒 Was hat sich geĂ€ndert? + +Alle Admin-Endpoints (`/api/admin/*`) benötigen jetzt **Bearer Token Authentication**. + +### Betroffene Endpoints + +- `/api/admin/groups` - Gruppen auflisten +- `/api/admin/groups/:id` - Gruppe abrufen +- `/api/admin/groups/:id/approve` - Gruppe genehmigen +- `/api/admin/groups/:id` - Gruppe löschen +- `/api/admin/groups/:id/images/:imageId` - Bild löschen +- `/api/admin/groups/by-consent` - Nach Consent filtern +- `/api/admin/consents/export` - Consent-Export +- `/api/admin/social-media/platforms` - Plattformen auflisten +- `/api/admin/reorder/:groupId/images` - Bilder neu anordnen +- `/api/admin/deletion-log` - Deletion Log +- `/api/admin/cleanup/*` - Cleanup-Funktionen +- `/api/admin/rate-limiter/stats` - Rate-Limiter-Statistiken +- `/api/admin/management-audit` - Audit-Log + +**System-Endpoints:** +- `/api/system/migration/migrate` - Migration ausfĂŒhren +- `/api/system/migration/rollback` - Migration zurĂŒckrollen + +--- + +## 📝 Erforderliche Änderungen + +### 1. Environment Variable hinzufĂŒgen + +```bash +# frontend/.env oder frontend/.env.local +REACT_APP_ADMIN_API_KEY=your-secure-admin-token-here +``` + +**Token generieren:** +```bash +# Linux/Mac: +openssl rand -hex 32 + +# Node.js: +node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" +``` + +**⚠ Wichtig**: Gleiches Token in Backend `.env` als `ADMIN_API_KEY` eintragen! + +### 2. API-Aufrufe anpassen + +#### Vorher (ohne Auth): +```javascript +const response = await fetch('/api/admin/groups'); +``` + +#### Nachher (mit Bearer Token): +```javascript +const response = await fetch('/api/admin/groups', { + headers: { + 'Authorization': `Bearer ${process.env.REACT_APP_ADMIN_API_KEY}`, + 'Content-Type': 'application/json' + } +}); +``` + +### 3. Zentrale API-Helper-Funktion erstellen + +**Empfohlen**: Erstelle eine zentrale Funktion fĂŒr alle Admin-API-Calls: + +```javascript +// src/services/adminApiService.js +const ADMIN_API_KEY = process.env.REACT_APP_ADMIN_API_KEY; + +export const adminFetch = async (url, options = {}) => { + const defaultHeaders = { + 'Authorization': `Bearer ${ADMIN_API_KEY}`, + 'Content-Type': 'application/json' + }; + + const response = await fetch(url, { + ...options, + headers: { + ...defaultHeaders, + ...options.headers + } + }); + + if (response.status === 403) { + throw new Error('Authentication failed - Invalid or missing admin token'); + } + + return response; +}; + +// Verwendung: +import { adminFetch } from './services/adminApiService'; + +const response = await adminFetch('/api/admin/groups'); +const data = await response.json(); +``` + +### 4. Error Handling erweitern + +```javascript +try { + const response = await adminFetch('/api/admin/groups'); + + if (response.status === 403) { + // Auth fehlt oder ungĂŒltig + console.error('Admin authentication required'); + // Redirect zu Login oder Fehlermeldung anzeigen + } + + if (response.status === 429) { + // Rate Limit ĂŒberschritten + console.error('Too many requests'); + } + + const data = await response.json(); + // ... +} catch (error) { + console.error('Admin API error:', error); +} +``` + +--- + +## 🔍 Betroffene Dateien finden + +Such nach allen Admin-API-Aufrufen: + +```bash +cd frontend/src + +# Alle Admin-API-Calls finden: +grep -r "/api/admin" --include="*.js" --include="*.jsx" + +# Alle Fetch-Calls in spezifischen Komponenten: +grep -r "fetch.*admin" Components/Pages/ +``` + +**Wahrscheinlich betroffene Komponenten:** +- `Components/Pages/ModerationGroupsPage.js` +- `Components/Pages/ModerationGroupImagesPage.js` +- `Components/ComponentUtils/ConsentManager.js` (wenn im moderate-Modus) +- `services/reorderService.js` (Admin-Reorder) +- Jede andere Komponente, die Admin-Funktionen aufruft + +--- + +## ✅ Checkliste + +- [ ] `REACT_APP_ADMIN_API_KEY` in `.env` hinzugefĂŒgt +- [ ] Token im Backend als `ADMIN_API_KEY` konfiguriert +- [ ] Zentrale `adminFetch` Funktion erstellt +- [ ] Alle Admin-API-Calls gefunden (grep) +- [ ] Authorization Header zu allen Admin-Calls hinzugefĂŒgt +- [ ] 403 Error Handling implementiert +- [ ] Frontend lokal getestet +- [ ] Production `.env` aktualisiert + +--- + +## đŸ§Ș Testing + +### Lokales Testing + +1. Backend mit Admin-Key starten: + ```bash + cd backend + echo "ADMIN_API_KEY=test-key-12345" >> .env + npm run dev + ``` + +2. Frontend mit Admin-Key starten: + ```bash + cd frontend + echo "REACT_APP_ADMIN_API_KEY=test-key-12345" >> .env.local + npm start + ``` + +3. Moderation-Seite öffnen und Admin-Funktionen testen + +### Test-FĂ€lle + +- ✅ Admin-Funktionen funktionieren mit gĂŒltigem Token +- ✅ 403 Error bei fehlendem/falschem Token +- ✅ Consent-Export funktioniert +- ✅ Gruppen löschen funktioniert +- ✅ Bilder neu anordnen funktioniert + +--- + +## 📚 Weitere Dokumentation + +- **Backend Auth-Doku**: `AUTHENTICATION.md` +- **API Route-Übersicht**: `backend/src/routes/README.md` +- **Route-Konfiguration**: `backend/src/routes/routeMappings.js` +- **OpenAPI Spec**: `backend/docs/openapi.json` +- **Swagger UI**: http://localhost:5001/api/docs (dev only) + +--- + +## 🆘 Troubleshooting + +### Problem: "403 Forbidden" Fehler + +**Ursachen:** +1. `REACT_APP_ADMIN_API_KEY` nicht gesetzt +2. Token falsch konfiguriert (Frontend ≠ Backend) +3. Token enthĂ€lt Leerzeichen/ZeilenumbrĂŒche + +**Lösung:** +```bash +# Frontend .env prĂŒfen: +cat frontend/.env | grep ADMIN_API_KEY + +# Backend .env prĂŒfen: +cat backend/.env | grep ADMIN_API_KEY + +# Beide mĂŒssen identisch sein! +``` + +### Problem: "ADMIN_API_KEY not configured" (500 Error) + +**Ursache:** Backend hat `ADMIN_API_KEY` nicht in `.env` + +**Lösung:** +```bash +cd backend +echo "ADMIN_API_KEY=$(openssl rand -hex 32)" >> .env +``` + +### Problem: Token wird nicht gesendet + +**PrĂŒfen in Browser DevTools:** +1. Network Tab öffnen +2. Admin-API-Request auswĂ€hlen +3. "Headers" Tab prĂŒfen +4. Sollte enthalten: `Authorization: Bearer ` + +### Problem: CORS-Fehler + +**Ursache:** Backend CORS-Middleware blockiert Authorization-Header + +**Lösung:** Bereits implementiert in `backend/src/middlewares/cors.js`: +```javascript +allowedHeaders: ['Content-Type', 'Authorization'] +``` + +--- + +## 🚀 Deployment + +### Production Checklist + +- [ ] Sicheren Admin-Token generiert (min. 32 Bytes hex) +- [ ] Token in Backend `.env` als `ADMIN_API_KEY` +- [ ] Token in Frontend Build-Environment als `REACT_APP_ADMIN_API_KEY` +- [ ] Token NICHT in Git committed (in `.gitignore`) +- [ ] HTTPS verwendet (Token ĂŒber unverschlĂŒsselte HTTP ist unsicher) +- [ ] Token-Rotation-Prozess dokumentiert +- [ ] Backup des Tokens an sicherem Ort gespeichert + +### Docker Deployment + +```yaml +# docker-compose.yml +services: + backend: + environment: + - ADMIN_API_KEY=${ADMIN_API_KEY} + + frontend: + environment: + - REACT_APP_ADMIN_API_KEY=${ADMIN_API_KEY} +``` + +```bash +# .env (nicht in Git!) +ADMIN_API_KEY=your-production-token-here +``` + +--- + +**Fragen?** Siehe `AUTHENTICATION.md` fĂŒr detaillierte Backend-Dokumentation. + +**Status der Backend-Changes:** ✅ VollstĂ€ndig implementiert und getestet (45/45 Tests passing) diff --git a/frontend/src/Components/Pages/ManagementPortalPage.js b/frontend/src/Components/Pages/ManagementPortalPage.js index 706a9e4..af5304b 100644 --- a/frontend/src/Components/Pages/ManagementPortalPage.js +++ b/frontend/src/Components/Pages/ManagementPortalPage.js @@ -145,7 +145,8 @@ function ManagementPortalPage() { try { const imageIds = newOrder.map(img => img.id); - const response = await fetch(`/api/groups/${group.groupId}/reorder`, { + // Use token-based management API + const response = await fetch(`/api/manage/${token}/reorder`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ imageIds: imageIds }) From 36e7302677f76267a9769fd0ab65a385ecc141b9 Mon Sep 17 00:00:00 2001 From: "matthias.lotz" Date: Sun, 16 Nov 2025 18:21:07 +0100 Subject: [PATCH 3/4] docs: Improve frontend migration guide visibility and remove obsolete test files - Add prominent migration guide reference in README.dev.md API section - Remove backend/TESTING.md (info now in README.dev.md) - Remove backend/test-openapi-paths.js (replaced by automated tests) --- README.dev.md | 28 ++++ backend/TESTING.md | 297 ---------------------------------- backend/test-openapi-paths.js | 64 -------- 3 files changed, 28 insertions(+), 361 deletions(-) delete mode 100644 backend/TESTING.md delete mode 100644 backend/test-openapi-paths.js diff --git a/README.dev.md b/README.dev.md index ee20a85..a33e303 100644 --- a/README.dev.md +++ b/README.dev.md @@ -1,5 +1,24 @@ # Development Setup +## ⚠ Wichtige Hinweise fĂŒr Frontend-Entwickler + +### 🔮 BREAKING CHANGES - API-Umstrukturierung (November 2025) + +Im Rahmen der OpenAPI-Auto-Generation wurden **massive Änderungen** an der API-Struktur vorgenommen: + +- **Authentication**: Alle Admin-Endpoints benötigen jetzt Bearer Token +- **Route-Struktur**: Einige Pfade haben sich geĂ€ndert (Single Source of Truth: `routeMappings.js`) +- **Error Handling**: Neue HTTP-Status-Codes (403 fĂŒr Auth-Fehler) + +**📖 Siehe:** +- **`frontend/MIGRATION-GUIDE.md`** - Detaillierte Migrations-Anleitung fĂŒr Frontend +- **`backend/src/routes/README.md`** - VollstĂ€ndige API-Route-Dokumentation +- **`AUTHENTICATION.md`** - Auth-System-Setup und Verwendung + +**GeschĂ€tzter Migrations-Aufwand**: 2-3 Stunden + +--- + ## Schnellstart ### Starten (Development Environment) @@ -33,6 +52,15 @@ docker compose -f docker/dev/docker-compose.yml logs -f backend-dev ## API-Entwicklung +### ⚠ BREAKING CHANGES - Frontend Migration erforderlich + +**Massive API-Änderungen im November 2025:** +- Bearer Token Authentication fĂŒr alle Admin-Endpoints +- Route-Pfade umstrukturiert (siehe `routeMappings.js`) +- Neue Error-Response-Formate + +**📖 Frontend Migration Guide**: `frontend/MIGRATION-GUIDE.md` + ### Route-Struktur Die API verwendet eine **Single Source of Truth** fĂŒr Route-Mappings: diff --git a/backend/TESTING.md b/backend/TESTING.md deleted file mode 100644 index b53930d..0000000 --- a/backend/TESTING.md +++ /dev/null @@ -1,297 +0,0 @@ -# API Testing Guide - -## 🎯 Testing-Strategie - -### 1. Smoke Tests (Aktuell) - -**Script:** `test-openapi-paths.js` - -**Was wird getestet:** -- ✅ Alle Pfade sind erreichbar -- ✅ Server antwortet -- ❌ Keine Schema-Validierung -- ❌ Keine Auth-Tests -- ❌ Keine Request Bodies - -**Verwendung:** -```bash -npm run test-openapi -``` - -**Zweck:** Schneller Check ob alle Routen mounted sind. - ---- - -## 🔧 Empfohlene Testing-Tools - -### Option 1: Dredd (Contract Testing) ⭐ EMPFOHLEN - -**Installation:** -```bash -npm install --save-dev dredd -``` - -**Setup:** -```bash -# dredd.yml erstellen -cat > dredd.yml << EOF -color: true -dry-run: false -hookfiles: null -language: nodejs -require: null -server: npm start -server-wait: 3 -init: false -custom: {} -names: false -only: [] -reporter: [] -output: [] -header: [] -sorted: false -user: null -inline-errors: false -details: false -method: [] -loglevel: warning -path: [] -blueprint: docs/openapi.json -endpoint: http://localhost:5000 -EOF -``` - -**AusfĂŒhren:** -```bash -npx dredd -``` - -**Vorteile:** -- ✅ Validiert Responses gegen OpenAPI-Schema -- ✅ Testet alle HTTP-Methoden -- ✅ PrĂŒft Request/Response Bodies -- ✅ UnterstĂŒtzt Hooks fĂŒr Auth - ---- - -### Option 2: Postman + Newman - -**Setup:** -1. OpenAPI importieren: - - Öffne Postman - - File → Import → `backend/docs/openapi.json` - -2. Collection generieren lassen - -3. Tests hinzufĂŒgen (automatisch oder manuell) - -4. Export als Collection - -**CLI-Tests:** -```bash -npm install --save-dev newman -npx newman run postman_collection.json -``` - -**Vorteile:** -- ✅ GUI fĂŒr manuelles Testen -- ✅ Automatische Test-Generierung -- ✅ CI/CD Integration -- ✅ Environment Variables - ---- - -### Option 3: Prism (Mock + Validate) - -**Installation:** -```bash -npm install --save-dev @stoplight/prism-cli -``` - -**Mock Server starten:** -```bash -npx prism mock docs/openapi.json -# Server lĂ€uft auf http://localhost:4010 -``` - -**Validierung:** -```bash -npx prism validate docs/openapi.json -``` - -**Vorteile:** -- ✅ Mock Server fĂŒr Frontend-Entwicklung -- ✅ Schema-Validierung -- ✅ Contract Testing -- ✅ Keine echte API nötig - ---- - -### Option 4: Jest + Supertest (Unit/Integration Tests) - -**Installation:** -```bash -npm install --save-dev jest supertest jest-openapi -``` - -**Beispiel Test:** -```javascript -// tests/api/groups.test.js -const request = require('supertest'); -const jestOpenAPI = require('jest-openapi'); -const app = require('../../src/server'); - -// Load OpenAPI spec -jestOpenAPI(require('../../docs/openapi.json')); - -describe('Groups API', () => { - it('GET /api/groups should return valid response', async () => { - const response = await request(app) - .get('/api/groups') - .expect(200); - - // Validate against OpenAPI schema - expect(response).toSatisfyApiSpec(); - - // Custom assertions - expect(response.body).toHaveProperty('groups'); - expect(Array.isArray(response.body.groups)).toBe(true); - }); - - it('GET /api/groups/:groupId should return 404 for invalid ID', async () => { - const response = await request(app) - .get('/api/groups/invalid-id') - .expect(404); - - expect(response).toSatisfyApiSpec(); - }); -}); -``` - -**package.json:** -```json -{ - "scripts": { - "test": "jest", - "test:watch": "jest --watch", - "test:coverage": "jest --coverage" - } -} -``` - -**Vorteile:** -- ✅ VollstĂ€ndige Integration Tests -- ✅ Schema-Validierung mit jest-openapi -- ✅ Code Coverage -- ✅ CI/CD Integration -- ✅ Watch Mode fĂŒr TDD - ---- - -## 📊 Vergleich - -| Tool | Setup | Schema Validation | Auth Support | CI/CD | Best For | -|------|-------|-------------------|--------------|-------|----------| -| **test-openapi-paths.js** | ✅ Fertig | ❌ | ❌ | ✅ | Quick smoke test | -| **Dredd** | ⚠ Mittel | ✅ | ✅ | ✅ | Contract testing | -| **Postman/Newman** | ⚠ Mittel | ✅ | ✅ | ✅ | Manual + automated | -| **Prism** | ✅ Easy | ✅ | ⚠ | ✅ | Mock + validate | -| **Jest + Supertest** | ⚠ Komplex | ✅ | ✅ | ✅ | Full testing suite | - ---- - -## 🚀 Empfehlung fĂŒr dieses Projekt - -### Phase 1: Quick Wins (Jetzt) -```bash -# 1. Behalte aktuelles Smoke-Test Script -npm run test-openapi - -# 2. Installiere Prism fĂŒr Validierung -npm install --save-dev @stoplight/prism-cli - -# 3. Validiere OpenAPI Spec -npm run validate-openapi # (siehe unten) -``` - -### Phase 2: Contract Testing (SpĂ€ter) -```bash -# 1. Installiere Dredd -npm install --save-dev dredd - -# 2. Erstelle dredd.yml (siehe oben) - -# 3. Schreibe Hooks fĂŒr Auth -# tests/dredd-hooks.js - -# 4. FĂŒhre aus -npm run test:contract -``` - -### Phase 3: Full Testing Suite (Optional) -```bash -# Jest + Supertest + jest-openapi -npm install --save-dev jest supertest jest-openapi @types/jest - -# Schreibe Integration Tests in tests/ -npm run test -``` - ---- - -## 📝 Neue NPM Scripts - -FĂŒge zu `package.json` hinzu: - -```json -{ - "scripts": { - "test-openapi": "node test-openapi-paths.js", - "validate-openapi": "prism validate docs/openapi.json", - "test:contract": "dredd", - "test": "jest", - "test:watch": "jest --watch" - } -} -``` - ---- - -## 🔗 Swagger-eigene Test-Tools - -**Swagger Inspector:** -- Online: https://inspector.swagger.io/ -- Import `openapi.json` -- Manuelles Testen in GUI -- Kann Test-Collections exportieren - -**Swagger Codegen:** -```bash -# Generiert Client-Code + Tests -npx @openapitools/openapi-generator-cli generate \ - -i docs/openapi.json \ - -g javascript \ - -o generated-client -``` - -**Swagger UI "Try it out":** -- Bereits aktiv unter http://localhost:5000/api/docs -- Manuelles Testing direkt in Browser -- ✅ Einfachstes Tool fĂŒr Quick Tests - ---- - -## ✅ Fazit - -**Aktuell:** `test-openapi-paths.js` ist OK fĂŒr Smoke Tests. - -**NĂ€chster Schritt:** -1. Installiere **Prism** fĂŒr Schema-Validierung -2. SpĂ€ter: **Dredd** fĂŒr Contract Testing -3. Optional: **Jest** fĂŒr vollstĂ€ndige Test-Suite - -**Best Practice:** Kombiniere mehrere Tools: -- Prism fĂŒr Schema-Validierung -- Dredd fĂŒr Contract Testing -- Jest fĂŒr Unit/Integration Tests -- Swagger UI fĂŒr manuelles Testing diff --git a/backend/test-openapi-paths.js b/backend/test-openapi-paths.js deleted file mode 100644 index 6fcaf04..0000000 --- a/backend/test-openapi-paths.js +++ /dev/null @@ -1,64 +0,0 @@ -/** - * OpenAPI Path Validator - * - * Basic smoke test to verify all OpenAPI paths are reachable. - * Does NOT replace proper API testing! - * - * For comprehensive testing, consider: - * - Dredd (npm install -g dredd) - Contract testing against OpenAPI spec - * - Postman/Newman - Import openapi.json and run automated tests - * - Prism (npm install -g @stoplight/prism-cli) - Mock server + validation - * - Jest/Supertest - Full integration tests with schema validation - * - * This script only checks if paths respond (basic reachability check). - */ - -const fs = require('fs'); -const path = require('path'); - -// Read generated openapi.json -const specPath = path.join(__dirname, 'docs', 'openapi.json'); -const spec = JSON.parse(fs.readFileSync(specPath, 'utf8')); - -const baseUrl = 'http://localhost:5000'; -const paths = Object.keys(spec.paths || {}); - -console.log(`🔍 Testing ${paths.length} paths from openapi.json against ${baseUrl}\n`); - -async function testPath(path, methods) { - const method = Object.keys(methods)[0]; // take first method (usually GET) - const url = `${baseUrl}${path}`; - - return new Promise((resolve) => { - const http = require('http'); - const req = http.request(url, { method: method.toUpperCase() }, (res) => { - const status = res.statusCode; - const statusEmoji = status === 200 ? '✅' : status === 404 ? '❌' : '⚠'; - console.log(`${statusEmoji} ${method.toUpperCase()} ${path} → ${status}`); - resolve({ path, status, ok: status === 200 }); - }); - req.on('error', (err) => { - console.log(`đŸ’„ ${method.toUpperCase()} ${path} → ERROR: ${err.message}`); - resolve({ path, status: 'ERROR', ok: false }); - }); - req.end(); - }); -} - -(async () => { - const results = []; - for (const p of paths) { - const result = await testPath(p, spec.paths[p]); - results.push(result); - } - - const failed = results.filter(r => !r.ok); - console.log(`\n📊 Summary: ${results.length} paths tested, ${failed.length} failed\n`); - - if (failed.length > 0) { - console.log('❌ Failed paths (likely missing route prefixes):'); - failed.forEach(f => console.log(` ${f.path} → ${f.status}`)); - console.log('\n💡 Hint: Generator scanned route files without mount prefixes.'); - console.log(' Check backend/src/routes/index.js for app.use() calls with prefixes like /api/admin'); - } -})(); From 7cb264820e8d07c12000f175141ff21276d1b2cf Mon Sep 17 00:00:00 2001 From: "matthias.lotz" Date: Sun, 16 Nov 2025 18:25:32 +0100 Subject: [PATCH 4/4] docs: Correct migration guide - ALL routes changed, not just admin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Critical corrections to frontend/MIGRATION-GUIDE.md: - Emphasize ALL API routes now have consistent /api prefix - Old routes had inconsistent prefixes (some with /api, some without) - List specific files with wrong routes that need fixing: * ModerationGroupsPage.js: /groups/* → /api/admin/groups/* * ModerationGroupImagesPage.js: /moderation/groups/* → /api/admin/groups/* * PublicGroupImagesPage.js: /groups/* → /api/groups/* - Add 3-phase checklist: Route Prefixes → Authentication → Testing - Provide grep commands to find ALL fetch/axios calls - Make clear this affects the entire frontend, not just admin features Migration effort estimate increased: 3-4 hours (route audit + auth) --- frontend/MIGRATION-GUIDE.md | 145 +++++++++++++++++++++++++++++++----- 1 file changed, 125 insertions(+), 20 deletions(-) diff --git a/frontend/MIGRATION-GUIDE.md b/frontend/MIGRATION-GUIDE.md index 68cd96b..1e1f6ca 100644 --- a/frontend/MIGRATION-GUIDE.md +++ b/frontend/MIGRATION-GUIDE.md @@ -1,16 +1,61 @@ -# Frontend Migration Guide - Admin API Authentication +# Frontend Migration Guide - API Umstrukturierung **Datum:** 16. November 2025 -**Betrifft:** Alle Admin-API-Aufrufe im Frontend -**Status:** ⚠ Aktion erforderlich +**Betrifft:** ALLE API-Aufrufe im Frontend +**Status:** ⚠ Aktion erforderlich - ALLE Routen prĂŒfen! --- -## 🔒 Was hat sich geĂ€ndert? +## ïżœ BREAKING CHANGE: Konsistente `/api` Prefixes -Alle Admin-Endpoints (`/api/admin/*`) benötigen jetzt **Bearer Token Authentication**. +**ALLE API-Routen haben sich geĂ€ndert!** -### Betroffene Endpoints +### Vorher (inkonsistent): +```javascript +// Teils mit /api +fetch('/api/upload/batch') +fetch('/api/manage/xyz') + +// Teils OHNE /api - FALSCH! +fetch('/groups/123') +fetch('/groups/123/approve') +fetch('/moderation/groups/123') +``` + +### Jetzt (konsistent): +```javascript +// ALLE Routen mit /api Prefix +fetch('/api/upload/batch') +fetch('/api/manage/xyz') +fetch('/api/groups/123') // Public +fetch('/api/admin/groups/123/approve') // Admin +fetch('/api/admin/groups/123') // Admin +``` + +--- + +## 🔒 Admin API Authentication + +Alle Admin-Endpoints (`/api/admin/*` und `/api/system/*`) benötigen jetzt **Bearer Token Authentication**. + +### Route-Hierarchie + +1. **Public API**: `/api/*` + - Öffentlich zugĂ€nglich + - `/api/upload`, `/api/groups`, `/api/download`, etc. + +2. **Management API**: `/api/manage/*` + - Token-basiert (UUID aus Upload-Response) + - FĂŒr Gruppenbesitzer + +3. **Admin API**: `/api/admin/*` ⚠ **BEARER TOKEN ERFORDERLICH** + - Moderation, Logs, Consents + - `/api/admin/groups`, `/api/admin/deletion-log`, etc. + +4. **System API**: `/api/system/migration/*` ⚠ **BEARER TOKEN ERFORDERLICH** + - Wartungsfunktionen + +### Betroffene Admin-Endpoints - `/api/admin/groups` - Gruppen auflisten - `/api/admin/groups/:id` - Gruppe abrufen @@ -34,7 +79,31 @@ Alle Admin-Endpoints (`/api/admin/*`) benötigen jetzt **Bearer Token Authentica ## 📝 Erforderliche Änderungen -### 1. Environment Variable hinzufĂŒgen +### 1. ALLE API-Routen prĂŒfen und `/api` hinzufĂŒgen + +**Schritt 1**: Finde alle API-Aufrufe im Frontend: + +```bash +# Alle fetch/axios Aufrufe finden +grep -r "fetch\(" frontend/src/ +grep -r "axios\." frontend/src/ +``` + +**Schritt 2**: PrĂŒfe jede Route und fĂŒge `/api` Prefix hinzu (falls fehlend): + +```javascript +// ❌ FALSCH (alte Routen) +fetch('/groups/123') +fetch('/groups/123/approve') +fetch('/moderation/groups/123') + +// ✅ RICHTIG (neue Routen) +fetch('/api/groups/123') // Public +fetch('/api/admin/groups/123/approve') // Admin (+ Bearer Token!) +fetch('/api/admin/groups/123') // Admin (+ Bearer Token!) +``` + +### 2. Environment Variable fĂŒr Admin Token hinzufĂŒgen ```bash # frontend/.env oder frontend/.env.local @@ -52,7 +121,7 @@ node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" **⚠ Wichtig**: Gleiches Token in Backend `.env` als `ADMIN_API_KEY` eintragen! -### 2. API-Aufrufe anpassen +### 3. API-Aufrufe fĂŒr Admin-Endpoints anpassen #### Vorher (ohne Auth): ```javascript @@ -133,36 +202,72 @@ try { ## 🔍 Betroffene Dateien finden -Such nach allen Admin-API-Aufrufen: +### Alle API-Calls prĂŒfen (KRITISCH!) ```bash cd frontend/src -# Alle Admin-API-Calls finden: -grep -r "/api/admin" --include="*.js" --include="*.jsx" +# ALLE API-Calls finden (fetch + axios): +grep -rn "fetch(" --include="*.js" --include="*.jsx" +grep -rn "axios\." --include="*.js" --include="*.jsx" -# Alle Fetch-Calls in spezifischen Komponenten: -grep -r "fetch.*admin" Components/Pages/ +# Spezifisch nach alten Routen OHNE /api suchen: +grep -rn "fetch('/groups" --include="*.js" +grep -rn "fetch('/moderation" --include="*.js" + +# Admin-API-Calls finden: +grep -rn "/api/admin" --include="*.js" --include="*.jsx" ``` -**Wahrscheinlich betroffene Komponenten:** +**Bekannte betroffene Dateien:** + +### Routen ohne `/api` Prefix (MÜSSEN GEFIXT WERDEN): - `Components/Pages/ModerationGroupsPage.js` + - ❌ `/groups/${groupId}/approve` → ✅ `/api/admin/groups/${groupId}/approve` + - ❌ `/groups/${groupId}` (DELETE) → ✅ `/api/admin/groups/${groupId}` + - ❌ `/api/social-media/platforms` → ✅ `/api/admin/social-media/platforms` + - `Components/Pages/ModerationGroupImagesPage.js` -- `Components/ComponentUtils/ConsentManager.js` (wenn im moderate-Modus) -- `services/reorderService.js` (Admin-Reorder) -- Jede andere Komponente, die Admin-Funktionen aufruft + - ❌ `/moderation/groups/${groupId}` → ✅ `/api/admin/groups/${groupId}` + +- `Components/Pages/PublicGroupImagesPage.js` + - ❌ `/groups/${groupId}` → ✅ `/api/groups/${groupId}` + +### Admin-Endpoints (benötigen Bearer Token): +- `Components/Pages/ModerationGroupsPage.js` - Alle Admin-Calls +- `Components/Pages/ModerationGroupImagesPage.js` - Gruppe laden + Bilder löschen +- `Components/ComponentUtils/DeletionLogSection.js` - Deletion Log +- `Components/ComponentUtils/ConsentManager.js` - Consent Export (wenn Admin) +- `services/reorderService.js` - Admin-Reorder (wenn vorhanden) + +### Public/Management Endpoints (nur Pfad prĂŒfen): +- `Utils/batchUpload.js` - Bereits korrekt (`/api/...`) +- `Components/Pages/ManagementPortalPage.js` - Bereits korrekt (`/api/manage/...`) +- `Utils/sendRequest.js` - Bereits korrekt (axios) --- ## ✅ Checkliste +### Phase 1: Route-Prefixes (ALLE Dateien) +- [ ] Alle `fetch()` und `axios` Calls gefunden (grep) +- [ ] Alle Routen ohne `/api` Prefix identifiziert +- [ ] `/api` Prefix zu Public-Routen hinzugefĂŒgt (`/api/groups`, `/api/upload`) +- [ ] Admin-Routen auf `/api/admin/*` geĂ€ndert +- [ ] Management-Routen auf `/api/manage/*` geprĂŒft (sollten schon korrekt sein) + +### Phase 2: Admin Authentication - [ ] `REACT_APP_ADMIN_API_KEY` in `.env` hinzugefĂŒgt - [ ] Token im Backend als `ADMIN_API_KEY` konfiguriert - [ ] Zentrale `adminFetch` Funktion erstellt -- [ ] Alle Admin-API-Calls gefunden (grep) -- [ ] Authorization Header zu allen Admin-Calls hinzugefĂŒgt +- [ ] Authorization Header zu ALLEN `/api/admin/*` Calls hinzugefĂŒgt +- [ ] Authorization Header zu `/api/system/*` Calls hinzugefĂŒgt (falls vorhanden) - [ ] 403 Error Handling implementiert -- [ ] Frontend lokal getestet + +### Phase 3: Testing & Deployment +- [ ] Frontend lokal getestet (alle Routen) +- [ ] Admin-Funktionen getestet (Approve, Delete, etc.) +- [ ] Public-Routen getestet (Gruppe laden, Upload) - [ ] Production `.env` aktualisiert ---