🎯 Simple Setup
No database setup required - start using services immediately with the predefined catalog in services.config.ts.
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.
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.
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.
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> )}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.
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-..." }Required Fields:
name (string): Event nameevent_date (string): Date in YYYY-MM-DD formatcsrf_token (string): CSRF protection tokenOptional Fields:
services (string[]): Array of service IDslocation (string): Event locationdescription (string): Event descriptionstart_time (string): Time in HH:MM formatduration (number): Duration in minutesValidation 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.
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> )}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:
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 |
✅ Choose JSON when:
Example Scenarios:
✅ Choose M2M when:
Example Scenarios:
Both systems coexist - you don’t have to choose one exclusively:
Supabase (PostgreSQL):
services @> '["haircuts"]')Turso (LibSQL):
For Supabase:
services @> '["haircuts"]'::jsonbFor Turso:
WHERE event_date >= ? AND services LIKE ?LIMIT 50 to reduce result sizeEfficient 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)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}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"}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`)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'])// ✅ 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)// ✅ 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})// ✅ 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'))// ✅ 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 checkingservices.config.ts for available services