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..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) @@ -15,6 +34,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 +50,89 @@ 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 + +### ⚠️ 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: + +📄 **`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 +153,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 +200,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/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/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 new file mode 100644 index 0000000..d91a192 --- /dev/null +++ b/docs/FEATURE_PLAN-autogen-openapi.md @@ -0,0 +1,195 @@ +# Feature Plan: Autogenerierte OpenAPI / Swagger Spec + API Restructuring + +**Branch:** `feature/autogen-openapi` +**Datum:** 16. November 2025 +**Status:** ✅ Complete - Auto-generation active, Single Source of Truth established + +## 🎯 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 + +--- + +## 📊 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 +``` + +--- + +## 🔧 Technische Implementierung + +### 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 + +--- + +## 📚 Für KI-Nutzung + +### API-Hierarchie verstehen +``` +/api/* ← Alle API-Endpoints + ├─ /upload, /groups ← Öffentlich + ├─ /manage/:token/* ← Token-basiert + ├─ /admin/* ← Geschützt + └─ /system/* ← Intern +``` + +### Neue Route hinzufügen +```bash +# 1. Route in passender Datei hinzufügen (z.B. admin.js) +router.get('/new-endpoint', ...) + +# 2. In routeMappings.js registrieren (falls neue Datei) +{ router: 'newRoute', prefix: '/api/admin', file: 'newRoute.js' } + +# 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 +``` + +--- + +## ✅ Implementierungsstatus (November 16, 2025) + +### 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 + +--- + +## ⏱️ 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%** | + +--- + +## 🚀 Frontend Migration Guide + +**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** + +--- + +**Erstellt:** 16. November 2025 +**Aktualisiert:** 16. November 2025 +**Status:** ✅ Production Ready 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 diff --git a/frontend/MIGRATION-GUIDE.md b/frontend/MIGRATION-GUIDE.md new file mode 100644 index 0000000..1e1f6ca --- /dev/null +++ b/frontend/MIGRATION-GUIDE.md @@ -0,0 +1,399 @@ +# Frontend Migration Guide - API Umstrukturierung + +**Datum:** 16. November 2025 +**Betrifft:** ALLE API-Aufrufe im Frontend +**Status:** ⚠️ Aktion erforderlich - ALLE Routen prüfen! + +--- + +## � BREAKING CHANGE: Konsistente `/api` Prefixes + +**ALLE API-Routen haben sich geändert!** + +### 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 +- `/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. 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 +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! + +### 3. API-Aufrufe für Admin-Endpoints 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 + +### Alle API-Calls prüfen (KRITISCH!) + +```bash +cd frontend/src + +# ALLE API-Calls finden (fetch + axios): +grep -rn "fetch(" --include="*.js" --include="*.jsx" +grep -rn "axios\." --include="*.js" --include="*.jsx" + +# 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" +``` + +**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` + - ❌ `/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 +- [ ] Authorization Header zu ALLEN `/api/admin/*` Calls hinzugefügt +- [ ] Authorization Header zu `/api/system/*` Calls hinzugefügt (falls vorhanden) +- [ ] 403 Error Handling implementiert + +### Phase 3: Testing & Deployment +- [ ] Frontend lokal getestet (alle Routen) +- [ ] Admin-Funktionen getestet (Approve, Delete, etc.) +- [ ] Public-Routen getestet (Gruppe laden, Upload) +- [ ] 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 })