Skip to content

Domain Models

Field-level notes on the models you will actually touch. The schema itself (packages/database/prisma/schema.prisma) is the source of truth — this page explains the parts that are not obvious from reading it.

Identity

User

The largest model in the schema, and the hub of nearly every relation.

FieldNotes
emailUnique, required
usernameUnique, nullable — set during onboarding, not at registration
display_name, first_name, last_name, bio, birthdate, genderProfile; snake_case on this model only
passwordbcrypt hash
role"user" by default — coarse platform role
userType"user" or "playpal"this is the creator flag
onboardedGates the onboarding flow
tokenVersionIncremented to revoke every issued JWT. See Authentication
monthlyPriceCreator subscription price in cents, default 499 ($4.99)
revenueCatUserIdUnique link to RevenueCat
subscriptionTiernull | "pro" | "elite" — platform tier, not creator subscriptions
stripeAccountIdStripe Connect account, unique
payoutsEnabledGates inclusion in a payout run
isLive, activeLiveStreamIdDenormalised live state
playcoin_balanceDuplicates Wallet.coinBalance — see the warning in Overview

role and userType are two separate concepts. userType is the one that determines whether someone is a creator.

AuthSession

Login audit records — device name, IP, user agent, isActive. Powers the "active sessions" and "login history" screens. Not consulted during authentication; only tokenVersion revokes access.

Badge / UserBadge

Badges differentiate user subtypes. The PlayPal badge marks creators; other badges are earned. A user picks one to display and the rest sit in an earned tab.

Block

@@unique([blockerId, blockedId]), cascading on both sides. Feed, search, and messaging queries are expected to filter against it.

Content

Post

FieldNotes
body@db.Text
type"text" | "clip" | "photos" — also read by ogun to pick image variants
visibility"public" | "private" | "followers", nullable
commentableAuthor can disable comments
status"pending" until media finishes processing, then "ready"
moderationStatusNullable; no moderation pipeline is wired up yet
gameIdOptional game tag

Media and Variant

Media is the original upload; Variant rows are the generated sizes.

Media fieldNotes
type"image" | "video"
originalUrl, storageKeyLocation in Spaces
status"pending""ready" | "failed"
errorMessagePopulated on failure — check here first when processing breaks
muxAssetId, muxPlaybackIdVideo only
orderPosition within a carousel

Variant.type is one of thumb, medium, full, story, cover. See Media Pipeline.

Reaction

Polymorphic — either postId or commentId is set. kind is the reaction type.

prisma
@@unique([userId, postId, commentId, kind], name: "unique_reaction")

PostImpression

@@unique([userId, postId]) means one impression per user per post, ever. viewDurationMs and videoWatchMs accumulate engagement depth rather than adding rows.

Mention

Polymorphic across postId and commentId, cascading from both, with separate relations for the mentioned user and the mentioner.

Messaging

Two parallel systems that do not share models.

Direct messages

Conversation ──< ConversationUser ──> User

     ├──< ConversationMessage
     └──< ConversationReadState
ModelNotes
ConversationdmKey is a unique deterministic key derived from the participant pair, so opening a DM is an upsert
ConversationMessagetype is "text" or "shared_post"; sharedPostId links the shared post. Soft-deleted via deletedAt
ConversationReadStatelastReadAt + lastReadMsgId per user per conversation — drives unread counts

Channels

Channel ──< ChannelRoom ──< ChannelMessage
   │             ├──< ChannelRoomParticipant   (live presence)
   │             └──< ChannelRoomReadState
   └──< ChannelMember
ModelNotes
ChannelOwned by a creator (userId)
ChannelRoomtype: chat | voice | video | e-date. visibility: public | members | vip | private — enforced by canAccessRoom
ChannelRoomParticipantLive state: isMuted, isCameraOff, isDeafend, isSpeaking. Ephemeral in nature but stored
ChannelMemberMembership with a role

ChannelRoom.visibility is the channel paywall. See Livestreaming & Voice.

Sessions

Session

A booked 1-on-1 between a creator (playpalId) and a fan (purchaserId).

FieldNotes
startDate, endDate, durationDuration is stored as well as derivable — keep them consistent
channelRoomIdThe private room created for the session
statusscheduled | active | completed | cancelled
cancelledAtSet on cancellation

private room visibility grants access only to these two users.

Availability

Doubles as both a recurring schedule and a blocked-date list:

ShapeFields used
Recurring weekly hoursdayOfWeek, startTime, endTime
A blocked calendar datedate, blocked: true

startTime / endTime are String, not DateTime — they are wall-clock times like "09:00". There is no timezone column, so times are implicitly in some unstated zone. Worth resolving before sessions span regions.

Commerce

Wallet and Ledger

Wallet holds balances (coinBalance in whole coins, balance in cents). Ledger is the append-only record of every change.

Ledger fields worth knowing:

FieldPurpose
typeLedgerType enum — the one place status is genuinely enforced
coinDelta, usdDeltaSigned changes
revenueCatEventId, stripeEventId, stripeChargeId, stripeInvoiceId, stripeTransferId, stripePayoutIdExternal references for reconciliation
idempotencyKeyUnique — the guard against double-posting
memoHuman-readable note

Never update a ledger row. Corrections are new ADJUSTMENT entries.

Payout

One row per creator per period: amount (cents), status (pendingcompleted | failed), stripeTransferId, stripePayoutId. Written by anansi.

Product, Order, OrderItem, Inventory

The in-app store — avatars, frames, banners, profile backgrounds. Inventory records what a user owns; UserAsset records what they have equipped.

Subscriptions

Two distinct models, easy to confuse:

ModelMeaning
SubscriptionThe user's platform tier (pro, elite)
CreatorSubscriptionA fan's subscription to a specific creator

CreatorSubscription carries both legacy Stripe fields (stripeSubscriptionId, currentPeriodEnd) and current RevenueCat fields (productId, startedAt, expiresAt). The Stripe fields are nullable and no longer the active path.

@@unique([fanId, creatorId]) — one subscription per pair.

Two expiry columns

currentPeriodEnd (Stripe era) and expiresAt (RevenueCat era) both exist. canAccessRoom checks expiresAt; anansi's earnings calculation checks currentPeriodEnd. A RevenueCat-created subscription has expiresAt set and currentPeriodEnd null, which the earnings query treats as never-expiring. Consolidating on one column would remove a real source of payout error.

RevenueCatEvent

prisma
model RevenueCatEvent {
  eventId     String   @id
  processedAt DateTime @default(now())
}

Two columns doing important work: the primary key is the idempotency guard for the purchase webhook.

Discovery

ModelNotes
GameSynced from IGDB. igdbId unique, igdbCoverImageId used to skip re-uploading unchanged covers. genres and platforms are String[]
UserGameA user's favourite games, chosen during onboarding
TrendingMaterialised trending entries
SearchLogEvery query, indexed on query and createdAt — powers trending searches
SponsorSponsored placements
LinkCreator's external links (merch, socials)

Notifications

Notification, NotificationPreference, and PushNotificationToken. Covered in Notifications.

Inspecting the schema

bash
cd packages/database
pnpm exec prisma studio          # browse data
pnpm exec prisma validate        # check the schema
pnpm exec prisma format          # canonical formatting

Internal documentation — PlayPalz platform