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.
| Field | Notes |
|---|---|
email | Unique, required |
username | Unique, nullable — set during onboarding, not at registration |
display_name, first_name, last_name, bio, birthdate, gender | Profile; snake_case on this model only |
password | bcrypt hash |
role | "user" by default — coarse platform role |
userType | "user" or "playpal" — this is the creator flag |
onboarded | Gates the onboarding flow |
tokenVersion | Incremented to revoke every issued JWT. See Authentication |
monthlyPrice | Creator subscription price in cents, default 499 ($4.99) |
revenueCatUserId | Unique link to RevenueCat |
subscriptionTier | null | "pro" | "elite" — platform tier, not creator subscriptions |
stripeAccountId | Stripe Connect account, unique |
payoutsEnabled | Gates inclusion in a payout run |
isLive, activeLiveStreamId | Denormalised live state |
playcoin_balance | Duplicates 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
| Field | Notes |
|---|---|
body | @db.Text |
type | "text" | "clip" | "photos" — also read by ogun to pick image variants |
visibility | "public" | "private" | "followers", nullable |
commentable | Author can disable comments |
status | "pending" until media finishes processing, then "ready" |
moderationStatus | Nullable; no moderation pipeline is wired up yet |
gameId | Optional game tag |
Media and Variant
Media is the original upload; Variant rows are the generated sizes.
Media field | Notes |
|---|---|
type | "image" | "video" |
originalUrl, storageKey | Location in Spaces |
status | "pending" → "ready" | "failed" |
errorMessage | Populated on failure — check here first when processing breaks |
muxAssetId, muxPlaybackId | Video only |
order | Position 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.
@@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| Model | Notes |
|---|---|
Conversation | dmKey is a unique deterministic key derived from the participant pair, so opening a DM is an upsert |
ConversationMessage | type is "text" or "shared_post"; sharedPostId links the shared post. Soft-deleted via deletedAt |
ConversationReadState | lastReadAt + lastReadMsgId per user per conversation — drives unread counts |
Channels
Channel ──< ChannelRoom ──< ChannelMessage
│ ├──< ChannelRoomParticipant (live presence)
│ └──< ChannelRoomReadState
└──< ChannelMember| Model | Notes |
|---|---|
Channel | Owned by a creator (userId) |
ChannelRoom | type: chat | voice | video | e-date. visibility: public | members | vip | private — enforced by canAccessRoom |
ChannelRoomParticipant | Live state: isMuted, isCameraOff, isDeafend, isSpeaking. Ephemeral in nature but stored |
ChannelMember | Membership 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).
| Field | Notes |
|---|---|
startDate, endDate, duration | Duration is stored as well as derivable — keep them consistent |
channelRoomId | The private room created for the session |
status | scheduled | active | completed | cancelled |
cancelledAt | Set on cancellation |
private room visibility grants access only to these two users.
Availability
Doubles as both a recurring schedule and a blocked-date list:
| Shape | Fields used |
|---|---|
| Recurring weekly hours | dayOfWeek, startTime, endTime |
| A blocked calendar date | date, 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:
| Field | Purpose |
|---|---|
type | LedgerType enum — the one place status is genuinely enforced |
coinDelta, usdDelta | Signed changes |
revenueCatEventId, stripeEventId, stripeChargeId, stripeInvoiceId, stripeTransferId, stripePayoutId | External references for reconciliation |
idempotencyKey | Unique — the guard against double-posting |
memo | Human-readable note |
Never update a ledger row. Corrections are new ADJUSTMENT entries.
Payout
One row per creator per period: amount (cents), status (pending → completed | 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:
| Model | Meaning |
|---|---|
Subscription | The user's platform tier (pro, elite) |
CreatorSubscription | A 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
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
| Model | Notes |
|---|---|
Game | Synced from IGDB. igdbId unique, igdbCoverImageId used to skip re-uploading unchanged covers. genres and platforms are String[] |
UserGame | A user's favourite games, chosen during onboarding |
Trending | Materialised trending entries |
SearchLog | Every query, indexed on query and createdAt — powers trending searches |
Sponsor | Sponsored placements |
Link | Creator's external links (merch, socials) |
Notifications
Notification, NotificationPreference, and PushNotificationToken. Covered in Notifications.
Inspecting the schema
cd packages/database
pnpm exec prisma studio # browse data
pnpm exec prisma validate # check the schema
pnpm exec prisma format # canonical formatting