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:
parent
901ecc7633
commit
c18c258135
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
||||||
643
backend/src/routes/management.js
Normal file
643
backend/src/routes/management.js
Normal 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;
|
||||||
Loading…
Reference in New Issue
Block a user