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
+421
View File
@@ -0,0 +1,421 @@
/**
* Event Rename Service
* Handles renaming events including slug updates, file system changes, and database updates
*/
const { db, logActivity } = require('../database/db');
const fs = require('fs').promises;
const path = require('path');
const logger = require('../utils/logger');
const { buildShareLinkVariants } = require('./shareLinkService');
const { queueEmail } = require('./emailProcessor');
class EventRenameService {
constructor() {
this.storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
}
/**
* Format a date to YYYY-MM-DD string
* @param {Date|string} date - Date object or string
* @returns {string} Formatted date string
*/
formatDate(date) {
if (!date) return '';
const d = new Date(date);
if (isNaN(d.getTime())) return String(date);
const year = d.getFullYear();
const month = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
/**
* Generate a slug from event details
* @param {string} eventType - Type of event (wedding, birthday, etc.)
* @param {string} eventName - Name of the event
* @param {string|Date} eventDate - Date of the event
* @returns {string} Generated slug
*/
generateSlug(eventType, eventName, eventDate) {
const processedEventName = eventName
.toLowerCase()
.replace(/[^a-z0-9]/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '');
const formattedDate = this.formatDate(eventDate);
return `${eventType}-${processedEventName}-${formattedDate}`;
}
/**
* Validate if a rename operation is possible
* @param {number} eventId - Event ID
* @param {string} newEventName - New event name
* @returns {Promise<{valid: boolean, error?: string, newSlug?: string}>}
*/
async validateRename(eventId, newEventName) {
try {
// Get current event
const event = await db('events').where({ id: eventId }).first();
if (!event) {
return { valid: false, error: 'Event not found' };
}
if (event.is_archived) {
return { valid: false, error: 'Cannot rename archived events' };
}
// Validate new name
if (!newEventName || newEventName.trim().length < 3) {
return { valid: false, error: 'Event name must be at least 3 characters' };
}
if (newEventName.trim().length > 100) {
return { valid: false, error: 'Event name must be less than 100 characters' };
}
// Generate new slug
const newSlug = this.generateSlug(event.event_type, newEventName.trim(), event.event_date);
// Check if slug already exists (for different event)
const existingEvent = await db('events')
.where({ slug: newSlug })
.whereNot({ id: eventId })
.first();
if (existingEvent) {
return { valid: false, error: 'An event with this name already exists for the same date', conflicts: [existingEvent.event_name] };
}
// Check if the slug is the same as current
if (newSlug === event.slug) {
return { valid: false, error: 'New name generates the same URL as the current name' };
}
return { valid: true, newSlug, currentSlug: event.slug };
} catch (error) {
logger.error('Validation error:', { error: error.message });
return { valid: false, error: 'Validation failed' };
}
}
/**
* Rename the event folder on the filesystem
* @param {string} oldSlug - Current slug
* @param {string} newSlug - New slug
* @returns {Promise<boolean>}
*/
async renameEventFolder(oldSlug, newSlug) {
const oldPath = path.join(this.storagePath, 'events/active', oldSlug);
const newPath = path.join(this.storagePath, 'events/active', newSlug);
try {
// Check if old folder exists
await fs.access(oldPath);
// Check if new folder already exists
try {
await fs.access(newPath);
throw new Error('Target folder already exists');
} catch (error) {
if (error.code !== 'ENOENT') {
throw error;
}
}
// Rename folder
await fs.rename(oldPath, newPath);
logger.info('Event folder renamed', { oldSlug, newSlug });
return true;
} catch (error) {
if (error.code === 'ENOENT') {
logger.warn('Event folder not found, skipping rename', { oldSlug });
return true; // Not a fatal error if folder doesn't exist
}
throw error;
}
}
/**
* Rename individual photo files to match new event name
* @param {number} eventId - Event ID
* @param {string} oldEventName - Old event name
* @param {string} newEventName - New event name
* @param {string} newSlug - New slug for path updates
* @returns {Promise<number>} Number of files renamed
*/
async renamePhotoFiles(eventId, oldEventName, newEventName, oldSlug, newSlug) {
const photos = await db('photos').where({ event_id: eventId });
let renamedCount = 0;
// Process event name for filenames
const oldNamePrefix = oldEventName.replace(/[^a-zA-Z0-9]/g, '_');
const newNamePrefix = newEventName.replace(/[^a-zA-Z0-9]/g, '_');
for (const photo of photos) {
try {
const oldFilename = photo.filename;
let newFilename = oldFilename;
// Replace event name prefix if present
if (oldFilename.startsWith(oldNamePrefix)) {
newFilename = oldFilename.replace(oldNamePrefix, newNamePrefix);
}
// Update path with new slug
const newPath = photo.path.replace(oldSlug, newSlug);
const newThumbnailPath = photo.thumbnail_path ?
photo.thumbnail_path.replace(oldSlug, newSlug) : null;
// Rename physical file if filename changed
if (newFilename !== oldFilename) {
const oldFilePath = path.join(this.storagePath, 'events/active', newSlug,
photo.type === 'collage' ? 'collages' : 'individual', oldFilename);
const newFilePath = path.join(this.storagePath, 'events/active', newSlug,
photo.type === 'collage' ? 'collages' : 'individual', newFilename);
try {
await fs.rename(oldFilePath, newFilePath);
} catch (error) {
if (error.code !== 'ENOENT') {
logger.warn('Could not rename photo file', { oldFilename, error: error.message });
}
}
}
// Update database record
await db('photos')
.where({ id: photo.id })
.update({
filename: newFilename,
path: newPath,
thumbnail_path: newThumbnailPath
});
renamedCount++;
} catch (error) {
logger.error('Error renaming photo', { photoId: photo.id, error: error.message });
}
}
return renamedCount;
}
/**
* Update database records for the rename
* @param {object} trx - Knex transaction
* @param {number} eventId - Event ID
* @param {string} oldSlug - Current slug
* @param {string} newSlug - New slug
* @param {string} newEventName - New event name
* @returns {Promise<{newShareLink: string}>}
*/
async updateDatabaseRecords(trx, eventId, oldSlug, newSlug, newEventName) {
const event = await trx('events').where({ id: eventId }).first();
// Generate new share link
const { sharePath, shareUrl, shareLinkToStore } = await buildShareLinkVariants({
slug: newSlug,
shareToken: event.share_token
});
// Update event
await trx('events')
.where({ id: eventId })
.update({
event_name: newEventName,
slug: newSlug,
share_link: shareLinkToStore
});
return { newShareLink: shareUrl };
}
/**
* Create a redirect entry for the old slug
* @param {object} trx - Knex transaction
* @param {number} eventId - Event ID
* @param {string} oldSlug - Old slug
* @param {string} newSlug - New slug
*/
async createSlugRedirect(trx, eventId, oldSlug, newSlug) {
// Check if table exists
const hasTable = await trx.schema.hasTable('slug_redirects');
if (!hasTable) {
logger.warn('slug_redirects table does not exist, skipping redirect creation');
return;
}
// Check if redirect already exists
const existingRedirect = await trx('slug_redirects')
.where({ old_slug: oldSlug })
.first();
if (existingRedirect) {
// Update existing redirect
await trx('slug_redirects')
.where({ old_slug: oldSlug })
.update({ new_slug: newSlug });
} else {
// Create new redirect
await trx('slug_redirects').insert({
old_slug: oldSlug,
new_slug: newSlug,
event_id: eventId
});
}
// Also update any existing redirects pointing to the old slug
await trx('slug_redirects')
.where({ new_slug: oldSlug })
.update({ new_slug: newSlug });
}
/**
* Send notification email about the rename
* @param {number} eventId - Event ID
* @param {string} newShareLink - New share link
*/
async sendRenamedEventEmail(eventId, newShareLink) {
const event = await db('events').where({ id: eventId }).first();
if (!event) return;
const recipientEmail = event.customer_email || event.host_email;
if (!recipientEmail) return;
const recipientName = event.customer_name || event.host_name ||
(recipientEmail ? recipientEmail.split('@')[0] : 'Guest');
await queueEmail(eventId, recipientEmail, 'gallery_link_updated', {
customer_name: recipientName,
event_name: event.event_name,
new_gallery_link: newShareLink,
event_date: event.event_date
});
}
/**
* Rollback a failed rename operation
* @param {object} backupData - Backup data from the rename attempt
*/
async rollbackRename(backupData) {
try {
if (backupData.folderRenamed && backupData.event) {
const oldPath = path.join(this.storagePath, 'events/active', backupData.newSlug);
const newPath = path.join(this.storagePath, 'events/active', backupData.event.slug);
try {
await fs.rename(oldPath, newPath);
logger.info('Rolled back folder rename');
} catch (error) {
logger.error('Failed to rollback folder rename', { error: error.message });
}
}
} catch (error) {
logger.error('Rollback failed', { error: error.message });
}
}
/**
* Main method to rename an event
* @param {number} eventId - Event ID
* @param {string} newEventName - New event name
* @param {boolean} resendEmail - Whether to resend invitation email
* @param {object} adminUser - Admin user performing the action
* @returns {Promise<{success: boolean, data?: object, error?: string}>}
*/
async renameEvent(eventId, newEventName, resendEmail = false, adminUser = null) {
const backupData = {};
try {
// 1. Validate
const validation = await this.validateRename(eventId, newEventName);
if (!validation.valid) {
return { success: false, error: validation.error };
}
// 2. Get current event data
const event = await db('events').where({ id: eventId }).first();
backupData.event = event;
backupData.newSlug = validation.newSlug;
const oldSlug = event.slug;
const oldName = event.event_name;
const newSlug = validation.newSlug;
// 3. Start transaction
const trx = await db.transaction();
try {
// 4. Rename folder (filesystem)
await this.renameEventFolder(oldSlug, newSlug);
backupData.folderRenamed = true;
// 5. Rename photo files and update paths
const filesRenamed = await this.renamePhotoFiles(eventId, oldName, newEventName.trim(), oldSlug, newSlug);
// 6. Update database records
const { newShareLink } = await this.updateDatabaseRecords(trx, eventId, oldSlug, newSlug, newEventName.trim());
// 7. Create redirect entry
await this.createSlugRedirect(trx, eventId, oldSlug, newSlug);
// 8. Log activity
await trx('activity_logs').insert({
activity_type: 'event_renamed',
actor_type: adminUser ? 'admin' : 'system',
actor_id: adminUser?.id || null,
actor_name: adminUser?.username || 'system',
metadata: JSON.stringify({
old_name: oldName,
new_name: newEventName.trim(),
old_slug: oldSlug,
new_slug: newSlug,
files_renamed: filesRenamed,
email_sent: resendEmail
}),
event_id: eventId
});
// 9. Commit transaction
await trx.commit();
// 10. Send email (after commit, non-critical)
let emailSent = false;
if (resendEmail) {
try {
await this.sendRenamedEventEmail(eventId, newShareLink);
emailSent = true;
} catch (error) {
logger.error('Failed to send rename notification email', { error: error.message });
}
}
return {
success: true,
data: {
eventId,
oldName,
newName: newEventName.trim(),
oldSlug,
newSlug,
newShareLink,
emailSent,
filesRenamed
}
};
} catch (error) {
await trx.rollback();
throw error;
}
} catch (error) {
logger.error('Event rename failed', { eventId, error: error.message });
await this.rollbackRename(backupData);
return { success: false, error: error.message || 'Failed to rename event' };
}
}
}
module.exports = new EventRenameService();
+251
View File
@@ -0,0 +1,251 @@
/**
* Photo Export Service
* Handles exporting filtered photos in various formats
*/
const archiver = require('archiver');
const { PassThrough } = require('stream');
const { XmpGenerator } = require('./xmpGenerator');
const { db } = require('../database/db');
const path = require('path');
const fs = require('fs').promises;
class PhotoExportService {
constructor() {
this.xmpGenerator = new XmpGenerator();
}
/**
* Get photos with full feedback data
* @param {number} eventId - Event ID
* @param {number[]} photoIds - Photo IDs to export (optional, exports all if not provided)
* @returns {Promise<Object[]>} Photos with feedback
*/
async getPhotosWithFeedback(eventId, photoIds = null) {
let query = db('photos')
.leftJoin('categories', 'photos.category_id', 'categories.id')
.where('photos.event_id', eventId)
.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.file_size',
'photos.created_at',
'categories.name as category_name'
)
.orderBy('photos.filename', 'asc');
if (photoIds && photoIds.length > 0) {
query = query.whereIn('photos.id', photoIds);
}
return await query;
}
/**
* Export photos in the specified format
* @param {number} eventId - Event ID
* @param {number[]} photoIds - Photo IDs to export
* @param {string} format - Export format (txt, csv, xmp, photos, json)
* @param {Object} options - Export options
* @returns {Promise<Object>} Export result with stream/content
*/
async exportPhotos(eventId, photoIds, format, options = {}) {
const photos = await this.getPhotosWithFeedback(eventId, photoIds);
if (photos.length === 0) {
throw new Error('No photos to export');
}
switch (format) {
case 'txt':
return this.exportAsTxt(photos, options);
case 'csv':
return this.exportAsCsv(photos, options);
case 'xmp':
return this.exportAsXmpZip(photos, options);
case 'json':
return this.exportAsJson(photos, eventId, options);
default:
throw new Error(`Unknown export format: ${format}`);
}
}
/**
* Export as plain text filename list
*/
exportAsTxt(photos, options = {}) {
const { filename_format = 'original', separator = 'newline' } = options;
const filenames = photos.map(photo =>
filename_format === 'original' ? photo.original_filename : photo.filename
);
let content;
switch (separator) {
case 'comma':
content = filenames.join(', ');
break;
case 'semicolon':
content = filenames.join('; ');
break;
default:
content = filenames.join('\n');
}
return {
type: 'text',
content,
filename: `photo_list_${Date.now()}.txt`,
contentType: 'text/plain'
};
}
/**
* Export as CSV with metadata
*/
exportAsCsv(photos, options = {}) {
const { filename_format = 'original' } = options;
const headers = [
'filename',
'original_filename',
'rating',
'rating_count',
'likes',
'favorites',
'comments',
'category',
'width',
'height',
'file_size',
'created_at'
];
const rows = photos.map(photo => [
filename_format === 'original' ? photo.original_filename : photo.filename,
photo.original_filename || '',
photo.average_rating ? photo.average_rating.toFixed(2) : '0.00',
photo.feedback_count || 0,
photo.like_count || 0,
photo.favorite_count || 0,
photo.comment_count || 0,
photo.category_name || '',
photo.width || '',
photo.height || '',
photo.file_size || '',
photo.created_at ? new Date(photo.created_at).toISOString() : ''
]);
const csvContent = [
headers.join(','),
...rows.map(row => row.map(cell => `"${String(cell).replace(/"/g, '""')}"`).join(','))
].join('\n');
return {
type: 'text',
content: csvContent,
filename: `photo_export_${Date.now()}.csv`,
contentType: 'text/csv'
};
}
/**
* Export as XMP sidecar files in a ZIP archive
*/
async exportAsXmpZip(photos, options = {}) {
const { filename_format = 'original' } = options;
const archive = archiver('zip', { zlib: { level: 9 } });
const passthrough = new PassThrough();
archive.pipe(passthrough);
for (const photo of photos) {
const baseFilename = filename_format === 'original'
? photo.original_filename
: photo.filename;
const xmpFilename = this.xmpGenerator.getXmpFilename(baseFilename);
const xmpContent = this.xmpGenerator.generateXmp(photo, options);
archive.append(xmpContent, { name: xmpFilename });
}
archive.finalize();
return {
type: 'stream',
stream: passthrough,
filename: `xmp_export_${Date.now()}.zip`,
contentType: 'application/zip'
};
}
/**
* Export as JSON metadata
*/
async exportAsJson(photos, eventId, options = {}) {
// Get event info
const event = await db('events')
.where('id', eventId)
.select('event_name', 'event_date', 'slug')
.first();
const exportData = {
export_info: {
event_name: event?.event_name || 'Unknown Event',
event_date: event?.event_date,
event_slug: event?.slug,
exported_at: new Date().toISOString(),
total_photos: photos.length
},
photos: photos.map(photo => ({
id: photo.id,
filename: photo.filename,
original_filename: photo.original_filename,
category: photo.category_name || null,
rating: {
average: photo.average_rating ? parseFloat(photo.average_rating.toFixed(2)) : 0,
count: photo.feedback_count || 0
},
likes: photo.like_count || 0,
favorites: photo.favorite_count || 0,
comments: photo.comment_count || 0,
dimensions: {
width: photo.width,
height: photo.height
},
file_size: photo.file_size,
created_at: photo.created_at
}))
};
return {
type: 'text',
content: JSON.stringify(exportData, null, 2),
filename: `photo_metadata_${Date.now()}.json`,
contentType: 'application/json'
};
}
/**
* Get export format display names
*/
static getFormatOptions() {
return [
{ value: 'txt', label: 'Filename List (TXT)', description: 'Simple text list of filenames' },
{ value: 'csv', label: 'Filename List (CSV)', description: 'Spreadsheet with metadata' },
{ value: 'xmp', label: 'XMP Sidecar Files (ZIP)', description: 'For Lightroom/Bridge/Capture One' },
{ value: 'json', label: 'Metadata (JSON)', description: 'Structured data for automation' }
];
}
}
module.exports = { PhotoExportService };
+165
View File
@@ -0,0 +1,165 @@
/**
* XMP Sidecar File Generator
* Generates Adobe XMP metadata files for photos with guest feedback
*/
class XmpGenerator {
/**
* Generate XMP sidecar content for a photo
* @param {Object} photo - Photo object with feedback data
* @param {Object} options - Generation options
* @returns {string} XMP file content
*/
generateXmp(photo, options = {}) {
const {
include_rating = true,
include_label = true,
include_description = true,
include_keywords = true
} = options;
const rating = include_rating ? this.mapRating(photo.average_rating) : 0;
const label = include_label ? this.mapLabel(photo.average_rating) : null;
const descriptionXml = include_description ? this.generateDescription(photo) : '';
const keywordsXml = include_keywords ? this.generateKeywords(photo) : '';
return `<?xml version="1.0" encoding="UTF-8"?>
<x:xmpmeta xmlns:x="adobe:ns:meta/" x:xmptk="PicPeak Export">
<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
<rdf:Description rdf:about=""
xmlns:xmp="http://ns.adobe.com/xap/1.0/"
xmlns:xmpMM="http://ns.adobe.com/xap/1.0/mm/"
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:photoshop="http://ns.adobe.com/photoshop/1.0/"
xmlns:Iptc4xmpCore="http://iptc.org/std/Iptc4xmpCore/1.0/xmlns/"
xmp:Rating="${rating}"${label ? `
xmp:Label="${label}"` : ''}>
${descriptionXml}
${keywordsXml}
</rdf:Description>
</rdf:RDF>
</x:xmpmeta>`;
}
/**
* Map PicPeak average rating to XMP 1-5 rating
* @param {number} avgRating - Average rating (0-5, decimal)
* @returns {number} XMP rating (0-5, integer)
*/
mapRating(avgRating) {
if (!avgRating || avgRating === 0) return 0;
if (avgRating >= 4.5) return 5;
if (avgRating >= 3.5) return 4;
if (avgRating >= 2.5) return 3;
if (avgRating >= 1.5) return 2;
return 1;
}
/**
* Map PicPeak rating to XMP color label
* @param {number} avgRating - Average rating
* @returns {string|null} XMP label color
*/
mapLabel(avgRating) {
if (!avgRating || avgRating === 0) return null;
if (avgRating >= 4.5) return 'Red'; // Top picks
if (avgRating >= 3.5) return 'Yellow'; // Good
if (avgRating >= 2.5) return 'Green'; // Average
if (avgRating >= 1.5) return 'Blue'; // Below average
return 'Purple'; // Low
}
/**
* Generate XMP description element
* @param {Object} photo - Photo object
* @returns {string} Description XML
*/
generateDescription(photo) {
const rating = photo.average_rating ? photo.average_rating.toFixed(1) : '0';
const likes = photo.like_count || 0;
const favorites = photo.favorite_count || 0;
const desc = `PicPeak Guest Feedback: ${rating} stars, ${likes} likes, ${favorites} favorites`;
return `<dc:description>
<rdf:Alt>
<rdf:li xml:lang="x-default">${this.escapeXml(desc)}</rdf:li>
</rdf:Alt>
</dc:description>`;
}
/**
* Generate XMP keywords element
* @param {Object} photo - Photo object
* @returns {string} Keywords XML
*/
generateKeywords(photo) {
const keywords = ['picpeak-export'];
if (photo.average_rating >= 4) {
keywords.push('guest-pick');
}
if (photo.average_rating >= 4.5) {
keywords.push('top-rated');
}
if (photo.like_count >= 5) {
keywords.push('popular');
}
if (photo.favorite_count > 0) {
keywords.push('favorited');
}
if (photo.category_name) {
keywords.push(this.sanitizeKeyword(photo.category_name));
}
return `<dc:subject>
<rdf:Bag>
${keywords.map(k => `<rdf:li>${this.escapeXml(k)}</rdf:li>`).join('\n ')}
</rdf:Bag>
</dc:subject>`;
}
/**
* Escape special XML characters
* @param {string} str - Input string
* @returns {string} Escaped string
*/
escapeXml(str) {
if (!str) return '';
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}
/**
* Sanitize keyword for XMP
* @param {string} keyword - Raw keyword
* @returns {string} Sanitized keyword
*/
sanitizeKeyword(keyword) {
return keyword
.toLowerCase()
.replace(/[^a-z0-9-]/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '');
}
/**
* Get XMP filename from photo filename
* @param {string} photoFilename - Photo filename
* @returns {string} XMP filename
*/
getXmpFilename(photoFilename) {
return photoFilename.replace(/\.[^.]+$/, '.xmp');
}
}
module.exports = { XmpGenerator };