feat: implement 4 new features with bug fixes and refactoring plan

## Features Implemented

### 1. Event Rename Functionality
- Add EventRenameDialog component with live slug preview
- Create eventRenameService for safe event renaming
- Add slug_redirects table for old URL redirects
- Support optional email notification on rename
- Fix date formatting in slug (YYYY-MM-DD format)

### 2. Optional Event Contact Fields
- Add settings to make customer name/email/admin email optional
- Create migration for field requirement settings
- Update CreateEventPage forms to show "(optional)" labels
- Fix boolean parsing in publicSettings.js

### 3. Photo Filtering & Export
- Add PhotoFilterPanel with rating/likes/favorites/comments filters
- Create PhotoExportMenu with ZIP/metadata/XMP export options
- Add photoExportService with Lightroom XMP sidecar generation
- Create photoFilterBuilder utility for query construction
- Wire up photo selection to export button via onSelectionChange

### 4. Custom CSS Gallery Templates
- Add CssTemplateEditor component with 3 template slots
- Create cssSanitizer utility blocking XSS vectors
- Add gallery CSS endpoint for template delivery
- Integrate Custom CSS tab into Settings page
- Include default "Elegant Dark" template

## Bug Fixes
- Fix event rename date formatting (was showing full Date string)
- Fix common.optional translation key missing in locales
- Fix photo export button staying disabled when photos selected
- Fix authService import missing in SettingsPage

## Documentation
- Add comprehensive REFACTORING_PLAN.md for codebase improvement
- Add test specification documents for all features
- Add feature documentation for CSS templates

## Database Migrations
- 049_add_slug_redirects.js
- 050_add_optional_event_fields_settings.js
- 051_add_photo_filter_indexes.js
- 052_add_css_templates.js
This commit is contained in:
Paul Nothaft
2026-01-02 09:56:19 +01:00
parent 64ceb20431
commit 77a4bfd499
38 changed files with 7989 additions and 58 deletions
+194
View File
@@ -0,0 +1,194 @@
/**
* Admin CSS Templates Routes
* Handles CRUD operations for custom CSS gallery templates
*/
const express = require('express');
const router = express.Router();
const { body, param, validationResult } = require('express-validator');
const { db, withRetry } = require('../database/db');
const { adminAuth } = require('../middleware/auth');
const { sanitizeCSS, validateCSS, MAX_CSS_SIZE } = require('../utils/cssSanitizer');
const { DEFAULT_CSS_TEMPLATE } = require('../../migrations/core/052_add_css_templates');
/**
* GET /admin/css-templates
* Get all CSS templates
*/
router.get('/', adminAuth, async (req, res) => {
try {
const templates = await withRetry(() =>
db('css_templates').orderBy('slot_number')
);
res.json({ success: true, templates });
} catch (error) {
console.error('Get CSS templates error:', error);
res.status(500).json({ error: 'Failed to fetch templates' });
}
});
/**
* GET /admin/css-templates/enabled
* Get only enabled templates (for event form dropdown)
*/
router.get('/enabled', adminAuth, async (req, res) => {
try {
const templates = await withRetry(() =>
db('css_templates')
.where({ is_enabled: true })
.select('id', 'name', 'slot_number')
.orderBy('slot_number')
);
res.json({ success: true, templates });
} catch (error) {
console.error('Get enabled templates error:', error);
res.status(500).json({ error: 'Failed to fetch templates' });
}
});
/**
* GET /admin/css-templates/:slotNumber
* Get a specific template by slot number
*/
router.get('/:slotNumber', adminAuth, [
param('slotNumber').isInt({ min: 1, max: 3 })
], async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
const { slotNumber } = req.params;
const template = await withRetry(() =>
db('css_templates')
.where({ slot_number: parseInt(slotNumber) })
.first()
);
if (!template) {
return res.status(404).json({ error: 'Template not found' });
}
res.json({ success: true, template });
} catch (error) {
console.error('Get template error:', error);
res.status(500).json({ error: 'Failed to fetch template' });
}
});
/**
* PUT /admin/css-templates/:slotNumber
* Update a template
*/
router.put('/:slotNumber', adminAuth, [
param('slotNumber').isInt({ min: 1, max: 3 }),
body('name').optional().isString().isLength({ max: 50 }),
body('css_content').optional().isString(),
body('is_enabled').optional().isBoolean()
], async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
const { slotNumber } = req.params;
const { name, css_content, is_enabled } = req.body;
// Validate CSS size
if (css_content && css_content.length > MAX_CSS_SIZE) {
return res.status(400).json({
error: `CSS content exceeds maximum size of ${MAX_CSS_SIZE / 1024}KB`
});
}
// Validate CSS syntax
if (css_content) {
const validation = validateCSS(css_content);
if (!validation.valid) {
return res.status(400).json({
error: 'Invalid CSS syntax',
details: validation.error
});
}
}
// Sanitize CSS
const { sanitized, warnings } = sanitizeCSS(css_content || '');
const updates = {
updated_at: db.fn.now()
};
if (name !== undefined) {
updates.name = name.substring(0, 50) || 'Untitled';
}
if (css_content !== undefined) {
updates.css_content = sanitized;
}
if (is_enabled !== undefined) {
updates.is_enabled = Boolean(is_enabled);
}
await withRetry(() =>
db('css_templates')
.where({ slot_number: parseInt(slotNumber) })
.update(updates)
);
const template = await withRetry(() =>
db('css_templates')
.where({ slot_number: parseInt(slotNumber) })
.first()
);
res.json({
success: true,
template,
sanitization_warnings: warnings
});
} catch (error) {
console.error('Update template error:', error);
res.status(500).json({ error: 'Failed to update template' });
}
});
/**
* POST /admin/css-templates/:slotNumber/reset
* Reset template to default (only for slot 1)
*/
router.post('/:slotNumber/reset', adminAuth, [
param('slotNumber').isInt({ min: 1, max: 1 }).withMessage('Only template 1 can be reset to default')
], async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
await withRetry(() =>
db('css_templates')
.where({ slot_number: 1 })
.update({
name: 'Elegant Dark',
css_content: DEFAULT_CSS_TEMPLATE,
is_enabled: true,
updated_at: db.fn.now()
})
);
const template = await withRetry(() =>
db('css_templates')
.where({ slot_number: 1 })
.first()
);
res.json({ success: true, template });
} catch (error) {
console.error('Reset template error:', error);
res.status(500).json({ error: 'Failed to reset template' });
}
});
module.exports = router;
+88
View File
@@ -0,0 +1,88 @@
/**
* Admin Event Rename Routes
* Handles event renaming operations
*/
const express = require('express');
const { body, validationResult } = require('express-validator');
const { adminAuth } = require('../middleware/auth-enhanced-v2');
const eventRenameService = require('../services/eventRenameService');
const router = express.Router();
/**
* POST /api/admin/events/:eventId/rename
* Rename an event
*/
router.post('/:eventId/rename', adminAuth, [
body('newEventName')
.trim()
.isLength({ min: 3, max: 100 })
.withMessage('Event name must be between 3 and 100 characters'),
body('resendEmail')
.optional()
.isBoolean()
.withMessage('resendEmail must be a boolean')
], async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ success: false, errors: errors.array() });
}
const { eventId } = req.params;
const { newEventName, resendEmail = false } = req.body;
const result = await eventRenameService.renameEvent(
parseInt(eventId, 10),
newEventName,
resendEmail,
req.admin
);
if (!result.success) {
return res.status(400).json(result);
}
res.json({
success: true,
message: 'Event renamed successfully',
data: result.data
});
} catch (error) {
console.error('Error renaming event:', error);
res.status(500).json({ success: false, error: 'Failed to rename event' });
}
});
/**
* POST /api/admin/events/:eventId/validate-rename
* Validate a potential rename without executing it
*/
router.post('/:eventId/validate-rename', adminAuth, [
body('newEventName')
.trim()
.isLength({ min: 3, max: 100 })
.withMessage('Event name must be between 3 and 100 characters')
], async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ valid: false, errors: errors.array() });
}
const { eventId } = req.params;
const { newEventName } = req.body;
const validation = await eventRenameService.validateRename(
parseInt(eventId, 10),
newEventName
);
res.json(validation);
} catch (error) {
console.error('Error validating rename:', error);
res.status(500).json({ valid: false, error: 'Validation failed' });
}
});
module.exports = router;
+63 -6
View File
@@ -16,6 +16,48 @@ const { validatePasswordInContext, getBcryptRounds } = require('../utils/passwor
const logger = require('../utils/logger');
const { buildShareLinkVariants } = require('../services/shareLinkService');
// 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'
])
.select('setting_key', 'setting_value');
const requirements = {
require_customer_name: true,
require_customer_email: true,
require_admin_email: 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;
});
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
};
}
};
const parseBooleanInput = (value, defaultValue = true) => {
if (value === undefined || value === null) {
return defaultValue;
@@ -97,9 +139,9 @@ router.post('/', adminAuth, [
body('event_type').isIn(['wedding', 'birthday', 'corporate', 'other']),
body('event_name').notEmpty().trim(),
body('event_date').isDate(),
body('customer_name').notEmpty().trim(),
body('customer_email').isEmail().normalizeEmail(),
body('admin_email').isEmail().normalizeEmail(),
body('customer_name').optional().trim(),
body('customer_email').optional().isEmail().normalizeEmail(),
body('admin_email').optional().isEmail().normalizeEmail(),
body('require_password').optional().isBoolean(),
body('password').optional().isString().custom((value, { req }) => {
const input = req.body.require_password;
@@ -141,7 +183,10 @@ router.post('/', adminAuth, [
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,
@@ -174,8 +219,20 @@ router.post('/', adminAuth, [
const customerColumnsAvailable = await hasCustomerContactColumns();
if (!customerName || !customerEmail) {
return res.status(400).json({ error: 'customer_name and customer_email are required' });
// 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 (validationErrors.length > 0) {
return res.status(400).json({ errors: validationErrors });
}
const requirePassword = parseBooleanInput(requirePasswordInput, true);
+223
View File
@@ -0,0 +1,223 @@
/**
* Admin Photo Export Routes
* Handles filtering and exporting photos based on guest feedback
*/
const express = require('express');
const router = express.Router();
const { body, query, validationResult } = require('express-validator');
const { db, withRetry } = require('../database/db');
const { adminAuth } = require('../middleware/auth');
const { PhotoFilterBuilder } = require('../utils/photoFilterBuilder');
const { PhotoExportService } = require('../services/photoExportService');
const exportService = new PhotoExportService();
/**
* GET /admin/photos/:eventId/filtered
* Get filtered photos with pagination
*/
router.get('/:eventId/filtered', adminAuth, [
query('min_rating').optional().isFloat({ min: 0, max: 5 }),
query('max_rating').optional().isFloat({ min: 0, max: 5 }),
query('has_likes').optional().isBoolean(),
query('min_likes').optional().isInt({ min: 0 }),
query('has_favorites').optional().isBoolean(),
query('min_favorites').optional().isInt({ min: 0 }),
query('has_comments').optional().isBoolean(),
query('category_id').optional().isInt(),
query('logic').optional().isIn(['AND', 'OR']),
query('sort').optional().isIn(['rating', 'likes', 'favorites', 'date', 'filename']),
query('order').optional().isIn(['asc', 'desc']),
query('page').optional().isInt({ min: 1 }),
query('limit').optional().isInt({ min: 1, max: 100 })
], async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
const eventId = parseInt(req.params.eventId);
// Verify event exists
const event = await withRetry(() =>
db('events').where('id', eventId).first()
);
if (!event) {
return res.status(404).json({ error: 'Event not found' });
}
// Parse filter params
const filters = {
min_rating: req.query.min_rating ? parseFloat(req.query.min_rating) : undefined,
max_rating: req.query.max_rating ? parseFloat(req.query.max_rating) : undefined,
has_likes: req.query.has_likes,
min_likes: req.query.min_likes ? parseInt(req.query.min_likes) : undefined,
has_favorites: req.query.has_favorites,
min_favorites: req.query.min_favorites ? parseInt(req.query.min_favorites) : undefined,
has_comments: req.query.has_comments,
category_id: req.query.category_id ? parseInt(req.query.category_id) : undefined,
logic: req.query.logic || 'AND'
};
const sort = req.query.sort || 'date';
const order = req.query.order || 'desc';
const page = parseInt(req.query.page) || 1;
const limit = parseInt(req.query.limit) || 50;
// Build filtered query
const filterBuilder = new PhotoFilterBuilder(
db('photos')
.leftJoin('categories', 'photos.category_id', 'categories.id')
.select(
'photos.id',
'photos.filename',
'photos.original_filename',
'photos.file_path',
'photos.average_rating',
'photos.feedback_count',
'photos.like_count',
'photos.favorite_count',
'photos.comment_count',
'photos.width',
'photos.height',
'photos.created_at',
'categories.name as category_name'
),
eventId
);
filterBuilder
.applyFilters(filters)
.applySorting(sort, order)
.applyPagination(page, limit);
const photos = await withRetry(() => filterBuilder.getQuery());
// Get count of filtered photos
const countResult = await withRetry(() =>
PhotoFilterBuilder.buildCountQuery(db, eventId, filters)
);
const filteredCount = parseInt(countResult[0]?.count) || 0;
// Get summary counts
const summary = await withRetry(() =>
PhotoFilterBuilder.getSummary(db, eventId)
);
res.json({
success: true,
data: {
photos,
pagination: {
total: summary.total,
filtered: filteredCount,
page,
limit,
pages: Math.ceil(filteredCount / limit)
},
summary
}
});
} catch (error) {
console.error('Filter photos error:', error);
res.status(500).json({ error: 'Failed to filter photos' });
}
});
/**
* GET /admin/photos/:eventId/filter-summary
* Get just the summary counts for filter UI
*/
router.get('/:eventId/filter-summary', adminAuth, async (req, res) => {
try {
const eventId = parseInt(req.params.eventId);
const summary = await withRetry(() =>
PhotoFilterBuilder.getSummary(db, eventId)
);
res.json({
success: true,
data: summary
});
} catch (error) {
console.error('Filter summary error:', error);
res.status(500).json({ error: 'Failed to get filter summary' });
}
});
/**
* POST /admin/photos/:eventId/export
* Export selected or filtered photos
*/
router.post('/:eventId/export', adminAuth, [
body('photo_ids').optional().isArray(),
body('photo_ids.*').optional().isInt(),
body('filter').optional().isObject(),
body('format').isIn(['txt', 'csv', 'xmp', 'json']),
body('options').optional().isObject()
], async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
const eventId = parseInt(req.params.eventId);
const { photo_ids, filter, format, options = {} } = req.body;
// Verify event exists
const event = await withRetry(() =>
db('events').where('id', eventId).first()
);
if (!event) {
return res.status(404).json({ error: 'Event not found' });
}
// If filter provided instead of photo_ids, get matching photo IDs
let photoIds = photo_ids;
if (!photoIds && filter) {
const filterBuilder = new PhotoFilterBuilder(
db('photos').select('id'),
eventId
);
filterBuilder.applyFilters(filter);
const filteredPhotos = await withRetry(() => filterBuilder.getQuery());
photoIds = filteredPhotos.map(p => p.id);
}
// Export photos
const result = await exportService.exportPhotos(eventId, photoIds, format, options);
if (result.type === 'stream') {
res.setHeader('Content-Type', result.contentType);
res.setHeader('Content-Disposition', `attachment; filename="${result.filename}"`);
result.stream.pipe(res);
} else {
res.setHeader('Content-Type', result.contentType);
res.setHeader('Content-Disposition', `attachment; filename="${result.filename}"`);
res.send(result.content);
}
} catch (error) {
console.error('Export photos error:', error);
res.status(500).json({ error: error.message || 'Failed to export photos' });
}
});
/**
* GET /admin/photos/export-formats
* Get available export format options
*/
router.get('/export-formats', adminAuth, (req, res) => {
res.json({
success: true,
data: PhotoExportService.getFormatOptions()
});
});
module.exports = router;
+78 -4
View File
@@ -14,13 +14,39 @@ const { getEventShareToken, resolveShareIdentifier, buildShareLinkVariants } = r
// Get storage path from environment or default
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../storage');
// Check for slug redirect (for renamed events)
async function checkSlugRedirect(slug) {
try {
const hasTable = await db.schema.hasTable('slug_redirects');
if (!hasTable) return null;
const redirect = await db('slug_redirects')
.where({ old_slug: slug })
.first();
return redirect ? redirect.new_slug : null;
} catch (error) {
logger.warn('Error checking slug redirect:', { slug, error: error.message });
return null;
}
}
// Resolve gallery identifier (slug or token) to canonical data
router.get('/resolve/:identifier', async (req, res) => {
try {
const { identifier } = req.params;
const result = await resolveShareIdentifier(identifier);
let result = await resolveShareIdentifier(identifier);
// If not found, check for redirect
if (!result) {
const newSlug = await checkSlugRedirect(identifier);
if (newSlug) {
return res.status(301).json({
redirect: true,
newSlug,
message: 'Gallery has been renamed'
});
}
return res.status(404).json({ error: 'Gallery not found' });
}
@@ -75,8 +101,8 @@ router.get('/:slug/info', async (req, res) => {
try {
const { slug } = req.params;
const { token } = req.query;
const event = await db('events')
let event = await db('events')
.where({ slug })
.select(
'event_name',
@@ -95,8 +121,17 @@ router.get('/:slug/info', async (req, res) => {
'color_theme'
)
.first();
if (!event) {
// Check for redirect
const newSlug = await checkSlugRedirect(slug);
if (newSlug) {
return res.status(301).json({
redirect: true,
newSlug,
message: 'Gallery has been renamed'
});
}
return res.status(404).json({ error: 'Gallery not found' });
}
@@ -920,4 +955,43 @@ router.post('/:eventId/upload', verifyGalleryAccess, async (req, res) => {
}
});
/**
* GET /:slug/css-template
* Get custom CSS template for gallery (public endpoint)
*/
router.get('/:slug/css-template', async (req, res) => {
try {
const { slug } = req.params;
// Find the event by slug
const event = await db('events')
.where({ slug })
.select('css_template_id')
.first();
if (!event || !event.css_template_id) {
// No custom CSS - return 204 No Content
return res.status(204).send();
}
// Get the template if it's enabled
const template = await db('css_templates')
.where({ id: event.css_template_id, is_enabled: true })
.select('css_content')
.first();
if (!template || !template.css_content) {
return res.status(204).send();
}
// Return CSS with caching headers
res.setHeader('Content-Type', 'text/css');
res.setHeader('Cache-Control', 'public, max-age=3600'); // 1 hour cache
res.send(template.css_content);
} catch (error) {
console.error('Get CSS template error:', error);
res.status(500).send('/* Error loading template */');
}
});
module.exports = router;
+21 -7
View File
@@ -5,13 +5,14 @@ const router = express.Router();
// Get public settings (branding and theme)
router.get('/', async (req, res) => {
try {
// Fetch branding, theme, general, and security settings
// Fetch branding, theme, general, security, analytics, and event settings
// Note: We include analytics in the query but it might not exist yet
const settings = await withRetry(async () => {
return await db('app_settings')
.where(function() {
this.whereIn('setting_type', ['branding', 'theme', 'general', 'security', 'analytics'])
.orWhere('setting_key', 'like', 'analytics_%');
this.whereIn('setting_type', ['branding', 'theme', 'general', 'security', 'analytics', 'boolean'])
.orWhere('setting_key', 'like', 'analytics_%')
.orWhere('setting_key', 'like', 'event_require_%');
})
.select('setting_key', 'setting_value');
});
@@ -19,10 +20,19 @@ router.get('/', async (req, res) => {
// Convert to object format
const settingsObject = {};
settings.forEach(setting => {
// Handle null/undefined values
if (setting.setting_value === null || setting.setting_value === undefined) {
settingsObject[setting.setting_key] = null;
return;
}
// If value is already a primitive (boolean, number), use it directly
if (typeof setting.setting_value === 'boolean' || typeof setting.setting_value === 'number') {
settingsObject[setting.setting_key] = setting.setting_value;
return;
}
// Try to parse string values as JSON
try {
settingsObject[setting.setting_key] = setting.setting_value
? JSON.parse(setting.setting_value)
: null;
settingsObject[setting.setting_key] = JSON.parse(setting.setting_value);
} catch (e) {
// If parsing fails, use the raw value
settingsObject[setting.setting_key] = setting.setting_value;
@@ -59,7 +69,11 @@ router.get('/', async (req, res) => {
umami_enabled: settingsObject.analytics_umami_enabled === true || settingsObject.analytics_umami_enabled === 'true',
umami_url: (settingsObject.analytics_umami_enabled === true || settingsObject.analytics_umami_enabled === 'true') ? (settingsObject.analytics_umami_url || null) : null,
umami_website_id: (settingsObject.analytics_umami_enabled === true || settingsObject.analytics_umami_enabled === 'true') ? (settingsObject.analytics_umami_website_id || null) : null,
umami_share_url: (settingsObject.analytics_umami_enabled === true || settingsObject.analytics_umami_enabled === 'true') ? (settingsObject.analytics_umami_share_url || null) : null
umami_share_url: (settingsObject.analytics_umami_enabled === true || settingsObject.analytics_umami_enabled === 'true') ? (settingsObject.analytics_umami_share_url || null) : null,
// Event field requirements
event_require_customer_name: settingsObject.event_require_customer_name !== false,
event_require_customer_email: settingsObject.event_require_customer_email !== false,
event_require_admin_email: settingsObject.event_require_admin_email !== false
};
res.json(publicSettings);