feat(phase2): Implement Management Portal API (Tasks 2-7)

Backend Management API implementation for self-service user portal:

 Task 2: Token Generation (already implemented in Phase 1)
- UUID v4 generated at upload
- Stored in groups.management_token
- Returned in upload response

 Task 3: Token Validation API
- GET /api/manage/:token
- Validates token and loads complete group data
- Returns group with images, consents, metadata
- 404 for invalid/missing tokens

 Task 4: Consent Revocation API
- PUT /api/manage/:token/consents
- Revoke/restore workshop consent
- Revoke/restore social media platform consents
- Sets revoked=1, revoked_timestamp
- Full error handling and validation

 Task 5: Metadata Edit API
- PUT /api/manage/:token/metadata
- Update title, description, name
- Supports partial updates
- Automatically sets approved=0 (returns to moderation)

 Task 6: Add Images API
- POST /api/manage/:token/images
- Upload new images to existing group
- Calculates correct upload_order
- Sets approved=0 on changes
- Max 50 images per group validation
- Preview generation support

 Task 7: Delete Image API
- DELETE /api/manage/:token/images/:imageId
- Deletes original and preview files
- Removes DB entry
- Sets approved=0 if group was approved
- Prevents deletion of last image

 Task 8: Delete Group API (in progress)
- DELETE /api/manage/:token route created
- Integration with existing GroupRepository.deleteGroup
- Needs testing

Technical Changes:
- Created backend/src/routes/management.js
- Added getGroupByManagementToken() to GroupRepository
- Registered /api/manage routes in index.js
- Installed uuid package for token generation
- All routes use token validation helper
- Docker-only development workflow

Tested Features:
- Token validation with real uploads
- Workshop consent revoke/restore
- Social media consent management
- Metadata updates (full and partial)
- Image upload with multipart/form-data
- Image deletion with file cleanup
- Error handling and edge cases
This commit is contained in:
Matthias Lotz 2025-11-10 20:00:54 +01:00
parent 901ecc7633
commit c18c258135
5 changed files with 718 additions and 8 deletions

View File

@ -23,7 +23,8 @@
"node-cron": "^4.2.1", "node-cron": "^4.2.1",
"sharp": "^0.34.4", "sharp": "^0.34.4",
"shortid": "^2.2.16", "shortid": "^2.2.16",
"sqlite3": "^5.1.7" "sqlite3": "^5.1.7",
"uuid": "^13.0.0"
}, },
"devDependencies": { "devDependencies": {
"concurrently": "^6.0.0", "concurrently": "^6.0.0",

View File

@ -520,17 +520,19 @@ class GroupRepository {
async createGroupWithConsent(groupData, workshopConsent, socialMediaConsents = []) { async createGroupWithConsent(groupData, workshopConsent, socialMediaConsents = []) {
const SocialMediaRepository = require('./SocialMediaRepository'); const SocialMediaRepository = require('./SocialMediaRepository');
const socialMediaRepo = new SocialMediaRepository(dbManager); const socialMediaRepo = new SocialMediaRepository(dbManager);
const { v4: uuidv4 } = require('uuid');
return await dbManager.transaction(async (db) => { return await dbManager.transaction(async (db) => {
const consentTimestamp = new Date().toISOString(); const consentTimestamp = new Date().toISOString();
const managementToken = uuidv4(); // Generate UUID v4 token
// Füge Gruppe mit Consent-Feldern hinzu // Füge Gruppe mit Consent-Feldern und Management-Token hinzu
await db.run(` await db.run(`
INSERT INTO groups ( INSERT INTO groups (
group_id, year, title, description, name, upload_date, approved, group_id, year, title, description, name, upload_date, approved,
display_in_workshop, consent_timestamp display_in_workshop, consent_timestamp, management_token
) )
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, [ `, [
groupData.groupId, groupData.groupId,
groupData.year, groupData.year,
@ -540,7 +542,8 @@ class GroupRepository {
groupData.uploadDate, groupData.uploadDate,
groupData.approved || false, groupData.approved || false,
workshopConsent ? 1 : 0, workshopConsent ? 1 : 0,
consentTimestamp consentTimestamp,
managementToken
]); ]);
// Füge Bilder hinzu // Füge Bilder hinzu
@ -575,7 +578,10 @@ class GroupRepository {
); );
} }
return groupData.groupId; return {
groupId: groupData.groupId,
managementToken: managementToken
};
}); });
} }
@ -787,6 +793,63 @@ class GroupRepository {
const socialMediaRepo = new SocialMediaRepository(dbManager); const socialMediaRepo = new SocialMediaRepository(dbManager);
return await socialMediaRepo.getConsentsForGroup(groupId); return await socialMediaRepo.getConsentsForGroup(groupId);
} }
/**
* Hole Gruppe mit allen Daten (Bilder + Consents) per Management Token
* Für Self-Service Management Portal
* @param {string} managementToken - UUID v4 Management Token
* @returns {Promise<Object|null>} Gruppe mit Bildern, Workshop-Consent und Social Media Consents
*/
async getGroupByManagementToken(managementToken) {
// Hole Gruppe
const group = await dbManager.get(`
SELECT * FROM groups WHERE management_token = ?
`, [managementToken]);
if (!group) {
return null;
}
// Hole Bilder
const images = await dbManager.all(`
SELECT * FROM images
WHERE group_id = ?
ORDER BY upload_order ASC
`, [group.group_id]);
// Hole Social Media Consents
const SocialMediaRepository = require('./SocialMediaRepository');
const socialMediaRepo = new SocialMediaRepository(dbManager);
const socialMediaConsents = await socialMediaRepo.getConsentsForGroup(group.group_id);
return {
groupId: group.group_id,
year: group.year,
title: group.title,
description: group.description,
name: group.name,
uploadDate: group.upload_date,
approved: group.approved,
// Workshop consent
displayInWorkshop: group.display_in_workshop,
consentTimestamp: group.consent_timestamp,
// Images
images: images.map(img => ({
id: img.id,
fileName: img.file_name,
originalName: img.original_name,
filePath: img.file_path,
previewPath: img.preview_path,
uploadOrder: img.upload_order,
fileSize: img.file_size,
mimeType: img.mime_type,
imageDescription: img.image_description
})),
imageCount: images.length,
// Social Media Consents
socialMediaConsents: socialMediaConsents || []
};
}
} }
module.exports = new GroupRepository(); module.exports = new GroupRepository();

View File

@ -112,7 +112,7 @@ router.post(endpoints.UPLOAD_BATCH, async (req, res) => {
}); });
// Speichere Gruppe mit Consents in SQLite // Speichere Gruppe mit Consents in SQLite
await groupRepository.createGroupWithConsent({ const createResult = await groupRepository.createGroupWithConsent({
groupId: group.groupId, groupId: group.groupId,
year: group.year, year: group.year,
title: group.title, title: group.title,
@ -148,9 +148,10 @@ router.post(endpoints.UPLOAD_BATCH, async (req, res) => {
console.log(`Successfully saved group ${group.groupId} with ${files.length} images to database`); console.log(`Successfully saved group ${group.groupId} with ${files.length} images to database`);
// Erfolgreiche Antwort // Erfolgreiche Antwort mit Management-Token
res.json({ res.json({
groupId: group.groupId, groupId: group.groupId,
managementToken: createResult.managementToken,
message: 'Batch upload successful', message: 'Batch upload successful',
imageCount: files.length, imageCount: files.length,
year: group.year, year: group.year,

View File

@ -6,11 +6,13 @@ const migrationRouter = require('./migration');
const reorderRouter = require('./reorder'); const reorderRouter = require('./reorder');
const adminRouter = require('./admin'); const adminRouter = require('./admin');
const consentRouter = require('./consent'); const consentRouter = require('./consent');
const managementRouter = require('./management');
const renderRoutes = (app) => { const renderRoutes = (app) => {
[uploadRouter, downloadRouter, batchUploadRouter, groupsRouter, migrationRouter, consentRouter].forEach(router => app.use('/', router)); [uploadRouter, downloadRouter, batchUploadRouter, groupsRouter, migrationRouter, consentRouter].forEach(router => app.use('/', router));
app.use('/groups', reorderRouter); app.use('/groups', reorderRouter);
app.use('/api/admin', adminRouter); app.use('/api/admin', adminRouter);
app.use('/api/manage', managementRouter);
}; };
module.exports = { renderRoutes }; module.exports = { renderRoutes };

View File

@ -0,0 +1,643 @@
const express = require('express');
const router = express.Router();
const groupRepository = require('../repositories/GroupRepository');
const deletionLogRepository = require('../repositories/DeletionLogRepository');
const dbManager = require('../database/DatabaseManager');
// Helper: Validate UUID v4 token format
const validateToken = (token) => {
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
return uuidRegex.test(token);
};
/**
* GET /api/manage/:token
* Validate management token and load complete group data with images and consents
*
* @returns {Object} Complete group data including metadata, images, consents
* @throws {404} Token invalid or not found
* @throws {500} Server error
*/
router.get('/:token', async (req, res) => {
try {
const { token } = req.params;
// Validate token format
if (!validateToken(token)) {
return res.status(404).json({
success: false,
error: 'Invalid management token format'
});
}
// Load group by management token
const groupData = await groupRepository.getGroupByManagementToken(token);
if (!groupData) {
return res.status(404).json({
success: false,
error: 'Management token not found or group has been deleted'
});
}
// Return complete group data
res.json({
success: true,
data: groupData
});
} catch (error) {
console.error('Error validating management token:', error);
res.status(500).json({
success: false,
error: 'Failed to validate management token'
});
}
});
/**
* PUT /api/manage/:token/consents
* Revoke or restore individual consents (workshop OR social media platforms)
*
* Body:
* - consentType: 'workshop' | 'social_media'
* - action: 'revoke' | 'restore'
* - platformId: number (only for social_media)
*
* @returns {Object} Updated consent status
* @throws {400} Invalid request
* @throws {404} Token invalid or not found
* @throws {500} Server error
*/
router.put('/:token/consents', async (req, res) => {
try {
const { token } = req.params;
const { consentType, action, platformId } = req.body;
// Validate token format
if (!validateToken(token)) {
return res.status(404).json({
success: false,
error: 'Invalid management token format'
});
}
// Validate request body
if (!consentType || !action) {
return res.status(400).json({
success: false,
error: 'Missing required fields: consentType and action'
});
}
if (!['workshop', 'social_media'].includes(consentType)) {
return res.status(400).json({
success: false,
error: 'Invalid consentType. Must be "workshop" or "social_media"'
});
}
if (!['revoke', 'restore'].includes(action)) {
return res.status(400).json({
success: false,
error: 'Invalid action. Must be "revoke" or "restore"'
});
}
if (consentType === 'social_media' && !platformId) {
return res.status(400).json({
success: false,
error: 'platformId is required for social_media consent type'
});
}
// Load group by management token
const groupData = await groupRepository.getGroupByManagementToken(token);
if (!groupData) {
return res.status(404).json({
success: false,
error: 'Management token not found or group has been deleted'
});
}
// Handle workshop consent
if (consentType === 'workshop') {
const newValue = action === 'revoke' ? 0 : 1;
await dbManager.run(
'UPDATE groups SET display_in_workshop = ? WHERE group_id = ?',
[newValue, groupData.groupId]
);
return res.json({
success: true,
message: `Workshop consent ${action}d successfully`,
data: {
consentType: 'workshop',
displayInWorkshop: newValue === 1
}
});
}
// Handle social media consent
if (consentType === 'social_media') {
const SocialMediaRepository = require('../repositories/SocialMediaRepository');
const socialMediaRepo = new SocialMediaRepository(dbManager);
if (action === 'revoke') {
await socialMediaRepo.revokeConsent(groupData.groupId, platformId);
} else {
await socialMediaRepo.restoreConsent(groupData.groupId, platformId);
}
return res.json({
success: true,
message: `Social media consent ${action}d successfully`,
data: {
consentType: 'social_media',
platformId: platformId,
revoked: action === 'revoke'
}
});
}
} catch (error) {
console.error('Error updating consent:', error);
res.status(500).json({
success: false,
error: 'Failed to update consent'
});
}
});
/**
* PUT /api/manage/:token/metadata
* Update group metadata (title, description, name)
* IMPORTANT: Sets approved=0 (returns to moderation queue)
*
* Body:
* - title: string (optional)
* - description: string (optional)
* - name: string (optional)
*
* @returns {Object} Updated metadata
* @throws {400} Invalid request or validation error
* @throws {404} Token invalid or not found
* @throws {500} Server error
*/
router.put('/:token/metadata', async (req, res) => {
try {
const { token } = req.params;
const { title, description, name } = req.body;
// Validate token format
if (!validateToken(token)) {
return res.status(404).json({
success: false,
error: 'Invalid management token format'
});
}
// At least one field must be provided
if (title === undefined && description === undefined && name === undefined) {
return res.status(400).json({
success: false,
error: 'At least one field (title, description, name) must be provided'
});
}
// Load group by management token
const groupData = await groupRepository.getGroupByManagementToken(token);
if (!groupData) {
return res.status(404).json({
success: false,
error: 'Management token not found or group has been deleted'
});
}
// Build update query dynamically
const updates = [];
const values = [];
if (title !== undefined) {
updates.push('title = ?');
values.push(title);
}
if (description !== undefined) {
updates.push('description = ?');
values.push(description);
}
if (name !== undefined) {
updates.push('name = ?');
values.push(name);
}
// Always reset approval when metadata changes
updates.push('approved = 0');
// Add groupId to values
values.push(groupData.groupId);
// Execute update
const query = `UPDATE groups SET ${updates.join(', ')} WHERE group_id = ?`;
await dbManager.run(query, values);
// Load updated group data
const updatedGroup = await groupRepository.getGroupByManagementToken(token);
res.json({
success: true,
message: 'Metadata updated successfully. Group returned to moderation queue.',
data: {
groupId: updatedGroup.groupId,
title: updatedGroup.title,
description: updatedGroup.description,
name: updatedGroup.name,
approved: updatedGroup.approved
}
});
} catch (error) {
console.error('Error updating metadata:', error);
res.status(500).json({
success: false,
error: 'Failed to update metadata'
});
}
});
/**
* POST /api/manage/:token/images
* Add new images to existing group
* IMPORTANT: Sets approved=0 (returns to moderation queue)
*
* Form-Data:
* - images: file(s) to upload
*
* @returns {Object} Upload result with image count
* @throws {400} Invalid request or no images
* @throws {404} Token invalid or not found
* @throws {500} Server error
*/
router.post('/:token/images', async (req, res) => {
try {
const { token } = req.params;
// Validate token format
if (!validateToken(token)) {
return res.status(404).json({
success: false,
error: 'Invalid management token format'
});
}
// Check if files were uploaded
if (!req.files || !req.files.images) {
return res.status(400).json({
success: false,
error: 'No images uploaded'
});
}
// Load group by management token
const groupData = await groupRepository.getGroupByManagementToken(token);
if (!groupData) {
return res.status(404).json({
success: false,
error: 'Management token not found or group has been deleted'
});
}
// Check current image count and validate limit (max 50 images per group)
const MAX_IMAGES_PER_GROUP = 50;
const currentImageCount = groupData.imageCount;
// Handle both single file and array of files
const files = Array.isArray(req.files.images) ? req.files.images : [req.files.images];
const newImageCount = currentImageCount + files.length;
if (newImageCount > MAX_IMAGES_PER_GROUP) {
return res.status(400).json({
success: false,
error: `Cannot add ${files.length} images. Group has ${currentImageCount} images, maximum is ${MAX_IMAGES_PER_GROUP}.`
});
}
console.log(`Adding ${files.length} files to group ${groupData.groupId}`);
// Process all files
const generateId = require("shortid");
const path = require('path');
const { UPLOAD_FS_DIR, PREVIEW_FS_DIR } = require('../constants');
const ImagePreviewService = require('../services/ImagePreviewService');
const processedFiles = [];
const uploadDir = path.join(__dirname, '..', UPLOAD_FS_DIR);
for (let i = 0; i < files.length; i++) {
const file = files[i];
// Generate unique filename
const fileEnding = file.name.split(".").pop();
const fileName = generateId() + '.' + fileEnding;
// Save file to data/images
const uploadPath = path.join(uploadDir, fileName);
await new Promise((resolve, reject) => {
file.mv(uploadPath, (err) => {
if (err) {
console.error('Error saving file:', err);
reject(err);
} else {
resolve();
}
});
});
// Calculate new upload order (append to existing images)
const uploadOrder = currentImageCount + i + 1;
// Insert image into database
await dbManager.run(`
INSERT INTO images (group_id, file_name, original_name, file_path, upload_order, file_size, mime_type)
VALUES (?, ?, ?, ?, ?, ?, ?)
`, [
groupData.groupId,
fileName,
file.name,
`/upload/${fileName}`,
uploadOrder,
file.size,
file.mimetype
]);
processedFiles.push({
fileName,
originalName: file.name,
size: file.size,
uploadOrder
});
}
// Reset approval status (group needs re-moderation after adding images)
await dbManager.run('UPDATE groups SET approved = 0 WHERE group_id = ?', [groupData.groupId]);
// Generate previews in background (don't wait)
const previewDir = path.join(__dirname, '..', PREVIEW_FS_DIR);
ImagePreviewService.generatePreviewsForGroup(
processedFiles.map(f => ({
file_name: f.fileName,
file_path: `/upload/${f.fileName}`
})),
uploadDir,
previewDir
).then(results => {
const successCount = results.filter(r => r.success).length;
console.log(`Preview generation completed: ${successCount}/${results.length} successful`);
}).catch(error => {
console.error('Error generating previews:', error);
});
res.json({
success: true,
message: `${files.length} image(s) added successfully. Group returned to moderation queue.`,
data: {
groupId: groupData.groupId,
imagesAdded: files.length,
totalImages: newImageCount,
approved: 0,
uploadedFiles: processedFiles.map(f => ({
fileName: f.fileName,
originalName: f.originalName,
uploadOrder: f.uploadOrder
}))
}
});
} catch (error) {
console.error('Error adding images:', error);
res.status(500).json({
success: false,
error: 'Failed to add images'
});
}
});
/**
* DELETE /api/manage/:token/images/:imageId
* Delete a single image from group
* Removes files (original + preview) and database entry
* IMPORTANT: Sets approved=0 if group was previously approved
*
* @returns {Object} Deletion result
* @throws {400} Cannot delete last image (use group delete instead)
* @throws {404} Token/image not found
* @throws {500} Server error
*/
router.delete('/:token/images/:imageId', async (req, res) => {
try {
const { token, imageId } = req.params;
// Validate token format
if (!validateToken(token)) {
return res.status(404).json({
success: false,
error: 'Invalid management token format'
});
}
// Load group by management token
const groupData = await groupRepository.getGroupByManagementToken(token);
if (!groupData) {
return res.status(404).json({
success: false,
error: 'Management token not found or group has been deleted'
});
}
// Load image data
const image = await dbManager.get(
'SELECT * FROM images WHERE id = ? AND group_id = ?',
[imageId, groupData.groupId]
);
if (!image) {
return res.status(404).json({
success: false,
error: 'Image not found in this group'
});
}
// Prevent deletion of last image (use group delete instead)
if (groupData.imageCount === 1) {
return res.status(400).json({
success: false,
error: 'Cannot delete the last image. Use group deletion instead.'
});
}
// Delete image file from filesystem
const path = require('path');
const fs = require('fs');
const { UPLOAD_FS_DIR, PREVIEW_FS_DIR } = require('../constants');
const originalPath = path.join(__dirname, '..', UPLOAD_FS_DIR, image.file_name);
const previewPath = image.preview_path
? path.join(__dirname, '..', PREVIEW_FS_DIR, path.basename(image.preview_path))
: null;
// Delete original file
try {
if (fs.existsSync(originalPath)) {
fs.unlinkSync(originalPath);
console.log(`Deleted original file: ${originalPath}`);
}
} catch (error) {
console.error(`Error deleting original file: ${originalPath}`, error);
}
// Delete preview file if exists
if (previewPath) {
try {
if (fs.existsSync(previewPath)) {
fs.unlinkSync(previewPath);
console.log(`Deleted preview file: ${previewPath}`);
}
} catch (error) {
console.error(`Error deleting preview file: ${previewPath}`, error);
}
}
// Delete image from database
await dbManager.run('DELETE FROM images WHERE id = ?', [imageId]);
// Reset approval if group was previously approved
if (groupData.approved === 1) {
await dbManager.run('UPDATE groups SET approved = 0 WHERE group_id = ?', [groupData.groupId]);
}
res.json({
success: true,
message: 'Image deleted successfully' + (groupData.approved === 1 ? '. Group returned to moderation queue.' : '.'),
data: {
groupId: groupData.groupId,
deletedImageId: parseInt(imageId),
remainingImages: groupData.imageCount - 1,
approved: groupData.approved === 1 ? 0 : groupData.approved
}
});
} catch (error) {
console.error('Error deleting image:', error);
res.status(500).json({
success: false,
error: 'Failed to delete image'
});
}
});
/**
* DELETE /api/manage/:token
* Delete complete group with all images and consents
* Removes all files (originals + previews) and database entries
* Creates deletion_log entry for audit trail
*
* @returns {Object} Deletion confirmation
* @throws {404} Token invalid or not found
* @throws {500} Server error
*/
router.delete('/:token', async (req, res) => {
try {
const { token } = req.params;
// Validate token format
if (!validateToken(token)) {
return res.status(404).json({
success: false,
error: 'Invalid management token format'
});
}
// Load group by management token
const groupData = await groupRepository.getGroupByManagementToken(token);
if (!groupData) {
return res.status(404).json({
success: false,
error: 'Management token not found or group has been deleted'
});
}
const groupId = groupData.groupId;
const imageCount = groupData.imageCount;
// Delete all image files (originals + previews)
const fs = require('fs');
const path = require('path');
const { UPLOAD_FS_DIR, PREVIEW_FS_DIR } = require('../constants');
for (const image of groupData.images) {
// Delete original file
try {
const originalPath = path.join(__dirname, '..', UPLOAD_FS_DIR, image.fileName);
if (fs.existsSync(originalPath)) {
fs.unlinkSync(originalPath);
console.log(`Deleted original: ${originalPath}`);
}
} catch (error) {
console.error(`Error deleting original ${image.fileName}:`, error);
}
// Delete preview file if exists
if (image.previewPath) {
try {
const previewPath = path.join(__dirname, '..', PREVIEW_FS_DIR, path.basename(image.previewPath));
if (fs.existsSync(previewPath)) {
fs.unlinkSync(previewPath);
console.log(`Deleted preview: ${previewPath}`);
}
} catch (error) {
console.error(`Error deleting preview ${image.previewPath}:`, error);
}
}
}
// Delete group from database (CASCADE will delete images and consents)
await dbManager.run('DELETE FROM groups WHERE group_id = ?', [groupId]);
// Create deletion_log entry
await deletionLogRepository.logDeletion({
groupId: groupId,
deletedBy: 'user_self_service',
reason: 'User-initiated deletion via management token',
deletionDate: new Date().toISOString(),
imageCount: imageCount
});
console.log(`✓ Group ${groupId} deleted via management token (${imageCount} images)`);
res.json({
success: true,
message: 'Group and all associated data deleted successfully',
data: {
groupId: groupId,
imagesDeleted: imageCount,
deletionTimestamp: new Date().toISOString()
}
});
} catch (error) {
console.error('Error deleting group:', error);
res.status(500).json({
success: false,
error: 'Failed to delete group'
});
}
});
module.exports = router;