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/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 a86b15e1..2742c2fc 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(), @@ -250,6 +257,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() 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/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/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index 93d56e89..4b5377eb 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -154,6 +154,7 @@ "events": "Veranstaltungen", "archives": "Archive", "settings": "Einstellungen", + "eventTypes": "Veranstaltungsarten", "branding": "Branding", "analytics": "Analytik", "emailSettings": "E-Mail-Einstellungen", @@ -161,6 +162,54 @@ "cmsPages": "CMS-Seiten", "users": "Benutzer" }, + "eventTypes": { + "title": "Veranstaltungsarten", + "subtitle": "Veranstaltungsarten und deren Standard-Themes anpassen", + "loading": "Lade Veranstaltungsarten...", + "loadError": "Fehler beim Laden der Veranstaltungsarten", + "createNew": "Neue Veranstaltungsart", + "edit": "Veranstaltungsart bearbeiten", + "created": "Veranstaltungsart erfolgreich erstellt", + "updated": "Veranstaltungsart erfolgreich aktualisiert", + "deleted": "Veranstaltungsart erfolgreich gelöscht", + "createError": "Fehler beim Erstellen der Veranstaltungsart", + "updateError": "Fehler beim Aktualisieren der Veranstaltungsart", + "deleteError": "Fehler beim Löschen der Veranstaltungsart", + "searchPlaceholder": "Veranstaltungsarten suchen...", + "showInactive": "Inaktive anzeigen", + "noResults": "Keine Veranstaltungsarten gefunden", + "empty": "Noch keine Veranstaltungsarten", + "system": "System", + "table": { + "type": "Typ", + "slugPrefix": "URL-Präfix", + "theme": "Standard-Theme", + "status": "Status", + "actions": "Aktionen" + }, + "form": { + "name": "Anzeigename", + "namePlaceholder": "z.B. Familienshooting", + "slugPrefix": "URL-Präfix", + "slugPrefixPlaceholder": "z.B. familie", + "slugPreview": "Beispiel-URL:", + "emoji": "Symbol", + "themePreset": "Standard-Theme", + "isActive": "Aktiv (sichtbar bei Veranstaltungserstellung)" + }, + "validation": { + "slugFormat": "Nur Buchstaben, Zahlen und Bindestriche erlaubt" + }, + "slugInfo": { + "title": "URL-Präfix Info:", + "description": "Das URL-Präfix wird zur Generierung von Galerie-URLs verwendet. Zum Beispiel erstellt eine Veranstaltungsart mit Präfix \"familie\" URLs wie: familie-mueller-familie-2025-01-22" + }, + "deleteConfirm": { + "title": "Veranstaltungsart löschen", + "message": "Sind Sie sicher, dass Sie löschen möchten", + "warning": "Diese Aktion kann nicht rückgängig gemacht werden. Stellen Sie sicher, dass keine Veranstaltungen diesen Typ verwenden." + } + }, "backup": { "external": { "warning": { diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 78523323..43fbe80c 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -154,6 +154,7 @@ "events": "Events", "archives": "Archives", "settings": "Settings", + "eventTypes": "Event Types", "branding": "Branding", "analytics": "Analytics", "emailSettings": "Email Settings", @@ -1445,6 +1446,54 @@ "datenschutz": "Privacy Policy", "pageUpdated": "Page updated successfully" }, + "eventTypes": { + "title": "Event Types", + "subtitle": "Customize event types and their default themes", + "loading": "Loading event types...", + "loadError": "Failed to load event types", + "createNew": "New Event Type", + "edit": "Edit Event Type", + "created": "Event type created successfully", + "updated": "Event type updated successfully", + "deleted": "Event type deleted successfully", + "createError": "Failed to create event type", + "updateError": "Failed to update event type", + "deleteError": "Failed to delete event type", + "searchPlaceholder": "Search event types...", + "showInactive": "Show inactive", + "noResults": "No event types found", + "empty": "No event types yet", + "system": "System", + "table": { + "type": "Type", + "slugPrefix": "URL Prefix", + "theme": "Default Theme", + "status": "Status", + "actions": "Actions" + }, + "form": { + "name": "Display Name", + "namePlaceholder": "e.g., Family Shoot", + "slugPrefix": "URL Prefix", + "slugPrefixPlaceholder": "e.g., family", + "slugPreview": "Example URL:", + "emoji": "Icon", + "themePreset": "Default Theme", + "isActive": "Active (visible in event creation)" + }, + "validation": { + "slugFormat": "Only letters, numbers, and hyphens allowed" + }, + "slugInfo": { + "title": "URL Prefix Info:", + "description": "The URL prefix is used to generate gallery URLs. For example, an event type with prefix \"family\" will create URLs like: family-smith-family-2025-01-22" + }, + "deleteConfirm": { + "title": "Delete Event Type", + "message": "Are you sure you want to delete", + "warning": "This action cannot be undone. Make sure no events are using this type." + } + }, "backup": { "external": { "warning": { diff --git a/frontend/src/pages/admin/CreateEventPage.tsx b/frontend/src/pages/admin/CreateEventPage.tsx index eeca593d..f6262799 100644 --- a/frontend/src/pages/admin/CreateEventPage.tsx +++ b/frontend/src/pages/admin/CreateEventPage.tsx @@ -21,6 +21,7 @@ import { useLocalizedDate } from '../../hooks/useLocalizedDate'; import { categoriesService } from '../../services/categories.service'; import { settingsService } from '../../services/settings.service'; import { cssTemplatesService } from '../../services/cssTemplates.service'; +import { eventTypesService } from '../../services/eventTypes.service'; import { useTranslation } from 'react-i18next'; import { ThemeConfig, GALLERY_THEME_PRESETS } from '../../types/theme.types'; import { Code } from 'lucide-react'; @@ -57,18 +58,12 @@ interface FormData { }; } -const EVENT_TYPE_PRESETS: Record = { - wedding: 'elegantWedding', - birthday: 'birthdayFun', - corporate: 'corporateTimeline', - other: 'default' -}; - -const EVENT_TYPES = [ - { value: 'wedding', labelKey: 'events.types.wedding', emoji: '💒' }, - { value: 'birthday', labelKey: 'events.types.birthday', emoji: '🎂' }, - { value: 'corporate', labelKey: 'events.types.corporate', emoji: '🏢' }, - { value: 'other', labelKey: 'events.types.other', emoji: '📸' }, +// Fallback event types (used when API is unavailable) +const FALLBACK_EVENT_TYPES = [ + { value: 'wedding', name: 'Wedding', emoji: '💒', theme_preset: 'elegantWedding' }, + { value: 'birthday', name: 'Birthday', emoji: '🎂', theme_preset: 'birthdayFun' }, + { value: 'corporate', name: 'Corporate', emoji: '🏢', theme_preset: 'corporateTimeline' }, + { value: 'other', name: 'Other', emoji: '📸', theme_preset: 'default' }, ]; export const CreateEventPage: React.FC = () => { @@ -132,6 +127,22 @@ export const CreateEventPage: React.FC = () => { queryFn: () => cssTemplatesService.getEnabledTemplates() }); + // Fetch event types + const { data: eventTypes } = useQuery({ + queryKey: ['event-types', 'active'], + queryFn: () => eventTypesService.getActiveEventTypes() + }); + + // Compute event types to use (API data or fallback) + const availableEventTypes = eventTypes?.length + ? eventTypes.map(et => ({ + value: et.slug_prefix, + name: et.name, + emoji: et.emoji, + theme_preset: et.theme_preset + })) + : FALLBACK_EVENT_TYPES; + // Fetch default settings const { data: settings } = useQuery({ queryKey: ['admin-settings'], @@ -163,7 +174,10 @@ export const CreateEventPage: React.FC = () => { // Update theme when event type changes useEffect(() => { - const recommendedPreset = EVENT_TYPE_PRESETS[formData.event_type]; + // Find the selected event type's theme preset + const selectedType = availableEventTypes.find(t => t.value === formData.event_type); + const recommendedPreset = selectedType?.theme_preset || 'default'; + if (recommendedPreset && GALLERY_THEME_PRESETS[recommendedPreset]) { setFormData(prev => ({ ...prev, @@ -171,7 +185,7 @@ export const CreateEventPage: React.FC = () => { theme_config: GALLERY_THEME_PRESETS[recommendedPreset].config })); } - }, [formData.event_type]); + }, [formData.event_type, availableEventTypes]); const createMutation = useMutation({ mutationFn: eventsService.createEvent, @@ -366,7 +380,7 @@ export const CreateEventPage: React.FC = () => { {t('events.eventType')}
- {EVENT_TYPES.map((type) => ( + {availableEventTypes.map((type) => ( ))}
diff --git a/frontend/src/pages/admin/EventTypesPage.tsx b/frontend/src/pages/admin/EventTypesPage.tsx new file mode 100644 index 00000000..81453c45 --- /dev/null +++ b/frontend/src/pages/admin/EventTypesPage.tsx @@ -0,0 +1,550 @@ +import React, { useState, useMemo } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { toast } from 'react-toastify'; +import { useTranslation } from 'react-i18next'; +import { + Plus, + Edit, + Trash2, + Search, + GripVertical, + Eye, + EyeOff, + X, + AlertTriangle, + Tags +} from 'lucide-react'; + +import { Button, Input, Card, Loading } from '../../components/common'; +import { eventTypesService, EventType, CreateEventTypeData, UpdateEventTypeData } from '../../services/eventTypes.service'; +import { GALLERY_THEME_PRESETS } from '../../types/theme.types'; + +// Common emoji options for event types +const EMOJI_OPTIONS = [ + '📷', '💒', '🎂', '🏢', '🎉', '🎊', '🎈', '👨‍👩‍👧', '💍', '🌸', + '🎄', '🎃', '🐣', '🎓', '🏆', '🎸', '🎭', '🍽️', '🏖️', '✈️' +]; + +export const EventTypesPage: React.FC = () => { + const { t } = useTranslation(); + const queryClient = useQueryClient(); + + // State + const [searchTerm, setSearchTerm] = useState(''); + const [showInactive, setShowInactive] = useState(false); + const [showCreateModal, setShowCreateModal] = useState(false); + const [editingType, setEditingType] = useState(null); + const [deleteConfirm, setDeleteConfirm] = useState(null); + + // Query + const { data: eventTypes, isLoading, error, refetch } = useQuery({ + queryKey: ['admin-event-types', showInactive], + queryFn: () => eventTypesService.getEventTypes(showInactive) + }); + + // Mutations + const createMutation = useMutation({ + mutationFn: eventTypesService.createEventType, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['admin-event-types'] }); + setShowCreateModal(false); + toast.success(t('eventTypes.created', 'Event type created successfully')); + }, + onError: (error: any) => { + toast.error(error.response?.data?.error || t('eventTypes.createError', 'Failed to create event type')); + } + }); + + const updateMutation = useMutation({ + mutationFn: ({ id, data }: { id: number; data: UpdateEventTypeData }) => + eventTypesService.updateEventType(id, data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['admin-event-types'] }); + setEditingType(null); + toast.success(t('eventTypes.updated', 'Event type updated successfully')); + }, + onError: (error: any) => { + toast.error(error.response?.data?.error || t('eventTypes.updateError', 'Failed to update event type')); + } + }); + + const deleteMutation = useMutation({ + mutationFn: eventTypesService.deleteEventType, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['admin-event-types'] }); + setDeleteConfirm(null); + toast.success(t('eventTypes.deleted', 'Event type deleted successfully')); + }, + onError: (error: any) => { + toast.error(error.response?.data?.error || t('eventTypes.deleteError', 'Failed to delete event type')); + } + }); + + // Filter event types + const filteredTypes = useMemo(() => { + if (!eventTypes) return []; + if (!searchTerm) return eventTypes; + + const term = searchTerm.toLowerCase(); + return eventTypes.filter(type => + type.name.toLowerCase().includes(term) || + type.slug_prefix.toLowerCase().includes(term) + ); + }, [eventTypes, searchTerm]); + + // Loading state + if (isLoading) { + return ( +
+ +
+ ); + } + + // Error state + if (error) { + return ( +
+

{t('eventTypes.loadError', 'Failed to load event types')}

+ +
+ ); + } + + return ( +
+ {/* Header */} +
+
+
+

+ + {t('eventTypes.title', 'Event Types')} +

+

+ {t('eventTypes.subtitle', 'Customize event types and their default themes')} +

+
+ +
+
+ + {/* Filters */} + +
+
+ setSearchTerm(e.target.value)} + leftIcon={} + /> +
+ +
+
+ + {/* Event Types List */} + +
+ + + + + + + + + + + + + {filteredTypes.length === 0 ? ( + + + + ) : ( + filteredTypes.map((type) => ( + + + + + + + + + )) + )} + +
+ {/* Drag handle column */} + + {t('eventTypes.table.type', 'Type')} + + {t('eventTypes.table.slugPrefix', 'URL Prefix')} + + {t('eventTypes.table.theme', 'Default Theme')} + + {t('eventTypes.table.status', 'Status')} + + {t('eventTypes.table.actions', 'Actions')} +
+ {searchTerm + ? t('eventTypes.noResults', 'No event types found') + : t('eventTypes.empty', 'No event types yet')} +
+ + +
+ {type.emoji} +
+
{type.name}
+ {type.is_system && ( + + {t('eventTypes.system', 'System')} + + )} +
+
+
+ + {type.slug_prefix} + + + {GALLERY_THEME_PRESETS[type.theme_preset]?.name || type.theme_preset || '-'} + + {type.is_active ? ( + + + {t('common.active', 'Active')} + + ) : ( + + + {t('common.inactive', 'Inactive')} + + )} + +
+ + {!type.is_system && ( + + )} +
+
+
+
+ + {/* Slug Preview Info */} +
+

+ {t('eventTypes.slugInfo.title', 'URL Prefix Info:')}{' '} + {t('eventTypes.slugInfo.description', 'The URL prefix is used to generate gallery URLs. For example, an event type with prefix "family" will create URLs like: family-smith-family-2025-01-22')} +

+
+ + {/* Create Modal */} + {showCreateModal && ( + setShowCreateModal(false)} + onSubmit={(data) => createMutation.mutate(data)} + isLoading={createMutation.isPending} + /> + )} + + {/* Edit Modal */} + {editingType && ( + setEditingType(null)} + onSubmit={(data) => updateMutation.mutate({ id: editingType.id, data })} + isLoading={updateMutation.isPending} + /> + )} + + {/* Delete Confirmation */} + {deleteConfirm && ( + setDeleteConfirm(null)} + onConfirm={() => deleteMutation.mutate(deleteConfirm.id)} + isLoading={deleteMutation.isPending} + /> + )} +
+ ); +}; + +// Event Type Modal Component +interface EventTypeModalProps { + eventType?: EventType; + onClose: () => void; + onSubmit: (data: CreateEventTypeData | UpdateEventTypeData) => void; + isLoading: boolean; +} + +const EventTypeModal: React.FC = ({ + eventType, + onClose, + onSubmit, + isLoading +}) => { + const { t } = useTranslation(); + const isEditing = !!eventType; + + const [form, setForm] = useState({ + name: eventType?.name || '', + slug_prefix: eventType?.slug_prefix || '', + emoji: eventType?.emoji || '📷', + theme_preset: eventType?.theme_preset || 'default' + }); + + const [errors, setErrors] = useState>>({}); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + const newErrors: typeof errors = {}; + + if (!form.name.trim()) { + newErrors.name = t('validation.required', 'This field is required'); + } + + if (!form.slug_prefix.trim()) { + newErrors.slug_prefix = t('validation.required', 'This field is required'); + } else if (!/^[a-z0-9-]+$/i.test(form.slug_prefix)) { + newErrors.slug_prefix = t('eventTypes.validation.slugFormat', 'Only letters, numbers, and hyphens allowed'); + } + + if (Object.keys(newErrors).length > 0) { + setErrors(newErrors); + return; + } + + // For editing, only send changed fields (plus is_active if toggling) + if (isEditing) { + const updates: UpdateEventTypeData = {}; + if (form.name !== eventType?.name) updates.name = form.name; + if (form.slug_prefix !== eventType?.slug_prefix) updates.slug_prefix = form.slug_prefix; + if (form.emoji !== eventType?.emoji) updates.emoji = form.emoji; + if (form.theme_preset !== eventType?.theme_preset) updates.theme_preset = form.theme_preset; + onSubmit(updates); + } else { + onSubmit(form); + } + }; + + return ( +
+ +
+
+

+ {isEditing + ? t('eventTypes.edit', 'Edit Event Type') + : t('eventTypes.createNew', 'New Event Type')} +

+ +
+ +
+
+ {/* Name */} + { + setForm({ ...form, name: e.target.value }); + setErrors({ ...errors, name: undefined }); + }} + error={errors.name} + /> + + {/* Slug Prefix */} +
+ { + const value = e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, '-'); + setForm({ ...form, slug_prefix: value }); + setErrors({ ...errors, slug_prefix: undefined }); + }} + error={errors.slug_prefix} + /> + {form.slug_prefix && ( +

+ {t('eventTypes.form.slugPreview', 'Example URL:')}{' '} + + {form.slug_prefix}-event-name-2025-01-22 + +

+ )} +
+ + {/* Emoji */} +
+ +
+ {EMOJI_OPTIONS.map((emoji) => ( + + ))} +
+
+ + {/* Theme Preset */} +
+ + +
+ + {/* Active toggle for editing */} + {isEditing && ( + + )} +
+ +
+ + +
+
+
+
+
+ ); +}; + +// Delete Confirmation Modal +interface DeleteConfirmModalProps { + eventType: EventType; + onClose: () => void; + onConfirm: () => void; + isLoading: boolean; +} + +const DeleteConfirmModal: React.FC = ({ + eventType, + onClose, + onConfirm, + isLoading +}) => { + const { t } = useTranslation(); + + return ( +
+ +
+
+
+ +
+

+ {t('eventTypes.deleteConfirm.title', 'Delete Event Type')} +

+
+ +

+ {t('eventTypes.deleteConfirm.message', 'Are you sure you want to delete')} "{eventType.name}"? +

+ +

+ {t('eventTypes.deleteConfirm.warning', 'This action cannot be undone. Make sure no events are using this type.')} +

+ +
+ + +
+
+
+
+ ); +}; + +export default EventTypesPage; diff --git a/frontend/src/pages/admin/index.ts b/frontend/src/pages/admin/index.ts index fdb5e0ba..4d120db9 100644 --- a/frontend/src/pages/admin/index.ts +++ b/frontend/src/pages/admin/index.ts @@ -11,4 +11,5 @@ export { SettingsPage } from './SettingsPage'; export { CMSPage } from './CMSPage'; export { BackupManagement } from './BackupManagement'; export { EventFeedbackPage } from './EventFeedbackPage'; -export { UserManagementPage } from './UserManagementPage'; \ No newline at end of file +export { UserManagementPage } from './UserManagementPage'; +export { EventTypesPage } from './EventTypesPage'; \ No newline at end of file diff --git a/frontend/src/services/eventTypes.service.ts b/frontend/src/services/eventTypes.service.ts new file mode 100644 index 00000000..0bcaba0b --- /dev/null +++ b/frontend/src/services/eventTypes.service.ts @@ -0,0 +1,100 @@ +/** + * Event Types Service + * API client for event type management + */ + +import { api } from '../config/api'; + +export interface EventType { + id: number; + name: string; + slug_prefix: string; + emoji: string; + theme_preset: string; + theme_config: string | null; + display_order: number; + is_system: boolean; + is_active: boolean; + created_at: string; + updated_at: string; +} + +export interface CreateEventTypeData { + name: string; + slug_prefix: string; + emoji?: string; + theme_preset?: string; + theme_config?: Record; + display_order?: number; +} + +export interface UpdateEventTypeData { + name?: string; + slug_prefix?: string; + emoji?: string; + theme_preset?: string; + theme_config?: Record; + display_order?: number; + is_active?: boolean; +} + +export const eventTypesService = { + /** + * Get all event types (for admin management) + */ + async getEventTypes(includeInactive = false): Promise { + const response = await api.get<{ eventTypes: EventType[] }>('/admin/event-types', { + params: { includeInactive: includeInactive.toString() } + }); + return response.data.eventTypes; + }, + + /** + * Get only active event types (for dropdowns/selection) + */ + async getActiveEventTypes(): Promise { + const response = await api.get<{ eventTypes: EventType[] }>('/admin/event-types/active'); + return response.data.eventTypes; + }, + + /** + * Get a single event type by ID + */ + async getEventType(id: number): Promise { + const response = await api.get(`/admin/event-types/${id}`); + return response.data; + }, + + /** + * Create a new event type + */ + async createEventType(data: CreateEventTypeData): Promise { + const response = await api.post('/admin/event-types', data); + return response.data; + }, + + /** + * Update an event type + */ + async updateEventType(id: number, data: UpdateEventTypeData): Promise { + const response = await api.put(`/admin/event-types/${id}`, data); + return response.data; + }, + + /** + * Delete an event type + */ + async deleteEventType(id: number): Promise { + await api.delete(`/admin/event-types/${id}`); + }, + + /** + * Reorder event types + */ + async reorderEventTypes(orderedIds: number[]): Promise { + const response = await api.post<{ eventTypes: EventType[] }>('/admin/event-types/reorder', { + orderedIds + }); + return response.data.eventTypes; + } +};