diff --git a/.gitignore b/.gitignore index 46f90243..7a1c752e 100644 --- a/.gitignore +++ b/.gitignore @@ -94,3 +94,11 @@ backup/ # Local SQLite files in backend backend/*.sqlite* + +# Test files and artifacts +test-logo*.jpg +test-logo*.png +test-results/ + +# Development docker compose +docker-compose.dev.yml diff --git a/backend/migrations/core/061_add_event_types_table.js b/backend/migrations/core/061_add_event_types_table.js new file mode 100644 index 00000000..08819893 --- /dev/null +++ b/backend/migrations/core/061_add_event_types_table.js @@ -0,0 +1,107 @@ +/** + * Migration: Add event_types table + * + * Creates a customizable event types system to replace hardcoded event types. + * This allows users to: + * - Rename existing event types (wedding, birthday, corporate, other) + * - Create custom event types with custom slug prefixes + * - Associate default theme presets with event types + * + * Backward compatible: Existing events keep their event_type strings. + * New events can use either legacy strings or custom event type slug_prefix. + */ + +const { createTableIfNotExists, insertIfNotExists } = require('../helpers'); + +exports.up = async function(knex) { + console.log('Creating event_types table...'); + + // Create event_types table + const hasEventTypesTable = await knex.schema.hasTable('event_types'); + if (!hasEventTypesTable) { + await knex.schema.createTable('event_types', (table) => { + table.increments('id').primary(); + table.string('name', 100).notNullable(); // Display name: "Family Shoot" + table.string('slug_prefix', 50).unique().notNullable(); // URL prefix: "family" + table.string('emoji', 10); // Icon emoji: "👨‍👩‍👧" + table.string('theme_preset', 50); // Default theme: "elegantWedding" + table.text('theme_config'); // Custom theme JSON overrides (optional) + table.integer('display_order').defaultTo(0); // Sorting in dropdowns + table.boolean('is_system').defaultTo(false); // Protect default types + table.boolean('is_active').defaultTo(true); // Allow hiding types + table.timestamp('created_at').defaultTo(knex.fn.now()); + table.timestamp('updated_at').defaultTo(knex.fn.now()); + + // Indexes for performance + table.index('slug_prefix'); + table.index('display_order'); + table.index('is_active'); + }); + console.log('event_types table created'); + } else { + console.log('event_types table already exists, skipping creation'); + } + + // Seed default event types (matching current hardcoded values) + const existingTypes = await knex('event_types').select('slug_prefix'); + const existingSlugs = existingTypes.map(t => t.slug_prefix); + + const defaultTypes = [ + { + name: 'Wedding', + slug_prefix: 'wedding', + emoji: '💒', + theme_preset: 'elegantWedding', + display_order: 1, + is_system: true, + is_active: true + }, + { + name: 'Birthday', + slug_prefix: 'birthday', + emoji: '🎂', + theme_preset: 'birthdayFun', + display_order: 2, + is_system: true, + is_active: true + }, + { + name: 'Corporate', + slug_prefix: 'corporate', + emoji: '🏢', + theme_preset: 'corporateTimeline', + display_order: 3, + is_system: true, + is_active: true + }, + { + name: 'Other', + slug_prefix: 'other', + emoji: '📸', + theme_preset: 'default', + display_order: 4, + is_system: true, + is_active: true + } + ]; + + const typesToInsert = defaultTypes.filter(type => !existingSlugs.includes(type.slug_prefix)); + + if (typesToInsert.length > 0) { + await knex('event_types').insert(typesToInsert); + console.log(`Inserted ${typesToInsert.length} default event types`); + } else { + console.log('Default event types already exist, skipping seed'); + } + + console.log('Migration 061_add_event_types_table completed successfully'); +}; + +exports.down = async function(knex) { + console.log('Rolling back event_types table...'); + + // Drop the table (data will be lost) + await knex.schema.dropTableIfExists('event_types'); + + console.log('event_types table dropped'); +}; diff --git a/backend/migrations/core/062_add_hero_logo_settings.js b/backend/migrations/core/062_add_hero_logo_settings.js new file mode 100644 index 00000000..1c8f651e --- /dev/null +++ b/backend/migrations/core/062_add_hero_logo_settings.js @@ -0,0 +1,70 @@ +/** + * Migration: Add hero logo customization settings to events table + * + * Allows per-event customization of the hero gallery logo: + * - hero_logo_visible: Show/hide the logo overlay + * - hero_logo_size: Logo size (small, medium, large, xlarge) + * - hero_logo_position: Logo position (top, center, bottom) + * + * Addresses GitHub Issue #138: Add Option to customize the Hero gallery layout + */ + +exports.up = async function (knex) { + console.log('Adding hero logo settings to events table...'); + + // Add hero_logo_visible column + const hasVisibleColumn = await knex.schema.hasColumn('events', 'hero_logo_visible'); + if (!hasVisibleColumn) { + await knex.schema.table('events', (table) => { + table.boolean('hero_logo_visible').notNullable().defaultTo(true); + }); + console.log('Added hero_logo_visible column'); + } + + // Add hero_logo_size column + const hasSizeColumn = await knex.schema.hasColumn('events', 'hero_logo_size'); + if (!hasSizeColumn) { + await knex.schema.table('events', (table) => { + table.string('hero_logo_size', 20).notNullable().defaultTo('medium'); + }); + console.log('Added hero_logo_size column'); + } + + // Add hero_logo_position column + const hasPositionColumn = await knex.schema.hasColumn('events', 'hero_logo_position'); + if (!hasPositionColumn) { + await knex.schema.table('events', (table) => { + table.string('hero_logo_position', 20).notNullable().defaultTo('top'); + }); + console.log('Added hero_logo_position column'); + } + + console.log('Migration 062_add_hero_logo_settings completed successfully'); +}; + +exports.down = async function (knex) { + console.log('Rolling back hero logo settings...'); + + const hasVisibleColumn = await knex.schema.hasColumn('events', 'hero_logo_visible'); + if (hasVisibleColumn) { + await knex.schema.table('events', (table) => { + table.dropColumn('hero_logo_visible'); + }); + } + + const hasSizeColumn = await knex.schema.hasColumn('events', 'hero_logo_size'); + if (hasSizeColumn) { + await knex.schema.table('events', (table) => { + table.dropColumn('hero_logo_size'); + }); + } + + const hasPositionColumn = await knex.schema.hasColumn('events', 'hero_logo_position'); + if (hasPositionColumn) { + await knex.schema.table('events', (table) => { + table.dropColumn('hero_logo_position'); + }); + } + + console.log('Hero logo settings columns dropped'); +}; diff --git a/backend/server.js b/backend/server.js index 8a277fb8..f2c3bf59 100644 --- a/backend/server.js +++ b/backend/server.js @@ -437,6 +437,7 @@ 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/admin/users', require('./src/routes/adminUsers')); +app.use('/api/admin/event-types', require('./src/routes/adminEventTypes')); app.use('/api/invite', require('./src/routes/acceptInvite')); app.use('/api/public/settings', require('./src/routes/publicSettings')); app.use('/api/public', require('./src/routes/publicCMS')); diff --git a/backend/src/routes/adminEventTypes.js b/backend/src/routes/adminEventTypes.js new file mode 100644 index 00000000..f18f4286 --- /dev/null +++ b/backend/src/routes/adminEventTypes.js @@ -0,0 +1,258 @@ +/** + * Admin Event Types Routes + * CRUD operations for managing customizable event types + * + * @module routes/adminEventTypes + */ + +const express = require('express'); +const { body, param, validationResult } = require('express-validator'); +const { logActivity } = require('../database/db'); +const { adminAuth } = require('../middleware/auth'); +const { requirePermission } = require('../middleware/permissions'); +const eventTypeService = require('../services/eventTypeService'); +const logger = require('../utils/logger'); + +const router = express.Router(); + +/** + * GET /admin/event-types + * Get all event types (for admin management) + */ +router.get('/', adminAuth, requirePermission('settings.view'), async (req, res) => { + try { + const includeInactive = req.query.includeInactive === 'true'; + const eventTypes = await eventTypeService.getAllEventTypes({ + activeOnly: !includeInactive + }); + + res.json({ eventTypes }); + } catch (error) { + logger.error('Error fetching event types:', { error: error.message }); + res.status(500).json({ error: 'Failed to fetch event types' }); + } +}); + +/** + * GET /admin/event-types/active + * Get only active event types (for dropdowns/selection) + */ +router.get('/active', adminAuth, async (req, res) => { + try { + const eventTypes = await eventTypeService.getActiveEventTypes(); + res.json({ eventTypes }); + } catch (error) { + logger.error('Error fetching active event types:', { error: error.message }); + res.status(500).json({ error: 'Failed to fetch event types' }); + } +}); + +/** + * GET /admin/event-types/:id + * Get a single event type by ID + */ +router.get('/:id', adminAuth, requirePermission('settings.view'), [ + param('id').isInt().withMessage('Invalid event type ID') +], async (req, res) => { + try { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ errors: errors.array() }); + } + + const { id } = req.params; + const eventType = await eventTypeService.getEventTypeById(parseInt(id)); + + if (!eventType) { + return res.status(404).json({ error: 'Event type not found' }); + } + + res.json(eventType); + } catch (error) { + logger.error('Error fetching event type:', { error: error.message }); + res.status(500).json({ error: 'Failed to fetch event type' }); + } +}); + +/** + * POST /admin/event-types + * Create a new event type + */ +router.post('/', adminAuth, requirePermission('settings.edit'), [ + body('name').notEmpty().trim().withMessage('Name is required'), + body('slug_prefix') + .notEmpty() + .trim() + .isLength({ min: 2, max: 50 }) + .matches(/^[a-z0-9-]+$/i) + .withMessage('Slug prefix must be 2-50 characters and contain only letters, numbers, and hyphens'), + body('emoji').optional().trim(), + body('theme_preset').optional().trim(), + body('theme_config').optional(), + body('display_order').optional().isInt({ min: 0 }) +], async (req, res) => { + try { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ errors: errors.array() }); + } + + const { + name, + slug_prefix, + emoji, + theme_preset, + theme_config, + display_order + } = req.body; + + const eventType = await eventTypeService.createEventType({ + name, + slug_prefix, + emoji, + theme_preset, + theme_config, + display_order + }); + + // Log activity + await logActivity('event_type_created', + { name, slug_prefix }, + null, + { type: 'admin', id: req.admin.id, name: req.admin.username } + ); + + res.status(201).json(eventType); + } catch (error) { + logger.error('Error creating event type:', { error: error.message }); + + if (error.code === 'DUPLICATE_SLUG_PREFIX') { + return res.status(400).json({ error: error.message }); + } + + res.status(500).json({ error: 'Failed to create event type' }); + } +}); + +/** + * PUT /admin/event-types/:id + * Update an event type + */ +router.put('/:id', adminAuth, requirePermission('settings.edit'), [ + param('id').isInt().withMessage('Invalid event type ID'), + body('name').optional().notEmpty().trim().withMessage('Name cannot be empty'), + body('slug_prefix') + .optional() + .trim() + .isLength({ min: 2, max: 50 }) + .matches(/^[a-z0-9-]+$/i) + .withMessage('Slug prefix must be 2-50 characters and contain only letters, numbers, and hyphens'), + body('emoji').optional().trim(), + body('theme_preset').optional().trim(), + body('theme_config').optional(), + body('display_order').optional().isInt({ min: 0 }), + body('is_active').optional().isBoolean() +], async (req, res) => { + try { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ errors: errors.array() }); + } + + const { id } = req.params; + const updates = req.body; + + const eventType = await eventTypeService.updateEventType(parseInt(id), updates); + + // Log activity + await logActivity('event_type_updated', + { id, changes: Object.keys(updates) }, + null, + { type: 'admin', id: req.admin.id, name: req.admin.username } + ); + + res.json(eventType); + } catch (error) { + logger.error('Error updating event type:', { error: error.message }); + + if (error.code === 'NOT_FOUND') { + return res.status(404).json({ error: error.message }); + } + if (error.code === 'DUPLICATE_SLUG_PREFIX') { + return res.status(400).json({ error: error.message }); + } + + res.status(500).json({ error: 'Failed to update event type' }); + } +}); + +/** + * DELETE /admin/event-types/:id + * Delete an event type (only non-system types with no events) + */ +router.delete('/:id', adminAuth, requirePermission('settings.edit'), [ + param('id').isInt().withMessage('Invalid event type ID') +], async (req, res) => { + try { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ errors: errors.array() }); + } + + const { id } = req.params; + const result = await eventTypeService.deleteEventType(parseInt(id)); + + // Log activity + await logActivity('event_type_deleted', + { id, name: result.deleted.name, slug_prefix: result.deleted.slug_prefix }, + null, + { type: 'admin', id: req.admin.id, name: req.admin.username } + ); + + res.json({ message: 'Event type deleted successfully' }); + } catch (error) { + logger.error('Error deleting event type:', { error: error.message }); + + if (error.code === 'NOT_FOUND') { + return res.status(404).json({ error: error.message }); + } + if (error.code === 'SYSTEM_TYPE' || error.code === 'IN_USE') { + return res.status(400).json({ error: error.message }); + } + + res.status(500).json({ error: 'Failed to delete event type' }); + } +}); + +/** + * POST /admin/event-types/reorder + * Reorder event types by providing an array of IDs in the desired order + */ +router.post('/reorder', adminAuth, requirePermission('settings.edit'), [ + body('orderedIds').isArray().withMessage('orderedIds must be an array'), + body('orderedIds.*').isInt().withMessage('Each ID must be an integer') +], async (req, res) => { + try { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ errors: errors.array() }); + } + + const { orderedIds } = req.body; + const eventTypes = await eventTypeService.reorderEventTypes(orderedIds); + + // Log activity + await logActivity('event_types_reordered', + { newOrder: orderedIds }, + null, + { type: 'admin', id: req.admin.id, name: req.admin.username } + ); + + res.json({ eventTypes, message: 'Event types reordered successfully' }); + } catch (error) { + logger.error('Error reordering event types:', { error: error.message }); + res.status(500).json({ error: 'Failed to reorder event types' }); + } +}); + +module.exports = router; diff --git a/backend/src/routes/adminEvents-enhanced.js b/backend/src/routes/adminEvents-enhanced.js index 73f528e7..672df8ab 100644 --- a/backend/src/routes/adminEvents-enhanced.js +++ b/backend/src/routes/adminEvents-enhanced.js @@ -6,8 +6,10 @@ const { buildShareLinkVariants } = require('../services/shareLinkService'); const { requirePermission } = require('../middleware/permissions'); // Enhanced event creation with password validation +// Note: This is a partial/reference file - dynamic event type validation should be implemented +// similar to adminEvents.js using eventTypeService.isValidEventType() router.post('/', adminAuth, requirePermission('events.create'), [ - body('event_type').isIn(['wedding', 'birthday', 'corporate', 'other']), + body('event_type').notEmpty().trim(), // Dynamic validation via eventTypeService body('event_name').notEmpty().trim(), body('event_date').isDate(), body('customer_email').isEmail().normalizeEmail(), diff --git a/backend/src/routes/adminEvents.js b/backend/src/routes/adminEvents.js index 3bb8d774..cdd84912 100644 --- a/backend/src/routes/adminEvents.js +++ b/backend/src/routes/adminEvents.js @@ -17,6 +17,7 @@ const { validatePasswordInContext, getBcryptRounds } = require('../utils/passwor const logger = require('../utils/logger'); const { buildShareLinkVariants } = require('../services/shareLinkService'); const { parseBooleanInput, parseStringInput, parseJsonInput } = require('../utils/parsers'); +const eventTypeService = require('../services/eventTypeService'); // Helper to get event field requirements from settings const getEventFieldRequirements = async () => { @@ -112,7 +113,13 @@ const hasCustomerContactColumns = async () => { // Create new event router.post('/', adminAuth, requirePermission('events.create'), [ - body('event_type').isIn(['wedding', 'birthday', 'corporate', 'other']), + body('event_type').notEmpty().trim().custom(async (value) => { + const isValid = await eventTypeService.isValidEventType(value); + if (!isValid) { + throw new Error('Invalid event type'); + } + return true; + }), body('event_name').notEmpty().trim(), body('event_date').optional().isDate(), body('customer_name').optional().trim(), @@ -151,7 +158,11 @@ router.post('/', adminAuth, requirePermission('events.create'), [ body('disable_right_click').optional().isBoolean(), body('watermark_downloads').optional().isBoolean(), body('watermark_text').optional().trim(), - body('css_template_id').optional({ nullable: true, checkFalsy: true }).isInt() + body('css_template_id').optional({ nullable: true, checkFalsy: true }).isInt(), + // Hero logo settings + body('hero_logo_visible').optional().isBoolean(), + body('hero_logo_size').optional().isIn(['small', 'medium', 'large', 'xlarge']), + body('hero_logo_position').optional().isIn(['top', 'center', 'bottom']) ], async (req, res) => { try { logger.debug('Create event request body', { body: req.body }); @@ -190,7 +201,11 @@ router.post('/', adminAuth, requirePermission('events.create'), [ moderate_comments = true, show_feedback_to_guests = true, // CSS Template - css_template_id = null + css_template_id = null, + // Hero logo settings + hero_logo_visible = true, + hero_logo_size = 'medium', + hero_logo_position = 'top' } = req.body; const customerName = getCustomerNameFromPayload(req.body); @@ -250,6 +265,10 @@ router.post('/', adminAuth, requirePermission('events.create'), [ } } + // Get event type info for slug generation + const eventTypeInfo = await eventTypeService.getEventTypeForSlug(event_type); + const slugPrefix = eventTypeInfo.slug_prefix || event_type; + // Generate unique slug const processedEventName = event_name .toLowerCase() @@ -324,7 +343,10 @@ router.post('/', adminAuth, requirePermission('events.create'), [ watermark_downloads: formatBoolean(watermark_downloads !== undefined ? watermark_downloads : false), watermark_text, require_password: formatBoolean(requirePassword), - css_template_id: css_template_id || null + css_template_id: css_template_id || null, + hero_logo_visible: formatBoolean(hero_logo_visible !== undefined ? hero_logo_visible : true), + hero_logo_size: hero_logo_size || 'medium', + hero_logo_position: hero_logo_position || 'top' }).returning('id'); // Handle both PostgreSQL (returns array of objects) and SQLite (returns array of IDs) @@ -601,7 +623,11 @@ router.put('/:id', adminAuth, requirePermission('events.edit'), [ } return true; }), - body('css_template_id').optional({ nullable: true, checkFalsy: true }).isInt() + body('css_template_id').optional({ nullable: true, checkFalsy: true }).isInt(), + // Hero logo settings + body('hero_logo_visible').optional().isBoolean(), + body('hero_logo_size').optional().isIn(['small', 'medium', 'large', 'xlarge']), + body('hero_logo_position').optional().isIn(['top', 'center', 'bottom']) ], async (req, res) => { try { const errors = validationResult(req); @@ -713,6 +739,11 @@ router.put('/:id', adminAuth, requirePermission('events.edit'), [ updates.password_hash = await bcrypt.hash(crypto.randomBytes(32).toString('hex'), getBcryptRounds()); } + // Format hero logo settings if provided + if (Object.prototype.hasOwnProperty.call(updates, 'hero_logo_visible')) { + updates.hero_logo_visible = formatBoolean(updates.hero_logo_visible); + } + // Update event await db('events') .where('id', id) @@ -946,7 +977,8 @@ router.post('/:id/resend-email', adminAuth, requirePermission('events.edit'), as // For resending creation email, we need the actual password // First, try to get it from the request body if provided - let galleryPassword = req.body.password; + // Use optional chaining to handle cases where req.body might be undefined + let galleryPassword = req.body?.password; // If no password provided, we can't decrypt the existing one // So we'll show a security message diff --git a/backend/src/routes/events.js b/backend/src/routes/events.js index 357ed65f..0f79e6d6 100644 --- a/backend/src/routes/events.js +++ b/backend/src/routes/events.js @@ -11,6 +11,7 @@ const path = require('path'); const router = express.Router(); const { buildShareLinkVariants } = require('../services/shareLinkService'); const { parseBooleanInput, parseStringInput } = require('../utils/parsers'); +const eventTypeService = require('../services/eventTypeService'); // Use parseStringInput from shared parsers for customer data extraction const getCustomerNameFromPayload = (payload = {}) => parseStringInput(payload.customer_name); @@ -55,7 +56,13 @@ const hasCustomerContactColumns = async () => { // Create new event router.post('/', adminAuth, [ - body('event_type').isIn(['wedding', 'birthday', 'corporate', 'other']), + body('event_type').notEmpty().trim().custom(async (value) => { + const isValid = await eventTypeService.isValidEventType(value); + if (!isValid) { + throw new Error('Invalid event type'); + } + return true; + }), body('event_name').notEmpty(), body('event_date').isDate(), body('customer_name').notEmpty().trim(), diff --git a/backend/src/routes/gallery.js b/backend/src/routes/gallery.js index a8a3db71..eb86f923 100644 --- a/backend/src/routes/gallery.js +++ b/backend/src/routes/gallery.js @@ -115,7 +115,10 @@ router.get('/:slug/info', async (req, res) => { 'require_password', 'color_theme', 'enable_devtools_protection', - 'use_canvas_rendering' + 'use_canvas_rendering', + 'hero_logo_visible', + 'hero_logo_size', + 'hero_logo_position' ) .first(); @@ -162,7 +165,10 @@ router.get('/:slug/info', async (req, res) => { watermark_downloads: event.watermark_downloads === true || event.watermark_downloads === 1 || event.watermark_downloads === '1', watermark_text: event.watermark_text, enable_devtools_protection: event.enable_devtools_protection === true || event.enable_devtools_protection === 1 || event.enable_devtools_protection === '1', - use_canvas_rendering: event.use_canvas_rendering === true || event.use_canvas_rendering === 1 || event.use_canvas_rendering === '1' + use_canvas_rendering: event.use_canvas_rendering === true || event.use_canvas_rendering === 1 || event.use_canvas_rendering === '1', + hero_logo_visible: event.hero_logo_visible !== false && event.hero_logo_visible !== 0 && event.hero_logo_visible !== '0', + hero_logo_size: event.hero_logo_size || 'medium', + hero_logo_position: event.hero_logo_position || 'top' }); } catch (error) { console.error('Error fetching gallery info:', error); @@ -332,6 +338,9 @@ router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => { watermark_text: req.event.watermark_text, enable_devtools_protection: req.event.enable_devtools_protection === true, use_canvas_rendering: req.event.use_canvas_rendering === true, + hero_logo_visible: req.event.hero_logo_visible !== false && req.event.hero_logo_visible !== 0 && req.event.hero_logo_visible !== '0', + hero_logo_size: req.event.hero_logo_size || 'medium', + hero_logo_position: req.event.hero_logo_position || 'top', ...protectionSettings }, categories: categories, diff --git a/backend/src/services/eventService.js b/backend/src/services/eventService.js index 31eb89d2..5676771b 100644 --- a/backend/src/services/eventService.js +++ b/backend/src/services/eventService.js @@ -14,6 +14,7 @@ const { formatBoolean } = require('../utils/dbCompat'); const { validatePasswordInContext, getBcryptRounds } = require('../utils/passwordValidation'); const { buildShareLinkVariants } = require('./shareLinkService'); const { parseBooleanInput, parseStringInput } = require('../utils/parsers'); +const eventTypeService = require('./eventTypeService'); const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage'); @@ -67,13 +68,23 @@ const mapEventForApi = (event) => { /** * Generate a unique slug for an event - * @param {string} eventType + * @param {string} eventType - Event type identifier (slug_prefix or legacy type) * @param {string} eventName * @param {string} eventDate * @returns {Promise} */ const generateUniqueSlug = async (eventType, eventName, eventDate) => { - const baseSlug = `${eventType}-${eventName.toLowerCase().replace(/[^a-z0-9]/g, '-')}-${eventDate}`; + // Get the slug_prefix from event type (supports both new dynamic types and legacy) + const eventTypeInfo = await eventTypeService.getEventTypeForSlug(eventType); + const slugPrefix = eventTypeInfo.slug_prefix || eventType; + + const processedName = eventName + .toLowerCase() + .replace(/[^a-z0-9]/g, '-') + .replace(/-+/g, '-') + .replace(/^-|-$/g, ''); + + const baseSlug = `${slugPrefix}-${processedName}-${eventDate}`; let slug = baseSlug; let counter = 1; diff --git a/backend/src/services/eventTypeService.js b/backend/src/services/eventTypeService.js new file mode 100644 index 00000000..1dbd8308 --- /dev/null +++ b/backend/src/services/eventTypeService.js @@ -0,0 +1,314 @@ +/** + * Event Type Service Layer + * Handles all event type-related business logic + * + * @module services/eventTypeService + */ + +const { db } = require('../database/db'); +const { formatBoolean } = require('../utils/dbCompat'); + +/** + * Get all event types + * @param {Object} options - Filter options + * @param {boolean} options.activeOnly - Only return active types + * @returns {Promise} - Array of event types + */ +const getAllEventTypes = async (options = {}) => { + const { activeOnly = false } = options; + + let query = db('event_types').select('*'); + + if (activeOnly) { + query = query.where('is_active', formatBoolean(true)); + } + + const types = await query.orderBy('display_order', 'asc'); + return types; +}; + +/** + * Get active event types for dropdown/selection + * @returns {Promise} - Array of active event types + */ +const getActiveEventTypes = async () => { + return getAllEventTypes({ activeOnly: true }); +}; + +/** + * Get an event type by ID + * @param {number} id - Event type ID + * @returns {Promise} + */ +const getEventTypeById = async (id) => { + const eventType = await db('event_types').where('id', id).first(); + return eventType || null; +}; + +/** + * Get an event type by slug prefix + * @param {string} slugPrefix - The slug prefix + * @returns {Promise} + */ +const getEventTypeBySlugPrefix = async (slugPrefix) => { + const eventType = await db('event_types') + .where('slug_prefix', slugPrefix.toLowerCase()) + .first(); + return eventType || null; +}; + +/** + * Check if a slug prefix is valid (exists in event_types or is a legacy type) + * @param {string} slugPrefix - The slug prefix to validate + * @returns {Promise} + */ +const isValidEventType = async (slugPrefix) => { + const normalized = slugPrefix.toLowerCase(); + + // Check in database + const eventType = await getEventTypeBySlugPrefix(normalized); + if (eventType && eventType.is_active) { + return true; + } + + // Legacy fallback: Accept old hardcoded values for backward compatibility + const legacyTypes = ['wedding', 'birthday', 'corporate', 'other']; + return legacyTypes.includes(normalized); +}; + +/** + * Get all valid slug prefixes (for validation) + * @returns {Promise} + */ +const getValidSlugPrefixes = async () => { + const types = await db('event_types') + .where('is_active', formatBoolean(true)) + .select('slug_prefix'); + + return types.map(t => t.slug_prefix); +}; + +/** + * Create a new event type + * @param {Object} eventTypeData - Event type data + * @returns {Promise} - Created event type + */ +const createEventType = async (eventTypeData) => { + const { + name, + slug_prefix, + emoji, + theme_preset, + theme_config, + display_order + } = eventTypeData; + + // Normalize slug_prefix + const normalizedSlugPrefix = slug_prefix.toLowerCase().replace(/[^a-z0-9-]/g, '-'); + + // Check for duplicate slug_prefix + const existing = await getEventTypeBySlugPrefix(normalizedSlugPrefix); + if (existing) { + const error = new Error('An event type with this slug prefix already exists'); + error.code = 'DUPLICATE_SLUG_PREFIX'; + throw error; + } + + // Get max display order if not provided + let finalDisplayOrder = display_order; + if (finalDisplayOrder === undefined || finalDisplayOrder === null) { + const maxOrder = await db('event_types').max('display_order as max').first(); + finalDisplayOrder = (maxOrder?.max || 0) + 1; + } + + const insertData = { + name, + slug_prefix: normalizedSlugPrefix, + emoji: emoji || '📷', + theme_preset: theme_preset || 'default', + theme_config: theme_config ? JSON.stringify(theme_config) : null, + display_order: finalDisplayOrder, + is_system: false, + is_active: true, + created_at: new Date(), + updated_at: new Date() + }; + + const insertResult = await db('event_types').insert(insertData).returning('id'); + const eventTypeId = insertResult[0]?.id || insertResult[0]; + + return getEventTypeById(eventTypeId); +}; + +/** + * Update an event type + * @param {number} id - Event type ID + * @param {Object} updates - Fields to update + * @returns {Promise} - Updated event type + */ +const updateEventType = async (id, updates) => { + const eventType = await getEventTypeById(id); + if (!eventType) { + const error = new Error('Event type not found'); + error.code = 'NOT_FOUND'; + throw error; + } + + // Build update object + const updateData = {}; + + if (updates.name !== undefined) { + updateData.name = updates.name; + } + + if (updates.slug_prefix !== undefined) { + const normalizedSlugPrefix = updates.slug_prefix.toLowerCase().replace(/[^a-z0-9-]/g, '-'); + + // Check for duplicate (excluding current) + const existing = await db('event_types') + .where('slug_prefix', normalizedSlugPrefix) + .whereNot('id', id) + .first(); + + if (existing) { + const error = new Error('An event type with this slug prefix already exists'); + error.code = 'DUPLICATE_SLUG_PREFIX'; + throw error; + } + + updateData.slug_prefix = normalizedSlugPrefix; + } + + if (updates.emoji !== undefined) { + updateData.emoji = updates.emoji; + } + + if (updates.theme_preset !== undefined) { + updateData.theme_preset = updates.theme_preset; + } + + if (updates.theme_config !== undefined) { + updateData.theme_config = updates.theme_config ? JSON.stringify(updates.theme_config) : null; + } + + if (updates.display_order !== undefined) { + updateData.display_order = updates.display_order; + } + + if (updates.is_active !== undefined) { + updateData.is_active = formatBoolean(updates.is_active); + } + + updateData.updated_at = new Date(); + + await db('event_types').where('id', id).update(updateData); + + return getEventTypeById(id); +}; + +/** + * Delete an event type + * @param {number} id - Event type ID + * @returns {Promise} + */ +const deleteEventType = async (id) => { + const eventType = await getEventTypeById(id); + if (!eventType) { + const error = new Error('Event type not found'); + error.code = 'NOT_FOUND'; + throw error; + } + + // Prevent deletion of system types + if (eventType.is_system) { + const error = new Error('Cannot delete system event types. You can deactivate them instead.'); + error.code = 'SYSTEM_TYPE'; + throw error; + } + + // Check if any events use this type + const eventsUsingType = await db('events') + .where('event_type', eventType.slug_prefix) + .count('id as count') + .first(); + + if (eventsUsingType && parseInt(eventsUsingType.count) > 0) { + const error = new Error(`Cannot delete: ${eventsUsingType.count} events are using this type. Deactivate it instead or reassign those events.`); + error.code = 'IN_USE'; + throw error; + } + + await db('event_types').where('id', id).del(); + + return { success: true, deleted: eventType }; +}; + +/** + * Reorder event types + * @param {Array} orderedIds - Array of IDs in new order + * @returns {Promise} + */ +const reorderEventTypes = async (orderedIds) => { + await db.transaction(async (trx) => { + for (let i = 0; i < orderedIds.length; i++) { + await trx('event_types') + .where('id', orderedIds[i]) + .update({ display_order: i + 1, updated_at: new Date() }); + } + }); + + return getAllEventTypes(); +}; + +/** + * Get event type info for slug generation + * Returns the slug_prefix to use for a given event type identifier + * @param {string} eventTypeIdentifier - Either an ID or slug_prefix + * @returns {Promise} - Event type with slug_prefix and theme_preset + */ +const getEventTypeForSlug = async (eventTypeIdentifier) => { + // Try to find by slug_prefix first + let eventType = await getEventTypeBySlugPrefix(eventTypeIdentifier); + + if (eventType) { + return eventType; + } + + // Try by ID if numeric + if (!isNaN(eventTypeIdentifier)) { + eventType = await getEventTypeById(parseInt(eventTypeIdentifier)); + if (eventType) { + return eventType; + } + } + + // Fallback for legacy types - return a compatible object + const legacyDefaults = { + wedding: { slug_prefix: 'wedding', theme_preset: 'elegantWedding', emoji: '💒' }, + birthday: { slug_prefix: 'birthday', theme_preset: 'birthdayFun', emoji: '🎂' }, + corporate: { slug_prefix: 'corporate', theme_preset: 'corporateTimeline', emoji: '🏢' }, + other: { slug_prefix: 'other', theme_preset: 'default', emoji: '📸' } + }; + + const normalized = eventTypeIdentifier.toLowerCase(); + if (legacyDefaults[normalized]) { + return legacyDefaults[normalized]; + } + + // Default fallback + return { slug_prefix: 'event', theme_preset: 'default', emoji: '📷' }; +}; + +module.exports = { + getAllEventTypes, + getActiveEventTypes, + getEventTypeById, + getEventTypeBySlugPrefix, + isValidEventType, + getValidSlugPrefixes, + createEventType, + updateEventType, + deleteEventType, + reorderEventTypes, + getEventTypeForSlug +}; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 0777c668..f27cd917 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -24,7 +24,8 @@ import { SettingsPage, BackupManagement, CMSPage, - UserManagementPage + UserManagementPage, + EventTypesPage } from './pages/admin'; import { AcceptInvitePage } from './pages/public/AcceptInvitePage'; import { AdminLayout, AdminAuthWrapper } from './components/admin'; @@ -128,6 +129,7 @@ function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/components/admin/AdminSidebar.tsx b/frontend/src/components/admin/AdminSidebar.tsx index f6ac4fd8..44a43d7a 100644 --- a/frontend/src/components/admin/AdminSidebar.tsx +++ b/frontend/src/components/admin/AdminSidebar.tsx @@ -11,7 +11,8 @@ import { Palette, FileText, HardDrive, - Users + Users, + Tags } from 'lucide-react'; import { useQuery } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; @@ -39,6 +40,7 @@ const navigation: NavItem[] = [ { nameKey: 'navigation.emailSettings', href: '/admin/email', icon: Mail, permission: 'email.view' }, { nameKey: 'navigation.branding', href: '/admin/branding', icon: Palette, permission: 'branding.view' }, { nameKey: 'navigation.settings', href: '/admin/settings', icon: Settings, permission: 'settings.view' }, + { nameKey: 'navigation.eventTypes', href: '/admin/event-types', icon: Tags, permission: 'settings.view' }, { nameKey: 'navigation.backup', href: '/admin/backup', icon: HardDrive, permission: 'backup.view' }, { nameKey: 'navigation.cmsPages', href: '/admin/cms', icon: FileText, permission: 'cms.view' }, { nameKey: 'navigation.users', href: '/admin/users', icon: Users, permission: 'users.view' }, diff --git a/frontend/src/components/admin/ThemeCustomizerEnhanced.tsx b/frontend/src/components/admin/ThemeCustomizerEnhanced.tsx index fffb70d5..df1df102 100644 --- a/frontend/src/components/admin/ThemeCustomizerEnhanced.tsx +++ b/frontend/src/components/admin/ThemeCustomizerEnhanced.tsx @@ -67,15 +67,16 @@ export const ThemeCustomizerEnhanced: React.FC = ( const handleChange = (key: keyof ThemeConfig, newValue: any) => { const updated = { ...localTheme, [key]: newValue }; setLocalTheme(updated); - + // When any change is made, mark it as custom if (selectedPreset !== 'custom' && onPresetChange) { setSelectedPreset('custom'); onPresetChange('custom'); } - + if (isPreviewMode) { - onChange(updated); + // Include customCss in the propagated theme + onChange({ ...updated, customCss }); } }; @@ -84,6 +85,7 @@ export const ThemeCustomizerEnhanced: React.FC = ( if (preset) { setSelectedPreset(presetKey); setLocalTheme(preset.config); + setCustomCss(''); // Clear custom CSS when selecting a preset if (onPresetChange) { onPresetChange(presetKey); } @@ -722,12 +724,17 @@ export const ThemeCustomizerEnhanced: React.FC = (