A holistic health practitioner directory enabling discovery via faceted search with geolocation, appointment booking, Stripe Connect payments, and Twilio SMS reminders. Built on Next.js 16 with React Server Components.
| Service | Status | Integration Level |
|---|---|---|
| PostgreSQL + Prisma 6 | Active | Full CRUD, faceted search, SMS logging |
| Clerk Auth | Active | OAuth sign-in/up, webhook sync, route protection |
| Twilio SMS | Active | Reminders, opt-out, status tracking, cron batch |
| Typesense Search | Initialized | Client created, dual keys — no collections or sync |
| Stripe Connect | Initialized | Client created — no payment endpoints |
| Redis Cache | Initialized | Singleton created — health check only |
All service clients use the singleton pattern with global reference to prevent connection leaks during Next.js hot reload. Production instances are created once per process.
Prisma singleton with connection pooling. Dev logging: queries + errors. Prod: errors only. Extensions: pg_trgm + pgvector.
ioredis singleton. Max 3 retries per request. Exponential backoff: 50ms × n, capped at 2s.
HTTPS on port 443. 2s connection timeout. Dual keys: admin (server) + search-only (NEXT_PUBLIC_ client).
Account SID + Auth Token init. E.164 phone format. Helper functions: sendSMS, formatPhoneNumber, isValidPhoneNumber.
API v2025-10-29.clover. TypeScript mode. Throws on missing STRIPE_SECRET_KEY env var.
ClerkProvider wraps root layout. Webhook signature verification at /api/webhooks/clerk. OAuth redirect flows.
8 core entities with PostgreSQL. Snake_case fields in DB, camelCase aliases in API responses. Decimals serialized to numbers. Arrays stored as PostgreSQL text arrays.
phone field. The SMS reminder pipeline executes correctly but produces zero delivered messages — all reminders bounce with error code NO_PHONE_NUMBER.
| Table | Index | Purpose |
|---|---|---|
| practitioners | [city, state] | Location filtering |
| practitioners | [is_active, is_verified] | Active/verified filtering |
| practitioners | [latitude, longitude] | Geo queries |
| bookings | [scheduled_at] | Reminder window queries |
| bookings | [status] | Status filtering |
| sms_logs | [to_phone_number] | Opt-out lookup |
| sms_logs | [booking_id] | Reminder deduplication |
| reviews | [is_published, practitioner_id] | Published reviews per practitioner |
| Route | Method | Auth | Purpose |
|---|---|---|---|
/api/practitioners | GET | Public | Search/filter with 9 query params + faceted counts |
/api/practitioners/[id] | GET | Public | Full profile with services, reviews, availability |
/api/health | GET | Public | DB + Redis + Typesense health with latency |
/api/reminders/send | POST | Bearer | Send SMS reminder for specific booking |
/api/cron/reminders | POST | CRON_SECRET | Batch send 24-hour reminders (hourly cron) |
/api/webhooks/clerk | POST | Signature | User lifecycle sync |
/api/webhooks/stripe | POST | Signature | Payment event processing |
/api/webhooks/twilio-sms | POST | — | SMS status updates + inbound messages |
Server-first architecture with clear RSC → Client boundaries. All interactive UI uses "use client". State management via useState + useEffect — no external state library.
| Group | Path | Auth | Purpose |
|---|---|---|---|
(auth) | /sign-in, /sign-up | Public | Clerk authentication UI |
(public) | /directory, /directory/[id] | Public | Practitioner discovery |
(protected) | /dashboard/* | Clerk | User management (placeholder) |
DirectoryPage (server) passes searchParams to DirectoryClient (client). The client manages all filter state and API fetching. Filter changes trigger: state update → useEffect → buildAPIUrl() → fetch → re-render with new results + counts.
"Near Me" button triggers navigator.geolocation. 10s timeout. No cache. Passes lat/lng to API with radius=25mi.
Auto-requests on mount. 5-minute cache (maximumAge: 300000). Shows DistanceBadge with color coding: near (≤3mi), moderate (≤10mi), far (>10mi).
Current search is SQL-based via Prisma queries with dynamic WHERE clauses. Typesense is initialized but not integrated.
| Filter | Implementation | Scaling Note |
|---|---|---|
| Specialties | PostgreSQL array hasSome | Fine for thousands; slow at 100K+ |
| Full-text | OR across 8+ fields with contains | No ranking, no fuzzy matching |
| Distance | In-memory Haversine post-query | O(n) — fetches all, then filters |
| Faceted counts | Computed from result set | Accurate but redundant computation |
| Locations | OR conditions on city/state pairs | 15 states, curated city lists |
| Ratings | average_rating >= threshold | Simple, performant |
12 parent categories containing 155+ subcategories. Hierarchical UI with expand/collapse, indeterminate checkbox states, and count badges. Categories span from Acupuncture & TCM (8 subcategories) to Mental Health & Counseling (29 subcategories).
The SMS system is the most mature integration — full lifecycle from cron scheduling to delivery tracking to opt-out handling.
Hourly trigger. Secured by CRON_SECRET (Bearer token or x-cron-secret header). Processes up to 100 bookings per run.
23-24 hours before appointment. Deduplication via sms_logs check (SENT or DELIVERED status).
50ms delay between sends. First 10 errors tracked in response. Batch continues on individual failures.
STOP/UNSUBSCRIBE/QUIT/CANCEL keywords detected. All sms_logs for that phone marked UNSUBSCRIBED.
These items require stakeholder input before engineering can proceed. Each has a recommended path.
These items activate when current priorities and client decisions resolve.
| Item | Blocked On | Current State |
|---|---|---|
| Typesense Collection Sync | CPI-1 (Search priority) | Client initialized, no collections |
| Stripe Connect Onboarding | CPI-2 (Payment model) | Client initialized, stripe_account_id field exists |
| SMS Delivery Activation | CPI-3 (Phone source) | Pipeline functional, zero deliveries |
| Dashboard Feature Build | CPI-5 (Dashboard scope) | Layout + routes exist, pages are placeholders |
| Booking Conflict Detection | CPI-7 (Time slots) | Day-level availability, no slot validation |
| Semantic Search (pgvector) | D-1 complete (Typesense first) | search_vector field exists, embedding pipeline undesigned |