The JSON services approach provides a simple, type-safe way to associate predefined services with events using a JSON array stored directly in the events table. This approach eliminates the need for JOIN queries and provides excellent performance with minimal setup.
🎯 Simple Setup
No database setup required - start using services immediately with the predefined catalog in services.config.ts.
⚡ Type Safe
Compile-time validation ensures only valid service IDs can be used throughout your application.
🚀 Fast Queries
JSONB with GIN indexing (Supabase) provides sub-20ms queries for filtering events by service.
🔄 Coexists with M2M
Works alongside M2M architecture for gradual migration - choose the right approach per event.
Quick Start
Section titled “Quick Start”Creating an event with JSON services is straightforward:
import { getDatabase } from '#libs/database'import type { EventData } from '#libs/database-types'
const db = getDatabase()
const eventData: EventData = { name: 'Community Outreach Event', event_date: '2026-02-15', location: 'Downtown Park', description: 'Monthly community services fair', start_time: '09:00', duration: 180, services: ['haircuts', 'showers', 'clothing'], // JSON array of service IDs created_by: userId,}
const eventId = await db.insertEvent(eventData)The services array is stored as JSONB (Supabase) or TEXT (Turso) in the database and validated against the predefined service catalog.
Service Catalog
Section titled “Service Catalog”Services are defined in src/constants/services.config.ts as a readonly array:
| Service ID | Name | Description |
|---|---|---|
haircuts | Haircuts | Professional haircutting services |
showers | Showers | Shower facilities and maintenance |
clothing | Clothing | Professional clothing and wardrobe services |
videography | Videography | Professional video recording and editing |
Adding New Services:
Edit services.config.ts to add new services to the catalog:
export const SERVICES = [ { id: 'haircuts', name: 'Haircuts', description: 'Professional haircutting services', displayOrder: 1, active: true, }, { id: 'meals', // New service name: 'Meals', description: 'Hot meal service', displayOrder: 5, active: true, }, // ... other services] as const satisfies readonly ServiceDefinition[]The ServiceId type automatically updates to include the new service, providing compile-time validation throughout your application.
Using Services in Forms
Section titled “Using Services in Forms”EventFormRHF Component
Section titled “EventFormRHF Component”The EventFormRHF component renders service checkboxes using the active service catalog:
import { useForm, Controller } from 'react-hook-form'import { zodResolver } from '@hookform/resolvers/zod'import { eventSchema, type EventFormData } from '#schemas/event.schema'import { getActiveServices } from '#constants/services.config'
export const EventFormRHF: React.FC<Props> = ({ csrfToken, event }) => { const activeServices = getActiveServices()
const { register, handleSubmit, control, formState: { errors } } = useForm<EventFormData>({ resolver: zodResolver(eventSchema), defaultValues: { services: event?.services ?? [], // Load existing services // ... other fields }, })
return ( <form onSubmit={handleSubmit(onSubmit)}> {/* Service checkboxes */} <fieldset> <legend>Services Offered</legend> <Controller name="services" control={control} render={({ field }) => ( <div> {activeServices.map(service => ( <label key={service.id}> <input type="checkbox" value={service.id} checked={field.value?.includes(service.id)} onChange={e => { const current = field.value || [] const newValue = e.target.checked ? [...current, service.id] : current.filter(id => id !== service.id) field.onChange(newValue) }} /> <span>{service.name}</span> <small>{service.description}</small> </label> ))} </div> )} /> {errors.services && <span>{errors.services.message}</span>} </fieldset>
<button type="submit">Create Event</button> </form> )}Validation
Section titled “Validation”The form automatically validates service IDs using Zod:
// Validation occurs when form is submittedconst result = eventSchema.safeParse(formData)
if (!result.success) { // Error: "One or more service IDs are invalid or inactive" console.error(result.error.flatten())}Invalid or inactive service IDs are rejected before reaching the API.
API Integration
Section titled “API Integration”Creating Events with Services
Section titled “Creating Events with Services”Send a POST request to /api/events/create with services in the request body:
const response = await fetch('/api/events/create', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: 'Community Outreach', event_date: '2026-02-15', location: 'Downtown Park', services: ['haircuts', 'showers', 'clothing'], csrf_token: csrfToken, }),})
if (!response.ok) { const error = await response.json() console.error('Event creation failed:', error) return}
const result = await response.json()// { success: true, eventId: "550e8400-..." }Request Payload
Section titled “Request Payload”Required Fields:
name(string): Event nameevent_date(string): Date in YYYY-MM-DD formatcsrf_token(string): CSRF protection token
Optional Fields:
services(string[]): Array of service IDslocation(string): Event locationdescription(string): Event descriptionstart_time(string): Time in HH:MM formatduration(number): Duration in minutes
Error Handling
Section titled “Error Handling”Validation Errors (400):
{ "error": "Validation failed", "details": { "fieldErrors": { "services": ["One or more service IDs are invalid or inactive"] } }}Authentication Errors (401):
{ "error": "Unauthorized"}Display validation errors inline in the form next to the services field.
Displaying Services
Section titled “Displaying Services”EventServiceList Component
Section titled “EventServiceList Component”Display resolved service names and descriptions for an event:
import { resolveServices } from '#constants/services.config'import type { Event } from '#libs/database-types'
type Props = { event: Event}
export const EventServiceList: React.FC<Props> = ({ event }) => { const services = resolveServices(event.services ?? [])
if (services.length === 0) { return <p>No services offered</p> }
return ( <ul> {services.map(service => ( <li key={service.id}> <strong>{service.name}</strong> <span>{service.description}</span> </li> ))} </ul> )}Resolving Service IDs
Section titled “Resolving Service IDs”The resolveServices() utility converts service IDs to full definitions:
import { resolveServices } from '#constants/services.config'
const event = await db.getEvent(eventId)const services = resolveServices(event.services ?? [])
// services = [// { id: 'haircuts', name: 'Haircuts', description: '...', ... },// { id: 'showers', name: 'Showers', description: '...', ... },// ]
// Display as comma-separated listconst serviceNames = services.map(s => s.name).join(', ')// "Haircuts, Showers, Clothing"Graceful Degradation:
- Inactive services are filtered out silently
- Unknown service IDs are ignored (no errors thrown)
- Ensures UI never displays invalid services
Decision Guide: JSON vs M2M
Section titled “Decision Guide: JSON vs M2M”Choose the right approach for your use case:
| Criterion | JSON Services | M2M Architecture |
|---|---|---|
| Setup | Zero - use config file | Requires DB records |
| Flexibility | Fixed catalog in code | Dynamic via admin UI |
| Performance (Supabase) | Excellent (~10-20ms) | Good (~15-30ms) |
| Performance (Turso) | Moderate (~50-100ms) | Good (~15-30ms) |
| Type Safety | Compile-time | Runtime |
| Service Metadata | ID + name only | Full (capacity, status) |
| Best For | Predefined catalogs | Dynamic catalogs |
When to Use JSON Services
Section titled “When to Use JSON Services”✅ Choose JSON when:
- Services are predefined and rarely change
- You need simple categorization (no capacity/pricing)
- You want maximum performance on Supabase
- You prefer code-based configuration over admin UI
- Your team is comfortable editing TypeScript
Example Scenarios:
- Event categories (workshops, meals, clothing drives)
- Appointment types (consultation, followup, emergency)
- Resource types (projector, whiteboard, microphone)
When to Use M2M
Section titled “When to Use M2M”✅ Choose M2M when:
- Services are added dynamically by admins
- You need rich metadata (capacity, pricing, images)
- You have per-service configuration needs
- You want an admin UI for service management
- You need analytics on service usage
Example Scenarios:
- Marketplace with seller-defined services
- Healthcare with specialty departments
- Event venues with configurable amenities
Migration Path
Section titled “Migration Path”Both systems coexist - you don’t have to choose one exclusively:
- New events: Use JSON services for simplicity
- Legacy events: Keep M2M if already implemented
- Gradual migration: Move events as they’re updated
- Hybrid approach: Use both based on event type
Performance Considerations
Section titled “Performance Considerations”Database Provider Differences
Section titled “Database Provider Differences”Supabase (PostgreSQL):
- JSONB Type: Native binary JSON with operator support
- GIN Index: Fast containment queries (
services @> '["haircuts"]') - Query Time: ~10-20ms for service filtering
- Scaling: Excellent - index remains effective at 1M+ events
Turso (LibSQL):
- TEXT Type: JSON stored as string (no native support)
- No Indexing: Full table scans for service queries
- Query Time: ~50-100ms for service filtering
- Scaling: Moderate - use date filters to limit scans
Optimization Tips
Section titled “Optimization Tips”For Supabase:
- Use JSONB containment operator:
services @> '["haircuts"]'::jsonb - GIN index automatically optimizes queries
- Single-service queries are fastest (~10ms)
For Turso:
- Combine service filter with date range:
WHERE event_date >= ? AND services LIKE ? - Use pagination:
LIMIT 50to reduce result size - Consider M2M for datasets > 50K events
Query Examples
Section titled “Query Examples”Efficient service query (Supabase):
// Abstraction layer handles JSONB operatorconst haircutEvents = await db.getEventsByService('haircuts')// Uses: WHERE services @> '["haircuts"]'::jsonb// Result: ~10-20ms with GIN indexDate-filtered query (Turso):
// Combine filters to reduce scan sizeconst upcomingHaircutEvents = await db.getEvents({ from_date: new Date().toISOString().split('T')[0], service: 'haircuts',})// Uses: WHERE event_date >= ? AND services LIKE '%"haircuts"%'// Result: ~30-60ms (reduced from 50-100ms full scan)Code Examples
Section titled “Code Examples”Complete Event Creation Flow
Section titled “Complete Event Creation Flow”import { getDatabase } from '#libs/database'import { getActiveServices, resolveServices } from '#constants/services.config'import type { EventData } from '#libs/database-types'
async function createEventWithServices(userId: string) { const db = getDatabase()
// 1. Get active services for form display const activeServices = getActiveServices() console.log('Available services:', activeServices.map(s => s.name))
// 2. Create event with selected services const eventData: EventData = { name: 'Community Outreach', event_date: '2026-02-15', location: 'Downtown Park', services: ['haircuts', 'showers', 'clothing'], created_by: userId, }
const eventId = await db.insertEvent(eventData)
// 3. Retrieve and display created event const event = await db.getEvent(eventId) const services = resolveServices(event.services ?? [])
console.log(`Event created: ${event.name}`) console.log('Services:', services.map(s => s.name).join(', '))
return event}Validating Service IDs
Section titled “Validating Service IDs”import { validateServiceIds } from '#constants/services.config'
// Validate before sending to APIfunction validateFormData(services: string[]) { const invalidIds = validateServiceIds(services)
if (invalidIds.length > 0) { throw new Error( `Invalid service IDs: ${invalidIds.join(', ')}` ) }
return true}
// Usageconst userSelection = ['haircuts', 'invalid', 'showers']
try { validateFormData(userSelection)} catch (error) { console.error(error.message) // "Invalid service IDs: invalid"}Querying Events by Service
Section titled “Querying Events by Service”import { getDatabase } from '#libs/database'import { resolveServices } from '#constants/services.config'
async function findEventsOfferingService(serviceId: string) { const db = getDatabase()
// Database abstraction handles provider differences const events = await db.getEventsByService(serviceId)
// Resolve services for display const eventsWithServices = events.map(event => ({ ...event, resolvedServices: resolveServices(event.services ?? []), }))
return eventsWithServices}
// Usageconst haircutEvents = await findEventsOfferingService('haircuts')console.log(`Found ${haircutEvents.length} events offering haircuts`)Filtering Events by Multiple Services
Section titled “Filtering Events by Multiple Services”import { getDatabase } from '#libs/database'
async function findEventsWithAllServices(serviceIds: string[]) { const db = getDatabase()
// Get all events (or use date filter for performance) const allEvents = await db.getEvents({ from_date: new Date().toISOString().split('T')[0], })
// Filter in application layer for multiple services const filtered = allEvents.filter(event => { const eventServices = event.services ?? [] return serviceIds.every(id => eventServices.includes(id)) })
return filtered}
// Usage: Find events offering both haircuts AND showersconst events = await findEventsWithAllServices(['haircuts', 'showers'])Best Practices
Section titled “Best Practices”1. Always Use Validation Utilities
Section titled “1. Always Use Validation Utilities”// ✅ Good - Use validation utilitiesimport { validateServiceIds, resolveServices } from '#constants/services.config'
const invalidIds = validateServiceIds(formData.services)if (invalidIds.length > 0) { throw new Error('Invalid services selected')}
// ❌ Bad - Manual validationconst services = formData.services.filter(id => ['haircuts', 'showers'].includes(id) // Hard-coded, out of sync)2. Graceful Degradation with resolveServices
Section titled “2. Graceful Degradation with resolveServices”// ✅ Good - Graceful degradationconst services = resolveServices(event.services ?? [])// Filters out inactive/unknown services silently
// ❌ Bad - Fail on unknown servicesconst services = event.services.map(id => { const service = getServiceById(id) if (!service) throw new Error(`Unknown service: ${id}`) return service})3. Combine Filters for Performance (Turso)
Section titled “3. Combine Filters for Performance (Turso)”// ✅ Good - Date filter reduces scan sizeconst events = await db.getEvents({ from_date: '2026-01-01', to_date: '2026-12-31', service: 'haircuts',})
// ❌ Bad - Full table scanconst allEvents = await db.getEvents()const filtered = allEvents.filter(e => e.services?.includes('haircuts'))4. Type-Safe Service IDs
Section titled “4. Type-Safe Service IDs”// ✅ Good - Import ServiceId typeimport type { ServiceId } from '#constants/services.config'
const selectedServices: ServiceId[] = ['haircuts', 'showers'] // Type-checked
// ❌ Bad - Plain stringsconst selectedServices: string[] = ['haircuts', 'invalid'] // No type checkingRelated Documentation
Section titled “Related Documentation”- Events System Guide - Overview of the events system
- Event Services M2M Guide - Alternative M2M approach
- Event Services Components - UI components for services
- JSON Services Technical Docs - Implementation details
- Recurring Events - Creating recurring events
Next Steps
Section titled “Next Steps”- Review Service Catalog: Check
services.config.tsfor available services - Create Your First Event: Use EventFormRHF component or API directly
- Display Services: Use EventServiceList component in event detail pages
- Monitor Performance: Check query times and optimize as needed
- Consider Migration: Evaluate JSON vs M2M for your specific use case