Files
picpeak/backend/src/routes/adminEvents.js
T
Paul Nothaft ef1c875f6e fix(events): stop mapping branding_logo_position onto hero_logo_position
Two settings with overlapping names but different value sets were being
conflated:

- branding_logo_position (header bar, horizontal): 'left'|'center'|'right'
- hero_logo_position    (hero block, vertical):   'top'|'center'|'bottom'

getBrandingDefaults() copied the global branding value over the per-event
hero value when seeding new events. Any admin with branding logo set to
'left' (the most common choice) created events with hero_logo_position
= 'left' written to the DB. Subsequent PUTs to /admin/events/:id then
failed validation with "Invalid value (field: hero_logo_position)" — the
validator only accepts top/center/bottom.

Fix:

1. Drop the bogus mapping. branding_logo_position is no longer read by
   getBrandingDefaults — it doesn't belong there. The fallback default
   ('top') is used unless the request body explicitly provides
   hero_logo_position, which is independently validated.

2. Migration 084_fix_hero_logo_position normalises any existing rows
   whose hero_logo_position is outside ('top','center','bottom') back
   to 'top'. Without this, affected events would continue to 400 on
   every save until the admin manually picks a valid option.

Reproduction: admin sets branding logo position to 'left' under global
branding, creates an event, opens the event detail page, clicks Save
without changing anything → 400. After this fix, save succeeds and new
events default to 'top' regardless of branding-bar position.
2026-05-02 00:04:06 +02:00

1729 lines
65 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const express = require('express');
const { body, validationResult } = require('express-validator');
const { db, logActivity } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat');
const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions');
const router = express.Router();
const bcrypt = require('bcrypt');
const crypto = require('crypto');
const fs = require('fs').promises;
const path = require('path');
const multer = require('multer');
const { archiveEvent } = require('../services/archiveService');
const { queueEmail } = require('../services/emailProcessor');
const { escapeLikePattern } = require('../utils/sqlSecurity');
// formatDate import removed - dates are formatted by email processor
const { validatePasswordInContext, getBcryptRounds } = require('../utils/passwordValidation');
const logger = require('../utils/logger');
const { buildShareLinkVariants } = require('../services/shareLinkService');
const { parseBooleanInput, parseStringInput } = require('../utils/parsers');
const eventTypeService = require('../services/eventTypeService');
const { validateFileType } = require('../utils/fileSecurityUtils');
const { requireEventOwnership } = require('../middleware/ownership');
const { getFrontendBaseUrl } = require('../utils/frontendUrl');
const downloadZipService = require('../services/downloadZipService');
// Shared validator for hero_image_anchor accepts legacy keywords or "X% Y%" focal point
const validateHeroImageAnchor = (value) => {
if (['top', 'center', 'bottom'].includes(value)) return true;
if (typeof value === 'string' && /^\d{1,3}%\s+\d{1,3}%$/.test(value)) {
const [x, y] = value.split(/\s+/).map(v => parseInt(v));
if (x >= 0 && x <= 100 && y >= 0 && y <= 100) return true;
}
throw new Error('Must be top, center, bottom, or "X% Y%" (0-100)');
};
// Get storage path from environment or default
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
// Configure multer for event logo uploads
const eventLogoStorage = multer.diskStorage({
destination: async (req, file, cb) => {
const uploadDir = path.join(getStoragePath(), 'uploads/logos/events');
await fs.mkdir(uploadDir, { recursive: true });
cb(null, uploadDir);
},
filename: (req, file, cb) => {
const ext = path.extname(file.originalname);
cb(null, `event-${req.params.id}-logo-${Date.now()}${ext}`);
}
});
const eventLogoUpload = multer({
storage: eventLogoStorage,
limits: { fileSize: 5 * 1024 * 1024 }, // 5MB
fileFilter: (req, file, cb) => {
const allowedMimeTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/svg+xml'];
if (validateFileType(file.originalname, file.mimetype, allowedMimeTypes)) {
return cb(null, true);
} else {
cb(new Error('Only JPEG, PNG, GIF and SVG image files are allowed'));
}
}
});
// Helper to get event field requirements from settings
const getEventFieldRequirements = async () => {
try {
const settings = await db('app_settings')
.whereIn('setting_key', [
'event_require_customer_name',
'event_require_customer_email',
'event_require_admin_email',
'event_require_event_date',
'event_require_expiration'
])
.select('setting_key', 'setting_value');
const requirements = {
require_customer_name: true,
require_customer_email: true,
require_admin_email: true,
require_event_date: true,
require_expiration: true
};
settings.forEach(s => {
let value = s.setting_value;
if (typeof value === 'string') {
try {
value = JSON.parse(value);
} catch (e) {
value = value === 'true';
}
}
if (s.setting_key === 'event_require_customer_name') requirements.require_customer_name = value;
if (s.setting_key === 'event_require_customer_email') requirements.require_customer_email = value;
if (s.setting_key === 'event_require_admin_email') requirements.require_admin_email = value;
if (s.setting_key === 'event_require_event_date') requirements.require_event_date = value;
if (s.setting_key === 'event_require_expiration') requirements.require_expiration = value;
});
return requirements;
} catch (error) {
logger.error('Failed to get event field requirements', { error: error.message });
return {
require_customer_name: true,
require_customer_email: true,
require_admin_email: true,
require_event_date: true,
require_expiration: true
};
}
};
// Helper to read app_settings booleans by key, used to inherit per-setting
// defaults onto new events. Returns `undefined` for missing/non-boolean rows
// so callers can fall back to a legacy default.
const readBooleanSetting = async (key) => {
try {
const setting = await db('app_settings').where('setting_key', key).first();
if (!setting) return undefined;
let value = setting.setting_value;
if (typeof value === 'string') {
try { value = JSON.parse(value); } catch { /* keep raw */ }
}
return typeof value === 'boolean' ? value : undefined;
} catch (error) {
logger.error('Failed to read app setting', { key, error: error.message });
return undefined;
}
};
// Helper to read the global "enable_devtools_protection" admin setting so
// new events inherit it instead of always falling back to the DB column default
// (#317 — admin disabled it globally but new events still got it ON).
const getDownloadProtectionDefaults = async () => {
return { enable_devtools_protection: await readBooleanSetting('enable_devtools_protection') };
};
// Helper to get branding defaults for new events (Feature 7: Branding Inheritance).
//
// Note: `branding_logo_position` (header bar — left/center/right) is a
// different concept from `hero_logo_position` (hero block — top/center/
// bottom) and must NOT be mapped here. A previous version copied the
// branding value over, which wrote 'left'/'right' into per-event
// hero_logo_position columns and broke any subsequent PUT validation
// (#357). Migration 084 heals existing rows.
const getBrandingDefaults = async () => {
try {
const settings = await db('app_settings')
.whereIn('setting_key', [
'branding_logo_display_hero',
'branding_logo_size'
])
.select('setting_key', 'setting_value');
const defaults = {
hero_logo_visible: true,
hero_logo_size: 'medium',
hero_logo_position: 'top'
};
settings.forEach(s => {
let value = s.setting_value;
if (typeof value === 'string') {
try { value = JSON.parse(value); } catch (e) { /* use as-is */ }
}
if (s.setting_key === 'branding_logo_display_hero') {
defaults.hero_logo_visible = value !== false;
}
if (s.setting_key === 'branding_logo_size' && value) {
defaults.hero_logo_size = value;
}
});
return defaults;
} catch (error) {
logger.error('Failed to get branding defaults', { error: error.message });
return {
hero_logo_visible: true,
hero_logo_size: 'medium',
hero_logo_position: 'top'
};
}
};
// Use parseStringInput from shared parsers for customer data extraction
const getCustomerNameFromPayload = (payload = {}) => parseStringInput(payload.customer_name);
const getCustomerEmailFromPayload = (payload = {}) => parseStringInput(payload.customer_email);
const getCustomerPhoneFromPayload = (payload = {}) => parseStringInput(payload.customer_phone);
// Whether the global "phone field" toggle (#322) is enabled. Cached for
// the request via a module-level read; drift is acceptable since this
// only governs whether to persist the field, not security boundaries.
const isPhoneFieldEnabled = async () => {
try {
const row = await db('app_settings').where('setting_key', 'event_phone_field_enabled').first();
if (!row) return false;
let value = row.setting_value;
if (typeof value === 'string') {
try { value = JSON.parse(value); } catch { /* keep raw */ }
}
return value === true;
} catch (error) {
logger.debug('Failed to read event_phone_field_enabled', { error: error.message });
return false;
}
};
const mapEventForApi = (event) => {
if (!event || typeof event !== 'object') {
return event;
}
const {
host_name,
host_email,
customer_name,
customer_email,
customer_phone,
password_hash: _ph,
client_password_hash: _cph,
...rest
} = event;
return {
...rest,
customer_name: customer_name ?? host_name ?? null,
customer_email: customer_email ?? host_email ?? null,
customer_phone: customer_phone ?? null
};
};
let customerColumnCache = null;
const hasCustomerContactColumns = async () => {
if (customerColumnCache === true) {
return true;
}
try {
const hasColumn = await db.schema.hasColumn('events', 'customer_email');
if (hasColumn) {
customerColumnCache = true;
}
return hasColumn;
} catch (error) {
logger.debug('Failed to detect customer_email column', { error: error.message });
return false;
}
};
// Create new event
router.post('/', adminAuth, requirePermission('events.create'), [
body('event_type').notEmpty().trim().custom(async (value) => {
const isValid = await eventTypeService.isValidEventType(value);
if (!isValid) {
throw new Error('Invalid event type');
}
return true;
}),
body('event_name').notEmpty().trim(),
body('event_date').optional({ values: 'falsy' }).isDate(),
body('customer_name').optional().trim(),
body('customer_email').optional({ values: 'falsy' }).isEmail().normalizeEmail(),
body('customer_phone').optional({ nullable: true, checkFalsy: true })
.isString().trim()
.isLength({ max: 32 }).withMessage('Phone number must be at most 32 characters'),
body('admin_email').optional({ values: 'falsy' }).isEmail().normalizeEmail(),
body('require_password').optional().isBoolean(),
body('password').optional().isString().custom((value, { req }) => {
const input = req.body.require_password;
const normalizeBoolean = (val, defaultValue = true) => {
if (val === undefined || val === null) return defaultValue;
if (typeof val === 'boolean') return val;
if (typeof val === 'number') return val !== 0;
if (typeof val === 'string') {
const normalized = val.trim().toLowerCase();
if (['false', '0', 'no', 'off'].includes(normalized)) return false;
if (['true', '1', 'yes', 'on'].includes(normalized)) return true;
}
return defaultValue;
};
const requirePassword = normalizeBoolean(input, true);
if (!requirePassword) {
return true;
}
if (typeof value !== 'string' || value.trim().length < 6) {
throw new Error('Password must be at least 6 characters long');
}
return true;
}),
body('expiration_days').isInt({ min: 1, max: 365 }).optional(),
body('welcome_message').optional().trim(),
body('color_theme').optional().trim(),
body('allow_user_uploads').optional().isBoolean().toBoolean(),
body('upload_category_id').optional({ nullable: true, checkFalsy: true }).isInt(),
body('allow_downloads').optional().isBoolean(),
body('disable_right_click').optional().isBoolean(),
body('enable_devtools_protection').optional().isBoolean(),
body('watermark_downloads').optional().isBoolean(),
body('watermark_text').optional().trim(),
// #328 follow-up: per-event opt-in for presigned-URL "Download All".
// Bypasses watermarks; admin must enable knowingly.
body('allow_presigned_download').optional().isBoolean(),
body('css_template_id').optional({ nullable: true, checkFalsy: true }).isInt(),
// Hero logo settings
body('hero_logo_visible').optional().isBoolean(),
body('hero_logo_size').optional().isIn(['small', 'medium', 'large', 'xlarge']),
body('hero_logo_position').optional().isIn(['top', 'center', 'bottom']),
// Header style settings (decoupled from layout)
body('header_style').optional().isIn(['hero', 'standard', 'minimal', 'none']),
body('hero_divider_style').optional().isIn(['wave', 'straight', 'angle', 'curve', 'none']),
// Hero image anchor position (#162) accepts legacy keywords or "X% Y%" focal point
body('hero_image_anchor').optional().custom(validateHeroImageAnchor),
// Client access settings (#172)
body('client_access_enabled').optional().isBoolean(),
body('client_password').optional().isString(),
body('default_photo_sort').optional().isIn([
'upload_date_desc', 'upload_date_asc',
'capture_date_desc', 'capture_date_asc',
'filename_asc', 'filename_desc'
])
], async (req, res) => {
try {
logger.debug('Create event request body', { body: req.body });
const errors = validationResult(req);
if (!errors.isEmpty()) {
console.error('Validation errors:', errors.array());
return res.status(400).json({ errors: errors.array() });
}
// Get field requirements from settings
const fieldRequirements = await getEventFieldRequirements();
const {
event_type,
event_name,
event_date,
admin_email,
password,
welcome_message = '',
color_theme = null,
expiration_days = 30,
allow_user_uploads = false,
upload_category_id = null,
allow_downloads = true,
disable_right_click = false,
enable_devtools_protection: enableDevtoolsProtectionInput,
watermark_downloads = false,
watermark_text = null,
allow_presigned_download = false,
require_password: requirePasswordInput,
// Feedback settings
feedback_enabled = false,
allow_ratings = true,
allow_likes = true,
allow_comments = true,
allow_favorites = true,
require_name_email = false,
moderate_comments = true,
show_feedback_to_guests = true,
// CSS Template
css_template_id = null,
// Hero logo settings
hero_logo_visible = true,
hero_logo_size = 'medium',
hero_logo_position = 'top',
// Header style settings
header_style = 'standard',
hero_divider_style = 'wave',
// Hero image anchor position (#162)
hero_image_anchor = 'center',
// Photo cap
photo_cap = null,
// Client access settings (#172)
client_access_enabled = false,
client_password = null,
// Draft mode
is_draft = true,
// Default photo sort
default_photo_sort = 'upload_date_desc'
} = req.body;
const customerName = getCustomerNameFromPayload(req.body);
const customerEmail = getCustomerEmailFromPayload(req.body);
// Phone field is opt-in via the global setting (#322). If disabled,
// ignore whatever the client posted — defence in depth against form
// bypass.
const phoneEnabled = await isPhoneFieldEnabled();
const customerPhone = phoneEnabled ? getCustomerPhoneFromPayload(req.body) : null;
const customerColumnsAvailable = await hasCustomerContactColumns();
// Conditional validation based on settings
const validationErrors = [];
if (fieldRequirements.require_customer_name && !customerName) {
validationErrors.push({ path: 'customer_name', msg: 'Customer name is required' });
}
if (fieldRequirements.require_customer_email && !customerEmail) {
validationErrors.push({ path: 'customer_email', msg: 'Customer email is required' });
}
if (fieldRequirements.require_admin_email && !admin_email) {
validationErrors.push({ path: 'admin_email', msg: 'Admin email is required' });
}
if (fieldRequirements.require_event_date && !event_date) {
validationErrors.push({ path: 'event_date', msg: 'Event date is required' });
}
if (validationErrors.length > 0) {
return res.status(400).json({ errors: validationErrors });
}
// Default require_password from global "event_default_require_password"
// setting when the body omits it (#317 — admins want to flip the default).
let requirePasswordFallback = true;
if (requirePasswordInput === undefined) {
const setting = await readBooleanSetting('event_default_require_password');
if (setting !== undefined) requirePasswordFallback = setting;
}
const requirePassword = parseBooleanInput(requirePasswordInput, requirePasswordFallback);
// Debug logging
logger.debug('Download control values', {
allow_downloads,
disable_right_click,
watermark_downloads,
watermark_text,
require_password: requirePassword,
types: {
allow_downloads: typeof allow_downloads,
disable_right_click: typeof disable_right_click,
watermark_downloads: typeof watermark_downloads
}
});
let passwordValidation = null;
if (requirePassword) {
passwordValidation = await validatePasswordInContext(password, 'gallery', {
eventName: event_name
});
if (!passwordValidation.valid) {
return res.status(400).json({
error: 'Password does not meet security requirements',
details: passwordValidation.errors,
score: passwordValidation.score,
feedback: passwordValidation.feedback
});
}
}
// Generate unique slug
const processedEventName = event_name
.toLowerCase()
.replace(/[^a-z0-9]/g, '-') // Replace non-alphanumeric with dash
.replace(/-+/g, '-') // Replace multiple dashes with single dash
.replace(/^-|-$/g, ''); // Remove leading/trailing dashes
// Use event_date in slug if provided, otherwise use random suffix
const slugSuffix = event_date || crypto.randomBytes(3).toString('hex');
const baseSlug = `${event_type}-${processedEventName}-${slugSuffix}`;
let slug = baseSlug;
let counter = 1;
while (await db('events').where({ slug }).first()) {
slug = `${baseSlug}-${counter}`;
counter++;
}
// Generate share link respecting configured format
const shareToken = crypto.randomBytes(16).toString('hex');
const { shareUrl, shareLinkToStore } = await buildShareLinkVariants({ slug, shareToken });
// Hash password with configurable rounds (random placeholder when not required)
const password_hash = requirePassword
? await bcrypt.hash(password, getBcryptRounds())
: await bcrypt.hash(crypto.randomBytes(32).toString('hex'), getBcryptRounds());
// Calculate expiration date (days after event date)
// If expiration is not required, expires_at will be null (never expires)
// If event_date is not provided, use current date as base for expiration
let expires_at = null;
if (fieldRequirements.require_expiration) {
const baseDate = event_date || new Date().toISOString().split('T')[0];
// Parse YYYY-MM-DD format as local date to avoid timezone issues
if (baseDate.match(/^\d{4}-\d{2}-\d{2}$/)) {
const [year, month, day] = baseDate.split('-').map(num => parseInt(num, 10));
expires_at = new Date(year, month - 1, day);
} else {
expires_at = new Date(baseDate);
}
expires_at.setDate(expires_at.getDate() + parseInt(expiration_days, 10));
}
// Create folder structure
const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
const eventPath = path.join(storagePath, 'events/active', slug);
await fs.mkdir(path.join(eventPath, 'collages'), { recursive: true });
await fs.mkdir(path.join(eventPath, 'individual'), { recursive: true });
// Sync header_style / hero_divider_style from color_theme JSON when not
// explicitly provided in the request body (#158).
let effectiveHeaderStyle = header_style;
let effectiveDividerStyle = hero_divider_style;
if (color_theme && (!req.body.header_style || !req.body.hero_divider_style)) {
try {
if (typeof color_theme === 'string' && color_theme.startsWith('{')) {
const parsed = JSON.parse(color_theme);
if (!req.body.header_style && parsed.headerStyle) {
effectiveHeaderStyle = parsed.headerStyle;
}
if (!req.body.hero_divider_style && parsed.heroDividerStyle) {
effectiveDividerStyle = parsed.heroDividerStyle;
}
}
} catch (_) {
// color_theme is not JSON nothing to extract
}
}
// Get branding defaults for hero logo settings (Feature 7: Branding Inheritance)
const brandingDefaults = await getBrandingDefaults();
const effectiveHeroLogoVisible = req.body.hero_logo_visible !== undefined ? hero_logo_visible : brandingDefaults.hero_logo_visible;
const effectiveHeroLogoSize = req.body.hero_logo_size || brandingDefaults.hero_logo_size;
const effectiveHeroLogoPosition = req.body.hero_logo_position || brandingDefaults.hero_logo_position;
// Inherit "Detect dev tools" from the global Image Security setting unless
// the request explicitly overrides it (#317 — admin disabled it globally
// but new events still got it ON because the column default is true).
const protectionDefaults = await getDownloadProtectionDefaults();
const effectiveEnableDevtoolsProtection =
enableDevtoolsProtectionInput !== undefined
? enableDevtoolsProtectionInput
: protectionDefaults.enable_devtools_protection !== undefined
? protectionDefaults.enable_devtools_protection
: true;
// Insert into database
const insertResult = await db('events').insert({
slug,
event_type,
event_name,
event_date: event_date || null,
...(customerColumnsAvailable ? { customer_name: customerName, customer_email: customerEmail } : {}),
...(customerPhone ? { customer_phone: customerPhone } : {}),
host_name: customerName || null,
host_email: customerEmail || null,
admin_email: admin_email || null,
password_hash,
welcome_message,
color_theme,
share_link: shareLinkToStore,
share_token: shareToken,
expires_at: expires_at ? expires_at.toISOString() : null,
created_at: new Date().toISOString(),
created_by: req.admin.id,
allow_user_uploads,
upload_category_id,
allow_downloads: formatBoolean(allow_downloads !== undefined ? allow_downloads : true),
disable_right_click: formatBoolean(disable_right_click !== undefined ? disable_right_click : false),
enable_devtools_protection: formatBoolean(effectiveEnableDevtoolsProtection),
watermark_downloads: formatBoolean(watermark_downloads !== undefined ? watermark_downloads : false),
watermark_text,
allow_presigned_download: formatBoolean(allow_presigned_download === true || allow_presigned_download === 'true'),
require_password: formatBoolean(requirePassword),
css_template_id: css_template_id || null,
hero_logo_visible: formatBoolean(effectiveHeroLogoVisible),
hero_logo_size: effectiveHeroLogoSize,
hero_logo_position: effectiveHeroLogoPosition,
header_style: effectiveHeaderStyle || 'standard',
hero_divider_style: effectiveDividerStyle || 'wave',
hero_image_anchor: hero_image_anchor || 'center',
photo_cap: photo_cap || null,
is_draft: formatBoolean(parseBooleanInput(is_draft, true)),
default_photo_sort: default_photo_sort || 'upload_date_desc',
// Client access (#172)
client_access_enabled: formatBoolean(client_access_enabled),
...(client_access_enabled && client_password ? {
client_password_hash: await bcrypt.hash(client_password, getBcryptRounds()),
client_share_token: crypto.randomBytes(32).toString('hex')
} : {})
}).returning('id');
// Handle both PostgreSQL (returns array of objects) and SQLite (returns array of IDs)
const eventId = insertResult[0]?.id || insertResult[0];
// Insert feedback settings if feedback is enabled
if (feedback_enabled) {
await db('event_feedback_settings').insert({
event_id: eventId,
feedback_enabled: formatBoolean(feedback_enabled),
allow_ratings: formatBoolean(allow_ratings),
allow_likes: formatBoolean(allow_likes),
allow_comments: formatBoolean(allow_comments),
allow_favorites: formatBoolean(allow_favorites),
require_name_email: formatBoolean(require_name_email),
moderate_comments: formatBoolean(moderate_comments),
show_feedback_to_guests: formatBoolean(show_feedback_to_guests),
created_at: new Date().toISOString(),
updated_at: new Date().toISOString()
});
}
// Log activity
await logActivity('event_created',
{ event_type, expires_at, require_password: requirePassword, password_strength: passwordValidation?.score },
eventId,
{ type: 'admin', id: req.admin.id, name: req.admin.username }
);
// Fire event.created webhook (#327). If the event is being published
// immediately (not a draft), event.published also fires below.
// Payload uses canonical event subject (#341) so receivers always see
// the same shape (id/slug/event_name + customer contact + share_*).
try {
const webhookService = require('../services/webhookService');
await webhookService.fire('event.created', {
event: {
...webhookService.buildEventSubject({
id: eventId,
slug,
event_name,
event_type,
event_date,
share_url: shareUrl,
share_token: shareToken,
customer_name: customerName,
customer_email: customerEmail,
customer_phone: customerPhone,
}),
is_draft: parseBooleanInput(is_draft, true),
},
});
} catch (e) { /* webhookService.fire never throws but be defensive */ }
// Queue creation email (only if there is a recipient and event is not a draft)
// Language detection is handled by email processor
const isDraft = parseBooleanInput(is_draft, true);
if (customerEmail && !isDraft) {
// Build email data with optional client access info
const emailData = {
customer_name: customerName,
customer_email: customerEmail,
host_name: customerName || (customerEmail ? customerEmail.split('@')[0] : null),
event_name,
event_date: event_date, // Pass raw date - will be formatted by email processor
gallery_link: shareUrl,
gallery_password: requirePassword ? password : 'No password required',
expiry_date: expires_at ? expires_at.toISOString() : null, // Pass ISO string - will be formatted by email processor
welcome_message: welcome_message || ''
};
// Include client access info in email when enabled (#172)
if (client_access_enabled && client_password) {
const createdEvent = await db('events').where('id', eventId).first();
const frontendUrl = process.env.FRONTEND_URL || process.env.APP_URL || '';
emailData.client_link = `${frontendUrl}/gallery/${slug}/client-access?token=${createdEvent.client_share_token}`;
emailData.client_password = client_password;
}
await db('email_queue').insert({
event_id: eventId,
recipient_email: customerEmail,
email_type: 'gallery_created',
email_data: JSON.stringify(emailData),
status: 'pending',
created_at: new Date()
// scheduled_at will use default value
});
}
// Fire event.published when the event is created NOT as a draft. The
// separate /publish endpoint fires it for the draft → live transition;
// this covers the "create-and-publish in one shot" path.
if (!isDraft) {
try {
const webhookService = require('../services/webhookService');
await webhookService.fire('event.published', {
event: webhookService.buildEventSubject({
id: eventId,
slug,
event_name,
event_type,
event_date,
share_url: shareUrl,
share_token: shareToken,
customer_name: customerName,
customer_email: customerEmail,
customer_phone: customerPhone,
}),
});
} catch (e) { /* non-fatal */ }
}
res.json({
id: eventId,
slug,
event_name,
event_type,
customer_name: customerName,
customer_email: customerEmail,
require_password: requirePassword,
photo_cap: photo_cap || null,
is_draft: isDraft,
share_link: shareUrl,
expires_at: expires_at ? expires_at.toISOString() : null,
created_at: new Date().toISOString()
});
} catch (error) {
console.error('Error creating event:', error);
res.status(500).json({ error: 'Failed to create event' });
}
});
// Get all events with pagination and filters
router.get('/', adminAuth, requirePermission('events.view'), async (req, res) => {
try {
const page = parseInt(req.query.page) || 1;
const limit = parseInt(req.query.limit) || 20;
const offset = (page - 1) * limit;
const search = req.query.search || '';
const status = req.query.status || 'all';
const allowedSortBy = ['created_at', 'event_name', 'slug', 'updated_at', 'expires_at', 'capture_date'];
const sortBy = allowedSortBy.includes(req.query.sortBy) ? req.query.sortBy : 'created_at';
const sortOrder = ['asc', 'desc'].includes(req.query.sortOrder) ? req.query.sortOrder : 'desc';
// Build query
let query = db('events');
// Editor role can only see their own events
if (req.admin.roleName === 'editor') {
query = query.where('created_by', req.admin.id);
}
// Apply search filter
if (search) {
const escapedSearch = escapeLikePattern(search);
query = query.where((builder) => {
builder.where('event_name', 'like', `%${escapedSearch}%`)
.orWhere('admin_email', 'like', `%${escapedSearch}%`)
.orWhere('customer_email', 'like', `%${escapedSearch}%`)
.orWhere('slug', 'like', `%${escapedSearch}%`);
});
}
// Apply status filter
if (status === 'active') {
query = query.where('is_active', formatBoolean(true)).where('is_archived', formatBoolean(false));
} else if (status === 'archived') {
query = query.where('is_archived', formatBoolean(true));
} else if (status === 'inactive') {
query = query.where('is_active', formatBoolean(false)).where('is_archived', formatBoolean(false));
} else if (status === 'draft') {
query = query.where('is_draft', formatBoolean(true));
} else if (status === 'expiring') {
const sevenDaysFromNow = new Date();
sevenDaysFromNow.setDate(sevenDaysFromNow.getDate() + 7);
query = query
.where('is_active', formatBoolean(true))
.where('is_archived', formatBoolean(false))
.where('expires_at', '<=', sevenDaysFromNow.toISOString())
.where('expires_at', '>', new Date().toISOString());
}
// Get total count for pagination
const countQuery = query.clone();
const [{ count }] = await countQuery.count('* as count');
// Apply sorting and pagination
const events = await query
.orderBy(sortBy, sortOrder)
.limit(limit)
.offset(offset);
// Get photo counts for each event
const eventIds = events.map(e => e.id);
const photoCounts = await db('photos')
.whereIn('event_id', eventIds)
.groupBy('event_id')
.select('event_id')
.count('* as count');
// Map photo counts to events
const photoCountMap = photoCounts.reduce((acc, { event_id, count }) => {
acc[event_id] = parseInt(count);
return acc;
}, {});
// Add photo counts to events and convert dates
const eventsWithCounts = events.map(event => ({
...event,
photo_count: photoCountMap[event.id] || 0,
// Convert Unix timestamps to ISO strings
created_at: event.created_at ? new Date(event.created_at).toISOString() : null,
expires_at: event.expires_at ? new Date(event.expires_at).toISOString() : null,
archived_at: event.archived_at ? new Date(event.archived_at).toISOString() : null
})).map(mapEventForApi);
res.json({
events: eventsWithCounts,
pagination: {
page,
limit,
total: parseInt(count),
totalPages: Math.ceil(count / limit)
}
});
} catch (error) {
console.error('Error fetching events:', error);
res.status(500).json({ error: 'Failed to fetch events' });
}
});
// Get single event details
router.get('/:id', adminAuth, requirePermission('events.view'), async (req, res) => {
try {
const { id } = req.params;
let query = db('events').where('id', id);
// Editor role can only see their own events
if (req.admin.roleName === 'editor') {
query = query.where('created_by', req.admin.id);
}
const event = await query.first();
if (!event) {
return res.status(404).json({ error: 'Event not found' });
}
// Get photo count
const [{ count: photoCount }] = await db('photos')
.where('event_id', id)
.count('* as count');
// Get total size
const [{ totalSize }] = await db('photos')
.where('event_id', id)
.sum('size_bytes as totalSize');
// Get recent photos
const recentPhotos = await db('photos')
.where('event_id', id)
.orderBy('uploaded_at', 'desc')
.limit(10)
.select('filename', 'type', 'size_bytes', 'uploaded_at');
// Get view and download statistics
const [{ totalViews }] = await db('access_logs')
.where('event_id', id)
.where('action', 'view')
.count('* as totalViews');
const [{ totalDownloads }] = await db('access_logs')
.where('event_id', id)
.where('action', 'download')
.count('* as totalDownloads');
const [{ uniqueVisitors }] = await db('access_logs')
.where('event_id', id)
.countDistinct('ip_address as uniqueVisitors');
res.json(mapEventForApi({
...event,
photo_count: parseInt(photoCount) || 0,
total_size: parseInt(totalSize) || 0,
total_views: parseInt(totalViews) || 0,
total_downloads: parseInt(totalDownloads) || 0,
unique_visitors: parseInt(uniqueVisitors) || 0,
recent_photos: recentPhotos
}));
} catch (error) {
console.error('Error fetching event:', error);
res.status(500).json({ error: 'Failed to fetch event details' });
}
});
// Publish a draft event (set is_draft=false and queue creation email)
router.post('/:id/publish', adminAuth, requirePermission('events.edit'), requireEventOwnership, async (req, res) => {
try {
const { id } = req.params;
const event = await db('events').where('id', id).first();
if (!event) {
return res.status(404).json({ error: 'Event not found' });
}
if (!parseBooleanInput(event.is_draft, false)) {
return res.status(400).json({ error: 'Event is already published' });
}
// Set is_draft to false
await db('events').where('id', id).update({ is_draft: formatBoolean(false) });
// Queue creation email
const customerEmail = event.customer_email || event.host_email;
const customerName = event.customer_name || event.host_name;
if (customerEmail) {
const frontendBase = await getFrontendBaseUrl();
const { shareUrl } = await buildShareLinkVariants({ slug: event.slug, shareToken: event.share_token });
const emailData = {
customer_name: customerName,
customer_email: customerEmail,
host_name: customerName || (customerEmail ? customerEmail.split('@')[0] : null),
event_name: event.event_name,
event_date: event.event_date,
gallery_link: shareUrl || `${frontendBase}/gallery/${event.slug}`,
gallery_password: parseBooleanInput(event.require_password, true) ? '(set at creation)' : 'No password required',
expiry_date: event.expires_at ? new Date(event.expires_at).toISOString() : null,
welcome_message: event.welcome_message || ''
};
await db('email_queue').insert({
event_id: id,
recipient_email: customerEmail,
email_type: 'gallery_created',
email_data: JSON.stringify(emailData),
status: 'pending',
created_at: new Date()
});
}
await logActivity('event_published',
{ event_name: event.event_name },
id,
{ type: 'admin', id: req.admin.id, name: req.admin.username }
);
// Fire event.published webhook (#327) — draft → live transition.
// Canonical payload (#341): includes customer contact + share_token.
try {
const webhookService = require('../services/webhookService');
const { shareUrl } = await buildShareLinkVariants({ slug: event.slug, shareToken: event.share_token });
await webhookService.fire('event.published', {
event: webhookService.buildEventSubject({
id: parseInt(id, 10),
slug: event.slug,
event_name: event.event_name,
event_type: event.event_type,
event_date: event.event_date,
share_url: shareUrl,
share_token: event.share_token,
customer_name: event.customer_name || event.host_name,
customer_email: event.customer_email || event.host_email,
customer_phone: event.customer_phone,
}),
});
} catch (e) { /* non-fatal */ }
res.json({ message: 'Event published successfully', is_draft: false });
} catch (error) {
logger.error('Error publishing event:', { error: error.message });
res.status(500).json({ error: 'Failed to publish event' });
}
});
// Update event
router.put('/:id', adminAuth, requirePermission('events.edit'), requireEventOwnership, [
body('event_name').optional().trim().notEmpty(),
body('admin_email').optional().isEmail(),
body('is_active').optional().isBoolean(),
body('expires_at').optional({ nullable: true, checkFalsy: true }).isISO8601(),
body('welcome_message').optional({ nullable: true, checkFalsy: true }).trim(),
body('color_theme').optional({ nullable: true }),
body('allow_user_uploads').optional().isBoolean(),
body('customer_name').optional({ nullable: true, checkFalsy: true }).trim(),
body('customer_email').optional().isEmail().normalizeEmail(),
body('customer_phone').optional({ nullable: true, checkFalsy: true })
.isString().trim()
.isLength({ max: 32 }).withMessage('Phone number must be at most 32 characters'),
body('upload_category_id').optional().custom((value) => {
// Accept null, undefined, or integer values
if (value === null || value === undefined) return true;
return Number.isInteger(Number(value));
}).withMessage('upload_category_id must be an integer or null'),
body('hero_photo_id').optional().custom((value) => {
// Accept null, undefined, or numeric values
if (value === null || value === undefined) return true;
// Check if it's a number or can be converted to a valid integer
const num = Number(value);
return !isNaN(num) && Number.isInteger(num);
}).withMessage('hero_photo_id must be an integer or null'),
body('allow_downloads').optional().isBoolean(),
body('disable_right_click').optional().isBoolean(),
body('watermark_downloads').optional().isBoolean(),
body('watermark_text').optional().trim(),
body('allow_presigned_download').optional().isBoolean(),
body('source_mode').optional().isIn(['managed', 'reference']),
body('external_path').optional({ nullable: true }).isString().trim(),
body('require_password').optional().isBoolean(),
// Download protection settings
body('protection_level').optional().isIn(['basic', 'standard', 'enhanced', 'maximum']),
body('enable_devtools_protection').optional().isBoolean(),
body('use_canvas_rendering').optional().isBoolean(),
body('overlay_protection').optional().isBoolean(),
body('image_quality').optional().isInt({ min: 1, max: 100 }),
body('fragmentation_level').optional().isInt({ min: 1, max: 10 }),
body('password').optional().isString().custom((value) => {
if (value === undefined || value === null || value === '') {
return true;
}
if (typeof value !== 'string' || value.trim().length < 6) {
throw new Error('Password must be at least 6 characters long');
}
return true;
}),
body('css_template_id').optional({ nullable: true, checkFalsy: true }).isInt(),
// Hero logo settings
body('hero_logo_visible').optional().isBoolean(),
body('hero_logo_size').optional().isIn(['small', 'medium', 'large', 'xlarge']),
body('hero_logo_position').optional().isIn(['top', 'center', 'bottom']),
// Header style settings (decoupled from layout)
body('header_style').optional().isIn(['hero', 'standard', 'minimal', 'none']),
body('hero_divider_style').optional().isIn(['wave', 'straight', 'angle', 'curve', 'none']),
// Hero image anchor position (#162) accepts legacy keywords or "X% Y%" focal point
body('hero_image_anchor').optional().custom(validateHeroImageAnchor),
// Client access settings (#172)
body('client_access_enabled').optional().isBoolean(),
body('client_password').optional().isString(),
body('regenerate_client_token').optional().isBoolean(),
body('default_photo_sort').optional().isIn([
'upload_date_desc', 'upload_date_asc',
'capture_date_desc', 'capture_date_asc',
'filename_asc', 'filename_desc'
])
], async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
logger.debug('Update event validation errors', { errors: errors.array(), body: req.body });
return res.status(400).json({ errors: errors.array() });
}
const { id } = req.params;
const updates = { ...req.body };
const customerColumnsAvailable = await hasCustomerContactColumns();
if (Object.prototype.hasOwnProperty.call(updates, 'host_name') || Object.prototype.hasOwnProperty.call(updates, 'host_email')) {
return res.status(400).json({ error: 'host_name and host_email are no longer supported. Use customer_name and customer_email instead.' });
}
if (Object.prototype.hasOwnProperty.call(updates, 'customer_name')) {
const nextName = getCustomerNameFromPayload(updates);
if (nextName) {
if (customerColumnsAvailable) {
updates.customer_name = nextName;
} else {
delete updates.customer_name;
}
updates.host_name = nextName;
} else {
delete updates.customer_name;
}
}
if (Object.prototype.hasOwnProperty.call(updates, 'customer_email')) {
const nextEmail = getCustomerEmailFromPayload(updates);
if (nextEmail) {
if (customerColumnsAvailable) {
updates.customer_email = nextEmail;
} else {
delete updates.customer_email;
}
updates.host_email = nextEmail;
} else {
delete updates.customer_email;
}
}
// Phone is gated on the global toggle (#322). Strip from the update
// unconditionally if disabled — even null/clear is rejected so an
// admin can't accidentally write to a field they've turned off.
if (Object.prototype.hasOwnProperty.call(updates, 'customer_phone')) {
const phoneEnabled = await isPhoneFieldEnabled();
if (!phoneEnabled) {
delete updates.customer_phone;
} else {
const nextPhone = getCustomerPhoneFromPayload(updates);
updates.customer_phone = nextPhone || null;
}
}
const hasRequirePasswordUpdate = Object.prototype.hasOwnProperty.call(updates, 'require_password');
let requirePasswordUpdate;
if (hasRequirePasswordUpdate) {
requirePasswordUpdate = parseBooleanInput(updates.require_password, true);
updates.require_password = formatBoolean(requirePasswordUpdate);
}
let newPasswordPlain;
if (Object.prototype.hasOwnProperty.call(updates, 'password')) {
if (updates.password === undefined || updates.password === null || updates.password === '') {
delete updates.password;
} else {
newPasswordPlain = updates.password;
delete updates.password;
}
}
if (Object.prototype.hasOwnProperty.call(updates, 'source_mode')) {
updates.source_mode = updates.source_mode === 'reference' ? 'reference' : 'managed';
}
if (Object.prototype.hasOwnProperty.call(updates, 'external_path')) {
const trimmedPath = updates.external_path ? String(updates.external_path).trim() : '';
updates.external_path = trimmedPath || null;
}
if (updates.source_mode === 'managed') {
updates.external_path = null;
}
if (updates.source_mode === 'reference' && (updates.external_path === null || updates.external_path === undefined)) {
return res.status(400).json({ error: 'external_path is required when source_mode is reference' });
}
// Handle client access fields (#172)
if (Object.prototype.hasOwnProperty.call(updates, 'client_access_enabled')) {
updates.client_access_enabled = formatBoolean(updates.client_access_enabled);
// Auto-generate client share token when first enabling
if (parseBooleanInput(updates.client_access_enabled, false) && !event.client_share_token) {
updates.client_share_token = crypto.randomBytes(32).toString('hex');
}
}
if (Object.prototype.hasOwnProperty.call(updates, 'client_password') && updates.client_password) {
updates.client_password_hash = await bcrypt.hash(updates.client_password, getBcryptRounds());
delete updates.client_password;
} else {
delete updates.client_password;
}
if (updates.regenerate_client_token) {
updates.client_share_token = crypto.randomBytes(32).toString('hex');
}
delete updates.regenerate_client_token;
// Log the update request for debugging
logger.debug('Update event request', {
id,
updates,
color_theme_length: updates.color_theme ? updates.color_theme.length : 0,
color_theme_type: typeof updates.color_theme,
hero_photo_id: updates.hero_photo_id,
hero_photo_id_type: typeof updates.hero_photo_id
});
// Check if event exists
let eventQuery = db('events').where('id', id);
// Editor role can only edit their own events
if (req.admin.roleName === 'editor') {
eventQuery = eventQuery.where('created_by', req.admin.id);
}
const event = await eventQuery.first();
if (!event) {
return res.status(404).json({ error: 'Event not found' });
}
const currentRequirePassword = parseBooleanInput(event.require_password, true);
if (hasRequirePasswordUpdate && requirePasswordUpdate === true && !currentRequirePassword && !newPasswordPlain) {
return res.status(400).json({ error: 'Password must be provided when enabling password requirement.' });
}
if (newPasswordPlain) {
updates.password_hash = await bcrypt.hash(newPasswordPlain, getBcryptRounds());
} else if (hasRequirePasswordUpdate && requirePasswordUpdate === false && currentRequirePassword) {
updates.password_hash = await bcrypt.hash(crypto.randomBytes(32).toString('hex'), getBcryptRounds());
}
// Enforce expires_at requirement based on app settings
if (Object.prototype.hasOwnProperty.call(updates, 'expires_at')) {
if (!updates.expires_at) {
const fieldReqs = await getEventFieldRequirements();
if (fieldReqs.require_expiration) {
return res.status(400).json({ error: 'Expiration date is required.' });
}
updates.expires_at = null;
}
}
// Format hero logo settings if provided
if (Object.prototype.hasOwnProperty.call(updates, 'hero_logo_visible')) {
updates.hero_logo_visible = formatBoolean(updates.hero_logo_visible);
}
// Sync header_style / hero_divider_style from color_theme JSON when not
// explicitly provided in the request body (#158). This ensures the
// database columns stay in sync even if the frontend only sends the
// serialised theme object.
if (updates.color_theme && !Object.prototype.hasOwnProperty.call(updates, 'header_style')) {
try {
const themeStr = typeof updates.color_theme === 'string' ? updates.color_theme : '';
if (themeStr.startsWith('{')) {
const parsed = JSON.parse(themeStr);
if (parsed.headerStyle) {
updates.header_style = parsed.headerStyle;
}
if (parsed.heroDividerStyle && !Object.prototype.hasOwnProperty.call(updates, 'hero_divider_style')) {
updates.hero_divider_style = parsed.heroDividerStyle;
}
}
} catch (_) {
// color_theme is not JSON (e.g. preset name) nothing to extract
}
}
// Update event
await db('events')
.where('id', id)
.update(updates);
// Log activity
await logActivity('event_updated',
{ changes: Object.keys(updates), eventName: event.event_name },
id,
{ type: 'admin', id: req.admin.id, name: req.admin.username }
);
// Invalidate download zip if watermark settings changed
const changeKeys = Object.keys(req.body);
if (changeKeys.includes('watermark_downloads') || changeKeys.includes('watermark_text')) {
downloadZipService.invalidate(parseInt(id));
}
res.json({ message: 'Event updated successfully' });
} catch (error) {
console.error('Error updating event:', error);
res.status(500).json({ error: 'Failed to update event' });
}
});
// Delete event
router.delete('/:id', adminAuth, requirePermission('events.delete'), requireEventOwnership, async (req, res) => {
try {
const { id } = req.params;
// Check if event exists
const event = await db('events').where('id', id).first();
if (!event) {
return res.status(404).json({ error: 'Event not found' });
}
// Start a transaction to ensure all deletions succeed or fail together
await db.transaction(async (trx) => {
// 1. Delete activity logs (audit trail)
await trx('activity_logs').where('event_id', id).del();
// 2. Delete access logs
await trx('access_logs').where('event_id', id).del();
// 3. Delete email queue entries
await trx('email_queue').where('event_id', id).del();
// 4. Delete photos (this will also handle hero_photo_id foreign key)
await trx('photos').where('event_id', id).del();
// 5. Finally delete the event
await trx('events').where('id', id).del();
// Delete event folder from storage if it exists
if (event.folder_path) {
const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
const eventFolderPath = path.join(storagePath, 'events', 'active', event.folder_path);
try {
const fsPromises = require('fs').promises;
await fsPromises.rm(eventFolderPath, { recursive: true, force: true });
} catch (err) {
console.error('Failed to delete event folder:', err);
// Don't fail the transaction if folder deletion fails
}
}
// Delete archive if exists
if (event.archive_path) {
const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
const archivePath = path.join(storagePath, event.archive_path);
try {
const fsPromises = require('fs').promises;
await fsPromises.unlink(archivePath);
} catch (err) {
console.error('Failed to delete archive file:', err);
// Don't fail the transaction if file deletion fails
}
}
// Delete custom event logo if exists
if (event.hero_logo_path) {
try {
const fsPromises = require('fs').promises;
await fsPromises.unlink(event.hero_logo_path);
} catch (err) {
logger.warn('Failed to delete event logo file during event deletion', { path: event.hero_logo_path, error: err.message });
}
}
});
// Log activity (outside transaction)
await logActivity('event_deleted',
{ event_name: event.event_name },
null,
{ type: 'admin', id: req.admin.id, name: req.admin.username }
);
res.json({ message: 'Event deleted successfully' });
} catch (error) {
console.error('Error deleting event:', error);
// Provide more specific error messages
if (error.message && error.message.includes('foreign key constraint')) {
res.status(500).json({
error: 'Cannot delete event due to existing references. Please contact support.'
});
} else {
res.status(500).json({
error: 'Failed to delete event'
});
}
}
});
// Toggle event status
router.post('/:id/toggle-status', adminAuth, requirePermission('events.edit'), requireEventOwnership, async (req, res) => {
try {
const { id } = req.params;
let eventQuery = db('events').where('id', id);
// Editor role can only edit their own events
if (req.admin.roleName === 'editor') {
eventQuery = eventQuery.where('created_by', req.admin.id);
}
const event = await eventQuery.first();
if (!event) {
return res.status(404).json({ error: 'Event not found' });
}
const newStatus = !event.is_active;
await db('events')
.where('id', id)
.update({
is_active: newStatus,
updated_at: new Date()
});
// Log activity
await logActivity(newStatus ? 'event_activated' : 'event_deactivated',
{ eventName: event.event_name },
id,
{ type: 'admin', id: req.admin.id, name: req.admin.username }
);
res.json({
message: `Event ${newStatus ? 'activated' : 'deactivated'} successfully`,
is_active: newStatus
});
} catch (error) {
console.error('Error toggling event status:', error);
res.status(500).json({ error: 'Failed to toggle event status' });
}
});
// Reset event password
router.post('/:id/reset-password', adminAuth, requirePermission('events.edit'), requireEventOwnership, async (req, res) => {
try {
const { id } = req.params;
const { sendEmail = true } = req.body;
let eventQuery = db('events').where('id', id);
// Editor role can only edit their own events
if (req.admin.roleName === 'editor') {
eventQuery = eventQuery.where('created_by', req.admin.id);
}
const event = await eventQuery.first();
if (!event) {
return res.status(404).json({ error: 'Event not found' });
}
if (event.is_archived) {
return res.status(400).json({ error: 'Cannot reset password for archived event' });
}
// Generate new password
const { generateReadablePassword } = require('../utils/passwordGenerator');
const newPassword = generateReadablePassword();
const passwordHash = await bcrypt.hash(newPassword, 10);
// Update event with new password
await db('events')
.where('id', id)
.update({
password_hash: passwordHash
});
// Log activity
await logActivity('password_reset',
{ eventName: event.event_name, emailSent: sendEmail },
id,
{ type: 'admin', id: req.admin.id, name: req.admin.username }
);
// Queue email notification if requested
if (sendEmail) {
const recipientEmail = event.customer_email || event.host_email;
const recipientName = event.customer_name || event.host_name || (recipientEmail ? recipientEmail.split('@')[0] : null);
await queueEmail(id, recipientEmail, 'gallery_created', {
customer_name: recipientName,
customer_email: recipientEmail,
host_name: recipientName,
event_name: event.event_name,
event_date: event.event_date, // Pass raw date - will be formatted by email processor
gallery_link: event.share_link,
gallery_password: newPassword,
expiry_date: event.expires_at // Pass raw date - will be formatted by email processor
});
}
res.json({
message: 'Password reset successfully',
newPassword: newPassword,
emailSent: sendEmail
});
} catch (error) {
console.error('Error resetting password:', error);
res.status(500).json({ error: 'Failed to reset password' });
}
});
// Resend creation email
router.post('/:id/resend-email', adminAuth, requirePermission('events.edit'), requireEventOwnership, async (req, res) => {
try {
const { id } = req.params;
// Get event details
let eventQuery = db('events').where('id', id);
// Editor role can only edit their own events
if (req.admin.roleName === 'editor') {
eventQuery = eventQuery.where('created_by', req.admin.id);
}
const event = await eventQuery.first();
if (!event) {
return res.status(404).json({ error: 'Event not found' });
}
// The email processor will determine the language based on:
// 1. Event language setting
// 2. App settings general_default_language
// 3. Email config default language
// 4. Domain-based detection
// So we don't need to determine it here
// For resending creation email, we need the actual password
// First, try to get it from the request body if provided
// Use optional chaining to handle cases where req.body might be undefined
let galleryPassword = req.body?.password;
// If no password provided, we can't decrypt the existing one
// So we'll show a security message
if (!galleryPassword) {
// We'll let the email processor determine the language for the security message
galleryPassword = '{{password_security_message}}';
}
// Dates will be formatted by the email processor based on recipient language
// Queue the email
const recipientEmail = event.customer_email || event.host_email;
const recipientName = event.customer_name || event.host_name || (recipientEmail ? recipientEmail.split('@')[0] : null);
await queueEmail(id, recipientEmail, 'gallery_created', {
customer_name: recipientName,
customer_email: recipientEmail,
host_name: recipientName,
event_name: event.event_name,
event_date: event.event_date, // Pass raw date - will be formatted by email processor
gallery_link: event.share_link,
gallery_password: galleryPassword,
expiry_date: event.expires_at, // Pass raw date - will be formatted by email processor
welcome_message: event.welcome_message || '',
eventId: id,
isResend: true // Flag to indicate this is a resend
});
// Log the activity using the proper schema
try {
await logActivity('email_resent', {
email_type: 'gallery_created',
recipient: recipientEmail,
ip_address: req.ip || '0.0.0.0',
user_agent: req.get('user-agent') || 'Unknown'
}, id, {
type: 'admin',
id: req.admin.id,
name: req.admin.username
});
} catch (logError) {
console.error('Warning: Failed to log activity:', logError);
// Don't fail the request if activity logging fails
}
res.json({
success: true,
message: 'Creation email has been queued for sending'
});
} catch (error) {
console.error('Error resending creation email:', error);
console.error('Stack trace:', error.stack);
res.status(500).json({ error: 'Failed to resend creation email' });
}
});
// Archive event
router.post('/:id/archive', adminAuth, requirePermission('events.archive'), requireEventOwnership, async (req, res) => {
try {
const { id } = req.params;
const event = await db('events').where('id', id).first();
if (!event) {
return res.status(404).json({ error: 'Event not found' });
}
if (event.is_archived) {
return res.status(400).json({ error: 'Event is already archived' });
}
// Use the archive service to create ZIP archive
await archiveEvent(event);
// Log activity
await logActivity('event_archived',
{ eventName: event.event_name },
id,
{ type: 'admin', id: req.admin.id, name: req.admin.username }
);
res.json({ message: 'Event archived successfully' });
} catch (error) {
console.error('Error archiving event:', error);
res.status(500).json({ error: 'Failed to archive event' });
}
});
// Bulk archive events
router.post('/bulk-archive', adminAuth, requirePermission('events.archive'), [
body('eventIds').isArray().withMessage('eventIds must be an array'),
body('eventIds.*').isInt().withMessage('Each eventId must be an integer')
], async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
const { eventIds } = req.body;
if (eventIds.length === 0) {
return res.status(400).json({ error: 'No events selected for archiving' });
}
// Get all events to archive
const events = await db('events')
.whereIn('id', eventIds)
.where('is_archived', formatBoolean(false));
if (events.length === 0) {
return res.status(400).json({ error: 'No valid events found to archive' });
}
const results = {
successful: [],
failed: []
};
// Process each event
for (const event of events) {
try {
// Use the archive service to create ZIP archive
await archiveEvent(event);
// Log activity
await logActivity('event_archived',
{ eventName: event.event_name, bulkOperation: true },
event.id,
{ type: 'admin', id: req.admin.id, name: req.admin.username }
);
results.successful.push({
id: event.id,
name: event.event_name
});
} catch (error) {
console.error(`Failed to archive event ${event.id}:`, error);
results.failed.push({
id: event.id,
name: event.event_name,
error: 'Failed to archive event. Check server logs for details.'
});
}
}
// Log bulk archive activity
await logActivity('bulk_archive_completed',
{
totalEvents: eventIds.length,
successfulCount: results.successful.length,
failedCount: results.failed.length
},
null,
{ type: 'admin', id: req.admin.id, name: req.admin.username }
);
res.json({
message: `Bulk archive completed: ${results.successful.length} succeeded, ${results.failed.length} failed`,
results
});
} catch (error) {
console.error('Error in bulk archive:', error);
res.status(500).json({ error: 'Failed to perform bulk archive' });
}
});
// Upload event custom logo
router.post('/:id/logo', adminAuth, requirePermission('events.edit'), requireEventOwnership, eventLogoUpload.single('logo'), async (req, res) => {
try {
const { id } = req.params;
// Check if event exists
let eventQuery = db('events').where('id', id);
if (req.admin.roleName === 'editor') {
eventQuery = eventQuery.where('created_by', req.admin.id);
}
const event = await eventQuery.first();
if (!event) {
return res.status(404).json({ error: 'Event not found' });
}
if (!req.file) {
return res.status(400).json({ error: 'No logo file provided' });
}
// Delete old logo file if exists
if (event.hero_logo_path) {
try {
await fs.unlink(event.hero_logo_path);
logger.debug('Deleted old event logo file', { path: event.hero_logo_path });
} catch (err) {
logger.warn('Failed to delete old event logo file', { path: event.hero_logo_path, error: err.message });
}
}
const logoUrl = `/uploads/logos/events/${req.file.filename}`;
const logoPath = req.file.path;
await db('events')
.where('id', id)
.update({
hero_logo_url: logoUrl,
hero_logo_path: logoPath
});
await logActivity('event_logo_uploaded',
{ eventName: event.event_name, filename: req.file.filename },
id,
{ type: 'admin', id: req.admin.id, name: req.admin.username }
);
res.json({
message: 'Event logo uploaded successfully',
hero_logo_url: logoUrl
});
} catch (error) {
logger.error('Error uploading event logo:', { error: error.message, eventId: req.params.id });
res.status(500).json({ error: 'Failed to upload event logo' });
}
});
// Delete event custom logo
router.delete('/:id/logo', adminAuth, requirePermission('events.edit'), requireEventOwnership, async (req, res) => {
try {
const { id } = req.params;
let eventQuery = db('events').where('id', id);
if (req.admin.roleName === 'editor') {
eventQuery = eventQuery.where('created_by', req.admin.id);
}
const event = await eventQuery.first();
if (!event) {
return res.status(404).json({ error: 'Event not found' });
}
// Delete logo file if exists
if (event.hero_logo_path) {
try {
await fs.unlink(event.hero_logo_path);
logger.debug('Deleted event logo file', { path: event.hero_logo_path });
} catch (err) {
logger.warn('Failed to delete event logo file', { path: event.hero_logo_path, error: err.message });
}
}
await db('events')
.where('id', id)
.update({
hero_logo_url: null,
hero_logo_path: null
});
await logActivity('event_logo_removed',
{ eventName: event.event_name },
id,
{ type: 'admin', id: req.admin.id, name: req.admin.username }
);
res.json({ message: 'Event logo removed successfully' });
} catch (error) {
logger.error('Error deleting event logo:', { error: error.message, eventId: req.params.id });
res.status(500).json({ error: 'Failed to delete event logo' });
}
});
module.exports = router;