- 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.
1192 lines
41 KiB
JavaScript
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;
|
|
|