📦 Global Service Catalog
All available services stored in one central location. Admins manage the catalog to ensure quality and consistency.
The event services system uses a many-to-many architecture with a global service catalog, allowing services to be reused across multiple events while maintaining centralized management.
The many-to-many (M2M) architecture separates service definitions (stored in a global catalog) from service assignments (which events offer which services). This provides better reusability, consistency, and management compared to the previous one-to-many model.
📦 Global Service Catalog
All available services stored in one central location. Admins manage the catalog to ensure quality and consistency.
🔗 Event Assignments
Events reference services from the catalog. Same service can be assigned to multiple events.
♻️ Service Reusability
Update a service once, changes apply to all events using it. No duplicate service definitions.
🛡️ Enhanced Security
Role-based access: Admins manage catalog, event creators assign services to their events.
Understanding the architectural change helps you work effectively with the new system.
How it worked:
Example:
// Old way: Create service for specific eventawait db.insertEventService({ event_id: 'event-a', name: 'Haircuts', description: 'Professional haircuts', capacity: 20})
// If you wanted the same service on another event, you had to create it againawait db.insertEventService({ event_id: 'event-b', name: 'Haircuts', // Duplicate definition description: 'Professional haircuts', capacity: 20})Problems:
How it works:
Example:
// New way: Service exists in global catalogconst catalogResponse = await fetch('/api/services')const services = await catalogResponse.json()const haircutsService = services.find(s => s.name === 'Haircuts')
// Assign existing service to event Aawait fetch('/api/services/assign', { method: 'POST', body: JSON.stringify({ event_id: 'event-a', service_id: haircutsService.id, csrf_token: csrfToken })})
// Assign same service to event B (reuse, not duplicate)await fetch('/api/services/assign', { method: 'POST', body: JSON.stringify({ event_id: 'event-b', service_id: haircutsService.id, // Same service csrf_token: csrfToken })})Benefits:
| Aspect | One-to-Many (Old) | Many-to-Many (New) |
|---|---|---|
| Service Storage | Per event (duplicated) | Global catalog (deduplicated) |
| Service Updates | Per event (inconsistent) | Global (affects all events) |
| Service Creation | Any event owner | Admins only (team_admin+) |
| Event Assignments | Implicit (create service) | Explicit (assign from catalog) |
| Reusability | No (create duplicate) | Yes (reference same service) |
| Catalog Visibility | No central list | Yes (browse all services) |
The service catalog is the central repository of all available services. Only team_admin, admin, and super_admin roles can manage the catalog.
To manage the service catalog:
team_admin, admin, or super_admin (levels 4-7)To view the service catalog:
// Get all services in catalogconst response = await fetch('/api/services')const services = await response.json()
console.log(services)// [// {// id: 'uuid-123',// name: 'Haircuts',// description: 'Professional haircut service',// capacity: 20,// status: 'active',// display_order: 0,// created_at: '2025-12-01T10:00:00Z',// updated_at: '2025-12-01T10:00:00Z'// },// ...// ]import { getDatabase } from '#libs/database'
const db = getDatabase()const services = await db.getServices({ status: 'active' })Filtering:
// Get only active servicesconst activeServices = await fetch('/api/services?status=active') .then(r => r.json())
// Get services sorted by display orderconst sortedServices = await fetch('/api/services?order_by=display_order') .then(r => r.json())Only admins can create services in the global catalog.
const response = await fetch('/api/services/create', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: 'Haircuts', description: 'Professional haircut service provided by certified barbers', capacity: 20, status: 'active', display_order: 0, csrf_token: csrfToken })})
if (response.ok) { const { id } = await response.json() console.log(`Service created: ${id}`)} else if (response.status === 403) { console.error('Permission denied: Requires team_admin+ role')}import { getDatabase } from '#libs/database'
const db = getDatabase()
try { const serviceId = await db.insertService({ name: 'Haircuts', description: 'Professional haircut service', capacity: 20, status: 'active', display_order: 0 })
console.log(`Service created: ${serviceId}`)} catch (error) { console.error('Failed to create service:', error)}Validation Rules:
| Field | Type | Required | Constraints |
|---|---|---|---|
| name | string | Yes | 1-255 characters |
| description | string | No | Up to 500 characters |
| capacity | number | No | Positive integer (nullable) |
| status | string | Yes | ’active’ or ‘inactive’ |
| display_order | number | Yes | Non-negative integer |
Updates to a service in the catalog affect all events using that service.
// Update service capacity globallyconst response = await fetch(`/api/services/${serviceId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ capacity: 30, // Increase capacity from 20 to 30 csrf_token: csrfToken })})
if (response.ok) { console.log('Service updated globally') console.log('All events using this service now see capacity: 30')}await db.updateService(serviceId, { capacity: 30 // Affects all events using this service})⚠️ Important: Service updates are global. If you update “Haircuts” capacity from 20 to 30, all events offering Haircuts will reflect the new capacity.
Deleting a service from the catalog cascades to all event assignments.
const response = await fetch(`/api/services/${serviceId}`, { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ csrf_token: csrfToken })})
if (response.ok) { console.log('Service deleted from catalog') console.log('All event assignments for this service also removed')}await db.deleteService(serviceId)// CASCADE deletes all assignments⚠️ Warning: Deleting a service removes it from all events using it. Consider setting status: 'inactive' instead to preserve history.
Event creators (and admins) can assign services from the catalog to their events.
To assign services:
team_admin+ roleconst response = await fetch('/api/services/assign', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ event_id: eventId, service_id: serviceId, csrf_token: csrfToken })})
if (response.ok) { const { id } = await response.json() console.log(`Service assigned: ${id}`)} else if (response.status === 409) { console.error('Service already assigned to this event')} else if (response.status === 403) { console.error('Permission denied: Must own event or be admin')}import { getDatabase } from '#libs/database'
const db = getDatabase()
try { const assignmentId = await db.assignServiceToEvent({ event_id: eventId, service_id: serviceId })
console.log(`Assignment created: ${assignmentId}`)} catch (error) { if (error.message.includes('unique_event_service_assignment')) { console.error('Service already assigned to this event') } else { console.error('Failed to assign service:', error) }}Duplicate Prevention:
The system prevents assigning the same service to an event multiple times:
// First assignment succeedsawait assignService(eventId, 'haircuts-service-id') // ✅ OK
// Second assignment failsawait assignService(eventId, 'haircuts-service-id') // ❌ 409 ConflictRetrieve all services assigned to an event, including full service details.
const response = await fetch(`/api/services/assignments?event_id=${eventId}`)const assignments = await response.json()
console.log(assignments)// [// {// id: 'assignment-uuid-123',// event_id: 'event-uuid',// service_id: 'service-uuid',// assigned_at: '2025-12-01T10:00:00Z',// service: {// name: 'Haircuts',// description: 'Professional haircut service',// capacity: 20,// status: 'active'// }// },// ...// ]Using the data:
{assignments.map(assignment => ( <div key={assignment.id}> <h3>{assignment.service.name}</h3> <p>{assignment.service.description}</p> <span>Capacity: {assignment.service.capacity}</span> <time>Assigned: {new Date(assignment.assigned_at).toLocaleDateString()}</time> </div>))}import { getDatabase } from '#libs/database'
const db = getDatabase()
const assignments = await db.getEventServiceAssignments(eventId)
assignments.forEach(assignment => { console.log(`Service: ${assignment.service.name}`) console.log(`Capacity: ${assignment.service.capacity}`) console.log(`Assigned: ${assignment.assigned_at}`)})⚠️ Note: Service details are fetched via JOIN, so you get the current service information (not a snapshot from assignment time).
Remove a service assignment from an event (does not delete the service from catalog).
const response = await fetch(`/api/services/unassign/${assignmentId}`, { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ csrf_token: csrfToken })})
if (response.ok) { console.log('Service unassigned from event') console.log('Service still exists in catalog')}await db.unassignServiceFromEvent(assignmentId)⚠️ Important: Unassigning removes the service from the event but does not delete the service from the global catalog.
When you assign a service to a parent recurring event, the assignment is automatically copied to all child instances.
// Parent event with 10 weekly instancesconst parentEvent = { id: 'parent-uuid', name: 'Weekly Community Outreach', is_recurring: true, recurrence_frequency: 'weekly', recurrence_end_count: 10}
// Assign service to parent eventawait fetch('/api/services/assign', { method: 'POST', body: JSON.stringify({ event_id: parentEvent.id, service_id: 'haircuts-service-id', csrf_token: csrfToken })})
// Result: Service automatically assigned to parent + all 10 children// Total: 11 assignment records createdDatabase after assignment:
-- event_service_assignments table-- (all referencing the same service in the catalog)
| id | event_id | service_id | assigned_at ||-----|----------------|--------------------|---------------------|| a1 | parent-uuid | haircuts-service | 2025-12-01 10:00:00 || a2 | child-1-uuid | haircuts-service | 2025-12-01 10:00:00 || a3 | child-2-uuid | haircuts-service | 2025-12-01 10:00:00 || ... | ... | ... | ... || a11 | child-10-uuid | haircuts-service | 2025-12-01 10:00:00 |import { getDatabase } from '#libs/database'
const db = getDatabase()
// Assign service to parent recurring eventconst assignmentId = await db.assignServiceToEvent({ event_id: parentEventId, service_id: serviceId})
// Behind the scenes, the database abstraction:// 1. Creates assignment for parent event// 2. Finds all child instances (WHERE parent_event_id = parentEventId)// 3. Batch inserts assignments for all children (single query)
console.log('Service assigned to parent + all child instances')Before (One-to-Many):
After (Many-to-Many):
Example:
// Update service capacityawait db.updateService('haircuts-service-id', { capacity: 30 // Increase from 20 to 30})
// Result: All 11 events (parent + 10 children) now show capacity 30// No need to update each instance separatelyAssignment copying uses batch insert for optimal performance:
-- Single batch INSERT query (not 10 separate queries)INSERT INTO event_service_assignments (event_id, service_id, assigned_at)VALUES ('child-1-uuid', 'service-uuid', CURRENT_TIMESTAMP), ('child-2-uuid', 'service-uuid', CURRENT_TIMESTAMP), -- ... all children in one queryPerformance stats:
The many-to-many architecture uses a dual authorization model: admins control the service catalog, while event creators control their event assignments.
Who can manage the global service catalog:
| Operation | Required Role | Authorization Check |
|---|---|---|
| View services | Any authenticated user | auth.uid() exists |
| Create service | team_admin, admin, super_admin | user_roles contains team_admin+ |
| Update service | team_admin, admin, super_admin | user_roles contains team_admin+ |
| Delete service | team_admin, admin, super_admin | user_roles contains team_admin+ |
Role Hierarchy:
✅ super_admin (level 7) - Full access✅ admin (level 6) - Full access✅ team_admin (level 4) - Full access❌ team_manager (level 3) - View only❌ volunteer (level 2) - View only❌ member (level 1) - View onlyWho can assign/unassign services:
| Operation | Required Role | Authorization Check |
|---|---|---|
| View assignments | Any authenticated user | auth.uid() exists |
| Assign service | Event creator OR team_admin+ | event.created_by = user_id OR team_admin+ |
| Unassign service | Event creator OR team_admin+ | event.created_by = user_id OR team_admin+ |
Example Authorization Check:
import { canManageEventServices } from '#utils/event-service-authorization'
const canAssign = canManageEventServices({ userRole: 'team_admin', // User's role userDbId: 'user-uuid-123', // User's database ID event: { id: 'event-uuid', created_by: 'user-uuid-123' // Event creator }})
if (canAssign) { // User can assign services to this event} else { // User cannot assign services (not owner, not admin)}| Action | Old Model (011) | New Model (012) |
|---|---|---|
| Create service definition | Event owner | Admin only |
| Update service definition | Event owner (own events) | Admin (global) |
| Delete service definition | Event owner (own events) | Admin (global) |
| Assign service to event | Implicit (create service) | Event owner OR admin |
| Remove service from event | Event owner | Event owner OR admin |
Key Change: Service catalog management now requires admin privileges, ensuring quality control and consistency.
If you were using the old one-to-many model (migration 011), the migration to many-to-many (migration 012) automatically handles data migration.
The migration process:
Creates new tables
services (global catalog)event_service_assignments (junction table)Deduplicates services
Preserves all assignments
Verifies data integrity
Drops old table
event_services table removed (BREAKING CHANGE)✅ All event-service relationships preserved
event_servicesevent_service_assignments✅ Assignment timestamps preserved
created_at → assigned_at✅ Service metadata preserved
created_at for merged servicesupdated_at preservedBefore Migration:
-- event_services table: 50 rows-- All with name='Haircuts', description='Professional haircuts', capacity=20
SELECT COUNT(*) FROM event_servicesWHERE name = 'Haircuts'; -- Returns: 50After Migration:
-- services table: 1 row (deduplicated)SELECT COUNT(*) FROM servicesWHERE name = 'Haircuts'; -- Returns: 1
-- event_service_assignments table: 50 rowsSELECT COUNT(*) FROM event_service_assignmentsWHERE service_id IN ( SELECT id FROM services WHERE name = 'Haircuts'); -- Returns: 50 (all assignments preserved)Result:
Old Code (Migration 011):
// Create service for eventawait fetch('/api/event-services/create', { method: 'POST', body: JSON.stringify({ event_id: eventId, name: 'Haircuts', description: 'Professional haircuts', capacity: 20, csrf_token: csrfToken })})New Code (Migration 012):
// Step 1: Check if service exists in catalogconst catalogResponse = await fetch('/api/services')const services = await catalogResponse.json()
let serviceId = services.find(s => s.name === 'Haircuts')?.id
if (!serviceId) { // Step 2a: Create service in catalog (admin only) const createResponse = await fetch('/api/services/create', { method: 'POST', body: JSON.stringify({ name: 'Haircuts', description: 'Professional haircuts', capacity: 20, csrf_token: csrfToken }) }) const { id } = await createResponse.json() serviceId = id}
// Step 3: Assign service to eventawait fetch('/api/services/assign', { method: 'POST', body: JSON.stringify({ event_id: eventId, service_id: serviceId, csrf_token: csrfToken })})Follow these guidelines for optimal use of the service catalog and assignments.
Use descriptive, standardized names:
// ✅ Good: Clear, professional namename: 'Haircuts'
// ❌ Bad: Lowercase, informalname: 'haircut service'
// ❌ Bad: Too specific (should be in description)name: 'Haircuts by Bob the Barber'Add detailed descriptions:
// ✅ Good: Helpful detailsdescription: 'Professional haircut service provided by certified barbers. Includes trim and styling.'
// ❌ Bad: Redundant with namedescription: 'Haircuts'
// ❌ Bad: Empty when context would helpdescription: ''Set realistic capacity limits:
// ✅ Good: Based on actual resourcescapacity: 20 // We have 2 barbers, 10 slots each
// ❌ Bad: Arbitrary or unrealisticcapacity: 1000 // We don't have 1000 slotsStart with ‘active’ status:
// ✅ Good: Service ready to usestatus: 'active'
// ❌ Bad: Creating inactive service (clutter)status: 'inactive'Review and merge duplicates periodically:
-- Find potential duplicatesSELECT name, description, capacity, COUNT(*) as countFROM servicesGROUP BY name, description, capacityHAVING COUNT(*) > 1;Update service details globally:
// ✅ Good: Update affects all eventsawait db.updateService(serviceId, { description: 'Updated description with more details'})
// ❌ Bad: Creating new service instead of updatingawait db.insertService({ name: 'Haircuts', // Duplicate! description: 'New description'})Use ‘inactive’ status instead of deleting:
// ✅ Good: Preserves historyawait db.updateService(serviceId, { status: 'inactive' })
// ❌ Bad: Loses all assignmentsawait db.deleteService(serviceId) // CASCADE deletes all assignments!Browse catalog before creating new services:
// ✅ Good: Check existing services firstconst services = await db.getServices({ status: 'active' })const existingService = services.find(s => s.name === 'Haircuts')
if (existingService) { // Use existing service await assignServiceToEvent(eventId, existingService.id)} else { // Create new service (if admin) const newServiceId = await createService({ name: 'Haircuts', ... }) await assignServiceToEvent(eventId, newServiceId)}
// ❌ Bad: Always creating without checkingconst newServiceId = await createService({ name: 'Haircuts', ... })Use existing services for consistency:
// ✅ Good: Reuse catalog serviceawait assignServiceToEvent(eventId, 'haircuts-service-id')
// ❌ Bad: Trying to create event-specific service (not supported)// This requires admin privileges and creates global serviceawait createService({ name: 'Haircuts for Event A', ... })Assign services to parent recurring events:
// ✅ Good: Assign to parent (auto-copies to children)await assignServiceToEvent(parentEventId, serviceId)
// ❌ Bad: Manually assigning to each child instancefor (const childEvent of childEvents) { await assignServiceToEvent(childEvent.id, serviceId)}Common issues and their solutions.
Error:
409 Conflict: Service has already been assigned to this eventCause:
Solution:
// ✅ Check if service is already assigned before attemptingconst assignments = await fetch(`/api/services/assignments?event_id=${eventId}`) .then(r => r.json())
const alreadyAssigned = assignments.some(a => a.service_id === serviceId)
if (!alreadyAssigned) { await assignService(eventId, serviceId)} else { console.log('Service already assigned')}Error:
403 Forbidden: Insufficient permissions to manage service catalogCause:
Solution:
// For catalog managementif (userRole === 'team_admin' || userRole === 'admin' || userRole === 'super_admin') { // User can create/update/delete services await createService({ name: 'New Service', ... })} else { console.error('Service catalog management requires admin privileges')}
// For event assignmentsconst canAssign = event.created_by === userId || userRole.includes('admin')if (canAssign) { await assignService(eventId, serviceId)} else { console.error('Must own event or be admin to assign services')}Error:
404 Not Found: Service does not existCause:
Solution:
// ✅ Verify service exists before usingconst service = await fetch(`/api/services/${serviceId}`) .then(r => r.ok ? r.json() : null)
if (service) { await assignService(eventId, serviceId)} else { console.error('Service not found in catalog') // Browse catalog to find correct service const allServices = await fetch('/api/services').then(r => r.json()) console.log('Available services:', allServices)}Symptom:
Cause:
Solution:
// ✅ Use inactive status instead of deletingawait db.updateService(serviceId, { status: 'inactive' })// Service hidden from UI but assignments preserved
// ❌ Avoid: Deleting serviceawait db.deleteService(serviceId)// CASCADE deletes all event_service_assignments!Recovery: