diff --git a/backend/src/routes/admin.js b/backend/src/routes/admin.js index 41b9546..0c35896 100644 --- a/backend/src/routes/admin.js +++ b/backend/src/routes/admin.js @@ -417,7 +417,7 @@ router.get('/groups', async (req, res) => { } */ try { - const { workshopOnly, platform } = req.query; + const { workshopOnly, platform, consents } = req.query; // Hole alle Gruppen mit vollständigen Infos (inkl. Bilder) let allGroups = await GroupRepository.getAllGroupsWithModerationInfo(); @@ -425,18 +425,38 @@ router.get('/groups', async (req, res) => { // 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); + const consentData = await GroupRepository.getSocialMediaConsentsForGroup(group.groupId); return { ...group, - socialMediaConsents: consents + socialMediaConsents: consentData }; }) ); - // Jetzt filtern wir basierend auf den Query-Parametern + // Jetzt filtern wir basierend auf den Query-Parametern let filteredGroups = groupsWithConsents; - if (workshopOnly === 'true') { + // Neuer Multi-Checkbox Filter + if (consents) { + const selectedConsents = consents.split(','); // z.B. ['workshop', 'facebook', 'instagram'] + + filteredGroups = groupsWithConsents.filter(group => { + // Gruppe muss mindestens einen der ausgewählten Consents haben + return selectedConsents.some(consentType => { + if (consentType === 'workshop') { + return group.display_in_workshop === 1 || group.display_in_workshop === true; + } else { + // Social Media Platform (facebook, instagram, tiktok) + return group.socialMediaConsents && + group.socialMediaConsents.some(consent => + consent.platform_name === consentType && + (consent.consented === 1 || consent.consented === true) && + (consent.revoked !== 1 && consent.revoked !== true) + ); + } + }); + }); + } else if (workshopOnly === 'true') { // Filter: Nur Gruppen MIT Werkstatt-Consent aber OHNE zugestimmte Social Media Consents filteredGroups = groupsWithConsents.filter(group => { // Muss Werkstatt-Consent haben @@ -922,15 +942,32 @@ router.delete('/groups/:groupId', async (req, res) => { try { const { groupId } = req.params; - const deleted = await GroupRepository.deleteGroup(groupId); + // Get group data before deletion for logging + const groupData = await GroupRepository.getGroupById(groupId); - if (!deleted) { + if (!groupData) { return res.status(404).json({ error: 'Group not found', message: `Gruppe mit ID ${groupId} wurde nicht gefunden` }); } + const imageCount = groupData.images ? groupData.images.length : 0; + const totalFileSize = groupData.images ? groupData.images.reduce((sum, img) => sum + (img.fileSize || 0), 0) : 0; + + // Create deletion_log entry BEFORE deleting + await DeletionLogRepository.createDeletionEntry({ + groupId: groupId, + year: groupData.year, + imageCount: imageCount, + uploadDate: groupData.uploadDate, + deletionReason: 'admin_moderation_deletion', + totalFileSize: totalFileSize + }); + + // Delete the group + await GroupRepository.deleteGroup(groupId); + res.json({ success: true, message: 'Gruppe erfolgreich gelöscht', diff --git a/backend/src/routes/consent.js b/backend/src/routes/consent.js index 8850b8c..9445a64 100644 --- a/backend/src/routes/consent.js +++ b/backend/src/routes/consent.js @@ -403,7 +403,8 @@ router.get('/consents/export', async (req, res) => { // Platform-Consents const consentMap = {}; group.socialMediaConsents.forEach(consent => { - consentMap[consent.platform_name] = consent.consented === 1; + // Consent ist nur dann aktiv wenn consented=1 UND nicht revoked + consentMap[consent.platform_name] = consent.consented === 1 && consent.revoked !== 1; }); platformNames.forEach(platform => { diff --git a/backend/src/server.js b/backend/src/server.js index 3a21947..3141206 100644 --- a/backend/src/server.js +++ b/backend/src/server.js @@ -45,6 +45,7 @@ class Server { // Starte Express Server initiateResources(this._app); this._app.use('/upload', express.static( __dirname + '/upload')); + this._app.use('/api/previews', express.static( __dirname + '/data/previews')); // Mount Swagger UI in dev only when available if (process.env.NODE_ENV !== 'production' && swaggerUi && swaggerDocument) { @@ -74,6 +75,7 @@ class Server { await dbManager.initialize(); initiateResources(this._app); this._app.use('/upload', express.static( __dirname + '/upload')); + this._app.use('/api/previews', express.static( __dirname + '/data/previews')); if (process.env.NODE_ENV !== 'production' && swaggerUi && swaggerDocument) { this._app.use('/api/docs', swaggerUi.serve, swaggerUi.setup(swaggerDocument)); diff --git a/docker/dev/docker-compose.yml b/docker/dev/docker-compose.yml index 57fc2d8..3c159eb 100644 --- a/docker/dev/docker-compose.yml +++ b/docker/dev/docker-compose.yml @@ -26,6 +26,7 @@ services: backend-dev: container_name: image-uploader-backend-dev + user: "1000:1000" build: context: ../../ dockerfile: docker/dev/backend/Dockerfile diff --git a/docker/dev/frontend/nginx.conf b/docker/dev/frontend/nginx.conf index 358d7a2..d76f530 100644 --- a/docker/dev/frontend/nginx.conf +++ b/docker/dev/frontend/nginx.conf @@ -1,128 +1,34 @@ server { listen 80; - # Allow large uploads (50MB) - client_max_body_size 50M; + # Allow large uploads (100MB for batch uploads) + client_max_body_size 100M; - # API proxy to backend-dev service - location /upload { + # ======================================== + # Backend API Routes (all under /api/) + # ======================================== + # Basierend auf routeMappings.js: + # - /api/upload, /api/download, /api/groups (Public API) + # - /api/manage/* (Management Portal, Token-basiert) + # - /api/admin/* (Admin/Moderation, Bearer Token) + # - /api/system/migration/* (System API) + + location /api/ { proxy_pass http://backend-dev:5000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; - # Allow large uploads for API too - client_max_body_size 50M; - } - - # API routes for new multi-upload features - location /api/upload { - proxy_pass http://backend-dev:5000/upload; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - - # Allow large uploads for batch upload + # Large uploads für Batch-Upload client_max_body_size 100M; } - - # API - Download original images - location /api/download { - proxy_pass http://backend-dev:5000/download; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - } - - # API - Preview/thumbnail images (optimized for gallery views) - location /api/previews { - proxy_pass http://backend-dev:5000/previews; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - } - - # API - Groups (NO PASSWORD PROTECTION) - location /api/groups { - proxy_pass http://backend-dev:5000/groups; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - } - - # API - Social Media Consent Management (NO PASSWORD PROTECTION) - location /api/social-media { - proxy_pass http://backend-dev:5000/api/social-media; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - } - - # API - Management Portal (NO PASSWORD PROTECTION - Token-based auth) - location /api/manage { - proxy_pass http://backend-dev:5000/api/manage; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - } - # Admin API routes (NO password protection - protected by /moderation page access) - location /api/admin { - proxy_pass http://backend-dev:5000/api/admin; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - } + # ======================================== + # Frontend Routes (React Dev Server) + # ======================================== - # Protected API - Moderation API routes (password protected) - must come before /groups - location /moderation/groups { - auth_basic "Restricted Area - Moderation API"; - auth_basic_user_file /etc/nginx/.htpasswd; - - proxy_pass http://backend-dev:5000/moderation/groups; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - } - - # API - Groups API routes (NO PASSWORD PROTECTION) - location ~ ^/groups/[a-zA-Z0-9_-]+(/.*)?$ { - proxy_pass http://backend-dev:5000; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - } - - location /download { - proxy_pass http://backend-dev:5000; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - } - - # Frontend page - Groups overview (NO PASSWORD PROTECTION) - React Dev Server - location /groups { - proxy_pass http://127.0.0.1:3000; - proxy_http_version 1.1; - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection "Upgrade"; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - } - - # Protected routes - Moderation (password protected) - React Dev Server + # Protected route - Moderation (HTTP Basic Auth) location /moderation { auth_basic "Restricted Area - Moderation"; auth_basic_user_file /etc/nginx/.htpasswd; @@ -136,18 +42,16 @@ server { proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } - # WebSocket support for hot reloading (React Dev Server) + # WebSocket support for React Hot Reloading location /ws { proxy_pass http://127.0.0.1:3000; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "Upgrade"; proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } - # Frontend files - React Dev Server + # All other routes → React Dev Server (Client-side routing) location / { proxy_pass http://127.0.0.1:3000; proxy_http_version 1.1; diff --git a/frontend/package.json b/frontend/package.json index e0e4a8b..16ef67a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -33,6 +33,7 @@ "test": "react-scripts test", "eject": "react-scripts eject" }, + "proxy": "http://localhost:5001", "eslintConfig": { "extends": [ "react-app", diff --git a/frontend/src/Components/ComponentUtils/GroupMetadataEditor.js b/frontend/src/Components/ComponentUtils/GroupMetadataEditor.js index db171a5..c67ca00 100644 --- a/frontend/src/Components/ComponentUtils/GroupMetadataEditor.js +++ b/frontend/src/Components/ComponentUtils/GroupMetadataEditor.js @@ -2,6 +2,8 @@ import React, { useState } from 'react'; import { Box, Typography, Paper } from '@mui/material'; import Swal from 'sweetalert2'; import DescriptionInput from './MultiUpload/DescriptionInput'; +import { adminRequest } from '../../services/adminApi'; +import { handleAdminError } from '../../services/adminErrorHandler'; /** * Manages group metadata with save functionality @@ -66,20 +68,24 @@ function GroupMetadataEditor({ // Different API endpoints for manage vs moderate const endpoint = isModerateMode - ? `/groups/${groupId}` + ? `/api/admin/groups/${groupId}` : `/api/manage/${token}/metadata`; const method = isModerateMode ? 'PATCH' : 'PUT'; - const res = await fetch(endpoint, { - method: method, - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(metadata) - }); + if (isModerateMode) { + await adminRequest(endpoint, method, metadata); + } else { + const res = await fetch(endpoint, { + method: method, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(metadata) + }); - if (!res.ok) { - const body = await res.json().catch(() => ({})); - throw new Error(body.error || 'Fehler beim Speichern der Metadaten'); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body.error || 'Fehler beim Speichern der Metadaten'); + } } await Swal.fire({ @@ -100,11 +106,16 @@ function GroupMetadataEditor({ } catch (error) { console.error('Error saving metadata:', error); - Swal.fire({ - icon: 'error', - title: 'Fehler', - text: error.message || 'Metadaten konnten nicht gespeichert werden' - }); + + if (isModerateMode) { + handleAdminError(error, 'Metadaten speichern'); + } else { + Swal.fire({ + icon: 'error', + title: 'Fehler', + text: error.message || 'Metadaten konnten nicht gespeichert werden' + }); + } } finally { setSaving(false); } diff --git a/frontend/src/Components/ComponentUtils/ImageDescriptionManager.js b/frontend/src/Components/ComponentUtils/ImageDescriptionManager.js index 32962c1..4b3b9ad 100644 --- a/frontend/src/Components/ComponentUtils/ImageDescriptionManager.js +++ b/frontend/src/Components/ComponentUtils/ImageDescriptionManager.js @@ -2,6 +2,8 @@ import React, { useState } from 'react'; import { Box, Typography, Paper } from '@mui/material'; import Swal from 'sweetalert2'; import ImageGallery from './ImageGallery'; +import { adminRequest } from '../../services/adminApi'; +import { handleAdminError } from '../../services/adminErrorHandler'; /** * Manages image descriptions with save functionality @@ -41,16 +43,20 @@ function ImageDescriptionManager({ try { // Different API endpoints for manage vs moderate const endpoint = mode === 'moderate' - ? `/groups/${groupId}/images/${imageId}` + ? `/api/admin/groups/${groupId}/images/${imageId}` : `/api/manage/${token}/images/${imageId}`; - const res = await fetch(endpoint, { - method: 'DELETE' - }); + if (mode === 'moderate') { + await adminRequest(endpoint, 'DELETE'); + } else { + const res = await fetch(endpoint, { + method: 'DELETE' + }); - if (!res.ok) { - const body = await res.json().catch(() => ({})); - throw new Error(body.error || 'Fehler beim Löschen des Bildes'); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body.error || 'Fehler beim Löschen des Bildes'); + } } await Swal.fire({ @@ -67,12 +73,16 @@ function ImageDescriptionManager({ } } catch (error) { - console.error('Error deleting image:', error); - Swal.fire({ - icon: 'error', - title: 'Fehler', - text: error.message || 'Bild konnte nicht gelöscht werden' - }); + if (mode === 'moderate') { + await handleAdminError(error, 'Bild löschen'); + } else { + console.error('Error deleting image:', error); + Swal.fire({ + icon: 'error', + title: 'Fehler', + text: error.message || 'Bild konnte nicht gelöscht werden' + }); + } } }; @@ -120,20 +130,24 @@ function ImageDescriptionManager({ // Different API endpoints for manage vs moderate const endpoint = mode === 'moderate' - ? `/groups/${groupId}/images/batch-description` + ? `/api/admin/groups/${groupId}/images/batch-description` : `/api/manage/${token}/images/descriptions`; const method = mode === 'moderate' ? 'PATCH' : 'PUT'; - const res = await fetch(endpoint, { - method: method, - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ descriptions }) - }); + if (mode === 'moderate') { + await adminRequest(endpoint, method, { descriptions }); + } else { + const res = await fetch(endpoint, { + method: method, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ descriptions }) + }); - if (!res.ok) { - const body = await res.json().catch(() => ({})); - throw new Error(body.error || 'Fehler beim Speichern der Beschreibungen'); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body.error || 'Fehler beim Speichern'); + } } await Swal.fire({ @@ -154,11 +168,16 @@ function ImageDescriptionManager({ } catch (error) { console.error('Error saving descriptions:', error); - Swal.fire({ - icon: 'error', - title: 'Fehler', - text: error.message || 'Beschreibungen konnten nicht gespeichert werden' - }); + + if (mode === 'moderate') { + handleAdminError(error, 'Beschreibungen speichern'); + } else { + Swal.fire({ + icon: 'error', + title: 'Fehler', + text: error.message || 'Beschreibungen konnten nicht gespeichert werden' + }); + } } finally { setSaving(false); } diff --git a/frontend/src/Components/Pages/ModerationGroupsPage.js b/frontend/src/Components/Pages/ModerationGroupsPage.js index 4778520..cbed756 100644 --- a/frontend/src/Components/Pages/ModerationGroupsPage.js +++ b/frontend/src/Components/Pages/ModerationGroupsPage.js @@ -1,7 +1,7 @@ import React, { useState, useEffect } from 'react'; import { Helmet } from 'react-helmet'; import { useNavigate } from 'react-router-dom'; -import { Container, Box, FormControl, InputLabel, Select, MenuItem } from '@mui/material'; +import { Container, Box, FormControl, FormLabel, FormGroup, FormControlLabel, Checkbox } from '@mui/material'; import FilterListIcon from '@mui/icons-material/FilterList'; import Swal from 'sweetalert2/dist/sweetalert2.js'; @@ -22,7 +22,12 @@ const ModerationGroupsPage = () => { const [error, setError] = useState(null); const [selectedGroup, setSelectedGroup] = useState(null); const [showImages, setShowImages] = useState(false); - const [consentFilter, setConsentFilter] = useState('all'); + const [consentFilters, setConsentFilters] = useState({ + workshop: false, + facebook: false, + instagram: false, + tiktok: false + }); const [platforms, setPlatforms] = useState([]); const navigate = useNavigate(); @@ -33,7 +38,7 @@ const ModerationGroupsPage = () => { useEffect(() => { loadModerationGroups(); - }, [consentFilter]); + }, [consentFilters]); const loadPlatforms = async () => { try { @@ -52,13 +57,10 @@ const ModerationGroupsPage = () => { let url = '/api/admin/groups'; const params = new URLSearchParams(); - if (consentFilter !== 'all') { - if (consentFilter === 'workshop-only') { - params.append('workshopOnly', 'true'); - } else { - // Platform filter (facebook, instagram, tiktok) - params.append('platform', consentFilter); - } + // Sende alle aktivierten Filter + const activeFilters = Object.keys(consentFilters).filter(key => consentFilters[key]); + if (activeFilters.length > 0) { + params.append('consents', activeFilters.join(',')); } if (params.toString()) { @@ -238,25 +240,53 @@ const ModerationGroupsPage = () => { alignItems: 'center', flexWrap: 'wrap' }}> - - - + + + Consent-Filter - - + + + setConsentFilters({...consentFilters, workshop: e.target.checked})} + size="small" + /> + } + label="Werkstatt" + /> + setConsentFilters({...consentFilters, facebook: e.target.checked})} + size="small" + /> + } + label="Facebook" + /> + setConsentFilters({...consentFilters, instagram: e.target.checked})} + size="small" + /> + } + label="Instagram" + /> + setConsentFilters({...consentFilters, tiktok: e.target.checked})} + size="small" + /> + } + label="TikTok" + /> +