Skip to main content

Data Model

Purpose: Document the Juniro API database schema and entity relationships Last Updated: February 2026 Source of Truth: juniro-api/prisma/schema.prisma


Overview

Juniro uses PostgreSQL with Prisma ORM. The data model supports:

  • Multi-region deployment (US and India with separate databases)
  • Multi-staff provider accounts (owner, admin, staff roles)
  • Flexible pricing (offerings layer between activities and sessions)
  • Geography normalization (admin-controlled country/city launch markets)
  • Messaging (parent ↔ provider conversations, feature-flagged)
  • Feature flags (global toggles for gradual rollout)
  • Idempotency (double-submit protection for payments and mutations)

Entity Relationship Diagram


Domain Groups

Geography (Admin-Controlled)

Controls which markets Juniro operates in. All geography data is admin-seeded.

TablePurpose
CountryCountries where Juniro operates (US, India)
CityCities within countries (launch markets)
LocalityNeighborhoods within cities (optional granularity)
Country (US, India)
└── City (Austin, Bangalore, ...)
└── Locality (Downtown, Koramangala, ...)

Users & Authentication

TablePurpose
UserBase user record, linked to Supabase Auth
ParentParent profile (PII: name, phone)
ChildChildren profiles (PII: name, birthdate)
ParentInterestCategory preferences per parent/child

Key Fields:

  • User.authId - Supabase Auth user ID
  • User.homeRegion - 'us' or 'in' (immutable after creation)
  • User.role - 'parent', 'provider', or 'admin'

Providers (Multi-Staff)

TablePurpose
ProviderAccountBusiness entity (can have multiple staff)
ProviderUserJoin table linking users to provider accounts
LocationPhysical locations owned by provider

Provider Roles:

  • owner - Full access, can manage staff
  • admin - Full access, cannot transfer ownership
  • staff - Limited access (view/create, no delete)

Lookup Pattern:

// Find provider for authenticated user
const providerUser = await db.providerUser.findFirst({
where: { userId: authUser.id },
include: { providerAccount: true }
})
const provider = providerUser?.providerAccount

Activities & Offerings

TablePurpose
CategoryActivity categories (see Category Structure below)
ActivityWhat the provider offers (Soccer Camp, Piano Lessons)
OfferingPricing packages for an activity
SessionSpecific time slots that can be booked

Offering Types:

  • single_session - One-time class
  • multi_session - Package of N sessions
  • camp - Multi-day camp
  • term - Semester enrollment

Hierarchy:

Activity (Soccer Skills Camp)
└── Offering (Drop-in: $25, 4-Week Package: $80)
└── Session (Feb 1 10am, Feb 8 10am, ...)

Category Structure:

Activities
├── Swimming
├── Dance
├── Art
├── Music
├── Sports
├── STEM
├── Martial Arts
└── Language

Camps
├── Day Camps
├── Summer Camps
└── Sports Camps

Childcare & Early Education
├── Daycare Centers
├── Preschools
├── Montessori
├── Pre-K Programs
└── After-School Care

Parties & Events
├── Entertainers
├── Party Places
└── Mobile Services

Wellness (Phase 2)
├── Pediatric Dentists
├── Therapists
└── Specialists

Medical (Phase 3)
├── Pediatricians
├── Clinics
└── Children's Hospitals

Bookings & Payments

TablePurpose
BookingParent books child into a session
PaymentPayment records (Stripe/Razorpay)
ReviewPost-activity reviews

Booking Status Flow:

pending_payment → confirmed → completed

cancelled / no_show / expired

Payment Status Flow:

created → pending → paid

failed → (retry) → paid

refunded

Messaging (Feature-Flagged)

Parent ↔ Provider conversations. Controlled by the MESSAGING_ENABLED feature flag.

TablePurpose
ConversationThread between a parent and a provider (optionally linked to a booking)
MessageIndividual message within a conversation

Key Design Decisions:

  • One conversation per parent–provider pair (@@unique([parentId, providerId]))
  • Conversations can be optionally linked to a specific Booking
  • Read tracking is split: parentReadAt and providerReadAt (each side tracks independently)
  • Messages support attachments via a JSON array ({ url, type, name })

Conversation Status Flow:

active → archived

blocked

Feature Flags

Global boolean toggles for gradual feature rollout. Admin-managed.

TablePurpose
FeatureFlagKey-value toggle (e.g., MESSAGING_ENABLED, AI_SEARCH_ENABLED)

Usage Pattern:

const flag = await db.featureFlag.findUnique({ where: { key: 'MESSAGING_ENABLED' } });
if (!flag?.value) throw new ForbiddenError('Messaging is not enabled');

Idempotency

Prevents double-submit on mutations (especially payments). Keys auto-expire.

TablePurpose
IdempotencyKeyMaps a client-generated key to a cached response

How It Works:

  1. Client sends a unique Idempotency-Key header
  2. Server checks if key exists → returns cached response if so
  3. Otherwise, processes the request and stores the result
  4. A cleanup job purges expired keys (indexed on expiresAt)

Key Tables Detail

User

ColumnTypeDescription
idUUIDPrimary key
authIdStringSupabase Auth ID (unique)
emailStringUser email
homeRegionString'us' or 'in' - immutable
countryIdUUID?FK to Country
roleString'parent', 'provider', 'admin'

ProviderAccount

ColumnTypeDescription
idUUIDPrimary key
businessNameStringDisplay name
slugStringURL-safe identifier (unique)
typeString'business' or 'individual'
statusString'pending', 'verified', 'suspended', 'rejected'
verifiedAtDateTime?When admin verified

Activity

ColumnTypeDescription
idUUIDPrimary key
providerIdUUIDFK to ProviderAccount
titleStringActivity title
slugStringURL-safe (unique per provider)
categoryIdUUIDFK to Category
ageMin / ageMaxInt?Age range
durationInt?Minutes
statusString'draft', 'published', 'archived'

Offering

ColumnTypeDescription
idUUIDPrimary key
activityIdUUIDFK to Activity
typeString'single_session', 'multi_session', 'camp', 'term'
nameString?Display name (e.g., "4-Week Package")
priceCentsIntPrice in cents
currencyString'USD' or 'INR'
sessionCountInt?For multi_session type
statusString'active' or 'inactive'

Session

ColumnTypeDescription
idUUIDPrimary key
activityIdUUIDFK to Activity
offeringIdUUIDFK to Offering
locationIdUUIDFK to Location
startsAtDateTimeSession start time
endsAtDateTimeSession end time
capacityIntMax attendees
enrolledIntCurrent enrollment count
statusString'scheduled', 'cancelled', 'completed'

Booking

ColumnTypeDescription
idUUIDPrimary key
sessionIdUUIDFK to Session
offeringIdUUIDFK to Offering
parentIdUUIDFK to Parent
childIdUUIDFK to Child
statusStringSee status flow above
expiresAtDateTime?For pending_payment timeout

Location

ColumnTypeDescription
idUUIDPrimary key
providerIdUUIDFK to ProviderAccount
nameStringLocation name
addressStringStreet address
cityIdUUIDFK to City
localityIdUUID?FK to Locality (optional)
latitude / longitudeFloat?Coordinates
isDefaultBooleanDefault location for provider

Payment

ColumnTypeDescription
idUUIDPrimary key
bookingIdUUIDFK to Booking
amountCentsIntAmount in smallest currency unit (cents/paise)
currencyString'USD' or 'INR'
processorString'stripe' (US) or 'razorpay' (India)
processorPaymentIdString?External payment ID from processor
processorCheckoutIdString?External checkout/session ID
statusStringSee Payment Status Flow above
statusReasonString?Human-readable reason for status change
idempotencyKeyString?Unique key for double-submit protection
paidAtDateTime?Timestamp when payment succeeded
failedAtDateTime?Timestamp when payment failed
refundedAtDateTime?Timestamp when refund completed

Review

ColumnTypeDescription
idUUIDPrimary key
bookingIdUUIDFK to Booking (unique — one review per booking)
parentIdUUIDFK to Parent
ratingInt1–5 star rating (CHECK constraint: 1 ≤ rating ≤ 5)
commentString?Parent's written review
responseString?Provider's response to the review
respondedAtDateTime?When the provider responded

Conversation

ColumnTypeDescription
idUUIDPrimary key
parentIdUUIDFK to Parent
providerIdUUIDFK to ProviderAccount
bookingIdUUID?FK to Booking (optional context)
subjectString?Conversation subject line
statusString'active', 'archived', 'blocked'
lastMessageAtDateTime?Timestamp of most recent message
parentReadAtDateTime?When the parent last read
providerReadAtDateTime?When the provider last read

Message

ColumnTypeDescription
idUUIDPrimary key
conversationIdUUIDFK to Conversation
senderIdUUIDFK to User (parent or provider staff)
senderTypeString'parent' or 'provider'
contentStringMessage body text
attachmentsJson?Array of { url, type, name }
readAtDateTime?When the recipient read the message

FeatureFlag

ColumnTypeDescription
idUUIDPrimary key
keyStringUnique flag name (e.g., 'MESSAGING_ENABLED')
valueBooleanWhether the feature is on or off
descriptionString?Human-readable explanation

IdempotencyKey

ColumnTypeDescription
keyStringPrimary key — client-generated unique key
userIdStringUser who made the request
responseJsonCached response body
expiresAtDateTimeAuto-expiry timestamp (indexed for cleanup)

Indexes & Constraints

Unique Constraints

TableConstraint
UserauthId
ProviderAccountslug
Activity(providerId, slug)
City(countryId, slug)
Locality(cityId, slug)
ProviderUser(providerAccountId, userId)
ReviewbookingId (one review per booking)
ParentInterest(parentId, categoryId, childId)
Conversation(parentId, providerId) (one thread per pair)
PaymentidempotencyKey
FeatureFlagkey
Categoryname, slug

Important Indexes

TableIndexPurpose
PaymentprocessorPaymentIdWebhook lookup
IdempotencyKeyexpiresAtCleanup job
ConversationparentIdParent inbox query
ConversationproviderIdProvider inbox query
MessageconversationIdThread message listing
MessagesenderIdUser message history

Data Integrity Rules

Business Rules (Enforced in Code)

  1. Booking capacity: session.enrolled <= session.capacity
  2. Review eligibility: Only completed bookings can be reviewed
  3. One review per booking: Unique constraint on Review.bookingId
  4. Provider response: One response per review
  5. Age validation: activity.ageMin <= activity.ageMax
  6. Session timing: session.startsAt < session.endsAt
  7. Rating range: 1 <= review.rating <= 5 (CHECK constraint)
  8. Messaging gate: Conversations can only be created when MESSAGING_ENABLED feature flag is true
  9. One conversation per pair: Unique constraint on (parentId, providerId) — new messages go into existing thread
  10. Idempotency expiry: Keys auto-expire; cleanup job deletes rows where expiresAt < now()

Cascade Deletes

ParentChildBehavior
UserParentCASCADE
ParentChildCASCADE
ParentParentInterestCASCADE
ProviderAccountProviderUserCASCADE
ProviderAccountLocationCASCADE
ProviderAccountActivityCASCADE
ActivityOfferingCASCADE
ActivitySessionCASCADE
ConversationMessageCASCADE

Enum & Status Values Reference

All status and type fields are stored as strings in the database (not Prisma enums) for flexibility. This table is the single reference for all allowed values.

Entity Statuses

TableFieldAllowed ValuesNotes
Userroleparent, provider, adminSet at signup
UserhomeRegionus, inImmutable after creation
ProviderAccountstatuspending, verified, suspended, rejectedAdmin-controlled
ProviderAccounttypebusiness, individualSet at signup
ProviderUserroleowner, admin, staffPer-staff permissions
Activitystatusdraft, published, archivedProvider-controlled
Offeringtypesingle_session, multi_session, camp, termDetermines pricing model
Offeringstatusactive, inactiveProvider-controlled
Sessionstatusscheduled, cancelled, completedLifecycle
Bookingstatuspending_payment, confirmed, cancelled, completed, no_show, expiredSee Booking Status Flow
Paymentstatuscreated, pending, paid, failed, refundedSee Payment Status Flow
Paymentprocessorstripe, razorpayRegion-dependent
PaymentcurrencyUSD, INRRegion-dependent
OfferingcurrencyUSD, INRRegion-dependent
Conversationstatusactive, archived, blockedMessaging lifecycle
MessagesenderTypeparent, providerWho sent the message
Reviewrating1, 2, 3, 4, 5Integer, CHECK constraint

Boolean Flags

TableFieldDefaultPurpose
CountryisActivefalseAdmin enables launch markets
CityisActivefalseAdmin enables launch cities
LocalityisActivefalseAdmin enables neighborhoods
CategoryisActivetrueToggle category visibility
LocationisDefaultfalseProvider's primary location
FeatureFlagvaluefalseGlobal feature toggle

PII Classification

Fields containing Personally Identifiable Information (PII) — these must never be replicated across regions.

TablePII FieldsSensitivity
UseremailMedium
ParentfirstName, lastName, phoneHigh
ChildfirstName, lastName, birthDateHigh
Locationaddress, latitude, longitudeMedium
PaymentprocessorPaymentId, processorCheckoutIdHigh
Messagecontent, attachmentsHigh

Multi-Region Considerations

Each region (US, India) has its own database with identical schema.

Never replicate across regions:

  • User PII (name, phone, address)
  • Child data
  • Payment details

Can share (read-only):

  • Category definitions
  • Non-PII activity metadata

User assignment:

  • homeRegion is set at signup based on location
  • Users can only write to their home region
  • Cross-region reads allowed for public data

Schema Evolution

Adding New Tables

  1. Add model to prisma/schema.prisma
  2. Run npm run db:migrate to create migration
  3. Update relevant route files
  4. Update this documentation

Migration Best Practices

  • Always use migrations (never db push in production)
  • Make columns nullable first, then backfill, then make required
  • Add indexes in separate migration from data changes
  • Test migrations on staging before production

References