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:
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* Migration: Add slug_redirects table for event rename feature
|
||||
* This table stores old slugs that should redirect to new slugs
|
||||
*/
|
||||
|
||||
exports.up = function(knex) {
|
||||
return knex.schema.createTable('slug_redirects', (table) => {
|
||||
table.increments('id').primary();
|
||||
table.string('old_slug', 255).notNullable().unique();
|
||||
table.string('new_slug', 255).notNullable();
|
||||
table.integer('event_id').references('id').inTable('events').onDelete('CASCADE');
|
||||
table.timestamp('created_at').defaultTo(knex.fn.now());
|
||||
|
||||
// Index for fast lookup
|
||||
table.index('old_slug');
|
||||
});
|
||||
};
|
||||
|
||||
exports.down = function(knex) {
|
||||
return knex.schema.dropTableIfExists('slug_redirects');
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Migration: Add optional event fields settings
|
||||
* These settings control whether customer name, customer email, and admin email
|
||||
* are required when creating new events.
|
||||
*/
|
||||
|
||||
exports.up = async function(knex) {
|
||||
const settings = [
|
||||
{ setting_key: 'event_require_customer_name', setting_value: JSON.stringify(true), setting_type: 'boolean' },
|
||||
{ setting_key: 'event_require_customer_email', setting_value: JSON.stringify(true), setting_type: 'boolean' },
|
||||
{ setting_key: 'event_require_admin_email', setting_value: JSON.stringify(true), setting_type: 'boolean' }
|
||||
];
|
||||
|
||||
for (const setting of settings) {
|
||||
const exists = await knex('app_settings').where('setting_key', setting.setting_key).first();
|
||||
if (!exists) {
|
||||
await knex('app_settings').insert({
|
||||
...setting,
|
||||
updated_at: knex.fn.now()
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
exports.down = function(knex) {
|
||||
return knex('app_settings')
|
||||
.whereIn('setting_key', [
|
||||
'event_require_customer_name',
|
||||
'event_require_customer_email',
|
||||
'event_require_admin_email'
|
||||
])
|
||||
.del();
|
||||
};
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* Migration: Add indexes for photo filtering performance
|
||||
* These indexes optimize queries that filter by feedback metrics
|
||||
*/
|
||||
|
||||
exports.up = async function(knex) {
|
||||
// Add comment_count column if it doesn't exist
|
||||
const hasCommentCount = await knex.schema.hasColumn('photos', 'comment_count');
|
||||
if (!hasCommentCount) {
|
||||
await knex.schema.alterTable('photos', (table) => {
|
||||
table.integer('comment_count').defaultTo(0);
|
||||
});
|
||||
}
|
||||
|
||||
// Add indexes for common filter queries
|
||||
// Note: PostgreSQL supports partial indexes, SQLite does not
|
||||
const client = knex.client.config.client;
|
||||
|
||||
if (client === 'pg' || client === 'postgresql') {
|
||||
// Partial indexes for PostgreSQL
|
||||
await knex.raw(`
|
||||
CREATE INDEX IF NOT EXISTS idx_photos_rating_filter
|
||||
ON photos(event_id, average_rating)
|
||||
WHERE average_rating > 0
|
||||
`);
|
||||
|
||||
await knex.raw(`
|
||||
CREATE INDEX IF NOT EXISTS idx_photos_likes_filter
|
||||
ON photos(event_id, like_count)
|
||||
WHERE like_count > 0
|
||||
`);
|
||||
|
||||
await knex.raw(`
|
||||
CREATE INDEX IF NOT EXISTS idx_photos_favorites_filter
|
||||
ON photos(event_id, favorite_count)
|
||||
WHERE favorite_count > 0
|
||||
`);
|
||||
|
||||
await knex.raw(`
|
||||
CREATE INDEX IF NOT EXISTS idx_photos_comments_filter
|
||||
ON photos(event_id, comment_count)
|
||||
WHERE comment_count > 0
|
||||
`);
|
||||
} else {
|
||||
// Regular indexes for SQLite
|
||||
await knex.raw(`
|
||||
CREATE INDEX IF NOT EXISTS idx_photos_rating_filter
|
||||
ON photos(event_id, average_rating)
|
||||
`);
|
||||
|
||||
await knex.raw(`
|
||||
CREATE INDEX IF NOT EXISTS idx_photos_likes_filter
|
||||
ON photos(event_id, like_count)
|
||||
`);
|
||||
|
||||
await knex.raw(`
|
||||
CREATE INDEX IF NOT EXISTS idx_photos_favorites_filter
|
||||
ON photos(event_id, favorite_count)
|
||||
`);
|
||||
|
||||
await knex.raw(`
|
||||
CREATE INDEX IF NOT EXISTS idx_photos_comments_filter
|
||||
ON photos(event_id, comment_count)
|
||||
`);
|
||||
}
|
||||
|
||||
// Create export_jobs table for tracking large exports
|
||||
const hasExportJobs = await knex.schema.hasTable('export_jobs');
|
||||
if (!hasExportJobs) {
|
||||
await knex.schema.createTable('export_jobs', (table) => {
|
||||
table.increments('id').primary();
|
||||
table.string('job_id', 50).unique().notNullable();
|
||||
table.integer('event_id').references('id').inTable('events').onDelete('CASCADE');
|
||||
table.integer('admin_user_id').references('id').inTable('admin_users').onDelete('SET NULL');
|
||||
table.string('format', 20).notNullable();
|
||||
table.string('status', 20).defaultTo('pending');
|
||||
table.integer('progress').defaultTo(0);
|
||||
table.integer('total_photos');
|
||||
table.json('options');
|
||||
table.string('file_path', 500);
|
||||
table.bigInteger('file_size');
|
||||
table.text('error_message');
|
||||
table.timestamp('created_at').defaultTo(knex.fn.now());
|
||||
table.timestamp('completed_at');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
exports.down = async function(knex) {
|
||||
// Drop indexes
|
||||
await knex.raw('DROP INDEX IF EXISTS idx_photos_rating_filter');
|
||||
await knex.raw('DROP INDEX IF EXISTS idx_photos_likes_filter');
|
||||
await knex.raw('DROP INDEX IF EXISTS idx_photos_favorites_filter');
|
||||
await knex.raw('DROP INDEX IF EXISTS idx_photos_comments_filter');
|
||||
|
||||
// Drop export_jobs table
|
||||
await knex.schema.dropTableIfExists('export_jobs');
|
||||
|
||||
// Note: We don't remove comment_count column as it might have data
|
||||
};
|
||||
@@ -0,0 +1,192 @@
|
||||
/**
|
||||
* Migration: Add CSS Templates feature
|
||||
* Creates css_templates table and adds css_template_id to events table
|
||||
*/
|
||||
|
||||
// Default CSS template content
|
||||
const DEFAULT_CSS_TEMPLATE = `/*
|
||||
* PicPeak Custom CSS Template: Elegant Dark
|
||||
*
|
||||
* Available CSS Custom Properties:
|
||||
* --gallery-bg: Background color
|
||||
* --gallery-text: Primary text color
|
||||
* --gallery-accent: Accent/highlight color
|
||||
* --gallery-border: Border color
|
||||
* --gallery-shadow: Box shadow value
|
||||
* --gallery-radius: Border radius value
|
||||
* --gallery-spacing: Base spacing unit
|
||||
*/
|
||||
|
||||
/* ===== Base Theme Variables ===== */
|
||||
.gallery-page {
|
||||
--gallery-bg: #1a1a2e;
|
||||
--gallery-bg-secondary: #16213e;
|
||||
--gallery-text: #eaeaea;
|
||||
--gallery-text-muted: #8b8b9a;
|
||||
--gallery-accent: #e94560;
|
||||
--gallery-accent-hover: #ff6b6b;
|
||||
--gallery-border: #2d2d44;
|
||||
--gallery-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
|
||||
--gallery-radius: 12px;
|
||||
--gallery-spacing: 16px;
|
||||
}
|
||||
|
||||
/* ===== Page Background ===== */
|
||||
.gallery-page {
|
||||
background: linear-gradient(135deg, var(--gallery-bg) 0%, var(--gallery-bg-secondary) 100%);
|
||||
min-height: 100vh;
|
||||
color: var(--gallery-text);
|
||||
}
|
||||
|
||||
/* ===== Gallery Header ===== */
|
||||
.gallery-header {
|
||||
background: rgba(22, 33, 62, 0.8);
|
||||
backdrop-filter: blur(10px);
|
||||
border-bottom: 1px solid var(--gallery-border);
|
||||
padding: calc(var(--gallery-spacing) * 2);
|
||||
}
|
||||
|
||||
.gallery-title {
|
||||
color: var(--gallery-text);
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
/* ===== Photo Grid ===== */
|
||||
.photo-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: var(--gallery-spacing);
|
||||
padding: calc(var(--gallery-spacing) * 2);
|
||||
}
|
||||
|
||||
/* ===== Photo Cards ===== */
|
||||
.photo-card {
|
||||
background: var(--gallery-bg-secondary);
|
||||
border-radius: var(--gallery-radius);
|
||||
overflow: hidden;
|
||||
transition: transform 0.3s ease, box-shadow 0.3s ease;
|
||||
border: 1px solid var(--gallery-border);
|
||||
}
|
||||
|
||||
.photo-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: var(--gallery-shadow);
|
||||
}
|
||||
|
||||
.photo-card img {
|
||||
width: 100%;
|
||||
height: 200px;
|
||||
object-fit: cover;
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.photo-card:hover img {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
/* ===== Buttons ===== */
|
||||
.gallery-btn {
|
||||
background: var(--gallery-accent);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: calc(var(--gallery-radius) / 2);
|
||||
padding: calc(var(--gallery-spacing) / 2) var(--gallery-spacing);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s ease, transform 0.2s ease;
|
||||
}
|
||||
|
||||
.gallery-btn:hover {
|
||||
background: var(--gallery-accent-hover);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
/* ===== Lightbox ===== */
|
||||
.lightbox-overlay {
|
||||
background: rgba(10, 10, 20, 0.95);
|
||||
backdrop-filter: blur(20px);
|
||||
}
|
||||
|
||||
/* ===== Responsive Adjustments ===== */
|
||||
@media (max-width: 768px) {
|
||||
.gallery-page {
|
||||
--gallery-spacing: 12px;
|
||||
}
|
||||
|
||||
.photo-grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
||||
}
|
||||
|
||||
.gallery-title {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
}`;
|
||||
|
||||
exports.up = async function(knex) {
|
||||
// Create css_templates table
|
||||
const hasTable = await knex.schema.hasTable('css_templates');
|
||||
if (!hasTable) {
|
||||
await knex.schema.createTable('css_templates', (table) => {
|
||||
table.increments('id').primary();
|
||||
table.integer('slot_number').notNullable();
|
||||
table.string('name', 50).notNullable().defaultTo('Untitled');
|
||||
table.text('css_content').notNullable().defaultTo('');
|
||||
table.boolean('is_enabled').notNullable().defaultTo(false);
|
||||
table.boolean('is_default').notNullable().defaultTo(false);
|
||||
table.timestamp('created_at').defaultTo(knex.fn.now());
|
||||
table.timestamp('updated_at').defaultTo(knex.fn.now());
|
||||
table.unique('slot_number');
|
||||
});
|
||||
|
||||
// Insert default templates
|
||||
await knex('css_templates').insert([
|
||||
{
|
||||
slot_number: 1,
|
||||
name: 'Elegant Dark',
|
||||
css_content: DEFAULT_CSS_TEMPLATE,
|
||||
is_enabled: true,
|
||||
is_default: true
|
||||
},
|
||||
{
|
||||
slot_number: 2,
|
||||
name: 'Untitled',
|
||||
css_content: '',
|
||||
is_enabled: false,
|
||||
is_default: false
|
||||
},
|
||||
{
|
||||
slot_number: 3,
|
||||
name: 'Untitled',
|
||||
css_content: '',
|
||||
is_enabled: false,
|
||||
is_default: false
|
||||
}
|
||||
]);
|
||||
}
|
||||
|
||||
// Add css_template_id to events table
|
||||
const hasColumn = await knex.schema.hasColumn('events', 'css_template_id');
|
||||
if (!hasColumn) {
|
||||
await knex.schema.alterTable('events', (table) => {
|
||||
table.integer('css_template_id').references('id').inTable('css_templates').onDelete('SET NULL');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
exports.down = async function(knex) {
|
||||
// Remove css_template_id from events table
|
||||
const hasColumn = await knex.schema.hasColumn('events', 'css_template_id');
|
||||
if (hasColumn) {
|
||||
await knex.schema.alterTable('events', (table) => {
|
||||
table.dropColumn('css_template_id');
|
||||
});
|
||||
}
|
||||
|
||||
// Drop css_templates table
|
||||
await knex.schema.dropTableIfExists('css_templates');
|
||||
};
|
||||
|
||||
// Export default template for use in reset functionality
|
||||
module.exports.DEFAULT_CSS_TEMPLATE = DEFAULT_CSS_TEMPLATE;
|
||||
@@ -432,6 +432,9 @@ app.use('/api/admin/feedback', require('./src/routes/adminFeedback'));
|
||||
app.use('/api/admin/image-security', require('./src/routes/adminImageSecurity'));
|
||||
app.use('/api/admin/thumbnails', require('./src/routes/adminThumbnails'));
|
||||
app.use('/api/admin/photos', require('./src/routes/adminPhotos'));
|
||||
app.use('/api/admin/photo-export', require('./src/routes/adminPhotoExport'));
|
||||
app.use('/api/admin/css-templates', require('./src/routes/adminCssTemplates'));
|
||||
app.use('/api/admin/events', require('./src/routes/adminEventRename'));
|
||||
app.use('/api/public/settings', require('./src/routes/publicSettings'));
|
||||
app.use('/api/public', require('./src/routes/publicCMS'));
|
||||
app.use('/api/images', require('./src/routes/protectedImages'));
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
@@ -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 };
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 };
|
||||
@@ -1,3 +1,43 @@
|
||||
/**
|
||||
* CSS Sanitizer
|
||||
* Sanitizes user-provided CSS to prevent security vulnerabilities
|
||||
*/
|
||||
|
||||
// Patterns that should be blocked for security
|
||||
const FORBIDDEN_PATTERNS = [
|
||||
// JavaScript execution
|
||||
/expression\s*\(/gi,
|
||||
/javascript:/gi,
|
||||
/behavior\s*:/gi,
|
||||
/-moz-binding/gi,
|
||||
/vbscript:/gi,
|
||||
|
||||
// External resources (potential data exfiltration)
|
||||
/@import/gi,
|
||||
|
||||
// Dangerous at-rules
|
||||
/@charset/gi,
|
||||
/@namespace/gi,
|
||||
|
||||
// IE-specific exploits
|
||||
/\\0/g, // Null byte
|
||||
/\\9/g, // IE CSS hack
|
||||
|
||||
// Script injection attempts
|
||||
/<script/gi,
|
||||
/<\/script/gi,
|
||||
/on\w+\s*=/gi, // onclick=, onload=, etc.
|
||||
];
|
||||
|
||||
// Pattern for external URLs (block external, allow data: for images)
|
||||
const EXTERNAL_URL_PATTERN = /url\s*\(\s*["']?(?!data:image)/gi;
|
||||
|
||||
// Maximum CSS size in bytes (100KB)
|
||||
const MAX_CSS_SIZE = 100 * 1024;
|
||||
|
||||
/**
|
||||
* Basic CSS sanitization (original function, kept for compatibility)
|
||||
*/
|
||||
function sanitizeCss(css) {
|
||||
if (!css || typeof css !== 'string') {
|
||||
return '';
|
||||
@@ -27,6 +67,105 @@ function sanitizeCss(css) {
|
||||
return sanitized.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Enhanced CSS sanitization with warnings
|
||||
* @param {string} cssContent - Raw CSS content
|
||||
* @returns {Object} - { sanitized: string, warnings: string[] }
|
||||
*/
|
||||
function sanitizeCSS(cssContent) {
|
||||
if (!cssContent || typeof cssContent !== 'string') {
|
||||
return { sanitized: '', warnings: [] };
|
||||
}
|
||||
|
||||
const warnings = [];
|
||||
let sanitized = cssContent;
|
||||
|
||||
// Check size
|
||||
if (sanitized.length > MAX_CSS_SIZE) {
|
||||
warnings.push(`CSS exceeds maximum size of ${MAX_CSS_SIZE / 1024}KB`);
|
||||
sanitized = sanitized.substring(0, MAX_CSS_SIZE);
|
||||
}
|
||||
|
||||
// Remove forbidden patterns
|
||||
for (const pattern of FORBIDDEN_PATTERNS) {
|
||||
const patternStr = pattern.toString();
|
||||
// Reset lastIndex for global patterns
|
||||
pattern.lastIndex = 0;
|
||||
if (pattern.test(sanitized)) {
|
||||
const patternName = patternStr.replace(/\/[gi]*/g, '').substring(0, 30);
|
||||
warnings.push(`Blocked potentially unsafe pattern: ${patternName}`);
|
||||
pattern.lastIndex = 0;
|
||||
sanitized = sanitized.replace(pattern, '/* BLOCKED */');
|
||||
}
|
||||
}
|
||||
|
||||
// Block external URLs (only allow data: URIs for images)
|
||||
EXTERNAL_URL_PATTERN.lastIndex = 0;
|
||||
if (EXTERNAL_URL_PATTERN.test(sanitized)) {
|
||||
warnings.push('Blocked external URL references. Only data: URIs are allowed for images.');
|
||||
EXTERNAL_URL_PATTERN.lastIndex = 0;
|
||||
sanitized = sanitized.replace(EXTERNAL_URL_PATTERN, '/* BLOCKED URL */ url(');
|
||||
}
|
||||
|
||||
// Remove HTML comments that might be used for injection
|
||||
sanitized = sanitized.replace(/<!--[\s\S]*?-->/g, '');
|
||||
|
||||
// Remove control characters
|
||||
sanitized = sanitized.replace(/[\u0000-\u001F\u007F]/g, '');
|
||||
|
||||
// Remove any remaining script-like content
|
||||
sanitized = sanitized.replace(/<[^>]*>/g, '/* BLOCKED TAG */');
|
||||
|
||||
return { sanitized: sanitized.trim(), warnings };
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate CSS syntax (basic check)
|
||||
* @param {string} cssContent - CSS content to validate
|
||||
* @returns {Object} - { valid: boolean, error?: string }
|
||||
*/
|
||||
function validateCSS(cssContent) {
|
||||
if (!cssContent || cssContent.trim() === '') {
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
// Basic bracket matching
|
||||
const openBraces = (cssContent.match(/{/g) || []).length;
|
||||
const closeBraces = (cssContent.match(/}/g) || []).length;
|
||||
|
||||
if (openBraces !== closeBraces) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `Mismatched braces: ${openBraces} opening, ${closeBraces} closing`
|
||||
};
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope CSS to gallery page
|
||||
* @param {string} cssContent - CSS content
|
||||
* @returns {string} - Scoped CSS
|
||||
*/
|
||||
function scopeToGalleryPage(cssContent) {
|
||||
if (!cssContent || cssContent.trim() === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
// If the CSS already uses .gallery-page, return as-is
|
||||
if (cssContent.includes('.gallery-page')) {
|
||||
return cssContent;
|
||||
}
|
||||
|
||||
// Simple scoping: wrap entire content in .gallery-page
|
||||
return `.gallery-page {\n${cssContent}\n}`;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
sanitizeCss,
|
||||
sanitizeCSS,
|
||||
validateCSS,
|
||||
scopeToGalleryPage,
|
||||
MAX_CSS_SIZE
|
||||
};
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* Photo Filter Query Builder
|
||||
* Builds Knex queries for filtering photos by feedback metrics
|
||||
*/
|
||||
|
||||
class PhotoFilterBuilder {
|
||||
constructor(queryBuilder, eventId) {
|
||||
this.query = queryBuilder;
|
||||
this.eventId = eventId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply all filters from a filter object
|
||||
*/
|
||||
applyFilters(filters = {}) {
|
||||
const {
|
||||
min_rating,
|
||||
max_rating,
|
||||
has_likes,
|
||||
min_likes,
|
||||
has_favorites,
|
||||
min_favorites,
|
||||
has_comments,
|
||||
category_id,
|
||||
logic = 'AND'
|
||||
} = filters;
|
||||
|
||||
// Always filter by event
|
||||
this.query.where('photos.event_id', this.eventId);
|
||||
|
||||
// Build conditions array
|
||||
const conditions = [];
|
||||
|
||||
if (min_rating !== undefined && min_rating !== null) {
|
||||
conditions.push(builder => builder.where('photos.average_rating', '>=', min_rating));
|
||||
}
|
||||
|
||||
if (max_rating !== undefined && max_rating !== null) {
|
||||
conditions.push(builder => builder.where('photos.average_rating', '<=', max_rating));
|
||||
}
|
||||
|
||||
if (has_likes === true || has_likes === 'true') {
|
||||
conditions.push(builder => builder.where('photos.like_count', '>', 0));
|
||||
}
|
||||
|
||||
if (min_likes !== undefined && min_likes !== null) {
|
||||
conditions.push(builder => builder.where('photos.like_count', '>=', min_likes));
|
||||
}
|
||||
|
||||
if (has_favorites === true || has_favorites === 'true') {
|
||||
conditions.push(builder => builder.where('photos.favorite_count', '>', 0));
|
||||
}
|
||||
|
||||
if (min_favorites !== undefined && min_favorites !== null) {
|
||||
conditions.push(builder => builder.where('photos.favorite_count', '>=', min_favorites));
|
||||
}
|
||||
|
||||
if (has_comments === true || has_comments === 'true') {
|
||||
conditions.push(builder => builder.where('photos.comment_count', '>', 0));
|
||||
}
|
||||
|
||||
if (category_id) {
|
||||
conditions.push(builder => builder.where('photos.category_id', category_id));
|
||||
}
|
||||
|
||||
// Apply conditions with AND/OR logic
|
||||
if (conditions.length > 0) {
|
||||
if (logic === 'OR') {
|
||||
this.query.where(builder => {
|
||||
conditions.forEach((condition, index) => {
|
||||
if (index === 0) {
|
||||
condition(builder);
|
||||
} else {
|
||||
builder.orWhere(subBuilder => condition(subBuilder));
|
||||
}
|
||||
});
|
||||
});
|
||||
} else {
|
||||
// AND logic (default)
|
||||
conditions.forEach(condition => {
|
||||
this.query.where(builder => condition(builder));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply sorting
|
||||
*/
|
||||
applySorting(sort = 'date', order = 'desc') {
|
||||
const sortMap = {
|
||||
rating: 'photos.average_rating',
|
||||
likes: 'photos.like_count',
|
||||
favorites: 'photos.favorite_count',
|
||||
date: 'photos.created_at',
|
||||
filename: 'photos.filename'
|
||||
};
|
||||
|
||||
const sortColumn = sortMap[sort] || sortMap.date;
|
||||
this.query.orderBy(sortColumn, order === 'asc' ? 'asc' : 'desc');
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply pagination
|
||||
*/
|
||||
applyPagination(page = 1, limit = 50) {
|
||||
const offset = (page - 1) * limit;
|
||||
this.query.limit(limit).offset(offset);
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the built query
|
||||
*/
|
||||
getQuery() {
|
||||
return this.query;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a count query for the same filters
|
||||
*/
|
||||
static buildCountQuery(db, eventId, filters = {}) {
|
||||
const builder = new PhotoFilterBuilder(
|
||||
db('photos').count('* as count'),
|
||||
eventId
|
||||
);
|
||||
builder.applyFilters(filters);
|
||||
return builder.getQuery();
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a summary query for feedback counts
|
||||
*/
|
||||
static async getSummary(db, eventId) {
|
||||
const result = await db('photos')
|
||||
.where('event_id', eventId)
|
||||
.select(
|
||||
db.raw('COUNT(*) as total'),
|
||||
db.raw('COUNT(CASE WHEN average_rating > 0 THEN 1 END) as with_ratings'),
|
||||
db.raw('COUNT(CASE WHEN like_count > 0 THEN 1 END) as with_likes'),
|
||||
db.raw('COUNT(CASE WHEN favorite_count > 0 THEN 1 END) as with_favorites'),
|
||||
db.raw('COUNT(CASE WHEN comment_count > 0 THEN 1 END) as with_comments')
|
||||
)
|
||||
.first();
|
||||
|
||||
return {
|
||||
total: parseInt(result.total) || 0,
|
||||
withRatings: parseInt(result.with_ratings) || 0,
|
||||
withLikes: parseInt(result.with_likes) || 0,
|
||||
withFavorites: parseInt(result.with_favorites) || 0,
|
||||
withComments: parseInt(result.with_comments) || 0
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { PhotoFilterBuilder };
|
||||
Reference in New Issue
Block a user