398 lines
12 KiB
JavaScript
398 lines
12 KiB
JavaScript
const express = require('express');
|
|
const { body, validationResult } = require('express-validator');
|
|
const bcrypt = require('bcrypt');
|
|
const crypto = require('crypto');
|
|
const { db } = require('../database/db');
|
|
const { formatBoolean } = require('../utils/dbCompat');
|
|
const { validatePasswordInContext, getBcryptRounds } = require('../utils/passwordValidation');
|
|
const { adminAuth } = require('../middleware/auth-enhanced-v2');
|
|
const fs = require('fs').promises;
|
|
const path = require('path');
|
|
const router = express.Router();
|
|
const { buildShareLinkVariants } = require('../services/shareLinkService');
|
|
|
|
const parseBooleanInput = (value, defaultValue = true) => {
|
|
if (value === undefined || value === null) {
|
|
return defaultValue;
|
|
}
|
|
if (typeof value === 'boolean') {
|
|
return value;
|
|
}
|
|
if (typeof value === 'number') {
|
|
return value !== 0;
|
|
}
|
|
if (typeof value === 'string') {
|
|
const normalized = value.trim().toLowerCase();
|
|
if (['false', '0', 'no', 'off'].includes(normalized)) {
|
|
return false;
|
|
}
|
|
if (['true', '1', 'yes', 'on'].includes(normalized)) {
|
|
return true;
|
|
}
|
|
}
|
|
return defaultValue;
|
|
};
|
|
|
|
const getCustomerNameFromPayload = (payload = {}) => {
|
|
if (typeof payload.customer_name === 'string') {
|
|
const trimmed = payload.customer_name.trim();
|
|
return trimmed || null;
|
|
}
|
|
return null;
|
|
};
|
|
|
|
const getCustomerEmailFromPayload = (payload = {}) => {
|
|
if (typeof payload.customer_email === 'string') {
|
|
const trimmed = payload.customer_email.trim();
|
|
return trimmed || null;
|
|
}
|
|
return null;
|
|
};
|
|
|
|
const mapEventForApi = (event) => {
|
|
if (!event || typeof event !== 'object') {
|
|
return event;
|
|
}
|
|
|
|
const {
|
|
host_name,
|
|
host_email,
|
|
customer_name,
|
|
customer_email,
|
|
...rest
|
|
} = event;
|
|
|
|
return {
|
|
...rest,
|
|
customer_name: customer_name ?? host_name ?? null,
|
|
customer_email: customer_email ?? host_email ?? 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) {
|
|
return false;
|
|
}
|
|
};
|
|
|
|
// Create new event
|
|
router.post('/', adminAuth, [
|
|
body('event_type').isIn(['wedding', 'birthday', 'corporate', 'other']),
|
|
body('event_name').notEmpty(),
|
|
body('event_date').isDate(),
|
|
body('customer_name').notEmpty().trim(),
|
|
body('customer_email').isEmail().normalizeEmail(),
|
|
body('admin_email').isEmail(),
|
|
body('require_password').optional().isBoolean(),
|
|
body('password').optional().isString().custom((value, { req }) => {
|
|
const requirePassword = parseBooleanInput(req.body.require_password, 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()
|
|
], async (req, res) => {
|
|
try {
|
|
const errors = validationResult(req);
|
|
if (!errors.isEmpty()) {
|
|
return res.status(400).json({ errors: errors.array() });
|
|
}
|
|
|
|
const {
|
|
event_type,
|
|
event_name,
|
|
event_date,
|
|
admin_email,
|
|
password,
|
|
require_password: requirePasswordInput = true,
|
|
welcome_message,
|
|
color_theme,
|
|
expiration_days = 30
|
|
} = req.body;
|
|
|
|
const customerEmail = getCustomerEmailFromPayload(req.body);
|
|
const customerName = getCustomerNameFromPayload(req.body);
|
|
|
|
if (!customerName || !customerEmail) {
|
|
return res.status(400).json({ error: 'customer_name and customer_email are required' });
|
|
}
|
|
|
|
const customerColumnsAvailable = await hasCustomerContactColumns();
|
|
|
|
const requirePassword = parseBooleanInput(requirePasswordInput, true);
|
|
|
|
if (requirePassword) {
|
|
const 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 baseSlug = `${event_type}-${event_name.toLowerCase().replace(/[^a-z0-9]/g, '-')}-${event_date}`;
|
|
let slug = baseSlug;
|
|
let counter = 1;
|
|
|
|
while (await db('events').where({ slug }).first()) {
|
|
slug = `${baseSlug}-${counter}`;
|
|
counter++;
|
|
}
|
|
|
|
// Generate share link variants (auto-detects short URL preference)
|
|
const shareToken = crypto.randomBytes(16).toString('hex');
|
|
const { sharePath, shareUrl, shareLinkToStore } = await buildShareLinkVariants({ slug, shareToken });
|
|
|
|
// Hash password (or 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)
|
|
const expires_at = new Date(event_date);
|
|
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 });
|
|
|
|
// Insert into database
|
|
const insertResult = await db('events').insert({
|
|
slug,
|
|
event_type,
|
|
event_name,
|
|
event_date,
|
|
...(customerColumnsAvailable ? { customer_name: customerName, customer_email: customerEmail } : {}),
|
|
host_name: customerName,
|
|
host_email: customerEmail,
|
|
admin_email,
|
|
password_hash,
|
|
welcome_message,
|
|
color_theme,
|
|
share_link: shareLinkToStore,
|
|
share_token: shareToken,
|
|
expires_at,
|
|
require_password: formatBoolean(requirePassword)
|
|
}).returning('id');
|
|
|
|
// Handle both PostgreSQL (returns array of objects) and SQLite (returns array of IDs)
|
|
const eventId = insertResult[0]?.id || insertResult[0];
|
|
|
|
// Queue creation email
|
|
const { queueEmail } = require('../services/emailProcessor');
|
|
await queueEmail(eventId, customerEmail, 'gallery_created', {
|
|
customer_name: customerName,
|
|
customer_email: customerEmail,
|
|
host_name: customerName,
|
|
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.toISOString(), // Pass ISO string - will be formatted by email processor
|
|
welcome_message: welcome_message || ''
|
|
});
|
|
|
|
res.json({
|
|
id: eventId,
|
|
slug,
|
|
share_link: shareUrl,
|
|
expires_at,
|
|
require_password: requirePassword,
|
|
customer_name: customerName,
|
|
customer_email: customerEmail
|
|
});
|
|
} catch (error) {
|
|
console.error(error);
|
|
res.status(500).json({ error: 'Failed to create event' });
|
|
}
|
|
});
|
|
|
|
// Get all events (admin)
|
|
router.get('/', adminAuth, async (req, res) => {
|
|
try {
|
|
const { status = 'all' } = req.query;
|
|
|
|
let query = db('events').select('*');
|
|
|
|
if (status === 'active') {
|
|
query = query.where('is_active', formatBoolean(true));
|
|
} else if (status === 'archived') {
|
|
query = query.where('is_archived', formatBoolean(true));
|
|
}
|
|
|
|
const events = await query.orderBy('created_at', 'desc');
|
|
|
|
// Add photo counts
|
|
for (const event of events) {
|
|
const photoCount = await db('photos').where('event_id', event.id).count('id as count').first();
|
|
event.photo_count = photoCount.count;
|
|
}
|
|
|
|
res.json(events.map(mapEventForApi));
|
|
} catch (error) {
|
|
res.status(500).json({ error: 'Failed to fetch events' });
|
|
}
|
|
});
|
|
|
|
// Update event
|
|
router.put('/:id', adminAuth, [
|
|
body('customer_name').optional().trim().notEmpty(),
|
|
body('customer_email').optional().isEmail().normalizeEmail(),
|
|
body('require_password').optional().isBoolean()
|
|
], async (req, res) => {
|
|
try {
|
|
const errors = validationResult(req);
|
|
if (!errors.isEmpty()) {
|
|
return res.status(400).json({ errors: errors.array() });
|
|
}
|
|
|
|
const { id } = req.params;
|
|
const updates = { ...req.body };
|
|
const customerColumnsAvailable = await hasCustomerContactColumns();
|
|
|
|
// Don't allow updating certain fields
|
|
delete updates.id;
|
|
delete updates.slug;
|
|
delete updates.created_at;
|
|
delete updates.password_confirmation;
|
|
|
|
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;
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|
|
|
|
const event = await db('events').where('id', id).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());
|
|
}
|
|
|
|
await db('events').where('id', id).update(updates);
|
|
|
|
res.json({ success: true });
|
|
} catch (error) {
|
|
res.status(500).json({ error: 'Failed to update event' });
|
|
}
|
|
});
|
|
|
|
// Delete event (mark as inactive)
|
|
router.delete('/:id', adminAuth, async (req, res) => {
|
|
try {
|
|
const { id } = req.params;
|
|
|
|
await db('events').where('id', id).update({ is_active: formatBoolean(false) });
|
|
|
|
res.json({ success: true });
|
|
} catch (error) {
|
|
res.status(500).json({ error: 'Failed to delete event' });
|
|
}
|
|
});
|
|
|
|
// Extend expiration
|
|
router.post('/:id/extend', adminAuth, [
|
|
body('days').isInt({ min: 1, max: 365 })
|
|
], async (req, res) => {
|
|
try {
|
|
const { id } = req.params;
|
|
const { days } = req.body;
|
|
|
|
const event = await db('events').where('id', id).first();
|
|
if (!event) {
|
|
return res.status(404).json({ error: 'Event not found' });
|
|
}
|
|
|
|
const newExpiration = new Date(event.expires_at);
|
|
newExpiration.setDate(newExpiration.getDate() + days);
|
|
|
|
await db('events').where('id', id).update({
|
|
expires_at: newExpiration,
|
|
is_active: formatBoolean(true) // Reactivate if expired
|
|
});
|
|
|
|
res.json({ expires_at: newExpiration });
|
|
} catch (error) {
|
|
res.status(500).json({ error: 'Failed to extend expiration' });
|
|
}
|
|
});
|
|
|
|
module.exports = router;
|