Project-Image-Uploader/backend/src/routes/management.js
matthias.lotz d76b4b2c9c docs(telegram): complete Phase 5 documentation and security improvements
- Updated README.md with Telegram features section in 'Latest Features'
- Added Telegram environment variables to Environment Variables table
- Updated FEATURE_PLAN-telegram.md: marked Phases 1-5 as completed
- Updated status table with completion dates (Phase 1-4: done, Phase 5: docs complete)

OpenAPI Documentation:
- Added swagger tags to reorder route (Management Portal)
- Added swagger tags to consent routes (Consent Management)
- Regenerated openapi.json with correct tags (no more 'default' category)

Environment Configuration:
- Updated .env.backend.example with Telegram variables and session secret
- Created docker/dev/.env.example with Telegram configuration template
- Created docker/prod/.env.example with production environment template
- Moved secrets from docker-compose.yml to .env files (gitignored)
- Changed docker/dev/docker-compose.yml to use placeholders: ${TELEGRAM_BOT_TOKEN}

Security Enhancements:
- Disabled test message on server start by default (TELEGRAM_SEND_TEST_ON_START=false)
- Extended pre-commit hook to detect hardcoded Telegram secrets
- Hook prevents commit if TELEGRAM_BOT_TOKEN or TELEGRAM_CHAT_ID are hardcoded
- All secrets must use environment variable placeholders

Phase 5 fully completed and documented.
2025-11-30 11:40:59 +01:00

1192 lines
41 KiB
JavaScript

const express = require('express');
const router = express.Router();
const groupRepository = require('../repositories/GroupRepository');
const deletionLogRepository = require('../repositories/DeletionLogRepository');
const dbManager = require('../database/DatabaseManager');
const { rateLimitMiddleware, recordFailedTokenValidation } = require('../middlewares/rateLimiter');
const auditLogMiddleware = require('../middlewares/auditLog');
const TelegramNotificationService = require('../services/TelegramNotificationService');
// Singleton-Instanz des Telegram Service
const telegramService = new TelegramNotificationService();
// Apply middleware to all management routes
router.use(rateLimitMiddleware);
router.use(auditLogMiddleware);
// 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) => {
/*
#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;
// Validate token format
if (!validateToken(token)) {
recordFailedTokenValidation(req); // Track brute-force attempts
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) {
recordFailedTokenValidation(req); // Track brute-force attempts
await res.auditLog('validate_token', 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'
});
}
// Log successful token validation
await res.auditLog('validate_token', true, groupData.groupId);
// Return complete group data
res.json({
success: true,
data: groupData
});
} catch (error) {
console.error('Error validating management token:', error);
await res.auditLog('validate_token', false, null, error.message);
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) => {
/*
#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;
// 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]
);
// Sende Telegram-Benachrichtigung (async, non-blocking)
if (telegramService.isAvailable()) {
telegramService.sendConsentChangeNotification({
name: groupData.name,
year: groupData.year,
title: groupData.title,
consentType: 'workshop',
action: action,
newValue: newValue === 1
}).catch(err => {
console.error('[Telegram] Consent change notification failed:', err.message);
});
}
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') {
// Check if consent exists before revoking
const existing = await dbManager.get(
'SELECT id FROM group_social_media_consents WHERE group_id = ? AND platform_id = ?',
[groupData.groupId, platformId]
);
if (existing) {
await socialMediaRepo.revokeConsent(groupData.groupId, platformId);
} else {
// Can't revoke what doesn't exist - return error
return res.status(400).json({
success: false,
error: 'Cannot revoke consent that was never granted'
});
}
} else {
// action === 'restore'
// Check if consent exists
const existing = await dbManager.get(
'SELECT id, revoked FROM group_social_media_consents WHERE group_id = ? AND platform_id = ?',
[groupData.groupId, platformId]
);
if (existing) {
// Restore existing consent
await socialMediaRepo.restoreConsent(groupData.groupId, platformId);
} else {
// Create new consent (user wants to grant consent for a platform they didn't select during upload)
await dbManager.run(
`INSERT INTO group_social_media_consents (group_id, platform_id, consented, consent_timestamp)
VALUES (?, ?, 1, CURRENT_TIMESTAMP)`,
[groupData.groupId, platformId]
);
}
}
// Sende Telegram-Benachrichtigung (async, non-blocking)
if (telegramService.isAvailable()) {
// Hole Platform-Name für Benachrichtigung
const platform = await dbManager.get(
'SELECT name FROM social_media_platforms WHERE id = ?',
[platformId]
);
telegramService.sendConsentChangeNotification({
name: groupData.name,
year: groupData.year,
title: groupData.title,
consentType: 'social_media',
action: action,
platform: platform ? platform.name : `Platform ${platformId}`
}).catch(err => {
console.error('[Telegram] Consent change notification failed:', err.message);
});
}
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/images/descriptions
* Batch update image descriptions for a group
*
* Body:
* - descriptions: [{ imageId: number, description: string }, ...]
*
* @returns {Object} Update result with count of updated images
* @throws {400} Invalid request or validation error
* @throws {404} Token invalid or not found
* @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;
// Validate token format
if (!validateToken(token)) {
return res.status(404).json({
success: false,
error: 'Invalid management token format'
});
}
// Validate descriptions array
if (!Array.isArray(descriptions) || descriptions.length === 0) {
return res.status(400).json({
success: false,
error: 'descriptions must be a non-empty array'
});
}
// Validate each description
for (const desc of descriptions) {
if (!desc.imageId || typeof desc.imageId !== 'number') {
return res.status(400).json({
success: false,
error: 'Each description must contain a valid imageId'
});
}
if (desc.description && desc.description.length > 200) {
return res.status(400).json({
success: false,
error: `Description for image ${desc.imageId} exceeds 200 characters`
});
}
}
// 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'
});
}
// Update descriptions
let updatedCount = 0;
for (const desc of descriptions) {
const updated = await groupRepository.updateImageDescription(
desc.imageId,
groupData.groupId,
desc.description || null
);
if (updated) {
updatedCount++;
}
}
await res.auditLog('update_image_descriptions', true, groupData.groupId,
`Updated ${updatedCount} image descriptions`);
res.json({
success: true,
message: `${updatedCount} image description(s) updated successfully`,
data: {
groupId: groupData.groupId,
updatedImages: updatedCount,
totalRequested: descriptions.length
}
});
} catch (error) {
console.error('Error updating image descriptions:', error);
await res.auditLog('update_image_descriptions', false, null, error.message);
res.status(500).json({
success: false,
error: 'Failed to update image descriptions'
});
}
});
/**
* 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) => {
/*
#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;
// 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) => {
/*
#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;
// 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) => {
/*
#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;
// 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) => {
/*
#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;
// 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.createDeletionEntry({
groupId: groupId,
year: groupData.year,
imageCount: imageCount,
uploadDate: groupData.uploadDate,
deletionReason: 'user_self_service_deletion',
totalFileSize: groupData.images.reduce((sum, img) => sum + (img.fileSize || 0), 0)
});
console.log(`✓ Group ${groupId} deleted via management token (${imageCount} images)`);
// Sende Telegram-Benachrichtigung (async, non-blocking)
if (telegramService.isAvailable()) {
telegramService.sendGroupDeletedNotification({
name: groupData.name,
year: groupData.year,
title: groupData.title,
imageCount: imageCount
}).catch(err => {
console.error('[Telegram] Group deletion notification failed:', err.message);
});
}
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'
});
}
});
router.put('/:token/reorder', async (req, res) => {
/*
#swagger.tags = ['Management Portal']
#swagger.summary = 'Reorder images in group'
#swagger.description = 'Reorder images within the managed group (token-based access)'
#swagger.parameters['token'] = {
in: 'path',
required: true,
type: 'string',
description: 'Management token (UUID v4)',
example: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'
}
#swagger.parameters['body'] = {
in: 'body',
required: true,
schema: {
imageIds: [1, 3, 2, 4]
}
}
#swagger.responses[200] = {
description: 'Images reordered successfully',
schema: { success: true, updatedCount: 4 }
}
#swagger.responses[400] = {
description: 'Invalid token format or imageIds'
}
#swagger.responses[404] = {
description: 'Token not found or group deleted'
}
*/
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;