Fix: Admin deletion log, CSV export revoked consents, consent filter UI

Backend Fixes:
- Admin deletions now create deletion_log entries (admin_moderation_deletion)
- Static mount for /previews added to serve preview images
- Admin groups endpoint supports consent filter parameter

Frontend Improvements:
- Replaced consent dropdown with checkbox UI (Workshop, Facebook, Instagram, TikTok)
- Checkboxes use OR logic for filtering
- Revoked consents excluded from filter counts
- Updated ModerationGroupsPage to send consents array to backend

Infrastructure:
- Simplified nginx.conf (proxy /api/* to backend, all else to frontend)
- Fixed docker-compose port mapping (5001:5000)

Tests: 11/11 passed 
This commit is contained in:
Matthias Lotz 2025-11-22 11:13:10 +01:00
parent 7af14a162d
commit 98b3616dc4
9 changed files with 198 additions and 192 deletions

View File

@ -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',

View File

@ -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 => {

View File

@ -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));

View File

@ -26,6 +26,7 @@ services:
backend-dev:
container_name: image-uploader-backend-dev
user: "1000:1000"
build:
context: ../../
dockerfile: docker/dev/backend/Dockerfile

View File

@ -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;

View File

@ -33,6 +33,7 @@
"test": "react-scripts test",
"eject": "react-scripts eject"
},
"proxy": "http://localhost:5001",
"eslintConfig": {
"extends": [
"react-app",

View File

@ -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);
}

View File

@ -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);
}

View File

@ -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'
}}>
<FormControl sx={{ minWidth: 250 }} size="small">
<InputLabel id="consent-filter-label">
<FilterListIcon sx={{ mr: 0.5, fontSize: 18, verticalAlign: 'middle' }} />
<FormControl component="fieldset" sx={{ minWidth: 250 }}>
<FormLabel component="legend" sx={{ display: 'flex', alignItems: 'center', mb: 1 }}>
<FilterListIcon sx={{ mr: 0.5, fontSize: 18 }} />
Consent-Filter
</InputLabel>
<Select
labelId="consent-filter-label"
value={consentFilter}
label="Consent-Filter"
onChange={(e) => setConsentFilter(e.target.value)}
>
<MenuItem value="all">Alle Gruppen</MenuItem>
<MenuItem value="workshop-only">Nur Werkstatt-Consent</MenuItem>
{platforms.map(platform => (
<MenuItem key={platform.id} value={platform.platform_name}>
{platform.display_name}
</MenuItem>
))}
</Select>
</FormLabel>
<FormGroup>
<FormControlLabel
control={
<Checkbox
checked={consentFilters.workshop}
onChange={(e) => setConsentFilters({...consentFilters, workshop: e.target.checked})}
size="small"
/>
}
label="Werkstatt"
/>
<FormControlLabel
control={
<Checkbox
checked={consentFilters.facebook}
onChange={(e) => setConsentFilters({...consentFilters, facebook: e.target.checked})}
size="small"
/>
}
label="Facebook"
/>
<FormControlLabel
control={
<Checkbox
checked={consentFilters.instagram}
onChange={(e) => setConsentFilters({...consentFilters, instagram: e.target.checked})}
size="small"
/>
}
label="Instagram"
/>
<FormControlLabel
control={
<Checkbox
checked={consentFilters.tiktok}
onChange={(e) => setConsentFilters({...consentFilters, tiktok: e.target.checked})}
size="small"
/>
}
label="TikTok"
/>
</FormGroup>
</FormControl>
<button