Files
picpeak/backend/src/routes/adminEvents.js
T
Paul Nothaft 78c8e9d9f9 feat(whatsapp): WhatsApp Business API notification channel (#640 part D)
Ports filpgame/picpeak's WhatsApp integration with substantial adaptation
to fit our codebase patterns. Deliver the gallery-ready notification via
Meta Graph API in addition to (or instead of) email — useful where the
customer base expects WhatsApp by default. Strictly opt-in behind the new
`whatsapp` feature flag.

### Backend

- **Migration 136** (`whatsapp_configs` + `whatsapp_queue`). Loose-FK on
  `event_id` matching our `inbound_documents` / `expenses` pattern (NOT
  filpgame's hard FK — deleting an event shouldn't RESTRICT on stale queue
  rows). Composite index on `(status, retry_count, created_at)` covers the
  poll path.
- **`whatsappService.js`**: thin Meta Graph client. Meta API version bumped
  v19 → v20 (filpgame's v19 deprecates Q3 2026); configurable via
  `WHATSAPP_META_API_VERSION` env var. Timeout dropped 10s → 8s for
  processor budget. Errors surface the Meta `error.code` so the processor
  can tell retryable from permanent.
- **`whatsappProcessor.js`**: queue processor polling every 30s (configurable
  via `WHATSAPP_QUEUE_POLL_MS`), 10 messages per cycle, 3 retries before
  marking `failed`. Default language sourced from
  `app_settings.general_default_language` (matches our email-language
  resolution pattern); replaces filpgame's hardcoded `pt_BR` fallback.
  Falls back to `en_US` if nothing is configured. No-ops gracefully when
  the `whatsapp` flag is off, the config row is missing, or the access
  token isn't set.
- **`adminWhatsapp.js`**: three routes (GET/PUT config, POST test). Gated
  by `requireFeatureFlag('whatsapp')` so operators who haven't enabled it
  can't see the surface. Access token masked as `'********'` on GET;
  masked values silently preserve the stored token on PUT. Enabling with
  no Phone Number ID, template name, or token (and none stored) fails at
  the validator.
- **Two hook points** in `adminEvents.js`:
  - **Create-and-publish-in-one-step**: queues immediately after the
    `gallery_created` email when `!isDraft && customerPhone &&
    waConfig.enabled`. Password from `req.body` is still in scope.
  - **Publish-from-draft** (`POST /:id/publish`): queues with the password
    the admin re-typed via PR #627's `PublishGalleryDialog`. When no
    password was typed (legacy API consumers without dialog), passes empty
    string so the password line renders blank rather than leaking the
    `(set at creation)` sentinel.
- **`server.js`**: starts `whatsappQueueProcessor` at boot. Non-fatal if it
  fails to start (logged as warning).
- **`feature_flags`**: new `whatsapp` flag in `KNOWN_FLAGS` and
  `DEFAULT_FLAGS` (default false).

### Frontend

- **`featureFlags.service.ts`**: `'whatsapp'` added to `FeatureKey` union.
- **`FeaturesTab.tsx`**: WhatsApp card in the Communication section
  (between Incoming mail and Messaging). Smartphone icon, "new" status,
  sidebar-hidden (no sidebar entry — config lives under Settings).
- **`whatsapp.service.ts`** (new): typed client for the three admin routes.
- **`WhatsAppTab.tsx`** (new): Settings tab. Form for Phone Number ID,
  WABA ID, access token (masked toggle), template name, and enabled flag.
  Separate card below for a static test send. Token masking matches the
  server's `'********'` sentinel — admin can edit other fields without
  re-entering the token.
- **`SettingsPage.tsx`**: WhatsApp tab nav item gated on `flags.whatsapp`
  (so it shows only when the feature is enabled); render block wires
  `<WhatsAppTab />`.

### i18n

22 new EN + 22 new DE entries covering the Settings tab form, the
Features-tab card, plus `admin.activities.whatsapp_config_updated` +
`admin.notificationMessages.whatsappConfigUpdated` for the bell /
dashboard surfaces from PR #637.

### Deliberately NOT included

- filpgame's **password-encryption-at-rest** layer
  (`password_encrypted`/`password_iv`/`password_key_version` columns).
  Our publish-from-draft password recovery uses the admin re-type flow
  from #627 (PublishGalleryDialog) — no plaintext at rest.

### Setup notes for operators

1. Create a Meta Business Account + WhatsApp Business App.
2. Register a phone number and obtain `phone_number_id` + `waba_id`.
3. Create a system-user access token (long-lived recommended).
4. Submit a message template for approval. The default `gallery_ready`
   expects 5 body parameters: customer name, event name, gallery link,
   password line, expiry date.
5. Enable the `whatsapp` feature flag.
6. Enter credentials under Settings → WhatsApp, send a test, then enable
   delivery.

### Test plan

- [x] Backend `node -c` on all new/changed files clean
- [x] `tsc --noEmit` on frontend clean
- [x] Backend dev container restart picks up new files, /health OK
- [ ] Manual: enable `whatsapp` flag → Settings → WhatsApp tab appears
- [ ] Manual: save config with masked-only token (existing token preserved)
- [ ] Manual: enable=true without phone_number_id rejected at PUT
- [ ] Manual: enable=true without stored or new token rejected at PUT
- [ ] Manual: create-and-publish event with customer_phone → queue row
      inserts with message_type='gallery_created'
- [ ] Manual: publish-from-draft via PublishGalleryDialog with password →
      queue row uses the admin-typed password in the {{4}} line
- [ ] Manual: test send to a real phone with valid Meta config + approved
      template → Meta returns messages[0].id, toast shows the id
- [ ] Manual: bell renders "WhatsApp configuration updated" in DE when
      the config_updated activity fires (via PR #637 smart default)
2026-06-18 22:57:22 +02:00

2312 lines
93 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 { slugify } = require('../utils/slug');
const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions');
const { IDENTITY_PRESERVING_NORMALIZE_EMAIL } = require('../utils/emailNormalization');
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 { normaliseEventTimeTriple } = require('../services/eventService');
const { hasColumnCached } = require('../utils/schemaCache');
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;
}
};
// Cascade-delete a single event: photos, audit/access logs, queued emails,
// the event row itself (in one transaction), then the on-disk folder /
// archive zip / hero logo (best-effort — file failures don't unwind the DB
// changes since the source of truth is the database). Used by both the
// per-event DELETE /:id route and the bulk-delete route to avoid drift.
//
// Throws { code: 'EVENT_NOT_FOUND' } if the event id doesn't exist so the
// bulk-delete loop can report it as a per-id failure without aborting the
// whole batch. Any other error propagates and is the caller's problem.
async function deleteEventCascade(eventId, adminContext) {
const event = await db('events').where('id', eventId).first();
if (!event) {
const err = new Error('Event not found');
err.code = 'EVENT_NOT_FOUND';
throw err;
}
await db.transaction(async (trx) => {
// 1. Delete activity logs (audit trail)
await trx('activity_logs').where('event_id', eventId).del();
// 2. Delete access logs
await trx('access_logs').where('event_id', eventId).del();
// 3. Delete email queue entries
await trx('email_queue').where('event_id', eventId).del();
// 4. Delete photos (also handles hero_photo_id foreign key)
await trx('photos').where('event_id', eventId).del();
// 5. Finally delete the event row
await trx('events').where('id', eventId).del();
// Best-effort filesystem cleanup. Failures are logged but don't unwind
// the transaction — the canonical state lives in the DB; orphan files
// are recoverable noise, a half-deleted DB row is a permanent mess.
//
// #608 — previous code read `event.folder_path`, but that column is
// never written anywhere in the codebase (grep confirms: two reads in
// this function, zero writes). It's always undefined, so the
// `if (event.folder_path)` branch silently no-op'd and every event
// delete since this cascade landed left its photos orphaned on disk.
// jodrmx's Pi report (v3.44.0) was the first surfacing.
//
// Files actually live at:
// {STORAGE_PATH}/events/active/{slug}/... (uploaded photos)
// {STORAGE_PATH}/events/archived/{slug}/... (after the event
// was archived — folder copy survives the archive flow)
//
// `event.slug` is NOT NULL on the events table and is slugify-sanitized
// on every write (lower-case ASCII + dashes only via utils/slug.js),
// so path-traversal isn't a concern.
const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
for (const sub of ['active', 'archived']) {
const eventFolderPath = path.join(storagePath, 'events', sub, event.slug);
try {
await fs.rm(eventFolderPath, { recursive: true, force: true });
} catch (fsErr) {
logger.warn('Failed to delete event folder during cascade delete', { eventId, path: eventFolderPath, error: fsErr.message });
}
}
if (event.archive_path) {
const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
const archiveFile = path.join(storagePath, event.archive_path);
try {
await fs.unlink(archiveFile);
} catch (fsErr) {
logger.warn('Failed to delete archive file during cascade delete', { eventId, path: archiveFile, error: fsErr.message });
}
}
if (event.hero_logo_path) {
try {
await fs.unlink(event.hero_logo_path);
} catch (fsErr) {
logger.warn('Failed to delete event logo during cascade delete', { eventId, path: event.hero_logo_path, error: fsErr.message });
}
}
});
// Audit trail (outside the transaction so a logging failure can't undo
// the actual delete).
await logActivity('event_deleted',
{ event_name: event.event_name },
null,
{ type: 'admin', id: adminContext.id, name: adminContext.username }
);
return { id: event.id, name: event.event_name };
}
// 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(),
// Migration 137 — calendar time fields.
body('event_time_start').optional({ values: 'falsy' }).matches(/^([01]\d|2[0-3]):[0-5]\d$/)
.withMessage('event_time_start must be HH:MM 24h'),
body('event_time_end').optional({ values: 'falsy' }).matches(/^([01]\d|2[0-3]):[0-5]\d$/)
.withMessage('event_time_end must be HH:MM 24h'),
body('is_full_day').optional().isBoolean().toBoolean(),
body('customer_name').optional().trim(),
body('customer_email').optional({ values: 'falsy' }).isEmail().normalizeEmail(IDENTITY_PRESERVING_NORMALIZE_EMAIL),
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(IDENTITY_PRESERVING_NORMALIZE_EMAIL),
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', 'banner', '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'
]),
// Per-event promotional override (#440). Three-way mode:
// inherit → fall back to global branding_promo_markdown
// custom → render this event's promo_markdown verbatim
// off → suppress entirely for this event
body('promo_mode').optional().isIn(['inherit', 'custom', 'off']),
body('promo_markdown').optional({ nullable: true }).isString(),
// Per-event opt-in for using hero photo as the social-share preview
// image (#474). When false (default), galleryOgService falls back to
// the brand logo for og:image / Twitter Card.
body('og_image_share_enabled').optional().isBoolean(),
// Customer accounts assigned to this event (#354). Optional array of
// customer_accounts.id — many-to-many via event_customer_assignments.
body('customer_account_ids').optional().isArray(),
body('customer_account_ids.*').optional().isInt({ min: 1 })
], 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,
// Migration 137 — calendar time fields. is_full_day defaults to
// true at the service layer when undefined (legacy form payloads).
event_time_start,
event_time_end,
is_full_day,
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: feedbackEnabledInput,
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);
// Default feedback_enabled from global "event_default_feedback_enabled"
// setting when the body omits it (#520 — same pattern as require_password
// above, lets admins make Guest Feedback ON the out-of-box default for
// new events instead of toggling it on every time).
let feedbackEnabledFallback = false;
if (feedbackEnabledInput === undefined) {
const setting = await readBooleanSetting('event_default_feedback_enabled');
if (setting !== undefined) feedbackEnabledFallback = setting;
}
const feedback_enabled = parseBooleanInput(feedbackEnabledInput, feedbackEnabledFallback);
// 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. Uses the shared util so accented names
// (Família, Decoração, etc.) get transliterated instead of dropped
// — see backend/src/utils/slug.js for the why (#525).
const processedEventName = slugify(event_name);
// 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;
// Migration 137 — normalise calendar time triple. Throws AppError
// 400 when is_full_day=false but times are malformed/inverted.
const calendarTriple = normaliseEventTimeTriple({
event_time_start, event_time_end, is_full_day,
});
const calendarColumnsExist = await hasColumnCached('events', 'is_full_day');
// Insert into database
const insertResult = await db('events').insert({
slug,
event_type,
event_name,
event_date: event_date || null,
...(calendarColumnsExist ? {
event_time_start: calendarTriple.event_time_start,
event_time_end: calendarTriple.event_time_end,
is_full_day: formatBoolean(calendarTriple.is_full_day),
} : {}),
...(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')
} : {}),
// Per-event opt-in for hero-photo OG share image (#474). Defaults
// false on create — admin opts in from the event detail page once
// they've picked a hero they're comfortable surfacing publicly.
og_image_share_enabled: formatBoolean(req.body.og_image_share_enabled === true),
}).returning('id');
// Handle both PostgreSQL (returns array of objects) and SQLite (returns array of IDs)
const eventId = insertResult[0]?.id || insertResult[0];
// Apply customer-account assignments (#354). Skip when the customer
// portal flag is off — the frontend hides the picker in that case,
// but a stale tab could still POST customer_account_ids; we ignore
// them rather than 403 the entire create.
if (Array.isArray(req.body.customer_account_ids)) {
try {
const customerAccountsService = require('../services/customerAccountsService');
if (await customerAccountsService.isCustomerPortalEnabled()) {
await customerAccountsService.setAssignmentsForEvent(
eventId,
req.body.customer_account_ids,
req.admin.id
);
}
} catch (e) {
logger.error('Failed to set customer assignments on event create', {
eventId, error: e.message,
});
}
}
// 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
});
}
// WhatsApp gallery_ready notification (#640D). Fires when the event is
// created NOT as a draft, the `whatsapp` flag is on, a config exists, and
// the customer supplied a phone number. Non-fatal: a queue failure should
// never block gallery creation.
if (!isDraft && customerPhone) {
try {
const { queueWhatsapp, getWhatsAppConfig } = require('../services/whatsappProcessor');
const waConfig = await getWhatsAppConfig();
if (waConfig && waConfig.enabled) {
await queueWhatsapp(eventId, customerPhone, 'gallery_created', {
customer_name: customerName || '',
event_name,
gallery_link: shareUrl,
gallery_password: requirePassword ? password : '',
expiry_date: expires_at ? expires_at.toISOString() : null,
language: null, // resolved by processor via general_default_language
});
}
} catch (waError) {
logger.warn('Failed to queue WhatsApp notification on create', { error: waError.message });
}
}
// 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');
// Customer accounts assigned to this event (#354). Hydrates the
// CustomerAccountPicker on the EventDetailsPage admin form. Returns
// an empty array on installs missing the table (e.g. pre-migrate).
let customerAccounts = [];
try {
const customerAccountsService = require('../services/customerAccountsService');
customerAccounts = await customerAccountsService.getAssignmentsForEvent(parseInt(id, 10));
} catch (e) {
logger.warn('Failed to load customer assignments for event', { eventId: id, error: e.message });
}
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,
customer_accounts: customerAccounts.map((c) => ({
id: c.id,
email: c.email,
display_name: c.display_name,
first_name: c.first_name,
last_name: c.last_name,
})),
}));
} 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, [
// Optional password the admin re-types in the publish dialog so the
// gallery_created email can carry the actual plaintext (#627). When the
// event is password-protected and the body carries a password, picpeak
// re-hashes + writes `password_hash` (the admin may have mistyped at
// creation; this guarantees the email content matches the live login
// password). When omitted, behaviour is the legacy sentinel for backward
// compat with API-only consumers.
body('password').optional().isString().isLength({ min: 6 })
.withMessage('Password must be at least 6 characters long'),
], async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
const { id } = req.params;
const { password } = req.body;
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' });
}
const requirePassword = parseBooleanInput(event.require_password, true);
const publishUpdates = { is_draft: formatBoolean(false) };
if (requirePassword && password) {
// Re-hash so the stored hash matches what the email carries — even if
// the admin mistypes vs. what was set at draft creation, the gallery
// password the customer receives is the one that actually works.
publishUpdates.password_hash = await bcrypt.hash(password, getBcryptRounds());
}
await db('events').where('id', id).update(publishUpdates);
// 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 });
let galleryPasswordForEmail;
if (!requirePassword) {
galleryPasswordForEmail = 'No password required';
} else if (password) {
// Admin re-typed the password in the publish dialog — put it straight
// into the email so the customer can actually log in (#627).
galleryPasswordForEmail = password;
} else {
// Legacy fallback for API-only publishes that don't carry the password.
galleryPasswordForEmail = '(set at creation)';
}
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: galleryPasswordForEmail,
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()
});
}
// WhatsApp gallery_ready on publish-from-draft (#640D). The PublishGallery
// dialog (#627) hands us the password back so we can deliver it via
// WhatsApp as well. Uses customer_phone from the persisted event row.
if (event.customer_phone) {
try {
const { queueWhatsapp, getWhatsAppConfig } = require('../services/whatsappProcessor');
const waConfig = await getWhatsAppConfig();
if (waConfig && waConfig.enabled) {
const { shareUrl: shareUrlForWa } = await buildShareLinkVariants({
slug: event.slug, shareToken: event.share_token,
});
await queueWhatsapp(parseInt(id, 10), event.customer_phone, 'gallery_created', {
customer_name: event.customer_name || event.host_name || '',
event_name: event.event_name,
gallery_link: shareUrlForWa || `${await getFrontendBaseUrl()}/gallery/${event.slug}`,
// Plaintext only when the admin re-typed at publish; otherwise
// omit so the buildComponents() helper renders an empty {{4}}
// line instead of leaking the "(set at creation)" sentinel.
gallery_password: requirePassword && password ? password : '',
expiry_date: event.expires_at ? new Date(event.expires_at).toISOString() : null,
language: null, // resolved by processor via general_default_language
});
}
} catch (waError) {
logger.warn('Failed to queue WhatsApp notification on publish', { error: waError.message });
}
}
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' });
}
});
// Duplicate an event (#626). Creates a new DRAFT gallery that inherits the
// source event's branding, behaviour, hero/header, feedback, and category
// configuration — admin then fills in customer + publishes via the publish
// dialog (#627), where the password is set. Photos, hero photo selection,
// client-access secrets, customer assignments, archive/sent state are NOT
// carried over.
router.post('/:id/duplicate', adminAuth, requirePermission('events.create'), requireEventOwnership, [
body('event_name').trim().notEmpty().withMessage('Event name is required'),
body('event_date').optional({ values: 'falsy' }).isDate(),
body('customer_name').optional().trim(),
body('customer_email').optional({ values: 'falsy' }).isEmail().normalizeEmail(IDENTITY_PRESERVING_NORMALIZE_EMAIL),
], async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
const { id } = req.params;
const source = await db('events').where('id', id).first();
if (!source) {
return res.status(404).json({ error: 'Source event not found' });
}
const { event_name, event_date, customer_name, customer_email } = req.body;
// Generate a fresh unique slug using the same shape as the create path.
const slugify = require('../utils/slug').slugify;
const processedEventName = slugify(event_name);
const slugSuffix = event_date || crypto.randomBytes(3).toString('hex');
const baseSlug = `${source.event_type}-${processedEventName}-${slugSuffix}`;
let slug = baseSlug;
let counter = 1;
// eslint-disable-next-line no-await-in-loop
while (await db('events').where({ slug }).first()) {
slug = `${baseSlug}-${counter}`;
counter += 1;
}
// Recompute expires_at: preserve the source's expiration window (delta
// between source.expires_at and source.event_date) so the duplicate keeps
// the same "active for N days" feel. Falls back to 30 days if source had
// no expiration set.
let newExpiresAt = null;
if (event_date) {
let expirationDays = 30;
if (source.expires_at && source.event_date) {
const days = Math.round(
(new Date(source.expires_at).getTime() - new Date(source.event_date).getTime())
/ (24 * 60 * 60 * 1000),
);
if (days > 0) expirationDays = days;
}
const [year, month, day] = event_date.split('-').map((s) => parseInt(s, 10));
const baseDate = new Date(year, month - 1, day);
baseDate.setDate(baseDate.getDate() + expirationDays);
newExpiresAt = baseDate;
}
const shareToken = crypto.randomBytes(16).toString('hex');
const { shareLinkToStore } = await buildShareLinkVariants({ slug, shareToken });
// Random-placeholder password hash. When the admin publishes via the
// PublishGalleryDialog (#627), the dialog re-hashes whatever they type and
// overwrites this. Pattern matches the create path at line ~606.
const password_hash = await bcrypt.hash(crypto.randomBytes(32).toString('hex'), getBcryptRounds());
// Create the storage folder structure (same as create path).
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 });
const customerColumnsAvailable = await hasCustomerContactColumns();
const calendarColumnsExist = await hasColumnCached('events', 'is_full_day');
// Build the insert row. Copy behaviour + branding fields from source;
// leave per-gallery secrets / state / photos blank.
const insertResult = await db('events').insert({
slug,
event_type: source.event_type,
event_name,
event_date: event_date || null,
...(calendarColumnsExist ? {
event_time_start: source.event_time_start,
event_time_end: source.event_time_end,
is_full_day: source.is_full_day,
} : {}),
...(customerColumnsAvailable ? {
customer_name: customer_name || null,
customer_email: customer_email || null,
} : {}),
host_name: customer_name || null,
host_email: customer_email || null,
admin_email: source.admin_email || null,
password_hash,
welcome_message: source.welcome_message || '',
color_theme: source.color_theme,
share_link: shareLinkToStore,
share_token: shareToken,
expires_at: newExpiresAt ? newExpiresAt.toISOString() : null,
created_at: new Date().toISOString(),
created_by: req.admin.id,
allow_user_uploads: source.allow_user_uploads,
upload_category_id: source.upload_category_id,
allow_downloads: source.allow_downloads,
disable_right_click: source.disable_right_click,
enable_devtools_protection: source.enable_devtools_protection,
watermark_downloads: source.watermark_downloads,
watermark_text: source.watermark_text,
allow_presigned_download: source.allow_presigned_download,
require_password: source.require_password,
css_template_id: source.css_template_id || null,
hero_logo_visible: source.hero_logo_visible,
hero_logo_size: source.hero_logo_size,
hero_logo_position: source.hero_logo_position,
header_style: source.header_style || 'standard',
hero_divider_style: source.hero_divider_style || 'wave',
hero_image_anchor: source.hero_image_anchor || 'center',
photo_cap: source.photo_cap || null,
is_draft: formatBoolean(true),
default_photo_sort: source.default_photo_sort || 'upload_date_desc',
// Client-access secrets and the OG-share opt-in deliberately do NOT
// carry over — admin re-decides per gallery.
client_access_enabled: formatBoolean(false),
og_image_share_enabled: formatBoolean(false),
}).returning('id');
const newEventId = insertResult[0]?.id || insertResult[0];
// Copy event_feedback_settings if the source had a row (only present when
// feedback_enabled was true on the source event).
const sourceFeedback = await db('event_feedback_settings').where({ event_id: id }).first();
if (sourceFeedback) {
await db('event_feedback_settings').insert({
event_id: newEventId,
feedback_enabled: sourceFeedback.feedback_enabled,
allow_ratings: sourceFeedback.allow_ratings,
allow_likes: sourceFeedback.allow_likes,
allow_comments: sourceFeedback.allow_comments,
allow_favorites: sourceFeedback.allow_favorites,
require_name_email: sourceFeedback.require_name_email,
moderate_comments: sourceFeedback.moderate_comments,
show_feedback_to_guests: sourceFeedback.show_feedback_to_guests,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
});
}
// Copy per-event photo categories (global categories are not duplicated —
// they apply to every event already). Mapping by name; photo_categories
// has no foreign key into photos here so we just clone the rows.
if (await db.schema.hasTable('photo_categories')) {
const sourceCategories = await db('photo_categories')
.where({ event_id: id })
.where(function () { this.whereNull('is_global').orWhere('is_global', formatBoolean(false)); })
.select('name', 'slug', 'is_global');
if (sourceCategories.length > 0) {
await db('photo_categories').insert(
sourceCategories.map((c) => ({
event_id: newEventId,
name: c.name,
slug: c.slug,
is_global: formatBoolean(false),
})),
);
}
}
await logActivity('event_duplicated',
{ source_event_id: parseInt(id, 10), source_event_name: source.event_name },
newEventId,
{ type: 'admin', id: req.admin.id, name: req.admin.username },
);
res.json({
message: 'Event duplicated successfully',
id: newEventId,
slug,
is_draft: true,
});
} catch (error) {
logger.error('Error duplicating event:', { error: error.message });
res.status(500).json({ error: 'Failed to duplicate event' });
}
});
// Update event
router.put('/:id', adminAuth, requirePermission('events.edit'), requireEventOwnership, [
body('event_name').optional().trim().notEmpty(),
body('event_date').optional({ values: 'falsy' }).isDate(),
// Migration 137 — calendar time fields. Same regex/range rule as POST.
body('event_time_start').optional({ values: 'falsy', nullable: true })
.matches(/^([01]\d|2[0-3]):[0-5]\d$/)
.withMessage('event_time_start must be HH:MM 24h'),
body('event_time_end').optional({ values: 'falsy', nullable: true })
.matches(/^([01]\d|2[0-3]):[0-5]\d$/)
.withMessage('event_time_end must be HH:MM 24h'),
body('is_full_day').optional().isBoolean().toBoolean(),
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(),
// Migration 143 — per-event reminder overrides. All three are
// optional; nullable values are accepted so admins can clear an
// override (e.g. drop a custom offset back to the global default).
body('event_reminder_disabled').optional().isBoolean(),
body('event_reminder_offset_days').optional({ nullable: true })
.custom((v) => v === null || (Number.isInteger(Number(v)) && Number(v) >= 0))
.withMessage('event_reminder_offset_days must be a non-negative integer or null'),
body('event_reminder_body_override').optional({ nullable: true, checkFalsy: true })
.isString().isLength({ max: 10_000 }),
body('customer_name').optional({ nullable: true, checkFalsy: true }).trim(),
body('customer_email').optional().isEmail().normalizeEmail(IDENTITY_PRESERVING_NORMALIZE_EMAIL),
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', 'banner', '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'
]),
// Per-event promotional override (#440). Three-way mode:
// inherit → fall back to global branding_promo_markdown
// custom → render this event's promo_markdown verbatim
// off → suppress entirely for this event
body('promo_mode').optional().isIn(['inherit', 'custom', 'off']),
body('promo_markdown').optional({ nullable: true }).isString(),
// Per-event opt-in for using hero photo as the social-share preview
// image (#474). When false (default), galleryOgService falls back to
// the brand logo for og:image / Twitter Card.
body('og_image_share_enabled').optional().isBoolean(),
// Customer accounts assigned to this event (#354). Optional array of
// customer_accounts.id — many-to-many via event_customer_assignments.
body('customer_account_ids').optional().isArray(),
body('customer_account_ids.*').optional().isInt({ min: 1 })
], 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' });
}
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;
// customer_account_ids (#354) is a body-only field consumed
// separately below by customerAccountsService.setAssignmentsForEvent
// — it isn't a column on the events table, so spreading it into
// the UPDATE statement throws "column does not exist" and crashes
// the entire edit with 500 Failed to update event.
delete updates.customer_account_ids;
// Migration 137 — calendar time triple. Renormalise only when at
// least one of the three fields was supplied; otherwise leave the
// row's current values alone. is_full_day=true forces both times
// to null. Drop the fields silently on un-migrated installs.
const timeFieldsTouched = (
Object.prototype.hasOwnProperty.call(updates, 'event_time_start')
|| Object.prototype.hasOwnProperty.call(updates, 'event_time_end')
|| Object.prototype.hasOwnProperty.call(updates, 'is_full_day')
);
if (timeFieldsTouched) {
if (await hasColumnCached('events', 'is_full_day')) {
const triple = normaliseEventTimeTriple({
event_time_start: updates.event_time_start,
event_time_end: updates.event_time_end,
is_full_day: updates.is_full_day,
});
updates.event_time_start = triple.event_time_start;
updates.event_time_end = triple.event_time_end;
updates.is_full_day = formatBoolean(triple.is_full_day);
} else {
delete updates.event_time_start;
delete updates.event_time_end;
delete updates.is_full_day;
}
}
// 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
// Allow admins to clear `expires_at` on edit even when the global
// `event_require_expiration` setting is ON (#426). The setting now
// controls only the create-time default — once an event exists, an
// admin editing it can override and remove the expiration. Empty /
// null values normalize to NULL in the column ("never expires").
if (Object.prototype.hasOwnProperty.call(updates, 'expires_at') && !updates.expires_at) {
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);
}
// Per-event opt-in for hero-photo OG share image (#474). Coerce so
// SQLite stores 0/1 and Postgres stores boolean true/false.
if (Object.prototype.hasOwnProperty.call(updates, 'og_image_share_enabled')) {
updates.og_image_share_enabled = formatBoolean(updates.og_image_share_enabled === true);
}
// Per-event promotional override (#440). Normalize promo_markdown to
// NULL when mode is anything other than 'custom' so we don't carry
// stale text after the admin switches modes. Empty markdown also
// becomes NULL.
if (Object.prototype.hasOwnProperty.call(updates, 'promo_mode')
|| Object.prototype.hasOwnProperty.call(updates, 'promo_markdown')) {
const mode = updates.promo_mode;
if (mode && mode !== 'custom') {
updates.promo_markdown = null;
} else if (Object.prototype.hasOwnProperty.call(updates, 'promo_markdown')) {
const md = typeof updates.promo_markdown === 'string' ? updates.promo_markdown.trim() : '';
updates.promo_markdown = md || null;
}
}
// 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
}
}
// 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) {
updates.client_share_token = crypto.randomBytes(32).toString('hex');
}
}
// Update event
await db('events')
.where('id', id)
.update(updates);
// Customer-account assignments (#354). Same skip semantics as POST:
// ignore when the customer portal flag is off so stale tabs don't
// 4xx the whole edit.
if (Array.isArray(req.body.customer_account_ids)) {
try {
const customerAccountsService = require('../services/customerAccountsService');
if (await customerAccountsService.isCustomerPortalEnabled()) {
await customerAccountsService.setAssignmentsForEvent(
parseInt(id, 10),
req.body.customer_account_ids,
req.admin.id
);
}
} catch (e) {
logger.error('Failed to set customer assignments on event update', {
eventId: id, error: e.message,
});
}
}
// 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;
await deleteEventCascade(id, { id: req.admin.id, username: req.admin.username });
res.json({ message: 'Event deleted successfully' });
} catch (error) {
if (error.code === 'EVENT_NOT_FOUND') {
return res.status(404).json({ error: 'Event not found' });
}
logger.error('Error deleting event', { eventId: req.params.id, error: error.message });
if (error.message && error.message.includes('foreign key constraint')) {
return res.status(500).json({
error: 'Cannot delete event due to existing references. Please contact support.'
});
}
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, password: clientPassword } = 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' });
}
// Use the admin-supplied password when provided; otherwise auto-generate
// (preserves the previous one-click behaviour for callers/cron that don't
// pass a body). Validation matches the create-event flow so the same
// strength rules apply both ways.
let newPassword;
if (typeof clientPassword === 'string' && clientPassword.length > 0) {
const passwordValidation = await validatePasswordInContext(clientPassword, 'gallery', {
eventName: event.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
});
}
newPassword = clientPassword;
} else {
const { generateReadablePassword } = require('../utils/passwordGenerator');
newPassword = generateReadablePassword();
}
const passwordHash = await bcrypt.hash(newPassword, getBcryptRounds());
// 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);
// event.share_link is the path-only form (`/gallery/<slug>/<token>`).
// Use the full URL so customers can click straight from the email.
const { shareUrl } = await buildShareLinkVariants({ slug: event.slug, shareToken: event.share_token });
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: shareUrl,
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);
// event.share_link is the path-only form; use the full URL so the
// customer's mail client renders a clickable absolute link.
const { shareUrl } = await buildShareLinkVariants({ slug: event.slug, shareToken: event.share_token });
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: shareUrl,
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' });
}
});
// Bulk delete — destructive, irreversible. Caps at 100 events per request
// to keep request time bounded; the per-event cascade touches 5 DB tables
// + 3 filesystem paths so 1000 events would risk timing out the request.
// Loops via deleteEventCascade so the per-event delete behaviour stays in
// lock-step with DELETE /:id.
//
// Confirmation is enforced client-side via the typed-DELETE pattern in
// BulkDeleteModal (#417). The previous server-side bcrypt-password gate
// was dropped because the destructive single-event DELETE /:id has never
// required a password either — events.delete permission + admin session
// is the auth boundary for both. The typed-literal client gate is the
// "accidental click" safeguard, and unlike a password input it isn't
// affected by passkey/Windows Hello autofill that auto-submits the form.
const BULK_DELETE_MAX = 100;
router.post('/bulk-delete', adminAuth, requirePermission('events.delete'), [
body('eventIds').isArray({ min: 1, max: BULK_DELETE_MAX }).withMessage(`eventIds must be an array of 1-${BULK_DELETE_MAX} ids`),
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;
// Editor-role events.delete permission is already gated by the route
// middleware. We do NOT additionally filter to created_by here because
// the per-event delete-cascade is global (matches DELETE /:id which
// also has no role-based filter — that's why events.delete is a
// sensitive permission).
const results = { successful: [], failed: [] };
const adminContext = { id: req.admin.id, username: req.admin.username };
for (const eventId of eventIds) {
try {
const deleted = await deleteEventCascade(eventId, adminContext);
results.successful.push(deleted);
} catch (err) {
results.failed.push({
id: eventId,
name: null,
error: err.code === 'EVENT_NOT_FOUND' ? 'Event not found' : 'Failed to delete event'
});
logger.warn('Bulk-delete: per-event failure', { eventId, error: err.message });
}
}
await logActivity('bulk_delete_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 delete completed: ${results.successful.length} succeeded, ${results.failed.length} failed`,
results
});
} catch (error) {
logger.error('Error in bulk delete', { error: error.message });
res.status(500).json({ error: 'Failed to perform bulk delete' });
}
});
// 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;