ef1c875f6e
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.
1729 lines
65 KiB
JavaScript
1729 lines
65 KiB
JavaScript
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;
|