feat: add customizable event types with admin management
Implements GitHub issue #139 - allows users to create and manage custom event types beyond the default presets (wedding, birthday, corporate, other). Backend: - Add event_types table migration with default system types - Create eventTypeService for CRUD operations with legacy fallback - Add adminEventTypes routes with full REST API - Update event validation to use dynamic event types - Update slug generation to use custom slug_prefix Frontend: - Add EventTypesPage with full CRUD admin interface - Add eventTypes.service.ts API client - Update CreateEventPage to fetch types dynamically - Add Event Types navigation in admin sidebar - Add i18n translations (EN/DE) Backward compatible: existing galleries continue to work, legacy types accepted even if database is empty via fallback mechanisms.
This commit is contained in:
@@ -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');
|
||||
};
|
||||
@@ -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'));
|
||||
|
||||
@@ -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;
|
||||
@@ -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(),
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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<string>}
|
||||
*/
|
||||
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;
|
||||
|
||||
|
||||
@@ -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>} - 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>} - 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<Object|null>}
|
||||
*/
|
||||
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<Object|null>}
|
||||
*/
|
||||
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<boolean>}
|
||||
*/
|
||||
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<string[]>}
|
||||
*/
|
||||
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<Object>} - 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<Object>} - 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<Object>}
|
||||
*/
|
||||
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<Array>}
|
||||
*/
|
||||
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<Object>} - 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
|
||||
};
|
||||
Reference in New Issue
Block a user