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​