4e2075c638
Mirror to GitHub / mirror (push) Successful in 25s
Test and Lint / backend-test (push) Successful in 1m7s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m12s
Version and Release / version-bump (push) Successful in 33s
Version and Release / trigger-drone (push) Successful in 3s
- Remove pre-formatting of dates before passing to email processor - Let email processor handle all date formatting based on recipient language - Fix Invalid Date errors by passing raw date values instead of formatted strings - Remove unused formatDate imports and variables 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
411 lines
14 KiB
JavaScript
411 lines
14 KiB
JavaScript
const express = require('express');
|
|
const path = require('path');
|
|
const fs = require('fs').promises;
|
|
const { db } = require('../database/db');
|
|
const { formatBoolean } = require('../utils/dbCompat');
|
|
const { adminAuth } = require('../middleware/auth-enhanced-v2');
|
|
const archiver = require('archiver');
|
|
const AdmZip = require('adm-zip');
|
|
const router = express.Router();
|
|
|
|
// Get all archived events
|
|
router.get('/', adminAuth, async (req, res) => {
|
|
try {
|
|
const page = parseInt(req.query.page) || 1;
|
|
const limit = parseInt(req.query.limit) || 20;
|
|
const offset = (page - 1) * limit;
|
|
|
|
// Get total count
|
|
const totalCount = await db('events')
|
|
.where('is_archived', formatBoolean(true))
|
|
.count('id as count')
|
|
.first();
|
|
|
|
// Get archived events
|
|
const archives = await db('events')
|
|
.select(
|
|
'events.*',
|
|
db.raw('COUNT(DISTINCT photos.id) as photo_count'),
|
|
db.raw('SUM(photos.size_bytes) as total_size')
|
|
)
|
|
.leftJoin('photos', 'events.id', 'photos.event_id')
|
|
.where('events.is_archived', formatBoolean(true))
|
|
.groupBy('events.id')
|
|
.orderBy('events.archived_at', 'desc')
|
|
.limit(limit)
|
|
.offset(offset);
|
|
|
|
// Check if archive files exist and get their sizes
|
|
const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
|
const archivesWithFileInfo = await Promise.all(archives.map(async (archive) => {
|
|
let archiveFileSize = 0;
|
|
if (archive.archive_path) {
|
|
try {
|
|
const fullArchivePath = path.join(storagePath, archive.archive_path);
|
|
const stats = await fs.stat(fullArchivePath);
|
|
archiveFileSize = stats.size;
|
|
} catch (error) {
|
|
console.error(`Archive file not found: ${archive.archive_path}`);
|
|
}
|
|
}
|
|
|
|
return {
|
|
id: archive.id,
|
|
slug: archive.slug,
|
|
eventName: archive.event_name,
|
|
eventDate: archive.event_date,
|
|
eventType: archive.event_type,
|
|
hostEmail: archive.host_email,
|
|
archivedAt: archive.archived_at ? new Date(archive.archived_at).toISOString() : null,
|
|
expiresAt: archive.expires_at ? new Date(archive.expires_at).toISOString() : null,
|
|
photoCount: archive.photo_count || 0,
|
|
originalSize: archive.total_size || 0,
|
|
archiveSize: archiveFileSize,
|
|
archivePath: archive.archive_path
|
|
};
|
|
}));
|
|
|
|
res.json({
|
|
archives: archivesWithFileInfo,
|
|
pagination: {
|
|
page,
|
|
limit,
|
|
total: totalCount.count,
|
|
totalPages: Math.ceil(totalCount.count / limit)
|
|
}
|
|
});
|
|
} catch (error) {
|
|
console.error('Archives list error:', error);
|
|
res.status(500).json({ error: 'Failed to fetch archives' });
|
|
}
|
|
});
|
|
|
|
// Get single archive details
|
|
router.get('/:id', adminAuth, async (req, res) => {
|
|
try {
|
|
const archive = await db('events')
|
|
.where('id', req.params.id)
|
|
.where('is_archived', formatBoolean(true))
|
|
.first();
|
|
|
|
if (!archive) {
|
|
return res.status(404).json({ error: 'Archive not found' });
|
|
}
|
|
|
|
// Get photo details
|
|
const photos = await db('photos')
|
|
.where('event_id', archive.id)
|
|
.select('filename', 'type', 'size_bytes', 'uploaded_at');
|
|
|
|
// Check archive file
|
|
let archiveFileInfo = null;
|
|
if (archive.archive_path) {
|
|
try {
|
|
const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
|
const fullArchivePath = path.join(storagePath, archive.archive_path);
|
|
const stats = await fs.stat(fullArchivePath);
|
|
archiveFileInfo = {
|
|
size: stats.size,
|
|
createdAt: stats.birthtime,
|
|
path: archive.archive_path
|
|
};
|
|
} catch (error) {
|
|
console.error('Archive file not found:', error);
|
|
}
|
|
}
|
|
|
|
res.json({
|
|
id: archive.id,
|
|
slug: archive.slug,
|
|
eventName: archive.event_name,
|
|
eventDate: archive.event_date,
|
|
eventType: archive.event_type,
|
|
hostEmail: archive.host_email,
|
|
adminEmail: archive.admin_email,
|
|
welcomeMessage: archive.welcome_message,
|
|
colorTheme: archive.color_theme,
|
|
createdAt: archive.created_at,
|
|
expiresAt: archive.expires_at,
|
|
archivedAt: archive.archived_at,
|
|
photos: photos,
|
|
archiveFile: archiveFileInfo
|
|
});
|
|
} catch (error) {
|
|
console.error('Archive details error:', error);
|
|
res.status(500).json({ error: 'Failed to fetch archive details' });
|
|
}
|
|
});
|
|
|
|
// Restore archive
|
|
router.post('/:id/restore', adminAuth, async (req, res) => {
|
|
try {
|
|
const archive = await db('events')
|
|
.where('id', req.params.id)
|
|
.where('is_archived', formatBoolean(true))
|
|
.first();
|
|
|
|
if (!archive) {
|
|
return res.status(404).json({ error: 'Archive not found' });
|
|
}
|
|
|
|
// Check if archive file exists
|
|
if (!archive.archive_path) {
|
|
return res.status(400).json({ error: 'No archive file found' });
|
|
}
|
|
|
|
const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
|
const fullArchivePath = path.join(storagePath, archive.archive_path);
|
|
|
|
try {
|
|
await fs.access(fullArchivePath);
|
|
} catch (error) {
|
|
return res.status(404).json({ error: 'Archive file not found on disk' });
|
|
}
|
|
|
|
// Extract the archive
|
|
try {
|
|
const zip = new AdmZip(fullArchivePath);
|
|
const eventsDir = path.join(storagePath, 'events/active');
|
|
const eventDir = path.join(eventsDir, archive.slug);
|
|
|
|
// Create event directory if it doesn't exist
|
|
await fs.mkdir(eventDir, { recursive: true });
|
|
|
|
// Log ZIP contents for debugging
|
|
console.log(`Extracting archive to: ${eventDir}`);
|
|
const entries = zip.getEntries();
|
|
console.log(`Archive contains ${entries.length} entries`);
|
|
|
|
// Extract files to the event directory
|
|
zip.extractAllTo(eventDir, true);
|
|
|
|
// Get list of extracted files to update database
|
|
const extractedPhotos = [];
|
|
|
|
// First, collect all category information from the ZIP structure
|
|
const categoriesMap = new Map();
|
|
|
|
for (const entry of entries) {
|
|
if (!entry.isDirectory && entry.entryName.match(/\.(jpg|jpeg|png|gif|webp)$/i)) {
|
|
const filename = path.basename(entry.entryName);
|
|
const dirPath = path.dirname(entry.entryName);
|
|
const actualFilePath = path.join(eventDir, entry.entryName);
|
|
|
|
try {
|
|
// Check if file was extracted successfully
|
|
const stats = await fs.stat(actualFilePath);
|
|
|
|
// Determine category from directory structure
|
|
let categoryId = null;
|
|
if (dirPath && dirPath !== '.') {
|
|
// Get the first level directory as category
|
|
const categoryName = dirPath.split(path.sep)[0];
|
|
|
|
if (!categoriesMap.has(categoryName)) {
|
|
// Check if this category exists in the database
|
|
const existingCategory = await db('photo_categories')
|
|
.where('event_id', archive.id)
|
|
.where('name', categoryName)
|
|
.first();
|
|
|
|
if (existingCategory) {
|
|
categoriesMap.set(categoryName, existingCategory.id);
|
|
} else {
|
|
// Create the category if it doesn't exist
|
|
const insertResult = await db('photo_categories').insert({
|
|
event_id: archive.id,
|
|
name: categoryName,
|
|
slug: categoryName.toLowerCase().replace(/[^a-z0-9]/g, '-'),
|
|
created_at: new Date()
|
|
}).returning('id');
|
|
|
|
const newCategoryId = insertResult[0]?.id || insertResult[0];
|
|
categoriesMap.set(categoryName, newCategoryId);
|
|
}
|
|
}
|
|
|
|
categoryId = categoriesMap.get(categoryName);
|
|
}
|
|
|
|
// Check if photo already exists in database
|
|
const existingPhoto = await db('photos')
|
|
.where('event_id', archive.id)
|
|
.where('filename', filename)
|
|
.first();
|
|
|
|
if (!existingPhoto) {
|
|
// Store relative path from storage root
|
|
const relativePath = path.relative(storagePath, actualFilePath);
|
|
extractedPhotos.push({
|
|
event_id: archive.id,
|
|
filename: filename,
|
|
original_filename: filename,
|
|
path: relativePath,
|
|
thumbnail_path: null, // Will be regenerated by thumbnail service
|
|
type: path.extname(filename).substring(1).toLowerCase(),
|
|
size_bytes: stats.size,
|
|
category_id: categoryId,
|
|
uploaded_at: new Date()
|
|
});
|
|
}
|
|
} catch (statError) {
|
|
console.error(`Failed to stat file: ${actualFilePath}`);
|
|
console.error(`Entry name was: ${entry.entryName}`);
|
|
console.error('Error:', statError.message);
|
|
// Skip this file if we can't stat it
|
|
continue;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Insert new photos if any
|
|
if (extractedPhotos.length > 0) {
|
|
await db('photos').insert(extractedPhotos);
|
|
}
|
|
|
|
} catch (extractError) {
|
|
console.error('Archive extraction error:', extractError);
|
|
return res.status(500).json({ error: 'Failed to extract archive: ' + extractError.message });
|
|
}
|
|
|
|
// Update event status
|
|
const thirtyDaysFromNow = new Date();
|
|
thirtyDaysFromNow.setDate(thirtyDaysFromNow.getDate() + 30);
|
|
|
|
await db('events')
|
|
.where('id', req.params.id)
|
|
.update({
|
|
is_archived: false,
|
|
is_active: true,
|
|
archive_path: null,
|
|
archived_at: null,
|
|
expires_at: thirtyDaysFromNow.toISOString() // Reset expiration - works on both DBs
|
|
});
|
|
|
|
// Log activity
|
|
await db('activity_logs').insert({
|
|
activity_type: 'archive_restored',
|
|
actor_type: 'admin',
|
|
actor_id: req.admin.id,
|
|
actor_name: req.admin.username,
|
|
event_id: archive.id,
|
|
metadata: JSON.stringify({ event_name: archive.event_name })
|
|
});
|
|
|
|
res.json({ message: 'Archive restored successfully' });
|
|
} catch (error) {
|
|
console.error('Archive restore error:', error);
|
|
res.status(500).json({ error: 'Failed to restore archive' });
|
|
}
|
|
});
|
|
|
|
// Download archive
|
|
router.get('/:id/download', adminAuth, async (req, res) => {
|
|
try {
|
|
const archive = await db('events')
|
|
.where('id', req.params.id)
|
|
.where('is_archived', formatBoolean(true))
|
|
.first();
|
|
|
|
if (!archive) {
|
|
return res.status(404).json({ error: 'Archive not found' });
|
|
}
|
|
|
|
if (!archive.archive_path) {
|
|
return res.status(404).json({ error: 'Archive file not found' });
|
|
}
|
|
|
|
// Check if file exists
|
|
const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
|
const fullArchivePath = path.join(storagePath, archive.archive_path);
|
|
|
|
try {
|
|
await fs.access(fullArchivePath);
|
|
} catch (error) {
|
|
return res.status(404).json({ error: 'Archive file not found on disk' });
|
|
}
|
|
|
|
// Set headers for download
|
|
res.setHeader('Content-Type', 'application/zip');
|
|
res.setHeader('Content-Disposition', `attachment; filename="${archive.slug}.zip"`);
|
|
|
|
// Stream the file
|
|
const fileStream = require('fs').createReadStream(fullArchivePath);
|
|
fileStream.pipe(res);
|
|
|
|
// Log download
|
|
await db('activity_logs').insert({
|
|
activity_type: 'archive_downloaded',
|
|
actor_type: 'admin',
|
|
actor_id: req.admin.id,
|
|
actor_name: req.admin.username,
|
|
event_id: archive.id,
|
|
metadata: JSON.stringify({ event_name: archive.event_name })
|
|
});
|
|
} catch (error) {
|
|
console.error('Archive download error:', error);
|
|
res.status(500).json({ error: 'Failed to download archive' });
|
|
}
|
|
});
|
|
|
|
// Delete archive permanently
|
|
router.delete('/:id', adminAuth, async (req, res) => {
|
|
try {
|
|
const archive = await db('events')
|
|
.where('id', req.params.id)
|
|
.where('is_archived', formatBoolean(true))
|
|
.first();
|
|
|
|
if (!archive) {
|
|
return res.status(404).json({ error: 'Archive not found' });
|
|
}
|
|
|
|
// Delete archive file if exists
|
|
if (archive.archive_path) {
|
|
try {
|
|
const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
|
const fullArchivePath = path.join(storagePath, archive.archive_path);
|
|
await fs.unlink(fullArchivePath);
|
|
} catch (error) {
|
|
console.error('Failed to delete archive file:', error);
|
|
}
|
|
}
|
|
|
|
// Delete thumbnails for this event
|
|
const photos = await db('photos').where('event_id', req.params.id).select('thumbnail_path');
|
|
const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
|
|
|
for (const photo of photos) {
|
|
if (photo.thumbnail_path) {
|
|
try {
|
|
const thumbPath = path.join(storagePath, photo.thumbnail_path.replace(/^\//, ''));
|
|
await fs.unlink(thumbPath);
|
|
} catch (error) {
|
|
// Ignore errors - thumbnail might already be deleted
|
|
}
|
|
}
|
|
}
|
|
|
|
// Delete from database (cascade will delete photos and logs)
|
|
await db('events').where('id', req.params.id).delete();
|
|
|
|
// Log activity
|
|
await db('activity_logs').insert({
|
|
activity_type: 'archive_deleted',
|
|
actor_type: 'admin',
|
|
actor_id: req.admin.id,
|
|
actor_name: req.admin.username,
|
|
metadata: JSON.stringify({
|
|
event_name: archive.event_name,
|
|
archived_date: archive.archived_at
|
|
})
|
|
});
|
|
|
|
res.json({ message: 'Archive deleted permanently' });
|
|
} catch (error) {
|
|
console.error('Archive delete error:', error);
|
|
res.status(500).json({ error: 'Failed to delete archive' });
|
|
}
|
|
});
|
|
|
|
module.exports = router; |