diff --git a/backend/package.json b/backend/package.json index 6272ac3..7185d94 100644 --- a/backend/package.json +++ b/backend/package.json @@ -23,7 +23,8 @@ "node-cron": "^4.2.1", "sharp": "^0.34.4", "shortid": "^2.2.16", - "sqlite3": "^5.1.7" + "sqlite3": "^5.1.7", + "uuid": "^13.0.0" }, "devDependencies": { "concurrently": "^6.0.0", diff --git a/backend/src/repositories/GroupRepository.js b/backend/src/repositories/GroupRepository.js index ce65161..e032676 100644 --- a/backend/src/repositories/GroupRepository.js +++ b/backend/src/repositories/GroupRepository.js @@ -520,17 +520,19 @@ class GroupRepository { async createGroupWithConsent(groupData, workshopConsent, socialMediaConsents = []) { const SocialMediaRepository = require('./SocialMediaRepository'); const socialMediaRepo = new SocialMediaRepository(dbManager); + const { v4: uuidv4 } = require('uuid'); return await dbManager.transaction(async (db) => { 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(` INSERT INTO groups ( 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.year, @@ -540,7 +542,8 @@ class GroupRepository { groupData.uploadDate, groupData.approved || false, workshopConsent ? 1 : 0, - consentTimestamp + consentTimestamp, + managementToken ]); // 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); 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} 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(); \ No newline at end of file diff --git a/backend/src/routes/batchUpload.js b/backend/src/routes/batchUpload.js index c511c31..a6c10be 100644 --- a/backend/src/routes/batchUpload.js +++ b/backend/src/routes/batchUpload.js @@ -112,7 +112,7 @@ router.post(endpoints.UPLOAD_BATCH, async (req, res) => { }); // Speichere Gruppe mit Consents in SQLite - await groupRepository.createGroupWithConsent({ + const createResult = await groupRepository.createGroupWithConsent({ groupId: group.groupId, year: group.year, 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`); - // Erfolgreiche Antwort + // Erfolgreiche Antwort mit Management-Token res.json({ groupId: group.groupId, + managementToken: createResult.managementToken, message: 'Batch upload successful', imageCount: files.length, year: group.year, diff --git a/backend/src/routes/index.js b/backend/src/routes/index.js index d7a3867..94a3f9b 100644 --- a/backend/src/routes/index.js +++ b/backend/src/routes/index.js @@ -6,11 +6,13 @@ const migrationRouter = require('./migration'); const reorderRouter = require('./reorder'); const adminRouter = require('./admin'); const consentRouter = require('./consent'); +const managementRouter = require('./management'); 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); }; module.exports = { renderRoutes }; \ No newline at end of file diff --git a/backend/src/routes/management.js b/backend/src/routes/management.js new file mode 100644 index 0000000..fad8115 --- /dev/null +++ b/backend/src/routes/management.js @@ -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;