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
coinFrozenAt, coinFrozenReasonSet by an admin coin freeze: no tipping, store spending or cash-out until cleared
monthlyPriceCreator subscription price in cents, default 499 ($4.99)
revenueCatUserIdUnique link to RevenueCat
subscriptionTiernull | "pro" | "elite" — platform tier, not creator subscriptions
biometricConsentAt, biometricConsentVersion, faceEnrolledAtFace enrollment consent record. The selfie itself is an encrypted VerificationAsset. See Biometrics
stripeAccountIdStripe Connect account, unique
payoutsEnabledGates inclusion in a payout run
isLive, activeLiveStreamIdDenormalised live state
playcoin_balanceLegacy, not written. Coin balances come from the coin ledger

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

AuthSession ​

One row per device sign-in: device name, IP, user agent, isActive. Powers the "active sessions" and "login history" screens. For the mobile app it is also the refresh session: refreshTokenHash, previousTokenHash + rotatedAt (2-minute rotation grace) and a sliding expiresAt. Refresh requires isActive; access tokens are revoked by tokenVersion. See Authentication.

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.

CreatorProfile ​

One per user (userId is unique), created on the first creator-onboarding call. It holds no PII: legal name, DOB, SSN, address and bank numbers live only in Stripe. See Creator Onboarding & KYC.

FieldNotes
verificationStatusNOT_STARTED → IN_PROGRESS → SUBMITTED → APPROVED | REJECTED; REVOKED when an admin revokes a PlayPal with forfeiture
idDocumentTypelicense | state_id | passport
submittedAt, reviewedAt, reviewedById, rejectionReasonAdmin review
tosAcceptedAtWhen the Stripe Connected Account Agreement was accepted (Stripe holds the IP and user agent)
bankLast4, bankNameDisplay only, as returned by Stripe
stripeRequirementsDueStripe's requirement descriptions, never values

APPROVED is what makes a user a creator: approval sets User.userType = "playpal" and the PlayPal badge.

VerificationAsset ​

One row per uploaded ID side, plus at most one reference selfie per user. The image itself is AES-256-GCM ciphertext in the private KYC bucket.

FieldNotes
type, sideid_document (front | back) or selfie (front). Creator review only ever touches id_document
storageKeykyc/<uuid>: opaque, no user id, no extension
iv, authTagAES-GCM parameters for the image
wrappedKey, keyVersionPer-image data key, wrapped by KYC_ENCRYPTION_KEY version keyVersion

Rows are deleted, along with the bucket object, on retake, on rejection and on account deletion.

VerificationAccessLog ​

Append-only audit of admin views of ID documents and of approve/reject decisions: adminId, subjectId, assetId, action, ip. It has no foreign keys on purpose, so the log outlives the assets and users it refers to.

Block ​

@@unique([blockerId, blockedId]), cascading on both sides. Feed, profile, search, discover, trending, comment, notification, and DM queries filter against it via getBlockedUserIds, which returns the union of both directions — see Moderation.

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 in the composer (0–4); indexed with postId
width, height, aspectRatioDisplay size after EXIF / video rotation
placeholderBase64 thumbhash
focalX, focalY0..1 crop focal point
rotationRotation the pipeline applied (diagnostic)
posterUrlVideo poster frame
durationMsMilliseconds (rows from before the v3 fix may hold seconds)
altTextOptional accessibility description
metaVersion3 once processed by the v3 pipeline

Variant is unique on (mediaId, type, format). v3 types are w320, w640, w1080, w1440, full (WebP), og (JPEG) and poster-w320/640/1080 (WebP, video); thumb, medium, story, cover are legacy. See Media Pipeline.

LinkPreview ​

A link card, unfurled server-side and referenced by Post.linkPreviewId (set null if the preview is deleted). Rows are immutable snapshots: a refresh after the 24h cache TTL creates a new row, so posts keep the card they were published with.

FieldNotes
url, urlHashNormalized URL and its sha256 (the cache key)
finalUrl, canonicalUrlWhere the fetch ended after redirects; the page's declared canonical
displayDomainDestination domain shown on the card (punycode where deceptive)
title, description, siteNameSanitized, capped at 200 / 300 / 80
cardType, cardHintlarge | small | text; the page's twitter:card
statuspending → ready | failed; only ready renders
imageSourceUrlThird-party image URL. Server-side only, never selected for clients
imageStatus, imageWidth/Height, imagePlaceholder, imageFocalX/Y, imageVariantsOur re-encoded copy (WebP w320/w640/w1080)

Clients get linkPreviewPublicSelect from @playpals/db. See Link Previews.

Comment ​

parentId makes a comment a reply. Threads are one level deep: the API always stores a reply against its top-level comment. Deleting a comment cascades to its replies and mentions. Reaction and Report rows do not cascade, so deleteComment clears reactions and detaches reports itself.

Reaction ​

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

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

This does not stop duplicates. One of postId/commentId is always NULL, and Postgres treats NULLs as distinct in a unique index. "One reaction per user per target" is enforced in services/reaction.ts instead.

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 paid 1-on-1 session or e-date (kind) between a PlayPal (playpalId) and a booker (purchaserId).

FieldNotes
startDate, endDate, durationDuration in minutes, stored as well as derivable — keep them consistent
blockedUntilendDate plus the PlayPal's buffer: what the exclusion constraints compare
statusSessionStatus: HELD, CONFIRMED, IN_PROGRESS, COMPLETED, CANCELLED, NO_SHOW_USER, NO_SHOW_PLAYPAL, EXPIRED
holdExpiresAtWhile HELD: when the reservation lapses
productId, storePurchaseId, creditIdWhat paid for it
cancelledById, outcomeWho cancelled; how it ended (drives credits and earnings)
channelRoomIdThe private room, created when it's confirmed

Exclusion constraints Session_no_playpal_overlap and Session_no_purchaser_overlap (raw SQL in 20261101120000_session_booking; Prisma doesn't model them) make overlapping holding sessions impossible. See Sessions and e-dates.

private room visibility grants access only to these two users, and only from 5 minutes before the start until the end, while the session is CONFIRMED or IN_PROGRESS.

SessionEvent ​

Append-only audit of a session (sessionId, actorId?, type, metadata, createdAt). A trigger (session_event_append_only, migration 20261103120000_session_events) refuses updates; rows go when their session does.

Availability ​

A PlayPal's hours per kind (ONE_ON_ONE | EDATE):

ShapeFields used
Recurring weekly hoursdayOfWeek (0 = Monday), startTime, endTime
A date's own hours (override)date, startTime, endTime, blocked: false
A blocked calendar datedate, blocked: true

startTime / endTime are wall-clock strings ("09:00", end may be "24:00") in the PlayPal's CreatorBookingSettings.timezone (or User.timezone, the device's). Alongside: CreatorSessionSettings (per kind: enabled, buffer, minimum notice, booking horizon) and CreatorOffering (per kind and length; productId prices it once booking is paid).

Commerce ​

Coin ledger ​

CoinAccount, CoinTransaction and CoinEntry hold every Play Coin balance and movement. Triggers make them append-only and keep them balanced; see Monetization. Write to them only through postCoinTransaction.

ModelFields worth knowing
CoinAccountuserId (null for system accounts), kind (CoinAccountKind), balance (trigger-maintained, user accounts only)
CoinTransactiontype, idempotencyKey (unique), requestHash, actorId, reason, reversesId, metadata
CoinEntryamount (signed, non-zero), balanceAfter
CoinPackageproductId (PK), coins (bonus included), bonusCoins, label, badge, sortOrder, offered. Server-side coin config; no prices
CoinLotCoins that entered PURCHASED together (one purchase or carry-over) and how many are remaining. Spends draw lots oldest first
TipsenderId, recipientId, targetType, postId/liveStreamId (no FK), coins, message, messageHiddenAt, recipientBucket, coinTransactionId. Never deleted; only the hidden-message fields change, and reversedAt/reversalTransactionId are set once if an admin reverses it
TipFundingWhat paid for a tip: sender bucket, coins, and the lotId for purchased coins. Append-only
EarningLotPending earnings that mature together: from one tip (tipId) or an admin credit / tip reversal (sourceTransactionId; a CHECK requires one). coins, remaining, maturesAt, status (pending | matured | reversed)
CoinCashoutA PlayPal's cash-out request: coins, cents at centsPer1000, status (requested | scheduled | paid | cancelled | returned), payoutId, and the request, close and return transaction ids
ReconciliationRunOne run of the ledger checks: source (scheduled | manual), status (ok | drift | error), checks (JSON [{name, ok, detail}])
FraudFlagkind, status (open | cleared | actioned), senderId, recipientId, tipId, details
CoinRefundOne refund per store transaction: status (refunded | reversed), coins, and where they came back from (fromPurchase, fromBuyerBalance, fromRecipients, toDebt), the clawback and reversal transaction ids. Never deleted
CoinRefundReversalPending earnings taken from one tip's recipient for a refund. Append-only
CoinPurchaseOne verified store purchase. Unique (store, storeTransactionId); coins, quantity, environment, price in micros, currency, commission/tax percentages, verifiedVia, coinTransactionId

Wallet and Ledger ​

Wallet holds the legacy coinBalance (no longer written) and balance in cents. Ledger is the append-only record of anansi's USD movements.

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 (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:

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

CreatorSubscription is the one row access checks read (canAccessRoom for vip rooms): status = "active" and expiresAt in the future. source says where it comes from:

  • "pass": 30-day VIP passes (VipPass); expiresAt is the end of the fan's last pass.
  • "revenuecat_legacy": an old auto-renewing vip_sub_* subscription, kept until the store expires it.

The Stripe-era fields (stripeSubscriptionId, currentPeriodEnd) are nullable and unused. Payouts no longer read this table at all: earnings come from CreatorEarning.

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

PlayPal store purchases ​

ModelMeaning
PriceTierA store product a PlayPal can price at: kind (VIP_PASS | SESSION | EDATE), USD priceCents, durationDays, offered
CheckoutIntentWhat a buyer is about to pay for (buyer, PlayPal, product), made before the store sheet opens
StorePurchaseOne verified store transaction; @@unique([store, storeTransactionId]), refundedAt
VipPassOne pass: startsAt/expiresAt, chained per fan and PlayPal; revokedAt on refund
PurchaseCreditA paid-for product not yet attached to anything, used by the buyer's next checkout of it
CreatorEarningA PlayPal's USD from one purchase, net of tax, store commission and platform fee. @@unique([source, sourceId]); status ESCROW → PENDING → AVAILABLE → SCHEDULED → PAID, or REVERSED

Post.visibility "vip" marks a post for the author's subscribers; Media.isPrivate says its files are private storage served through signed URLs (set at upload for a VIP post, and flipped by ogun's media-visibility job when the post's visibility changes).

AgeVerification (one per user) records the e-date age check: PENDING → APPROVED or REJECTED, with reviewer and reason. The license photo itself is a VerificationAsset of type age_document, deleted at the decision. See Age Verification.

CreatorEarning has CHECKs that its parts add up to grossCents and that only SCHEDULED/PAID rows carry a payoutId. Payout splits its amount into subscriptionCents, sessionCents and coinCents. See Monetization.

RevenueCatEvent ​

prisma
model RevenueCatEvent {
  eventId     String   @id
  processedAt DateTime @default(now())
  type        String?
  payload     Json?
}

The primary key skips webhook events already handled. type and payload keep the whole event for audit and reprocessing (coin refunds are stored here until clawbacks are handled); rows from before they were added have them null.

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