Files
civitai__civitai/packages/civitai-db-schema/prisma/schema.full.prisma
T
Justin Maier 9282fd5deb fix(shop): read the sold count from the purchase rows, not the meta counter (#4942)
* fix(shop): read the sold count from the purchase rows, not the meta counter

A listing had two live answers to "how many sold". `_count.purchases` counts
real purchase rows and is what the sold-out gate, the quantity floor, the
delete guard and the MostPopular sort use. `meta.purchases` is a denormalised
JSONB counter, and it was what every displayed count read.

They disagree on 47 of 1,902 prod listings. One of those renders "20 remaining"
on a sold-out item behind a buy button that throws.

Two selects gain `_count` — the shared `cosmeticShopItemSelect` and
`getPackDetail`'s own, which does not use it. Four sanitizers emit the row
count. Three further paths return `meta` to the client as-is and have no
whitelist to change, so `withSoldCount` writes the row count onto the key they
read; without it the same ShopItem component would show a correct number on a
creator storefront and the drifting one on /shop.

The counter is still written and nothing is backfilled. Fixing the writer is a
separate PR.

The index is required and goes in BEFORE the deploy — not because a reader
would 500 without it, but so the first shop page after the deploy is not the
one that discovers the seq scan. Applied by hand.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(shop): keep the sold-count change a read, and pin the selects that carry it

Review round on #4942. Three real defects in the first pass.

The change was not read-only. `getShopItemById` seeds the moderator item
editor, that form posts the whole `meta` object back, and `purchases` is a
declared key so zod keeps it — so every save wrote the derived row count into
the stored counter, from a client cache that can be older than the value it
replaced. The update now keeps what is stored; only a purchase moves it. The
response still reports the rows, like every other read.

Both `_count` select lines were pinned by nothing. Prisma mocks ignore
`select` and every fixture hand-writes `_count`, so deleting either line left
the whole suite green and threw on six read paths in production. Asserted
against the query the code emits.

The migration and schema comments measured a plan Prisma does not produce. A
relation `_count` is a LEFT JOIN to one whole-table GROUP BY, not a correlated
per-row subquery: 13.7 ms and 1,190 buffers, not 140 ms and 71,400, and the
cost does not scale with page size. Rewritten with the real plans, where the
index actually pays (single-item reads), and what it does not fix.

Also: `getSectionById` and the upsert return were the two paths still serving
the counter; both now go through `withSoldCount`, with controls. The
`StickerShopPanel` comment justified its non-interleaved shelves on a sort-key
mismatch this change removes.

Every guard here was reverted and re-run; each fails naming the wrong value.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(shop): pin the selects and the response the last round left unguarded

Second review round, run mutants rather than argued ones. Three things the
fix round itself introduced.

Two more selects were pinned by nothing, the same shape as the two the last
round closed: deleting `meta: true` from the existing-item select left every
write-back test green while every moderator save would write `purchases: 0`
over the stored counter, and redefining `_count` on `creatorStorefrontItemSelect`
killed the sold count on the creator storefront and the community hub with
nothing red. Both now assert the query the code emitted.

Tightening one test replaced an assertion instead of adding to it, and the
behaviour it covered went in the same change — so the upsert response was
unpinned in both directions. It is deliberately NOT mapped through
`withSoldCount`: its only consumer invalidates and discards the payload, so
mapping it fixed nothing and pinned a value nobody reads. That decision is now
recorded by an assertion rather than left to the next reader.

The migration header claimed the index would let multi-row paths read the index
instead of the heap. It will not: every buffer is a `shared hit`, the table is
fully cached, and `relallvisible` is 550 of 1,190 pages. Also names
`getCommunityCosmetics` as the heaviest consumer — under MostPopular it carries
two whole-table aggregates, confirmed by reading Prisma's emitted SQL.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(shop): correct the pin comments, cover the create path, drop a dead aggregate

Round-3 review findings.

Three comments claimed the test assertions were the only thing holding a
`select` line. They are not: Prisma narrows the row to the select, so dropping
a field is `TS2339` at the read. Reworded to what is true — a fast readable
second signal, and the condition under which it would become the gate.

The create branch's `purchases: 0` was covered by nothing: deleting it leaves
603 tests and typecheck green, because `meta` is Json. A new listing has sold
nothing and `purchases` is client-supplied, so the zero is imposed, not trusted.

Removing `withSoldCount` from the upsert response left the `_count` in that
transaction's select dead — a whole-table aggregate on the primary, inside an
open write transaction, that nothing reads. The write path now selects without
it.

The index migration is marked NOT APPROVED: the owner's answer was to replace
the Prisma query with raw SQL first and re-measure, since the doubled aggregate
is a Prisma artifact rather than a database necessity.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 18:15:19 -06:00

8602 lines
334 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
// Migration steps
// 1. Add the darkpit env var
// 2. `npm run db:migrate -- --name dont-drift-me-bro --no-check`
// 3. review and revise your migration then manually apply it to the DB.
// 4. `npm run db:applied` to mark the last migration as applied.
generator client {
provider = "prisma-client-js"
previewFeatures = ["metrics"]
}
generator enums {
provider = "node ./scripts/prisma-enum-generator.mjs"
output = "../src/enums.ts"
}
generator typescriptInterfaces {
provider = "prisma-generator-typescript-interfaces"
output = "../src/models.ts"
}
// Kysely table types (Generated<>/ColumnType<> wrappers) for apps that query via Kysely
// (e.g. the SvelteKit auth hub). Mirrors civitai-advertising's setup. Consumed via the
// `@civitai/db-schema/kysely` subpath export.
generator kysely {
provider = "prisma-kysely"
output = "../src/kysely"
fileName = "types.ts"
enumFileName = "enums.ts"
}
// The set of tables carrying a Prisma `@updatedAt` column, consumed by @civitai/db-queries'
// updatedAtPlugin. MUST stay registered here: the generated file is typed `Set<keyof DB>`
// against prisma-kysely's output, so if this generator does not run alongside the one above,
// the two drift and the mismatch surfaces as a repo-wide `tsc` failure.
generator updatedAtTables {
provider = "node ./scripts/prisma-updated-at-generator.mjs"
output = "../src/kysely/updated-at-tables.ts"
}
datasource db {
provider = "postgresql"
// NOTE: When using postgresql, mysql or sqlserver, uncomment the @db.Text annotations in model Account below
// Further reading:
// https://next-auth.js.org/adapters/prisma#create-the-prisma-schema
// https://www.prisma.io/docs/reference/api-reference/prisma-schema-reference#string
url = env("DATABASE_URL")
shadowDatabaseUrl = env("SHADOW_DATABASE_URL")
}
// Necessary for Next auth
model Account {
id Int @id @default(autoincrement())
userId Int
type String
provider String
providerAccountId String
refresh_token String? @db.Text
access_token String? @db.Text
expires_at Int?
token_type String?
scope String?
id_token String? @db.Text
session_state String?
metadata Json @default("{}")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([provider, providerAccountId])
@@index([provider, userId])
}
// We aren't using DB sessions, but next-auth likes this... I guess.
model Session {
id Int @id @default(autoincrement())
sessionToken String @unique
userId Int
expires DateTime
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
model SessionInvalidation {
userId Int
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
invalidatedAt DateTime @default(now())
@@id([userId, invalidatedAt])
}
model UserReferral {
id Int @id @default(autoincrement())
userReferralCodeId Int?
userReferralCode UserReferralCode? @relation(fields: [userReferralCodeId], references: [id], onDelete: SetNull)
source String?
landingPage String?
loginRedirectReason String?
createdAt DateTime @default(now())
userId Int @unique
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
note String?
firstPaidAt DateTime?
paidMonthCount Int @default(0)
}
model UserReferralCode {
id Int @id @default(autoincrement())
userId Int
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
code String @unique
note String?
deletedAt DateTime?
createdAt DateTime @default(now())
referees UserReferral[]
attributions ReferralAttribution[]
@@index([userId], type: Hash)
}
enum ReferralRewardStatus {
Pending
Settled
Redeemed
Expired
Revoked
}
enum ReferralRewardKind {
MembershipToken
BuzzKickback
MilestoneBonus
RefereeBonus
}
model ReferralReward {
id Int @id @default(autoincrement())
userId Int
user User @relation("ReferralRewardUser", fields: [userId], references: [id], onDelete: Cascade)
refereeId Int?
referee User? @relation("ReferralRewardReferee", fields: [refereeId], references: [id], onDelete: SetNull)
kind ReferralRewardKind
status ReferralRewardStatus @default(Pending)
tokenAmount Int @default(0)
buzzAmount Int @default(0)
// Referral Points snapshot at the time the reward was created. For
// BuzzKickback / MilestoneBonus this equals buzzAmount (1pt per blue buzz).
// For MembershipToken this equals constants.referrals.pointsPerTierMonth[tier]
// at the time of the paid month so re-tuning the constants doesn't retroactively
// re-evaluate historical rewards (no phantom milestones).
points Int @default(0)
tierGranted String?
sourceEventId String
metadata Json @default("{}")
earnedAt DateTime @default(now())
settledAt DateTime?
redeemedAt DateTime?
expiresAt DateTime?
revokedAt DateTime?
revokedReason String?
@@unique([kind, sourceEventId])
@@index([userId, status])
@@index([userId, kind])
@@index([refereeId])
@@index([expiresAt])
@@index([status, settledAt])
}
model ReferralMilestone {
id Int @id @default(autoincrement())
userId Int
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
threshold Int
bonusAmount Int
awardedAt DateTime @default(now())
@@unique([userId, threshold])
}
enum ReferralRedemptionType {
MembershipPerks
}
model ReferralRedemption {
id Int @id @default(autoincrement())
userId Int
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
tokensSpent Int
rewardType ReferralRedemptionType @default(MembershipPerks)
metadata Json @default("{}")
createdAt DateTime @default(now())
@@index([userId])
@@index([rewardType])
}
model ReferralAttribution {
id Int @id @default(autoincrement())
referralCodeId Int
referralCode UserReferralCode @relation(fields: [referralCodeId], references: [id], onDelete: Cascade)
refereeId Int
referee User @relation(fields: [refereeId], references: [id], onDelete: Cascade)
eventType String
sourceEventId String?
tier String?
amount Int?
paymentProvider String?
stripePaymentIntentId String?
stripeInvoiceId String?
stripeChargeId String?
paymentMethodFingerprint String?
ipAddress String?
metadata Json @default("{}")
createdAt DateTime @default(now())
@@index([referralCodeId, createdAt])
@@index([refereeId])
@@index([paymentMethodFingerprint])
@@index([ipAddress])
@@index([stripePaymentIntentId])
}
model UserPaymentConfiguration {
userId Int @unique
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
// Tipalti
tipaltiAccountId String? @unique
tipaltiAccountStatus String @default("PendingOnboarding")
tipaltiPaymentsEnabled Boolean @default(false)
tipaltiWithdrawalMethod CashWithdrawalMethod?
// Stripe, mainly here for safekeeping or in case we ever support this again.
stripeAccountId String? @unique
stripeAccountStatus String @default("PendingOnboarding")
stripePaymentsEnabled Boolean @default(false)
//
meta Json @default("{}")
}
enum BuzzWithdrawalRequestStatus {
Requested
Canceled // Refunds buzz - allows user to cancel existing one.
Rejected
Approved // Different than transferred in that a 3rd party can say it's a valid request, and the owners can reject after the fact.
Reverted
Transferred
ExternallyResolved
}
model BuzzWithdrawalRequestHistory {
id String @id @default(cuid())
requestId String
request BuzzWithdrawalRequest @relation(fields: [requestId], references: [id], onDelete: Cascade)
updatedById Int
updatedBy User @relation(fields: [updatedById], references: [id], onDelete: Cascade)
status BuzzWithdrawalRequestStatus @default(Requested)
note String?
createdAt DateTime @default(now())
metadata Json @default("{}")
}
enum UserPaymentConfigurationProvider {
Stripe
Tipalti
}
model BuzzWithdrawalRequest {
id String @id @default(cuid())
userId Int?
user User? @relation(fields: [userId], references: [id])
requestedToProvider UserPaymentConfigurationProvider @default(Stripe)
requestedToId String
buzzWithdrawalTransactionId String // from Buzz Service
requestedBuzzAmount Int // 1000
platformFeeRate Int // 3000 = 30% so we can do 30.25
transferredAmount Int?
transferId String? // Stripe ID
currency Currency? // We should always transfer on USD really (?)
metadata Json @default("{}")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
status BuzzWithdrawalRequestStatus @default(Requested) // updated with trigger
history BuzzWithdrawalRequestHistory[]
}
enum CashWithdrawalStatus {
// Tipalti
Paid
Rejected
Scheduled
Submitted
Deferred
DeferredInternal
Canceled
Cleared
FraudReview
PendingPayerFunds
InternalValue
// Civitai Custom State
FailedFee
Reclaimed
}
enum CashWithdrawalMethod {
NoPM
WireTransfer
Payoneer
PayPal
ACH
Check
eCheck
HoldMyPayments
Custom
Intercash
Card
TipaltiInternalValue
}
model CashWithdrawal {
id String @id @default(cuid())
transactionId String? @unique
userId Int
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
amount Int
method CashWithdrawalMethod
fee Int
status CashWithdrawalStatus
note String?
metadata Json @default("{}")
createdAt DateTime? @default(now())
updatedAt DateTime? @updatedAt
@@index([userId])
}
model CryptoWallet {
userId Int
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
chain String @default("evm")
wallet String @unique
smartAccount String? @unique
payCurrency String @default("usdcbase")
@@id([userId, chain])
}
model CryptoDeposit {
paymentId BigInt @id
userId Int
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
status String @default("waiting")
payCurrency String
payAmount Float?
outcomeAmount Float?
buzzCredited Int?
bonusBuzz Int?
multiplier Int?
depositFee Float?
serviceFee Float?
feeCurrency String?
paidFiat Float?
chain String?
retryCount Int @default(0)
stuckNotifiedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([userId, createdAt(sort: Desc)])
@@index([status])
}
enum CryptoTransactionStatus {
WaitingForRamp // Ramp url created
RampTimedOut // Been 5 minutes since url was created and still not tx associated with key
RampFailed // Failed to ramp
RampInProgress // Ramp is in progress
RampSuccess // Ramp completed successfully
WaitingForSweep // Sweep tx created
SweepFailed // Sweep tx failed
Complete // Sweep tx completed, Buzz paid
}
model CryptoTransaction {
key String @id @default(cuid())
userId Int
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
status CryptoTransactionStatus @default(WaitingForRamp)
amount Int // (compute in pennies for USDC)
currency Currency @default(USDC)
sweepTxHash String?
note String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
enum RewardsEligibility {
Eligible
Ineligible
Protected
}
model User {
id Int @id @default(autoincrement())
name String?
username String? @unique @db.Citext
email String? @unique @db.Citext
emailVerified DateTime?
image String?
showNsfw Boolean @default(false)
blurNsfw Boolean @default(true)
browsingLevel Int @default(1)
onboarding Int @default(0)
flags Int @default(0)
isModerator Boolean? @default(false)
createdAt DateTime @default(now())
deletedAt DateTime?
subscriptions CustomerSubscription[]
membershipGiftsGiven MembershipGift[] @relation("membershipGiftsGiven")
membershipGiftsReceived MembershipGift[] @relation("membershipGiftsReceived")
mutedAt DateTime? /// Set on moderator mute-confirmation; cleared via trigger on unmute
muted Boolean @default(false)
muteExpiresAt DateTime? /// For timed mutes from strike escalation
bannedAt DateTime?
autoplayGifs Boolean? @default(true)
filePreferences Json @default("{\"size\": \"pruned\", \"fp\": \"fp16\", \"format\": \"SafeTensor\"}")
meta Json? @default("{}")
leaderboardShowcase String?
referral UserReferral?
excludeFromLeaderboards Boolean @default(false)
rewardsEligibility RewardsEligibility @default(Eligible)
eligibilityChangedAt DateTime?
// Payment provider related
customerId String? @unique // Stripe
paddleCustomerId String? @unique
profile UserProfile?
profilePictureId Int? @unique
profilePicture Image? @relation("profilePicture", fields: [profilePictureId], references: [id], onDelete: SetNull)
settings Json? @default("{}")
publicSettings Json? @default("{}")
paymentConfiguration UserPaymentConfiguration?
accounts Account[]
sessions Session[]
images Image[]
models Model[] @relation("creator")
deletedModels Model[] @relation("deletedBy")
saves SavedModel[]
imports Import[]
keys ApiKey[]
oauthClients OauthClient[]
oauthConsents OauthConsent[]
roles UserRole[]
membershipOverride UserMembershipOverride?
links UserLink[]
comments Comment[]
commentReactions CommentReaction[]
notificationSettings UserNotificationSettings[]
webhooks Webhook[]
interests ModelInterest[]
engagingUsers UserEngagement[] @relation("engagingUsers")
engagedUsers UserEngagement[] @relation("engagedUsers")
engagedModels ModelEngagement[]
engagedModelVersions ModelVersionEngagement[]
metrics UserMetric[]
reports Report[]
feedback Feedback[]
feedbackHandled Feedback[] @relation("FeedbackHandledBy")
questions Question[]
answers Answer[]
commentsv2 CommentV2[]
questionReactions QuestionReaction[]
answerReactions AnswerReaction[]
commentV2Reactions CommentV2Reaction[]
threadMutes ThreadMute[]
answerVotes AnswerVote[]
tagsEngaged TagEngagement[]
imageReactions ImageReaction[]
sessionInvalidation SessionInvalidation[]
stats UserStat?
rank UserRank?
downloads DownloadHistory[]
purchases Purchase[]
cosmetics UserCosmetic[]
postReactions PostReaction[]
posts Post[]
resourceReviews ResourceReview[]
tagImageVotes TagsOnImageVote[]
tagModelVotes TagsOnModelsVote[]
tagPostVotes TagsOnPostVote[]
resourceReviewReactions ResourceReviewReaction[]
articleReactions ArticleReaction[]
articles Article[]
articleEngagements ArticleEngagement[]
articleRatingReviewsSubmitted ArticleRatingReview[] @relation("ArticleRatingReviewSubmitter")
articleRatingReviewsResolved ArticleRatingReview[] @relation("ArticleRatingReviewResolver")
leaderboardResults LeaderboardResult[]
receivedReports UserReport[]
engagedImages ImageEngagement[]
collections Collection[]
hubs UserHub[]
hubFollows UserHubFollow[]
collectionItems CollectionItem[]
reviewedCollectionItems CollectionItem[] @relation("reviewedBy")
contributingCollections CollectionContributor[]
collectionInvitesReceived CollectionInvite[] @relation("collectionInviteRecipient")
collectionInvitesSent CollectionInvite[] @relation("collectionInviteSender")
homeBlocks HomeBlock[]
bounties Bounty[]
bountyEntries BountyEntry[]
sponsoredBounties BountyBenefactor[]
engagedBounties BountyEngagement[]
bountyEntryReactions BountyEntryReaction[]
referralCodes UserReferralCode[]
referralRewards ReferralReward[] @relation("ReferralRewardUser")
referralRewardsAsReferee ReferralReward[] @relation("ReferralRewardReferee")
referralMilestones ReferralMilestone[]
referralRedemptions ReferralRedemption[]
referralAttributions ReferralAttribution[]
clubs Club[]
memberships ClubMembership[]
addedClubPosts ClubPost[]
accessGrantedBy EntityAccess[]
clubAdmin ClubAdmin[]
clubPostReactions ClubPostReaction[]
withdrawalRequests BuzzWithdrawalRequest[]
actionedWithdrawalRequests BuzzWithdrawalRequestHistory[]
chatMembers ChatMember[]
chatMessages ChatMessage[]
chatOwners Chat[]
builds BuildGuide[]
createdRewards PurchasableReward[]
purchasedRewards UserPurchasedRewards[]
VaultItem VaultItem[]
Vault Vault[]
redeemedCodes RedeemableCode[]
addedCosmeticShopSections CosmeticShopSection[]
addedCosmeticShopItems CosmeticShopItem[]
purchasedCosmetics UserCosmeticShopPurchases[]
wishlistedCosmeticShopItems UserCosmeticShopItemWishlist[]
resoldCosmeticShopItems UserCosmeticShopItemResale[]
createdCosmetics Cosmetic[] @relation("CosmeticCreator")
donationGoals DonationGoal[]
donations Donation[]
collaboratingOn EntityCollaborator[] @relation("entityCollaboratorParticipant")
collaborationsCreated EntityCollaborator[] @relation("entityCollaboratorCreator")
adTokens AdToken[]
ratingRequests ImageRatingRequest[]
collectionItemScores CollectionItemScore[]
appeals Appeal[] @relation("submittedAppeals")
resolvedAppeals Appeal[] @relation("resolvedAppeals")
cashWithdrawals CashWithdrawal[]
bids Bid[]
recurringBids BidRecurring[]
moderationRules ModerationRule[]
playerInfo NewOrderPlayer?
CryptoWallet CryptoWallet[]
CryptoDeposit CryptoDeposit[]
CryptoTransaction CryptoTransaction[]
userRestrictions UserRestriction[] @relation("userRestrictions")
challengesCreated Challenge[]
challengeWins ChallengeWinner[]
challengeJudges ChallengeJudge[]
challengeEventsCreated ChallengeEvent[]
challengeEngagements ChallengeEngagement[]
rewardsBonusEventsCreated RewardsBonusEvent[] @relation("RewardsBonusEventCreator")
strikes UserStrike[] @relation("userStrikes")
issuedStrikes UserStrike[] @relation("strikeIssuedBy")
voidedStrikes UserStrike[] @relation("strikeVoidedBy")
generationPresets GenerationPreset[]
ownedWildcardSets WildcardSet[] @relation("WildcardSetOwner")
blurbs Blurb[]
// 3D Models
model3ds Model3D[] @relation("model3dCreator")
deletedModel3Ds Model3D[] @relation("model3dDeletedBy")
model3dEngagements Model3DEngagement[]
model3dReviews Model3DReview[]
// Comics
comicProjects ComicProject[]
comicReferences ComicReference[]
comicProjectEngagements ComicProjectEngagement[]
comicChapterReads ComicChapterRead[]
// App Blocks back-relations
blockUserSettings BlockUserSettings[]
promotedPlatformBlocks PlatformDefaultBlock[] @relation("PlatformDefaultBlockPromoter")
blockUserSubscriptions BlockUserSubscription[]
blockBuzzAttributionsAsPurchaser BlockBuzzAttribution[] @relation("BlockBuzzAttributionPurchaser")
blockBuzzAttributionsAsAppOwner BlockBuzzAttribution[] @relation("BlockBuzzAttributionAppOwner")
blockAttributionPayouts BlockAttributionPayout[] @relation("BlockAttributionPayoutOwner")
blockSpendAttributionsAsSpender BlockSpendAttribution[] @relation("BlockSpendAttributionSpender")
blockSpendAttributionsAsAppOwner BlockSpendAttribution[] @relation("BlockSpendAttributionAppOwner")
blockSpendAttributionsAsContentAuthor BlockSpendAttribution[] @relation("BlockSpendAttributionContentAuthor")
blockAuthorFeeAccrualsAsAppOwner BlockAuthorFeeAccrual[] @relation("BlockAuthorFeeAccrualAppOwner")
blockAuthorFeeAccrualsAsViewer BlockAuthorFeeAccrual[] @relation("BlockAuthorFeeAccrualViewer")
blockSubscriptionAttributionsAsPurchaser BlockSubscriptionAttribution[] @relation("BlockSubscriptionAttributionPurchaser")
blockSubscriptionAttributionsAsAppOwner BlockSubscriptionAttribution[] @relation("BlockSubscriptionAttributionAppOwner")
publishRequestsSubmitted AppBlockPublishRequest[] @relation("PublishRequestSubmitter")
publishRequestsReviewed AppBlockPublishRequest[] @relation("PublishRequestReviewer")
blockScopeInvocations BlockScopeInvocation[]
appUserScopeGrants AppUserScopeGrant[]
appDevForgejoIdentity AppDevForgejoIdentity?
appListings AppListing[]
appListingReviews AppListingReview[]
appListingPublishRequestsSubmitted AppListingPublishRequest[] @relation("AppListingPublishRequestSubmitter")
appListingPublishRequestsReviewed AppListingPublishRequest[] @relation("AppListingPublishRequestReviewer")
appListingReportsReported AppListingReport[] @relation("AppListingReportReporter")
appListingReportsResolved AppListingReport[] @relation("AppListingReportResolver")
appListingModerationEvents AppListingModerationEvent[] @relation("AppListingModEventActor")
// App Listing Collaborators — editor seats held / issued, the ownership audit
// trail this user acted in or was targeted by, and any transfer they are party to.
appCollaboratorSeats AppCollaborator[] @relation("AppCollaboratorMember")
appCollaboratorInvitesSent AppCollaborator[] @relation("AppCollaboratorInviter")
appOwnershipEventsActed AppOwnershipEvent[] @relation("AppOwnershipEventActor")
appOwnershipEventsTargeted AppOwnershipEvent[] @relation("AppOwnershipEventTarget")
appOwnershipTransfersFrom AppOwnershipTransfer[] @relation("AppOwnershipTransferFrom")
appOwnershipTransfersTo AppOwnershipTransfer[] @relation("AppOwnershipTransferTo")
targetedAnnouncements AnnouncementUser[]
dismissedAnnouncements AnnouncementDismissal[]
authoredAnnouncements Announcement[] @relation("authoredAnnouncements")
announcementSpends AnnouncementSpend[] @relation("announcementSpends")
announcementMutesGiven UserAnnouncementMute[] @relation("announcementMutesGiven")
announcementMutesReceived UserAnnouncementMute[] @relation("announcementMutesReceived")
placementSuspension PlacementSuspension? @relation("PlacementSuspensionUser")
placementsReceived Placement[] @relation("PlacementOwner")
placementsMade Placement[] @relation("PlacementPlacer")
placementsSold Placement[] @relation("PlacementSeller")
pricingSlots PricingSlot[]
@@index([deletedAt])
}
model CustomerSubscription {
id String @id
userId Int
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
buzzType String @default("yellow")
metadata Json
status String
priceId String
price Price @relation(fields: [priceId], references: [id])
productId String
product Product @relation(fields: [productId], references: [id])
cancelAtPeriodEnd Boolean
cancelAt DateTime?
canceledAt DateTime?
currentPeriodStart DateTime
currentPeriodEnd DateTime
createdAt DateTime
endedAt DateTime?
updatedAt DateTime?
@@unique([userId, buzzType])
}
enum PaymentProvider {
Stripe
Paddle
Civitai
}
enum MembershipGiftStatus {
Pending
Fulfilled
Failed
Refunded
Revoked
}
model MembershipGift {
id String @id @default(cuid())
gifterId Int
gifter User @relation("membershipGiftsGiven", fields: [gifterId], references: [id], onDelete: Cascade)
recipientId Int
recipient User @relation("membershipGiftsReceived", fields: [recipientId], references: [id], onDelete: Cascade)
tier String
months Int
amountCents Int
status MembershipGiftStatus @default(Pending)
message String?
anonymous Boolean @default(false)
stripeCheckoutSessionId String? @unique
stripePaymentIntentId String? @unique
stripeCouponId String?
stripeSubscriptionId String?
fulfilledAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([recipientId])
@@index([gifterId])
}
model Product {
id String @id
active Boolean
name String
description String?
metadata Json
defaultPriceId String?
provider PaymentProvider @default(Stripe)
prices Price[]
customerSubscriptions CustomerSubscription[]
purchases Purchase[]
}
model Price {
id String @id
productId String
product Product @relation(fields: [productId], references: [id])
active Boolean
currency String
description String?
type String
unitAmount Int?
interval String?
intervalCount Int?
metadata Json
customerSubscriptions CustomerSubscription[]
purchases Purchase[]
provider PaymentProvider @default(Stripe)
redeemableCodes RedeemableCode[]
}
model Purchase {
id Int @id @default(autoincrement())
userId Int
customer User @relation(fields: [userId], references: [id])
productId String?
product Product? @relation(fields: [productId], references: [id])
priceId String?
price Price? @relation(fields: [priceId], references: [id])
status String?
createdAt DateTime @default(now())
}
enum UserEngagementType {
Follow
Hide
Block
}
model UserEngagement {
userId Int
user User @relation("engagingUsers", fields: [userId], references: [id], onDelete: Cascade)
targetUserId Int
targetUser User @relation("engagedUsers", fields: [targetUserId], references: [id], onDelete: Cascade)
type UserEngagementType
createdAt DateTime @default(now())
@@id([userId, targetUserId])
@@index([type, userId])
}
model UserMetric {
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
userId Int
timeframe MetricTimeframe
followingCount Int @default(0)
followerCount Int @default(0)
reactionCount Int @default(0)
hiddenCount Int @default(0)
uploadCount Int @default(0)
reviewCount Int @default(0)
answerCount Int @default(0)
answerAcceptCount Int @default(0)
updatedAt DateTime @default(now())
@@id([userId, timeframe])
}
enum LinkType {
Sponsorship
Social
Other
}
model UserLink {
id Int @id @default(autoincrement())
userId Int
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
url String
type LinkType
}
model VerificationToken {
identifier String
token String @unique
expires DateTime
@@unique([identifier, token])
}
enum ModelType {
Checkpoint
TextualInversion
Hypernetwork
AestheticGradient
LORA
LoCon
DoRA
Controlnet
Upscaler
MotionModule
VAE
TextEncoder
UNet
CLIPVision
Poses
Wildcards
Workflows
ComfyWorkflows
Detection
VisionLanguage
CLIP
LLM
Other
}
enum ImportStatus {
Pending
Processing
Failed
Completed
}
model Import {
id Int @id @default(autoincrement())
userId Int?
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
createdAt DateTime @default(now())
startedAt DateTime?
finishedAt DateTime?
source String
status ImportStatus @default(Pending)
data Json?
parentId Int?
parent Import? @relation("ImportChildren", fields: [parentId], references: [id], onDelete: SetNull)
modelVersion ModelVersion[]
model Model?
children Import[] @relation("ImportChildren")
importId Int?
}
enum HuggingFaceImportStatus {
Queued
Transferring
Completed
Failed
Canceled
}
/// One file pulled from Hugging Face into our storage. The row IS the provenance record: the
/// uploaded object (bucket/key/url) is traceable back to the exact repo revision it came from.
model HuggingFaceImport {
id Int @id @default(autoincrement())
repo String
/// Commit sha, never a branch name — an import must name the exact bytes it took.
revision String
filename String
/// What a moderator calls this batch, defaulting to the repo's own name. Never appears in a
/// storage key — the key is the ordinary upload shape — so this is free to be corrected.
groupName String
sourceUrl String
/// Both come from the HF tree API before any bytes move: size, and lfs.oid which is the content
/// sha256 for LFS files. The sha is what lets us skip a file we already store under the same hash;
/// the transfer itself is not verified against it.
sizeBytes BigInt?
sourceSha256 String?
status HuggingFaceImportStatus @default(Queued)
bytesTransferred BigInt @default(0)
/// The resume point. A transfer is a sequence of ranged reads from HF written as multipart parts,
/// and `uploadId` + `parts` is what lets a LATER job run continue one an earlier run left unfinished
/// instead of starting the file again.
uploadId String?
partSize Int?
parts Json?
bucket String?
key String?
url String?
error String?
attempts Int @default(0)
nextAttemptAt DateTime?
userId Int?
modelVersionId Int?
modelFileId Int?
/// Where this file is headed, recorded before the bytes move so the transfer job can attach it
/// itself. `modelVersionId` cannot carry this: it means "attached to", and detaching clears it.
attachVersionId Int?
attachType String?
/// Worker lease. A transfer outlives any one job run, so a claim plus a heartbeat is what stops two
/// runs moving the same file and what lets the next run tell "in flight" from "abandoned".
claimedBy String?
claimedAt DateTime?
heartbeatAt DateTime?
startedAt DateTime?
completedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([repo, revision, filename])
@@index([status, createdAt])
@@index([groupName])
/// Also `HuggingFaceImport_pending_attach_idx` — a PARTIAL index on ("completedAt") for the attach
/// sweep, which Prisma cannot express, so it lives in its migration and will not round-trip here.
/// Also `HuggingFaceImport_pending_attach_idx` — a PARTIAL index on ("completedAt") for the attach
/// sweep, which Prisma cannot express, so it lives in its migration and will not round-trip here.
}
enum ModelStatus {
/// saved but incomplete
Draft
/// actively training
Training
/// complete
Published
/// scheduled for publish
Scheduled
/// taken from published -> hidden intentionally?
Unpublished
/// taken from published -> hidden unintentionally?
UnpublishedViolation
/// unused?
GatherInterest
/// deleted by user/system
Deleted
}
enum TrainingStatus {
/// not submitted yet (in draft mode)
Pending
/// submitted for processing (in civitai queue/delay, OR not picked up by external system)
Submitted
/// awaiting approval or confirmation
Paused
/// rejected for violation
Denied
/// actively generating
Processing
/// done, waiting for publish
InReview
/// something went wrong either externally or internally
Failed
/// published
Approved
/// moderation review expired before completion
Expired
}
enum CommercialUse {
None
Image
RentCivit
Rent
Sell
SellMerge
}
enum CheckpointType {
Trained
Merge
}
enum ModelUploadType {
Created
Trained
}
enum ModelUsageControl {
Download
Generation
InternalGeneration
ExternalGeneration
}
enum ModelModifier {
Archived
TakenDown
}
enum ContentType {
Image
Character
Text
Audio
}
model Model {
id Int @id @default(autoincrement())
name String @db.Citext
description String?
type ModelType
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
lastVersionAt DateTime?
nsfw Boolean @default(false)
tosViolation Boolean @default(false)
poi Boolean @default(false)
minor Boolean @default(false)
userId Int
user User @relation("creator", fields: [userId], references: [id])
status ModelStatus @default(Draft)
publishedAt DateTime?
fromImport Import? @relation(fields: [fromImportId], references: [id], onDelete: SetNull)
fromImportId Int? @unique
meta Json @default("{}")
deletedAt DateTime?
deletedBy Int?
deletedByUser User? @relation("deletedBy", fields: [deletedBy], references: [id], onDelete: SetNull)
checkpointType CheckpointType?
uploadType ModelUploadType @default(Created)
locked Boolean @default(false)
underAttack Boolean @default(false)
mode ModelModifier?
unlisted Boolean @default(false)
gallerySettings Json @default("{\"users\": [], \"tags\": [], \"images\": []}")
availability Availability @default(Public)
nsfwLevel Int @default(0)
lockedProperties String[] @default([])
scannedAt DateTime?
sfwOnly Boolean @default(false)
isOfficial Boolean @default(false)
// Licensing
allowNoCredit Boolean @default(true)
// Must stay equal to the column default in Postgres: Prisma supplies this itself on create, so a
// narrower value here never reaches the database default and silently licenses the row instead.
allowCommercialUse CommercialUse[] @default([Image, RentCivit, Rent, Sell, SellMerge])
allowDerivatives Boolean @default(true)
allowDifferentLicense Boolean @default(true)
modelVersions ModelVersion[]
tagsOnModels TagsOnModels[]
tagsOnModelsVotes TagsOnModelsVote[]
tags ModelTag[]
metrics ModelMetric[]
saves SavedModel[]
reports ModelReport[]
engagements ModelEngagement[]
comments Comment[]
interests ModelInterest[]
licenses License[]
reportStats ModelReportStat?
hashes ModelHash[]
threads Thread[]
resourceReviews ResourceReview[]
metricsDaily ModelMetricDaily[]
baseModelMetrics ModelBaseModelMetric[]
associatedFrom ModelAssociations[] @relation("ToModelAssociation")
associations ModelAssociations[] @relation("FromModelAssociation")
collectionItems CollectionItem[]
generationCoverage GenerationCoverage[]
flags ModelFlag[]
coveredCheckpoints CoveredCheckpoint[]
@@index([name])
@@index([status, nsfw])
}
enum ModelFlagStatus {
Pending
Resolved
}
model ModelFlag {
modelId Int @id
model Model @relation(fields: [modelId], references: [id], onDelete: Cascade)
poi Boolean @default(false)
minor Boolean @default(false)
sfwOnly Boolean @default(false)
nsfw Boolean @default(false)
triggerWords Boolean @default(false)
poiName Boolean @default(false)
status ModelFlagStatus @default(Pending)
details Json?
createdAt DateTime @default(now())
@@index([status])
}
model License {
id Int @id @default(autoincrement())
name String
url String
models Model[]
}
model ModelInterest {
userId Int
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
modelId Int
model Model @relation(fields: [modelId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
@@id([userId, modelId])
}
enum ModelEngagementType {
Favorite
Hide
Mute
Notify
}
model ModelEngagement {
userId Int
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
modelId Int
model Model @relation(fields: [modelId], references: [id], onDelete: Cascade)
type ModelEngagementType
createdAt DateTime @default(now())
@@id([userId, modelId])
@@index([modelId], type: Hash)
}
enum ModelVersionSponsorshipSettingsType {
FixedPrice
Bidding
}
model ModelVersionSponsorshipSettings {
id Int @id @default(autoincrement())
modelVersionMonetizationId Int @unique
modelVersionMonetization ModelVersionMonetization @relation(fields: [modelVersionMonetizationId], references: [id], onDelete: Cascade)
type ModelVersionSponsorshipSettingsType @default(FixedPrice)
currency Currency @default(BUZZ)
unitAmount Int
}
enum ModelVersionMonetizationType {
PaidAccess
PaidEarlyAccess
PaidGeneration
CivitaiClubOnly
MySubscribersOnly
Sponsored
}
model ModelVersionMonetization {
id Int @id @default(autoincrement())
modelVersionId Int @unique
modelVersion ModelVersion @relation(fields: [modelVersionId], references: [id], onDelete: Cascade)
type ModelVersionMonetizationType @default(PaidAccess)
currency Currency @default(BUZZ)
unitAmount Int?
sponsorshipSettings ModelVersionSponsorshipSettings?
}
model ModelVersion {
id Int @id @default(autoincrement())
index Int?
name String
description String?
modelId Int
model Model @relation(fields: [modelId], references: [id], onDelete: Cascade)
trainedWords String[]
steps Int?
epochs Int?
clipSkip Int?
vaeId Int?
vae ModelVersion? @relation("vae", fields: [vaeId], references: [id], onDelete: SetNull)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
publishedAt DateTime?
initialPublishedAt DateTime?
status ModelStatus @default(Draft)
trainingStatus TrainingStatus?
trainingDetails Json?
fromImport Import? @relation(fields: [fromImportId], references: [id], onDelete: SetNull)
fromImportId Int?
inaccurate Boolean @default(false)
baseModel String
baseModelType String @default("Standard")
meta Json @default("{}")
requireAuth Boolean @default(false)
settings Json?
availability Availability @default(Public)
nsfwLevel Int @default(0)
uploadType ModelUploadType @default(Created)
usageControl ModelUsageControl @default(Download)
earlyAccessTimeFrame Int @default(0)
flags Int @default(0)
licensingFee Decimal? @db.Decimal(10, 2)
licensingFeeType LicensingFeeType? @default(PerImageBuzz)
licensingFeeSettlementCurrency LicensingFeeSettlementCurrency? @default(Buzz)
// Licensing lineage: the root version whose fee this version inherits (e.g. a
// checkpoint built on Anima-Turbo points at the turbo version). Null means NO
// lineage fee — a derivative carries an explicit parent, and there is no
// (baseModel, modelType) rule table behind it (see the resolution comment in
// api/v1/model-versions/mini/[id].ts). Decoupled from baseModel so it never
// affects gen-compat / leaderboards / filters.
licensingSourceVersionId Int?
licensingSource ModelVersion? @relation("licensingSource", fields: [licensingSourceVersionId], references: [id], onDelete: SetNull)
monetization ModelVersionMonetization?
metrics ModelVersionMetric[]
files ModelFile[]
runStrategies RunStrategy[]
engagements ModelVersionEngagement[]
downloads DownloadHistory[]
imageResources ImageResource[]
posts Post[]
resourceReviews ResourceReview[]
hashes ModelHash[]
metricsDaily ModelMetricDaily[]
modelVersionExploration ModelVersionExploration[]
vaeFor ModelVersion[] @relation("vae")
licensingDerivatives ModelVersion[] @relation("licensingSource")
generationCoverage GenerationCoverage?
recommendedResources RecommendedResource[] @relation("recommendedResources")
recommendedTo RecommendedResource[] @relation("recommendedTo")
DonationGoal DonationGoal[]
featuredInfo FeaturedModelVersion[]
ImageResourceNew ImageResourceNew[]
coveredCheckpoints CoveredCheckpoint[]
wildcardSet WildcardSet?
licensingRoot LicensingRoot?
@@index([modelId], type: Hash)
@@index([licensingSourceVersionId])
}
// A version registered as a chargeable licensing root for its (baseModel,
// modelType). Membership = a row exists; `isDefault` marks the one root a
// derivative pre-selects (enforced one-per-scope by a partial unique index).
// Replaces the ModelVersion.LicensingRoot flag + BaseModelLicensingFee pointer.
model LicensingRoot {
id Int @id @default(autoincrement())
baseModel String
modelType ModelType
modelVersionId Int @unique
modelVersion ModelVersion @relation(fields: [modelVersionId], references: [id], onDelete: Cascade)
isDefault Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([baseModel, modelType])
}
// One row per ENTITY an owner has put a price on — a licensing fee or a permanent paid-access gate,
// not one per kind. A slot is returned only by clearing the last price off an entity nothing has
// transacted against; the row is DELETED then, because the allowance counts rows created this calendar
// month. No foreign key to the entity, because the key is polymorphic — so deleting a version does not
// refund its slot, and rows outliving their entity are inert (the count is month-scoped).
//
// See the migration for the apply-order and lock caveats.
model PricingSlot {
entityType PaidAccessEntityType
entityId Int
ownerId Int
owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
@@id([entityType, entityId])
@@index([ownerId, createdAt])
}
enum LicensingFeeType {
PerImageBuzz
}
enum LicensingFeeSettlementCurrency {
Buzz
Cash
}
enum ModelVersionEngagementType {
Notify
}
model ModelVersionEngagement {
userId Int
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
modelVersionId Int
modelVersion ModelVersion @relation(fields: [modelVersionId], references: [id], onDelete: Cascade)
type ModelVersionEngagementType
createdAt DateTime @default(now())
@@id([userId, modelVersionId])
}
enum ModelHashType {
AutoV1
AutoV2
AutoV3
SHA256
CRC32
BLAKE3
// SHA256 truncated to 12 — the width A1111/Forge write for LoRAs, which no other stored
// hash matches. Same kind of derived truncation as AutoV2 (sha256[0:10]).
SHA256_12
// left(sshs_model_hash, 12) from the safetensors header, stored verbatim — the 0x prefix some
// trainers write is part of the value A1111/Forge emit, and the hash under it is computed at
// training time, so it is not derivable from the file's bytes. See docs/image-resource-hash-matching.md.
SSHS_12
}
model RecommendedResource {
id Int @id @default(autoincrement())
resourceId Int
resource ModelVersion @relation("recommendedTo", fields: [resourceId], references: [id], onDelete: Cascade)
sourceId Int?
source ModelVersion? @relation("recommendedResources", fields: [sourceId], references: [id], onDelete: Cascade)
settings Json?
@@index([sourceId], type: Hash)
}
model ModelFileHash {
file ModelFile @relation(fields: [fileId], references: [id], onDelete: Cascade)
fileId Int
type ModelHashType
hash String @db.Citext
createdAt DateTime @default(now())
@@id([fileId, type])
@@index([hash], type: Hash)
}
enum ScanResultCode {
Pending
Success
Danger
Error
}
enum ModelFileVisibility {
Sensitive // Choosing not to share
Private // Hidden
Public // Available to all
}
model ModelFile {
id Int @id @default(autoincrement())
name String
overrideName String?
url String
sizeKB Float
createdAt DateTime @default(now())
type String @default("Model")
modelVersionId Int
modelVersion ModelVersion @relation(fields: [modelVersionId], references: [id], onDelete: Cascade)
pickleScanResult ScanResultCode @default(Pending)
exists Boolean?
pickleScanMessage String?
virusScanResult ScanResultCode @default(Pending)
virusScanMessage String?
scannedAt DateTime?
scanRequestedAt DateTime?
rawScanResult Json?
hashes ModelFileHash[]
metadata Json?
headerData Json?
visibility ModelFileVisibility @default(Public)
dataPurged Boolean @default(false)
replacedAt DateTime?
@@index([modelVersionId], type: Hash)
}
model File {
id Int @id @default(autoincrement())
name String
url String
sizeKB Float
createdAt DateTime @default(now())
entityId Int
entityType String
metadata Json?
@@index([entityType, entityId])
}
enum MetricTimeframe {
Day
Week
Month
Year
AllTime
}
model ModelMetric {
model Model @relation(fields: [modelId], references: [id], onDelete: Cascade)
modelId Int
downloadCount Int @default(0)
commentCount Int @default(0)
collectedCount Int @default(0)
imageCount Int @default(0)
tippedCount Int @default(0)
tippedAmountCount Int @default(0)
generationCount Int @default(0)
thumbsUpCount Int @default(0)
thumbsDownCount Int @default(0)
earnedAmount Int @default(0)
updatedAt DateTime @default(now())
// Model Metadata for quick access
poi Boolean @default(false)
minor Boolean @default(false)
nsfwLevel Int @default(0)
userId Int @default(0)
lastVersionAt DateTime?
mode ModelModifier?
status ModelStatus @default(Draft)
availability Availability @default(Public)
@@id([modelId])
}
model ModelVersionMetric {
modelVersion ModelVersion @relation(fields: [modelVersionId], references: [id], onDelete: Cascade)
modelVersionId Int
downloadCount Int @default(0)
commentCount Int @default(0)
collectedCount Int @default(0)
imageCount Int @default(0)
tippedCount Int @default(0)
tippedAmountCount Int @default(0)
generationCount Int @default(0)
thumbsUpCount Int @default(0)
thumbsDownCount Int @default(0)
earnedAmount Int @default(0)
updatedAt DateTime @default(now())
@@id(modelVersionId)
}
model ModelBaseModelMetric {
model Model @relation(fields: [modelId], references: [id], onDelete: Cascade)
modelId Int
baseModel String
// Sort columns (aggregated from ModelVersionMetric)
thumbsUpCount Int @default(0)
downloadCount Int @default(0)
imageCount Int @default(0)
// Denormalized filter columns (synced from Model via trigger)
status ModelStatus @default(Draft)
availability Availability @default(Public)
nsfwLevel Int @default(0)
mode ModelModifier?
poi Boolean @default(false)
minor Boolean @default(false)
updatedAt DateTime @default(now())
@@id([modelId, baseModel])
}
model ModelMetricDaily {
modelId Int
model Model @relation(fields: [modelId], references: [id], onDelete: Cascade)
modelVersionId Int
modelVersion ModelVersion @relation(fields: [modelVersionId], references: [id], onDelete: Cascade)
type String
date DateTime @db.Date
count Int
@@id([modelId, modelVersionId, type, date])
@@index(modelVersionId)
}
enum AssociationType {
Suggested
}
model ModelAssociations {
id Int @id @default(autoincrement())
fromModelId Int
fromModel Model @relation("FromModelAssociation", fields: [fromModelId], references: [id], onDelete: Cascade)
toModelId Int?
toModel Model? @relation("ToModelAssociation", fields: [toModelId], references: [id], onDelete: Cascade)
toArticleId Int?
toArticle Article? @relation(fields: [toArticleId], references: [id], onDelete: Cascade)
associatedById Int?
createdAt DateTime @default(now())
type AssociationType
index Int?
@@index([toModelId], type: Hash)
@@index([fromModelId], type: Hash)
@@index([toArticleId], type: Hash)
}
model DownloadHistory {
userId Int
modelVersionId Int
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
modelVersion ModelVersion @relation(fields: [modelVersionId], references: [id], onDelete: Cascade)
downloadAt DateTime
hidden Boolean @default(false)
@@id([userId, modelVersionId])
@@index([userId, downloadAt])
}
/// Append-only moderator action trail. Deliberately NOT unique on (activity, entityType, entityId): the
/// same action on the same entity happening twice is two events, and collapsing them loses the history the
/// "who did what, when" panels are built on.
model ModActivity {
id Int @id @default(autoincrement())
userId Int?
activity String
entityType String?
entityId Int?
createdAt DateTime @default(now())
@@index([entityType, entityId, createdAt])
@@index([userId, createdAt])
@@index([createdAt])
}
enum ReportReason {
TOSViolation
NSFW
Ownership
AdminAttention
Claim
CSAM
Automated
Spam
StickerPlacement
}
enum ReportStatus {
Pending
Processing
Actioned
Unactioned
}
model Report {
id Int @id @default(autoincrement())
userId Int
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
reason ReportReason
createdAt DateTime @default(now())
details Json?
internalNotes String?
previouslyReviewedCount Int @default(0)
alsoReportedBy Int[] @default([]) // UserIds
status ReportStatus
statusSetAt DateTime?
statusSetBy Int?
model ModelReport?
comment CommentReport?
commentV2 CommentV2Report?
image ImageReport?
resourceReview ResourceReviewReport?
article ArticleReport?
post PostReport?
reportedUser UserReport?
collection CollectionReport?
bounty BountyReport?
challenge ChallengeReport?
bountyEntry BountyEntryReport?
chat ChatReport?
comicProject ComicProjectReport?
automated ReportAutomated?
model3d Model3DReport?
model3dReview Model3DReviewReport?
announcement AnnouncementReport?
}
model ResourceReviewReport {
resourceReviewId Int
resourceReview ResourceReview @relation(fields: [resourceReviewId], references: [id], onDelete: Cascade)
reportId Int @unique
report Report @relation(fields: [reportId], references: [id], onDelete: Cascade)
@@id([reportId, resourceReviewId])
@@index([resourceReviewId], type: Hash)
}
model ModelReport {
modelId Int
model Model @relation(fields: [modelId], references: [id], onDelete: Cascade)
reportId Int @unique
report Report @relation(fields: [reportId], references: [id], onDelete: Cascade)
@@id([reportId, modelId])
@@index([modelId], type: Hash)
}
model CommentReport {
commentId Int
comment Comment @relation(fields: [commentId], references: [id], onDelete: Cascade)
reportId Int @unique
report Report @relation(fields: [reportId], references: [id], onDelete: Cascade)
@@id([reportId, commentId])
@@index([commentId], type: Hash)
}
model CommentV2Report {
commentV2Id Int
commentV2 CommentV2 @relation(fields: [commentV2Id], references: [id], onDelete: Cascade)
reportId Int @unique
report Report @relation(fields: [reportId], references: [id], onDelete: Cascade)
@@id([reportId, commentV2Id])
@@index([commentV2Id], type: Hash)
}
model ImageReport {
imageId Int
image Image @relation(fields: [imageId], references: [id], onDelete: Cascade)
reportId Int @unique
report Report @relation(fields: [reportId], references: [id], onDelete: Cascade)
@@id([reportId, imageId])
@@index([imageId], type: Hash)
}
model ArticleReport {
articleId Int
article Article @relation(fields: [articleId], references: [id], onDelete: Cascade)
reportId Int @unique
report Report @relation(fields: [reportId], references: [id], onDelete: Cascade)
@@id([reportId, articleId])
@@index([articleId], type: Hash)
}
model PostReport {
postId Int
post Post @relation(fields: [postId], references: [id], onDelete: Cascade)
reportId Int @unique
report Report @relation(fields: [reportId], references: [id], onDelete: Cascade)
@@id([reportId, postId])
@@index([postId], type: Hash)
}
model UserReport {
userId Int
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
reportId Int @unique
report Report @relation(fields: [reportId], references: [id], onDelete: Cascade)
@@id([reportId, userId])
@@index([userId], type: Hash)
}
model CollectionReport {
collectionId Int
collection Collection @relation(fields: [collectionId], references: [id], onDelete: Cascade)
reportId Int @unique
report Report @relation(fields: [reportId], references: [id], onDelete: Cascade)
@@id([reportId, collectionId])
@@index([collectionId], type: Hash)
}
model BountyReport {
bountyId Int
bounty Bounty @relation(fields: [bountyId], references: [id], onDelete: Cascade)
reportId Int @unique
report Report @relation(fields: [reportId], references: [id], onDelete: Cascade)
@@id([reportId, bountyId])
@@index([bountyId], type: Hash)
}
model BountyEntryReport {
bountyEntryId Int
bountyEntry BountyEntry @relation(fields: [bountyEntryId], references: [id], onDelete: Cascade)
reportId Int @unique
report Report @relation(fields: [reportId], references: [id], onDelete: Cascade)
@@id([reportId, bountyEntryId])
@@index([bountyEntryId], type: Hash)
}
model ChatReport {
chatId Int
chat Chat @relation(fields: [chatId], references: [id], onDelete: Cascade)
reportId Int @unique
report Report @relation(fields: [reportId], references: [id], onDelete: Cascade)
@@id([reportId, chatId])
@@index([chatId], type: Hash)
}
model ComicProjectReport {
comicProjectId Int
comicProject ComicProject @relation(fields: [comicProjectId], references: [id], onDelete: Cascade)
reportId Int @unique
report Report @relation(fields: [reportId], references: [id], onDelete: Cascade)
@@id([reportId, comicProjectId])
@@index([comicProjectId], type: Hash)
}
model ResourceReview {
id Int @id @default(autoincrement())
modelId Int
model Model @relation(fields: [modelId], references: [id], onDelete: Cascade)
modelVersionId Int
modelVersion ModelVersion @relation(fields: [modelVersionId], references: [id], onDelete: Cascade)
rating Int
recommended Boolean @default(true)
details String?
userId Int
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
thread Thread?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
exclude Boolean @default(false)
nsfw Boolean @default(false)
tosViolation Boolean @default(false)
metadata Json?
reactions ResourceReviewReaction[]
helper ResourceReviewHelper?
reports ResourceReviewReport[]
@@unique([modelVersionId, userId])
@@index([modelVersionId], type: Hash)
@@index([userId], type: Hash)
}
model ResourceReviewReaction {
id Int @id @default(autoincrement())
review ResourceReview @relation(fields: [reviewId], references: [id], onDelete: Cascade)
reviewId Int
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
userId Int
reaction ReviewReactions
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([reviewId, userId, reaction])
}
enum ReviewReactions {
Like
Dislike
Laugh
Cry
Heart
}
model Post {
id Int @id @default(autoincrement())
nsfw Boolean @default(false)
title String?
detail String?
userId Int
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
modelVersionId Int?
modelVersion ModelVersion? @relation(fields: [modelVersionId], references: [id], onDelete: SetNull)
model3dId Int?
model3d Model3D? @relation(fields: [model3dId], references: [id], onDelete: SetNull)
model3dReviewId Int? @unique
model3dReview Model3DReview? @relation(fields: [model3dReviewId], references: [id], onDelete: SetNull)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
publishedAt DateTime?
metadata Json?
tosViolation Boolean @default(false)
collectionId Int?
// SetNull, not Cascade: this is a back-reference recording where the post was created, not
// ownership. `CollectionItem` is the membership join and cascades correctly; cascading here
// deleted the posts of everyone who entered a contest or contributed to a collaborative
// collection, and `Image.postId` (SET NULL) then left their images alive with no post.
collection Collection? @relation(fields: [collectionId], references: [id], onDelete: SetNull)
unlisted Boolean @default(false)
availability Availability @default(Public)
nsfwLevel Int @default(0)
images Image[]
tags TagsOnPost[]
reactions PostReaction[]
thread Thread?
helper PostHelper?
stats PostStat?
metrics PostMetric[]
resourceHelper PostResourceHelper[]
imageTags PostImageTag[]
tagsComposite PostTag[]
tagVotes TagsOnPostVote[]
reports PostReport[]
collectionItems CollectionItem[]
@@index([modelVersionId])
@@index([model3dId])
@@index([publishedAt])
}
model PostMetric {
postId Int
post Post @relation(fields: [postId], references: [id], onDelete: Cascade)
timeframe MetricTimeframe
likeCount Int @default(0)
dislikeCount Int @default(0)
laughCount Int @default(0)
cryCount Int @default(0)
heartCount Int @default(0)
commentCount Int @default(0)
collectedCount Int @default(0)
updatedAt DateTime @default(now())
ageGroup MetricTimeframe @default(Day)
@@id([postId, timeframe])
@@index([postId, ageGroup])
}
enum ImageGenerationProcess {
txt2img
txt2imgHiRes
img2img
inpainting
}
enum NsfwLevel {
None
Soft
Mature
X
Blocked
}
enum ImageIngestionStatus {
Pending
Scanned
Error
Blocked
NotFound
PendingManualAssignment
Rescan
}
enum MediaType {
image
video
audio
}
model Image {
id Int @id @default(autoincrement())
pHash BigInt?
name String?
url String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
userId Int
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
meta Json? // image generation params
hash String?
height Int?
width Int?
type MediaType @default(image)
metadata Json @default("{}") // file metadata
nsfw NsfwLevel @default(None)
nsfwLevel Int @default(0)
nsfwLevelLocked Boolean @default(false)
tosViolation Boolean @default(false)
analysis Json?
generationProcess ImageGenerationProcess?
featuredAt DateTime?
postId Int?
post Post? @relation(fields: [postId], references: [id], onDelete: SetNull)
needsReview String?
hideMeta Boolean @default(false)
index Int?
scannedAt DateTime?
scanRequestedAt DateTime?
mimeType String?
sizeKB Int?
ingestion ImageIngestionStatus @default(Pending)
blockedFor String?
scanJobs Json?
assignedUser User? @relation("profilePicture")
sortAt DateTime @default(dbgenerated()) // authored by image_post_triggers (set_image_sort_at BEFORE INSERT/UPDATE); no client/DB literal default — keeps the field client-optional while the trigger owns the value
minor Boolean @default(false)
poi Boolean @default(false)
acceptableMinor Boolean @default(false)
reports ImageReport[]
reactions ImageReaction[]
thread Thread?
tags TagsOnImageDetails[]
tagVotes TagsOnImageVote[]
tagComposites ImageTag[]
modHelper ImageModHelper?
resources ImageResource[]
resourceHelper ImageResourceHelper[]
engagements ImageEngagement[]
collectionItems CollectionItem[]
collections Collection[]
connections ImageConnection[]
UserProfile UserProfile[]
userProfileSfwCover UserProfile[] @relation("UserProfileSfwCoverImage")
announcementCovers Announcement[] @relation("AnnouncementCover")
clubCover Club[] @relation("coverImage")
clubHeader Club[] @relation("headerImage")
clubAvatar Club[] @relation("avatarImage")
clubTierCover ClubTier[]
clubPostCoverImage ClubPost[]
article Article?
purchasableRewards PurchasableReward[]
tools ImageTool[]
techniques ImageTechnique[]
CosmeticShopSection CosmeticShopSection[]
flags ImageFlag[]
ratingRequests ImageRatingRequest[]
tagsNew TagsOnImageNew[]
imageResourceNew ImageResourceNew[]
imageTagsForReview ImageTagForReview[]
comicPanels ComicPanel[]
comicReferenceImages ComicReferenceImage[]
comicProjectCover ComicProject[] @relation("comicProjectCover")
comicProjectHero ComicProject[] @relation("comicProjectHero")
challengesCover Challenge[] @relation("ChallengeCoverImage")
challengeWins ChallengeWinner[]
challengeEventCovers ChallengeEvent[] @relation("ChallengeEventCover")
model3dThumbnails Model3D[] @relation("model3dThumbnail")
model3dSources Model3D[] @relation("model3dSource")
appListingIcons AppListing[] @relation("AppListingIcon")
appListingCovers AppListing[] @relation("AppListingCover")
appListingScreenshots AppListingScreenshot[] @relation("AppListingScreenshot")
@@index([featuredAt])
@@index([postId], type: Hash)
@@index([userId, postId])
@@index([userId, id], map: "image_userid_id_idx")
@@index([sortAt])
}
model ImageTagForReview {
imageId Int
image Image @relation(fields: [imageId], references: [id], onDelete: Cascade)
tagId Int
@@id([imageId, tagId])
@@index([tagId])
}
model ImageMetaFlags {
imageId Int @id
hasMeta Boolean
onSite Boolean
}
model ImageFlag {
imageId Int @id
image Image @relation(fields: [imageId], references: [id], onDelete: Cascade)
promptNsfw Boolean @default(false)
resourcesNsfw Boolean @default(false)
}
enum BlockImageReason {
Ownership
CSAM
TOS
}
model BlockedImage {
hash BigInt @id
reason BlockImageReason @default(Ownership)
createdAt DateTime @default(now())
}
model ImageConnection {
imageId Int
image Image @relation(fields: [imageId], references: [id], onDelete: Cascade)
entityId Int
entityType String
@@id([imageId, entityType, entityId])
@@index([entityType, entityId])
}
enum EntityModerationStatus {
Pending
Succeeded
Failed
Expired
Canceled
}
model EntityModeration {
id Int @id @default(autoincrement())
entityType String
entityId Int
workflowId String?
status EntityModerationStatus @default(Pending)
retryCount Int @default(0)
blocked Boolean?
triggeredLabels String[] @default([])
result Json?
contentHash String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([entityType, entityId])
@@index([workflowId])
// NOTE: a partial index `(status, updatedAt) WHERE status IN
// ('Pending', 'Failed', 'Expired', 'Canceled') AND retryCount < 9` exists
// to support the retry-failed-text-moderation job. It is created via a
// manual migration and is not modeled here because Prisma does not
// support partial indexes. See:
// prisma/migrations/20260407111422_add_entity_moderation_retry_index/
}
enum ImageEngagementType {
Favorite
Hide
}
model ImageEngagement {
userId Int
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
imageId Int
image Image @relation(fields: [imageId], references: [id], onDelete: Cascade)
type ImageEngagementType
createdAt DateTime @default(now())
@@id([userId, imageId])
@@index([imageId])
}
model ImageResource {
id Int @id @default(autoincrement())
modelVersionId Int?
modelVersion ModelVersion? @relation(fields: [modelVersionId], references: [id], onDelete: Cascade)
name String?
hash String?
imageId Int
image Image @relation(fields: [imageId], references: [id], onDelete: Cascade)
strength Int?
detected Boolean @default(false)
@@unique([modelVersionId, name, imageId])
@@index([imageId], type: Hash)
@@index([imageId, modelVersionId])
}
model ImageResourceNew {
imageId Int
image Image @relation(fields: [imageId], references: [id], onDelete: Cascade)
modelVersionId Int
modelVersion ModelVersion @relation(fields: [modelVersionId], references: [id], onDelete: Cascade)
strength Int?
detected Boolean @default(false)
@@id([imageId, modelVersionId])
@@index([modelVersionId])
}
model ResourceOverride {
hash String
modelVersionId Int
type ModelHashType
createdAt DateTime @default(now())
@@id([hash])
}
model ImageRatingRequest {
userId Int
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
imageId Int
image Image @relation(fields: [imageId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
nsfwLevel Int
status ReportStatus @default(Pending)
weight Int @default(1)
@@id([imageId, userId])
}
model ArticleRatingReview {
id Int @id @default(autoincrement())
articleId Int
article Article @relation(fields: [articleId], references: [id], onDelete: Cascade)
userId Int
user User @relation("ArticleRatingReviewSubmitter", fields: [userId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
resolvedAt DateTime?
resolvedBy Int?
resolver User? @relation("ArticleRatingReviewResolver", fields: [resolvedBy], references: [id], onDelete: SetNull)
currentLevel Int
suggestedLevel Int
appliedLevel Int?
userComment String?
modComment String?
status ReportStatus @default(Pending)
@@index([status, createdAt])
@@index([userId])
@@index([articleId, createdAt(sort: Desc)])
}
model CollectionMetric {
collection Collection @relation(fields: [collectionId], references: [id], onDelete: Cascade)
collectionId Int
timeframe MetricTimeframe
followerCount Int @default(0)
itemCount Int @default(0)
contributorCount Int @default(0)
updatedAt DateTime @default(now())
@@id([collectionId, timeframe])
}
enum ImageOnModelType {
Example
Training
}
enum TagTarget {
Model
Question
Image
Post
Tag
Article
Bounty
Collection
Model3D
}
enum TagType {
UserGenerated
Label
Moderation
System
}
model Tag {
id Int @id @default(autoincrement())
name String @db.Citext
/// Casing for display, where capitalising `name` gets it wrong ("LoRA", "ComfyUI").
displayName String?
color String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
target TagTarget[]
type TagType @default(UserGenerated)
nsfw NsfwLevel @default(None)
nsfwLevel Int @default(1)
unlisted Boolean @default(false)
unfeatured Boolean @default(false)
isCategory Boolean @default(false)
adminOnly Boolean @default(false)
toTags TagsOnTags[] @relation("TagsOnTags_fromTag")
fromTags TagsOnTags[] @relation("TagsOnTags_toTag")
tagsOnModels TagsOnModels[]
tagsOnModelsVotes TagsOnModelsVote[]
tagsOnQuestion TagsOnQuestions[]
tagsOnImageVotes TagsOnImageVote[]
tagsOnPosts TagsOnPost[]
tagsOnArticles TagsOnArticle[]
tagsOnCollection TagsOnCollection[]
tagsOnImageComposite ImageTag[]
tagsOnModelComposite ModelTag[]
usersEngaged TagEngagement[]
metrics TagMetric[]
stats TagStat?
rank TagRank?
tagsOnPostComposite PostTag[]
tagsOnPostVotes TagsOnPostVote[]
tagsOnBounties TagsOnBounty[]
CollectionItem CollectionItem[]
tagsOnImage TagsOnImageDetails[]
tagsOnModel3D TagsOnModel3D[]
@@unique([name])
}
enum TagsOnTagsType {
Parent
Replace
Append
}
model TagsOnTags {
fromTagId Int
fromTag Tag @relation("TagsOnTags_fromTag", fields: [fromTagId], references: [id], onDelete: Cascade)
toTagId Int
toTag Tag @relation("TagsOnTags_toTag", fields: [toTagId], references: [id], onDelete: Cascade)
type TagsOnTagsType @default(Parent)
createdAt DateTime @default(now())
@@id([fromTagId, toTagId])
@@index([toTagId], type: Hash)
}
model TagsOnModels {
model Model @relation(fields: [modelId], references: [id], onDelete: Cascade)
modelId Int
tag Tag @relation(fields: [tagId], references: [id], onDelete: Cascade)
tagId Int
createdAt DateTime @default(now())
@@id([modelId, tagId])
@@index([modelId], type: Hash)
}
model TagsOnModelsVote {
modelId Int
model Model @relation(fields: [modelId], references: [id], onDelete: Cascade)
tagId Int
tag Tag @relation(fields: [tagId], references: [id], onDelete: Cascade)
userId Int
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
vote Int // 1 or -1
createdAt DateTime @default(now())
@@id([tagId, modelId, userId])
@@index([modelId], type: Hash)
@@index([userId], type: Hash)
}
model TagsOnQuestions {
question Question @relation(fields: [questionId], references: [id], onDelete: Cascade)
questionId Int
tag Tag @relation(fields: [tagId], references: [id], onDelete: Cascade)
tagId Int
@@id([tagId, questionId])
@@index([questionId], type: Hash)
}
enum TagSource {
User
Rekognition
WD14
Computed
ImageHash
Hive
MinorDetection
HiveDemographics
Clavata
SpineRating
AiRecognition
AnimeRecognition
}
model TagsOnImageNew {
imageId Int
// The FK on this column is INTENTIONALLY ABSENT and must not be re-added.
// Enforcement lives in the `after_image_delete_trigger` trigger (function
// `after_image_delete`, programmability/image_delete_triggers.sql), verified
// enabled in production, which deletes this table's rows on image delete. The
// FK was created ON DELETE CASCADE in 20250303170613_tags_on_image_new and
// deliberately dropped in 20250314203912_drop_tags_on_image, one day after that
// trigger took over — this table is far too large to carry the constraint.
// `onDelete: Cascade` documents the semantics the trigger already implements.
// It is NOT a to-do to add the constraint.
//
// 🔴 THE TOOLING WILL TRY TO ADD IT ANYWAY. This model carries no `/// @view`
// annotation, so `pnpm db:migrate` sees it and will emit
// ALTER TABLE "TagsOnImageNew" ADD CONSTRAINT "TagsOnImageNew_imageId_fkey"
// ... ON DELETE CASCADE;
// If you are reviewing a generated migration, DELETE that statement. Note it now
// reads as plausible: before this declaration was corrected the generated DDL said
// RESTRICT, which was obviously wrong on sight; CASCADE is not.
image Image @relation(fields: [imageId], references: [id], onDelete: Cascade)
tagId Int
attributes Int @db.SmallInt()
@@id([imageId, tagId])
@@index([tagId])
}
model ShadowTagsOnImage {
imageId Int
tagId Int
confidence Int
@@id([imageId, tagId])
@@index([tagId])
}
model TagsOnImageVote {
imageId Int
image Image @relation(fields: [imageId], references: [id], onDelete: Cascade)
tagId Int
tag Tag @relation(fields: [tagId], references: [id], onDelete: Cascade)
userId Int
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
vote Int // 1 or -1
createdAt DateTime @default(now())
applied Boolean @default(false)
@@id([tagId, imageId, userId])
@@index([imageId], type: Hash)
@@index([userId], type: Hash)
}
model TagsOnPost {
postId Int
post Post @relation(fields: [postId], references: [id], onDelete: Cascade)
tagId Int
tag Tag @relation(fields: [tagId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
confidence Int?
disabled Boolean @default(false)
needsReview Boolean @default(false)
@@id([tagId, postId])
@@index([postId], type: Hash)
}
model TagsOnArticle {
articleId Int
article Article @relation(fields: [articleId], references: [id], onDelete: Cascade)
tagId Int
tag Tag @relation(fields: [tagId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
@@id([tagId, articleId])
@@index([articleId], type: Hash)
}
model TagsOnBounty {
bountyId Int
bounty Bounty @relation(fields: [bountyId], references: [id], onDelete: Cascade)
tagId Int
tag Tag @relation(fields: [tagId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
@@id([tagId, bountyId])
@@index([bountyId], type: Hash)
}
model TagsOnPostVote {
postId Int
post Post @relation(fields: [postId], references: [id], onDelete: Cascade)
tagId Int
tag Tag @relation(fields: [tagId], references: [id], onDelete: Cascade)
userId Int
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
vote Int // 1 or -1
createdAt DateTime @default(now())
@@id([tagId, postId, userId])
@@index([postId], type: Hash)
@@index([userId], type: Hash)
}
model TagMetric {
tag Tag @relation(fields: [tagId], references: [id], onDelete: Cascade)
tagId Int
timeframe MetricTimeframe
modelCount Int @default(0)
imageCount Int @default(0)
postCount Int @default(0)
articleCount Int @default(0)
hiddenCount Int @default(0)
followerCount Int @default(0)
updatedAt DateTime @default(now())
@@id([tagId, timeframe])
}
model SavedModel {
model Model @relation(fields: [modelId], references: [id], onDelete: Cascade)
modelId Int
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
userId Int
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@id([modelId, userId])
}
model RunStrategy {
modelVersionId Int
modelVersion ModelVersion @relation(fields: [modelVersionId], references: [id], onDelete: Cascade)
partnerId Int
partner Partner @relation(fields: [partnerId], references: [id], onDelete: Cascade)
url String
createdAt DateTime @default(now())
@@id([modelVersionId, partnerId])
}
enum PartnerPricingModel {
Duration
PerImage
}
model Partner {
id Int @id @default(autoincrement())
name String
homepage String?
tos String?
privacy String?
startupTime Int? // Seconds
onDemand Boolean
onDemandStrategy String? // URL Template
onDemandTypes ModelType[] @default([])
onDemandBaseModels String[] @default([])
stepsPerSecond Int
pricingModel PartnerPricingModel
price String
about String?
createdAt DateTime @default(now())
nsfw Boolean @default(false)
poi Boolean @default(false)
personal Boolean @default(false)
token String? @unique
tier Int @default(0)
logo String?
disabled Boolean @default(false)
runStrategies RunStrategy[]
}
model KeyValue {
key String @id
value Json
}
/// Free-text feedback captured from in-product prompts. `area` is a plain
/// string, not an enum, so adding a feedback surface needs no migration.
model Feedback {
id Int @id @default(autoincrement())
area String
userId Int
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
message String
context Json @default("{}")
status String @default("new")
createdAt DateTime @default(now())
/// Moderator-internal. Never seeded into a Bug — a Bug is public, this is not.
triageNote String?
handledById Int?
handledBy User? @relation("FeedbackHandledBy", fields: [handledById], references: [id], onDelete: SetNull)
handledAt DateTime?
bugId Int?
bug Bug? @relation(fields: [bugId], references: [id], onDelete: SetNull)
@@index([area, status, createdAt(sort: Desc)])
@@index([userId, createdAt(sort: Desc)])
@@index([status, createdAt(sort: Desc)])
}
enum ApiKeyType {
System
User
Access
Refresh
}
model ApiKey {
id Int @id @default(autoincrement())
key String @unique
name String
tokenScope Int @default(33554431) // Bitwise flags — default = Full (all scopes)
userId Int
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
type ApiKeyType @default(User)
expiresAt DateTime?
lastUsedAt DateTime?
clientId String?
client OauthClient? @relation(fields: [clientId], references: [id], onDelete: Cascade)
buzzLimit Json? // { daily?: number, weekly?: number, monthly?: number }
@@index([clientId])
}
model OauthClient {
id String @id
secret String?
name String
description String @default("")
logoUrl String?
redirectUris String[] @default([])
allowedOrigins String[] @default([])
grants String[] @default(["authorization_code", "refresh_token"])
allowedScopes Int @default(33554431)
isConfidential Boolean @default(true)
/// Login gating: "open" (anyone), "testers" (only users holding the "tester" UserRole), "disabled" (no one).
/// Read by the auth hub's /authorize gate. First-party (spoke) clients have no row here, so are never gated.
accessMode String @default("open")
userId Int
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
isVerified Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
tokens ApiKey[]
consents OauthConsent[]
appBlocks AppBlock[]
buzzAttributions BlockBuzzAttribution[]
spendAttributions BlockSpendAttribution[] @relation("BlockSpendAttributionApp")
authorFeeAccruals BlockAuthorFeeAccrual[] @relation("BlockAuthorFeeAccrualApp")
subscriptionAttributions BlockSubscriptionAttribution[] @relation("BlockSubscriptionAttributionApp")
connectListings AppListing[]
@@index([userId])
}
/// Generic per-user role grants (e.g. "tester"). Read directly by the auth hub — the OAuth /authorize gate
/// treats accessMode "testers" as "must hold the 'tester' role". Managed from the auth hub admin UI
/// (/admin/access). Keyed by (userId, role) so a user can hold multiple roles.
model UserRole {
userId Int
role String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
roleRef Role @relation(fields: [role], references: [id], onDelete: Cascade)
note String?
addedById Int?
createdAt DateTime @default(now())
@@id([userId, role])
@@index([role])
}
/// Role definitions, managed from the auth hub. `id` is the app-namespaced role string (e.g.
/// "moderator:volunteer") that `UserRole.role` references. Exists so a role can be created before any
/// members; what a role unlocks is decided per-app in code, not stored here.
model Role {
id String @id
description String?
createdById Int?
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
members UserRole[]
}
/// Comped membership tier, managed from the auth hub (/admin/membership). The hub folds this into the
/// session it produces, so the user gets tier-gated access without a CustomerSubscription. It grants only
/// what reads `session.tier` — NOT the subscription-derived perks (monthly Buzz, vault, badge, multiplier).
/// One row per user; the override only ever raises a tier, never lowers a paid one.
model UserMembershipOverride {
userId Int @id
tier String
note String?
grantedById Int?
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
/// App Blocks — v1 substrate. Registry of all blocks across all apps (OauthClients).
/// Application-generated TEXT primary key (prefixed ULID: `ab_<ulid>`).
/// See docs/features/app-blocks.md for the architecture overview.
model AppBlock {
id String @id
appId String @map("app_id")
app OauthClient @relation(fields: [appId], references: [id], onDelete: Cascade)
blockId String @map("block_id")
version String
manifest Json
status String @default("pending")
contentRating String @map("content_rating")
promotionEligible Boolean @default(false) @map("promotion_eligible")
healthStatus String @default("unknown") @map("health_status")
healthCheckedAt DateTime? @map("health_checked_at") @db.Timestamptz(6)
// v2 substrate columns (NULL-safe for v1, included to avoid later migration)
renderMode String @default("iframe") @map("render_mode")
trustTier String @default("unverified") @map("trust_tier")
assetBundleUrl String? @map("asset_bundle_url")
assetBundleSha256 String? @map("asset_bundle_sha256")
// H-2: scope set captured at moderator approval. Empty array = never
// been approved (Prisma doesn't support nullable arrays). Token issuance
// fails closed when the requested scope isn't in this set.
approvedScopes String[] @default([]) @map("approved_scopes")
// W2 apps-as-repos — Forgejo repo currently backing this block. Nullable
// because pre-W2 hackathon rows (apb_01KSD3NP23CQE4TMW14XTEFSNS) won't
// have these until W12 migration. Written by the git-push webhook
// (sha) + build-callback (deployedAt).
currentVersionSha String? @map("current_version_sha")
currentVersionDeployedAt DateTime? @map("current_version_deployed_at") @db.Timestamptz(6)
repoUrl String? @map("repo_url")
// F-E E3 marketplace browse metadata — platform-controlled, mod-assigned at
// review (NOT manifest/publisher fields, to avoid SEO gaming + the SDK/npm
// cross-repo dep). ADDITIVE + manually applied (migration
// 20260614120000_e3_marketplace_metadata; CNPG nvme0 does not auto-apply).
// NULL/false until applied — read only by the dark, mod-gated marketplace.
// `category` is free-text (taxonomy = MARKETPLACE_CATEGORIES const) so adding
// a category needs no migration.
category String? // mod-assigned marketplace category (free-text)
featured Boolean @default(false) // E4 curation: staff-pick rail
featuredOrder Int? @map("featured_order") // E4 curation: rail order
// F-E E5 marketplace screenshot gallery — publisher-supplied screenshots
// auto-discovered from the submitted bundle's `screenshots/` dir, validated
// (count/size/magic-bytes/name), uploaded to the bundle MinIO at approve, and
// recorded here as an array of { key, index, ext, contentType } entries.
// PUBLIC display data (served via the gated /api/blocks/screenshot/... route),
// but MOD-REVIEWED before approval. ADDITIVE + manually applied (migration
// 20260615120000_e5_screenshots; CNPG nvme0 does not auto-apply). NULL until
// applied — read ONLY by the dark, mod-gated getAppDetail (NOT listAvailable).
screenshots Json? // E5: [{ key, index, ext, contentType }]
// Off-site (external-link) app — PURE EXTERNAL LINK product model. When set,
// this listing is a stateless discovery/marketing entry that opens an external
// URL in a new tab: NO install, NO scopes, NO block token, NO subscription,
// and NO on-platform iframe/page hosting. Presence of `externalUrl` is the
// discriminator (no separate appType enum). Always a well-formed https:// URL
// (validated at registration). NULL = a normal on-platform (embedded) app, the
// existing flow. Mutually exclusive with on-platform hosting: an external app
// declares no page/iframe slot and skips the bundle / `<slug>.<APPS_DOMAIN>`
// validation. ADDITIVE + MANUALLY APPLIED (CNPG nvme0 does not auto-apply; see
// migration 20260629120000_add_appblock_external_url). NULL until applied.
externalUrl String? @map("external_url")
// PER-APP generation SPEND CLASS — drives the daily-Buzz + velocity ceilings
// enforced by app-spend-cap.service.ts. 'standard' (the default, and every row
// that exists today) is byte-identical to the pre-change global ceilings.
//
// 🔴 A DEDICATED AXIS, NOT `trust_tier`. `trust_tier` is a BROWSER-ISOLATION
// decision — it gates the iframe sandbox allowlist (allow-same-origin +
// allow-scripts) and inline/hybrid renderMode. Deriving spend from it would
// mean a moderator granting a RENDERING capability silently granted a 5x money
// ceiling; production already carries rows tiered `internal` for rendering
// that were never a spend decision. The two vocabularies deliberately share no
// value, so one can never be silently written into the other's column.
//
// 🔴 PLATFORM-CONTROLLED, mod-only write (BlockRegistry.setAppSpendCapConfig
// behind moderatorProcedure). Never publisher-declared and never sourced from
// the manifest: a developer must not be able to raise their own abuse ceiling.
// Same posture as trust_tier / category / featured above.
spendTier String @default("standard") @map("spend_tier")
// PER-APP generation-cap OVERRIDE — the moderator escape hatch on top of the
// `spend_tier`-derived ceilings. NULL = no override, use the tier's limits. A
// non-NULL value REPLACES that one field only (they are independent), and may
// TIGHTEN as well as loosen — clamping a single abusive app without demoting
// its tier. Bounded 1..1e9 / 1..100_000 by CHECK constraints matching the code
// bounds exactly, so a hand-written value cannot mean one thing in the row and
// another at enforcement.
//
// ADDITIVE + MANUALLY APPLIED (migration
// 20260731120000_app_block_spend_tier_and_cap_override; CNPG nvme0 does not
// auto-apply). Apply BEFORE the deploy: the cap resolver itself degrades to
// the strictest tier (= the pre-change ceilings, never "uncapped"), but any
// `appBlock` query without an explicit `select` raises P2022 until it lands.
spendCapBuzzPerDay Int? @map("spend_cap_buzz_per_day")
spendVelocityMaxGens Int? @map("spend_velocity_max_gens")
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
platformDefault PlatformDefaultBlock?
userSubscriptions BlockUserSubscription[]
buzzAttributions BlockBuzzAttribution[]
spendAttributions BlockSpendAttribution[] @relation("BlockSpendAttributionAppBlock")
authorFeeAccruals BlockAuthorFeeAccrual[] @relation("BlockAuthorFeeAccrualAppBlock")
subscriptionAttributions BlockSubscriptionAttribution[] @relation("BlockSubscriptionAttributionAppBlock")
publishRequests AppBlockPublishRequest[]
scopeInvocations BlockScopeInvocation[]
userScopeGrants AppUserScopeGrant[]
appListing AppListing?
// NB: App Listing Collaborators (seats / ownership events / ownership transfers)
// are keyed to `AppListing`, NOT here — see the AppCollaborator model header. An
// off-site listing has no AppBlock at all, so a block-keyed seat could never exist
// for one of the store's two kinds.
@@unique([appId, blockId], map: "app_blocks_app_block_uniq")
// W1 audit C-3 fix: enforce one app per slug at the DB layer. The
// (appId, blockId) constraint above doesn't protect because each
// approve mints a fresh appId.
@@unique([blockId], map: "app_blocks_block_id_unique")
@@index([status], map: "app_blocks_status_idx")
// F-E E3 marketplace browse indexes (created by the manual E3 migration).
@@index([status, category], map: "app_blocks_status_category_idx")
@@index([featured, featuredOrder], map: "app_blocks_featured_order_idx")
@@map("app_blocks")
}
/// App Listing COLLABORATORS — a consent-gated editor seat on a STORE LISTING.
///
/// 🔴 KEYED TO `AppListing`, NOT `AppBlock`. `AppBlock` is the ON-SITE runtime record;
/// an OFF-SITE listing (external-link / OAuth-connect) has no AppBlock at all
/// (`app_listings.app_block_id IS NULL`), so a block-keyed seat was structurally unable
/// to exist for one of the store's two kinds. `AppListing` is the store-facing parent of
/// BOTH, so the seat lives here. Ownership remains canonically `OauthClient.userId` for
/// an on-site listing — the seat's KEY and the ownership COLUMN are different questions.
///
/// 🔴 A SEAT BELONGS TO A **PARENT** LISTING, NEVER TO A SHADOW REVISION
/// (`revisionOfId != null`). `applyApprovedRevision` DELETES the shadow, and this FK
/// CASCADEs — a seat on a shadow would silently vanish on approve. A SQL CHECK cannot
/// express "parent only" (it cannot see another row), so it is enforced in the service:
/// `inviteCollaborator` refuses a shadow, and `resolveListingAccess` resolves a shadow
/// to its parent before any seat lookup.
///
/// 🔴 WHAT A SEAT UNLOCKS IS DERIVED FROM THE LISTING'S `kind`, not stored. An off-site
/// editor gets content/media, submit-for-review and analytics, but NOT earnings
/// (`BlockBuzzAttribution` is block-scoped) and NOT submit-version / git (there is no
/// bundle and no repo). See `capabilitiesForKind` in `app-access.service.ts`.
///
/// 🔴 `displayed` (the public-byline opt-in) LIVES HERE FOR A LOAD-BEARING REASON, and
/// this is load-bearing rather than incidental: `applyApprovedRevision`'s OFFSITE
/// branch copies the shadow's `name/tagline/description/category/contentRating/
/// externalUrl/connect*` straight onto the live parent. An `AppListing` column
/// holding a display-author flag would therefore be CLOBBERED by any later shadow
/// approve — an immediate-apply product decision would silently revert on the next
/// mod re-review. Collaborator rows are outside BOTH branches' copy sets, so
/// immediate-apply is correct BY CONSTRUCTION, not by a promise to remember.
///
/// CONSENT MODEL (mirrors `EntityCollaborator`'s status-visibility rules, NOT its
/// code — that model is hard-scoped to Posts): an invite starts `pending` and confers
/// ZERO capability and ZERO public visibility. Only `accepted` grants anything.
///
/// `role` is TEXT (not an enum) so a future role needs no migration; `'editor'` is the
/// only value the code writes today. `status` is TEXT for the same reason, bounded by
/// a CHECK constraint in the migration.
///
/// MANUAL-APPLY (datapacket-talos rule #8): the main civitai DB (CNPG nvme0) does NOT
/// run `prisma migrate deploy`. The committed SQL is hand-applied per environment. All
/// reads are NULL-safe / empty-safe, so the feature is INERT until it lands.
model AppCollaborator {
appListingId String @map("app_listing_id")
appListing AppListing @relation(fields: [appListingId], references: [id], onDelete: Cascade)
userId Int @map("user_id")
user User @relation("AppCollaboratorMember", fields: [userId], references: [id], onDelete: Cascade)
/// Capability role. 'editor' today. What it actually unlocks is DERIVED from the
/// listing's kind (`capabilitiesForKind`), not stored here: content + media + submit
/// for review + analytics on both kinds, plus earnings and submit-version/git on
/// on-site only. Owner-only actions (managing collaborators, initiating a transfer)
/// are NOT a role — they are reserved to the listing owner.
role String @default("editor")
/// 'pending' | 'accepted' | 'rejected'. Only 'accepted' confers capability, and
/// only 'accepted' is ever visible publicly.
status String @default("pending")
/// Public-byline opt-in. Immediate-apply (no mod review) — safe because this row is
/// outside the revision copy sets (see the header). A collaborator is listed on the
/// public listing only when status='accepted' AND displayed=true.
displayed Boolean @default(true)
/// The OWNER who issued the invite (audit + the "invited by" chip).
invitedBy Int @map("invited_by")
inviter User @relation("AppCollaboratorInviter", fields: [invitedBy], references: [id], onDelete: Cascade)
/// Last time an invite NOTIFICATION was emitted for this row — the re-invite
/// throttle key. NB: the `EntityCollaborator` sibling has an inverted throttle
/// (`>=` where it means `<=`); this one is written correctly (see the service).
lastNotifiedAt DateTime? @map("last_notified_at") @db.Timestamptz(6)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
respondedAt DateTime? @map("responded_at") @db.Timestamptz(6)
@@id([appListingId, userId])
/// "which listings can this user edit" — the hot path (`resolveAccessibleListingIds`).
@@index([userId, status], map: "app_collaborators_user_status_idx")
/// "who is on this listing" — the roster + the public byline read.
@@index([appListingId, status], map: "app_collaborators_listing_status_idx")
@@map("app_collaborators")
}
/// APPEND-ONLY audit trail of every collaborator/ownership action on a listing.
///
/// Deliberately a separate table from `AppListingModerationEvent` (which is
/// mod-ACTION-scoped): these are AUTHOR actions. Mirrors that table's survivability
/// posture — every FK is NULLABLE + SET NULL so an event outlives the listing, the
/// actor and the target.
model AppOwnershipEvent {
id String @id // aoe_<ULID>
appListingId String? @map("app_listing_id")
appListing AppListing? @relation(fields: [appListingId], references: [id], onDelete: SetNull)
/// Denormalized so the event stays self-describing after the listing is gone.
slug String
/// invite | accept | reject | remove | leave | display | transfer_initiated |
/// transfer_accepted | transfer_cancelled. Bounded by a CHECK in the migration.
action String
/// Who performed the action.
actorUserId Int? @map("actor_user_id")
actor User? @relation("AppOwnershipEventActor", fields: [actorUserId], references: [id], onDelete: SetNull)
/// Who it was performed ON (the invitee / removed editor / transfer recipient).
targetUserId Int? @map("target_user_id")
target User? @relation("AppOwnershipEventTarget", fields: [targetUserId], references: [id], onDelete: SetNull)
/// Structured extras (role, before/after owner, expiry, …).
metadata Json?
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
@@index([appListingId, createdAt(sort: Desc)], map: "app_ownership_events_listing_idx")
@@index([actorUserId, createdAt(sort: Desc)], map: "app_ownership_events_actor_idx")
@@map("app_ownership_events")
}
/// An IN-FLIGHT ownership transfer (owner initiates → recipient accepts).
///
/// 🔴 WHY A THIRD TABLE rather than folding this into `AppCollaborator` with a
/// `role='owner_transfer'` row: `AppCollaborator`'s PK is `(appListingId, userId)`, so a
/// transfer to someone who is ALREADY an editor would collide with their seat row and
/// the two states would have to share one `status` column. And deriving the pending
/// state from `AppOwnershipEvent` (the append-only log) cannot carry a UNIQUE, so two
/// concurrent `initiateTransfer` calls could both "succeed". This table carries the
/// real constraint: a PARTIAL UNIQUE on `app_listing_id WHERE status='pending'` — at
/// most one in-flight transfer per listing, enforced at the DB.
model AppOwnershipTransfer {
id String @id // aot_<ULID>
appListingId String @map("app_listing_id")
appListing AppListing @relation(fields: [appListingId], references: [id], onDelete: Cascade)
/// Snapshot of the owner at initiate time — re-asserted in-tx at accept, so a
/// transfer initiated by an owner who has since lost the listing cannot complete.
fromUserId Int @map("from_user_id")
fromUser User @relation("AppOwnershipTransferFrom", fields: [fromUserId], references: [id], onDelete: Cascade)
toUserId Int @map("to_user_id")
toUser User @relation("AppOwnershipTransferTo", fields: [toUserId], references: [id], onDelete: Cascade)
/// 'pending' | 'accepted' | 'cancelled' | 'rejected' | 'expired'. CHECK in the migration.
status String @default("pending")
/// Hard expiry — an unaccepted transfer stops being acceptable after this instant.
/// Enforced in the ACCEPT path (a read-time predicate), so no sweeper job is
/// required for correctness; a sweeper would only tidy the rows.
expiresAt DateTime @map("expires_at") @db.Timestamptz(6)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
respondedAt DateTime? @map("responded_at") @db.Timestamptz(6)
/// NB: the migration ALSO creates a PARTIAL UNIQUE index
/// (`app_listing_id WHERE status='pending'`) that Prisma cannot express. That partial
/// index — not this plain one — is the one-in-flight-transfer guard; the service
/// relies on its P2002.
@@index([appListingId, status], map: "app_ownership_transfers_listing_status_idx")
@@index([toUserId, status], map: "app_ownership_transfers_to_status_idx")
@@map("app_ownership_transfers")
}
/// W1 v0 publish request — every version of every app goes through the
/// moderator review queue. The dev uploads a ZIP via /apps/submit (first
/// version) or /apps/<slug>/submit-version (subsequent). civitai-web
/// stores the bundle on the in-cluster MinIO tenant, extracts + validates the
/// manifest, computes diff summaries, and inserts a row here in
/// status='pending'. A mod approves or rejects via /apps/review.
///
/// On approve the platform (a) auto-creates the OauthClient if first
/// version, (b) inserts/updates the app_blocks row, (c) commits the
/// bundle contents to the Forgejo repo, which triggers the existing
/// Tekton build → callback → apply chain.
///
/// Forgejo is invisible to developers at every step.
model AppBlockPublishRequest {
id String @id // pubreq_<ULID>
appBlockId String? @map("app_block_id") // NULL while first request pending; FK on approve
appBlock AppBlock? @relation(fields: [appBlockId], references: [id], onDelete: SetNull)
slug String // app slug; carries identity across the first-request lifecycle
submittedByUserId Int @map("submitted_by_user_id")
submittedBy User @relation("PublishRequestSubmitter", fields: [submittedByUserId], references: [id], onDelete: Cascade)
submittedAt DateTime @default(now()) @map("submitted_at") @db.Timestamptz(6)
version String // semver, e.g. "0.1.0"
manifest Json // parsed block.manifest.json from the ZIP
bundleKey String @map("bundle_key") // S3 key: app-block-bundles/<sha256>.zip
bundleSha256 String @map("bundle_sha256") // for integrity + dedup
bundleSizeBytes BigInt @map("bundle_size_bytes")
fileSummary Json @map("file_summary") // { added, removed, changed } vs previous approved version
manifestDiffSummary Json @map("manifest_diff_summary") // field-level diff
status String // 'pending' | 'approved' | 'rejected' | 'withdrawn'
reviewedByUserId Int? @map("reviewed_by_user_id")
reviewedBy User? @relation("PublishRequestReviewer", fields: [reviewedByUserId], references: [id], onDelete: SetNull)
reviewedAt DateTime? @map("reviewed_at") @db.Timestamptz(6)
rejectionReason String? @map("rejection_reason")
approvalNotes String? @map("approval_notes")
forgejoCommitSha String? @map("forgejo_commit_sha") // populated on approve after Forgejo commit succeeds
// #4059 build provenance — an UNTRUSTED CLIENT CLAIM about the tree the bundle
// was built from. NOT interchangeable with forgejoCommitSha above: that is a
// SERVER-side sha written on approve once the Forgejo commit succeeded, so it
// is a fact. These two are whatever the submitting client said; the server
// never confirms the bundle was actually built from sourceCommit, and nothing
// downstream may imply it did. NULL = unknown (a pre-feature row, or a client
// that sent nothing) — never "clean".
sourceCommit String? @map("source_commit") // client-claimed 40-hex git sha the bundle was built from
sourceDirty Boolean? @map("source_dirty") // client-claimed uncommitted-changes flag. NULL (unknown) != false (known clean)
deployState String? @map("deploy_state") // Phase 2 build/deploy lifecycle (approved requests): building|deploying|live|failed
deployDetail String? @map("deploy_detail") // human-readable detail, primarily the failure reason
deployUpdatedAt DateTime? @map("deploy_updated_at") @db.Timestamptz(6) // last deploy_state transition
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
@@index([status, submittedAt(sort: Desc)], map: "app_block_publish_requests_queue_idx") // mod queue
@@index([appBlockId, submittedAt(sort: Desc)], map: "app_block_publish_requests_app_history_idx")
@@index([submittedByUserId, status], map: "app_block_publish_requests_my_submissions_idx")
@@index([slug, status], map: "app_block_publish_requests_slug_idx")
// RAW partial unique index (NOT expressible in Prisma @@unique — it carries a
// WHERE clause): "app_block_publish_requests_one_pending_per_slug" UNIQUE
// (slug) WHERE status='pending'. Enforces at-most-one PENDING request per slug
// at the DB; submitVersion + recordPendingFromPush rely on its P2002 to close
// the read-then-write race. Created/re-asserted in
// 20260528210000_w1_uniqueness_constraints and
// 20260630130000_app_block_one_pending_per_slug_idempotent (manual-apply).
@@map("app_block_publish_requests")
}
/// App Store Listings (W13) — the store-facing record, decoupled from runtime.
/// Fronts BOTH on-site App Blocks (an AppBlock, iframe/page apps we host) and
/// off-site apps (external-link or OAuth-connect) in one /apps store.
/// Application-generated TEXT primary key (prefixed ULID: apl_<ulid>).
///
/// P0 (this migration) = data model + backfill ONLY, fully dark: NO UI, NO
/// read-path change. Assets (icon/cover/screenshots) are NULLABLE here; the
/// mandatory icon+cover+screenshot approve-gate lands in P1. See
/// claudedocs/app-blocks-app-store-listings-plan-2026-07-01.md.
///
/// MANUAL-APPLY (rule #8 / gotcha #14): the main civitai DB (CNPG nvme0) does
/// NOT run prisma migrate deploy; the committed SQL is hand-applied per env.
model AppListing {
id String @id // apl_<ULID>
// Integer surrogate for CommentsV2. The store `id` is a TEXT ULID, but a
// CommentsV2 `Thread` parent FK must be Int (`Thread.appListingId Int? @unique`)
// — CommentsV2 is integer-keyed end to end. This UNIQUE auto-increment surrogate
// bridges the two; it is NOT the PK (`id` stays the ULID). DB-assigned
// (autoincrement) + backfilled for existing rows by the w13 comments migration.
serialId Int @unique @default(autoincrement()) @map("serial_id")
// The CommentsV2 discussion thread for this listing (parent = serialId). Optional
// 1:1 — created lazily on the first comment/lock (mirrors every other Thread parent).
thread Thread? @relation("AppListingThread")
// Store kind discriminator: 'onsite' (an AppBlock we host) or 'offsite'
// (external-link or OAuth-connect). Validated in the service + a DB CHECK.
kind String
// Globally-unique store slug across BOTH kinds. For on-site listings this is
// the AppBlock.block_id (matches <slug>.civit.ai); off-site slugs are chosen.
slug String @unique
name String
tagline String?
description String?
// Assets via the standard Image path. NULLABLE in P0 (the mandatory-asset
// gate is P1). Named relations because Image is referenced 3x (icon/cover/
// screenshot) so Prisma can disambiguate the back-references.
iconId Int? @map("icon_id")
icon Image? @relation("AppListingIcon", fields: [iconId], references: [id], onDelete: SetNull)
coverId Int? @map("cover_id")
cover Image? @relation("AppListingCover", fields: [coverId], references: [id], onDelete: SetNull)
// Free-text marketplace category (taxonomy = MARKETPLACE_CATEGORIES const, so
// adding a category needs no migration — mirrors AppBlock.category).
category String?
// Store lifecycle: draft|pending|approved|rejected|removed. Validated in the
// service + a DB CHECK (the CHECK lives ONLY in the migration .sql — Prisma
// can't express it; canonical code set = APP_LISTING_STATUSES). P3b adds
// 'removed' (the mod delist target); see app-blocks-p3b-delist-claim-scope.
status String @default("draft")
// Maturity rating. For on-site listings this MIRRORS AppBlock.content_rating
// (single source — the listing must NOT override the runtime .red/.com serving
// gate). Off-site listings carry their own. Same domain as AppBlock (g..x).
contentRating String? @map("content_rating")
// Off-site external-link target (Visit CTA). NULL for on-site / OAuth-connect.
externalUrl String? @map("external_url")
// OPTIONAL public source-repository link ("this app is open source"), rendered as
// a `Source` row on the store DETAIL page only — never on a grid card. NOT the
// internal Forgejo repo (`AppBlock.repoUrl`); this is an author-declared public
// link, host-allowlisted to github.com / gitlab.com / codeberg.org and normalised
// to `https://<host>/<owner>/<repo>` by `validateRepositoryUrl`. Manifest-governed
// for on-site listings (`manifest.repository`), listing-form-governed for off-site.
// MANUAL-APPLY, like every migration here — the read paths degrade to NULL while
// the column is absent rather than 500ing the public store.
sourceRepoUrl String? @map("source_repo_url")
// AUTHOR-DECLARED "this app is in beta" flag + an optional short note, rendered as
// a badge on the store card and a badge + notice on the detail page and the app's
// run page. DISPLAY ONLY — it feeds no ranking, no curation, no launch gate and no
// consent surface.
//
// 🔴 LISTING-NATIVE ON BOTH KINDS, and deliberately NOT manifest-governed. Unlike
// `sourceRepoUrl` (which an on-site listing mirrors from `manifest.repository` on
// every version sync), beta is a statement the AUTHOR makes about the LISTING, so an
// on-site author edits it in the same store-details form an off-site author uses and
// no manifest key can set or clear it.
//
// 🔴 TRIVIAL, NOT MATERIAL. An edit applies IN PLACE with no moderator re-review —
// the same posture `tagline` / `description` / `category` already have — so these
// keys are deliberately absent from `MATERIAL_LISTING_PATCH_FIELDS`, and beta is
// never staged on a shadow revision. See `app-listing-beta.service.ts`.
//
// MANUAL-APPLY, like every migration here — every read path degrades to "not beta"
// while the columns are absent rather than 500ing the public store.
isBeta Boolean @default(false) @map("is_beta")
betaMessage String? @map("beta_message")
// Off-site OAuth-connect client (Connect CTA). NULL for on-site/external-link.
connectClientId String? @map("connect_client_id")
connectClient OauthClient? @relation(fields: [connectClientId], references: [id], onDelete: SetNull)
// W13 OAuth-connect scope review (DARK — additive, nullable; nothing reads
// these yet). `connectRequestedScopes` = the TokenScope bitmask the listing
// requests, always a subset ⊆ `connectClient.allowedScopes` (the per-client
// ceiling). `connectScopeJustifications` = a JSON object keyed by TokenScope
// enum-key STRING (e.g. "ModelsRead") → the dev's rationale (≤500 chars,
// SCOPE_JUSTIFICATION_MAX_LENGTH). Both NULL for external-link / on-site
// listings (only OAuth-connect listings carry them).
connectRequestedScopes Int? @map("connect_requested_scopes")
connectScopeJustifications Json? @map("connect_scope_justifications")
// 1:1 backing AppBlock (UNIQUE) + idempotency key. Set for EVERY backfilled
// row — on-site AND the #2821 off-site rows (both come from an AppBlock). It is
// NOT a kind discriminator: discriminate on `kind`, never on appBlockId nullness.
// Only a natively-created off-site listing (no backing AppBlock) leaves it NULL.
appBlockId String? @unique @map("app_block_id")
appBlock AppBlock? @relation(fields: [appBlockId], references: [id], onDelete: SetNull)
// Edit-without-withdraw (shadow-draft revision): a hidden DRAFT clone of an
// APPROVED parent listing points here at its parent. On mod re-approve the
// shadow's contents are copied onto the parent (which keeps its id/slug/
// app_block_id/metrics/reports) and the shadow is deleted; on reject/withdraw
// the shadow is deleted and the parent is untouched. NULL for a normal (top-
// level) listing. CASCADE so deleting a parent removes its in-flight shadow.
// A request is a REVISION iff its listing's revisionOfId != null (no extra
// column on the request — it is inferred). The read path filters
// revisionOfId != null out (shadows are draft, so already hidden — this is
// defense-in-depth).
revisionOfId String? @map("revision_of_id")
revisionOf AppListing? @relation("AppListingRevision", fields: [revisionOfId], references: [id], onDelete: Cascade)
revisions AppListing[] @relation("AppListingRevision")
// Curation rail (mirrors AppBlock.featured / featured_order).
featured Boolean @default(false)
featuredOrder Int? @map("featured_order")
// Listing owner (the developer). For on-site = the OauthClient owner.
userId Int @map("user_id")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
screenshots AppListingScreenshot[]
reviews AppListingReview[]
metric AppListingMetric?
publishRequests AppListingPublishRequest[]
// P3b off-site moderation: user reports + the append-only mod audit trail.
reports AppListingReport[]
moderationEvents AppListingModerationEvent[]
// App Listing Collaborators — the editor seats, the append-only ownership audit
// trail, and any in-flight ownership transfer. Keyed HERE (not to AppBlock) so an
// OFF-SITE listing, which has no AppBlock, can hold seats too.
collaborators AppCollaborator[]
ownershipEvents AppOwnershipEvent[]
ownershipTransfers AppOwnershipTransfer[]
// Marketplace read-path indexes (future P2 read path).
@@index([status, kind], map: "app_listings_status_kind_idx")
@@index([featured, featuredOrder], map: "app_listings_featured_order_idx")
@@index([category], map: "app_listings_category_idx")
@@index([userId], map: "app_listings_user_idx")
// FK indexes so an Image DELETE (hot path) doesn't seq-scan this table once
// P1 populates icon/cover (Postgres does NOT auto-index a FK). Free now (empty).
@@index([iconId], map: "app_listings_icon_idx")
@@index([coverId], map: "app_listings_cover_idx")
// Shadow-lookup (WHERE revisionOfId = parent) + the parent-delete cascade scan.
// NB: the migration creates this as a PARTIAL UNIQUE index (UNIQUE on
// revisionOfId WHERE revisionOfId IS NOT NULL) so a parent has at most ONE
// in-flight shadow at the DB level. Prisma can't express a partial-unique
// index, so this stays a plain @@index in the schema; the real one-shadow
// guard is the migration's partial-unique index + the app-level P2002 →
// idempotent-reuse handling in beginListingRevision.
@@index([revisionOfId], map: "app_listings_revision_of_id_key")
@@map("app_listings")
}
/// Ordered + captioned screenshot rows for a listing (NOT a Json blob) so the
/// creator can reorder/caption for both kinds. Table CREATED in P0; POPULATED
/// in P1 (the asset pipeline). imageId is NULLABLE in P0.
model AppListingScreenshot {
id String @id // apls_<ULID>
appListingId String @map("app_listing_id")
appListing AppListing @relation(fields: [appListingId], references: [id], onDelete: Cascade)
imageId Int? @map("image_id")
image Image? @relation("AppListingScreenshot", fields: [imageId], references: [id], onDelete: SetNull)
order Int @default(0)
caption String?
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
@@index([appListingId, order], map: "app_listing_screenshots_order_idx")
// FK index so an Image DELETE (hot path) doesn't seq-scan this table once P1
// populates screenshots (Postgres does NOT auto-index a FK). Free now (empty).
@@index([imageId], map: "app_listing_screenshots_image_idx")
@@map("app_listing_screenshots")
}
/// Steam-style "recommend" review, keyed to AppListing (NOT AppBlock). Shaped
/// like ResourceReview (schema ResourceReview) core columns. This is now the
/// ONLY app-review table: the legacy 5-star `AppBlockReview` was removed
/// unmigrated (it never held a production row, and its write form had no
/// reachable entry point).
///
/// NOTE: the `reactions` (helpful votes) and `reports` CHILD tables that
/// ResourceReview has are NOT built here.
model AppListingReview {
id Int @id @default(autoincrement())
appListingId String @map("app_listing_id")
appListing AppListing @relation(fields: [appListingId], references: [id], onDelete: Cascade)
userId Int @map("user_id")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
recommended Boolean
details String?
// Moderator controls: keep abusive reviews out of the recommend-% aggregate.
exclude Boolean @default(false)
tosViolation Boolean @default(false) @map("tos_violation")
metadata Json?
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
// One review per (user, listing).
@@unique([appListingId, userId], map: "app_listing_reviews_listing_user_uniq")
// Aggregate read path (COUNT recommended WHERE NOT exclude).
@@index([appListingId, exclude], map: "app_listing_reviews_listing_agg_idx")
@@map("app_listing_reviews")
}
/// Job-populated rollup (mirror ModelMetric shape) so recommend-% + card counts
/// are a READ, not a live aggregate. Table only in P0; the population job is P5.
/// Per-kind counter mapping (P5): on-site = install/open/tip; off-site =
/// connect (OAuth grant) / visit (click-through) / tip.
model AppListingMetric {
appListingId String @id @map("app_listing_id")
appListing AppListing @relation(fields: [appListingId], references: [id], onDelete: Cascade)
thumbsUpCount Int @default(0) @map("thumbs_up_count")
thumbsDownCount Int @default(0) @map("thumbs_down_count")
installCount Int @default(0) @map("install_count")
openCount Int @default(0) @map("open_count")
connectCount Int @default(0) @map("connect_count")
visitCount Int @default(0) @map("visit_count")
tippedCount Int @default(0) @map("tipped_count")
tippedAmountCount Int @default(0) @map("tipped_amount_count")
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
@@map("app_listing_metrics")
}
/// Sibling to AppBlockPublishRequest, for the OFF-SITE submission + unified
/// moderation queue (P3). Off-site has no bundle/build/manifest/deploy, so this
/// is deliberately lighter (no bundle/forgejo/deploy columns) and keeps the
/// on-site Tekton build queue clean. STRUCTURE ONLY in P0; wired in P3.
model AppListingPublishRequest {
id String @id // alpr_<ULID>
appListingId String? @map("app_listing_id") // Off-site (B1): set at SUBMIT to the draft AppListing. On-site: NULL until approve. Nullable (SetNull on listing delete).
appListing AppListing? @relation(fields: [appListingId], references: [id], onDelete: SetNull)
kind String // 'onsite' | 'offsite'
slug String
submittedByUserId Int @map("submitted_by_user_id")
submittedBy User @relation("AppListingPublishRequestSubmitter", fields: [submittedByUserId], references: [id], onDelete: Cascade)
submittedAt DateTime @default(now()) @map("submitted_at") @db.Timestamptz(6)
status String // 'pending' | 'approved' | 'rejected' | 'withdrawn'
reviewedByUserId Int? @map("reviewed_by_user_id")
reviewedBy User? @relation("AppListingPublishRequestReviewer", fields: [reviewedByUserId], references: [id], onDelete: SetNull)
reviewedAt DateTime? @map("reviewed_at") @db.Timestamptz(6)
rejectionReason String? @map("rejection_reason")
approvalNotes String? @map("approval_notes")
// Off-site apps get a lightweight manual changelog field (P3).
changelog String?
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
@@index([status, submittedAt(sort: Desc)], map: "app_listing_publish_requests_queue_idx")
@@index([appListingId, submittedAt(sort: Desc)], map: "app_listing_publish_requests_listing_history_idx")
@@index([submittedByUserId, status], map: "app_listing_publish_requests_my_submissions_idx")
@@index([slug, status], map: "app_listing_publish_requests_slug_idx")
@@map("app_listing_publish_requests")
}
/// App Store Listings (W13) — P3b OFF-SITE moderation report.
///
/// A user-facing report affordance for an approved off-site AppListing (e.g. it
/// impersonates a real app, links malware, is broken). Feeds a mod queue that
/// arbitrates a delist / claim. DEDICATED table (NOT the shared site `Report`
/// hub) to keep the off-site path isolated from the shared moderation blast
/// radius — see app-blocks-p3b-delist-claim-scope-2026-07-06.md.
///
/// DARK/INERT in PR1: no proc reads or writes this table yet.
/// DB-only extras (NOT expressible in Prisma; live in the migration .sql):
/// - reason CHECK IN (impersonation, phishing-malware, broken, inappropriate, spam, other)
/// - status CHECK IN (pending, resolved, dismissed)
/// - a PARTIAL-UNIQUE index (app_listing_id, reporter_user_id) WHERE status='pending'
/// (one OPEN report per reporter per listing — DB-layer anti-spam dedup).
/// Prisma can't express partial-unique, so the schema below carries only a
/// plain composite @@index; the partial-unique lives ONLY in the .sql.
model AppListingReport {
id String @id // alrp_<ULID>
appListingId String @map("app_listing_id")
appListing AppListing @relation(fields: [appListingId], references: [id], onDelete: Cascade)
// The reporter. CASCADE on GDPR user-delete.
reporterUserId Int @map("reporter_user_id")
reporter User @relation("AppListingReportReporter", fields: [reporterUserId], references: [id], onDelete: Cascade)
// Report reason (CHECK-constrained in the .sql).
reason String
// Free-text elaboration (bounded in the schema layer when the proc lands).
details String?
// Lifecycle: pending|resolved|dismissed (CHECK-constrained in the .sql).
status String @default("pending")
resolvedByUserId Int? @map("resolved_by_user_id")
resolvedBy User? @relation("AppListingReportResolver", fields: [resolvedByUserId], references: [id], onDelete: SetNull)
resolvedAt DateTime? @map("resolved_at") @db.Timestamptz(6)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
// Mod events this report triggered (SetNull back-relation — see below).
moderationEvents AppListingModerationEvent[]
// FIFO mod queue (open reports first).
@@index([status, createdAt(sort: Desc)], map: "app_listing_reports_queue_idx")
// Per-listing history.
@@index([appListingId], map: "app_listing_reports_listing_idx")
// Dedup support: the .sql upgrades this pair to a partial-unique WHERE pending.
@@index([appListingId, reporterUserId], map: "app_listing_reports_dedup_idx")
@@map("app_listing_reports")
}
/// App Store Listings (W13) — P3b OFF-SITE moderation audit trail.
///
/// An immutable, append-only record of every moderator action on an off-site
/// listing (delist / relist / claim / purge / report-resolve / report-dismiss).
/// Written in the SAME transaction as its mutation so a crash can't split the
/// mutation from its audit record. DEDICATED table (do NOT overload
/// AppListingPublishRequest) — see the P3b scope doc.
///
/// The listing FK, the actor FK, and the report FK are all nullable + SetNull so
/// an event SURVIVES a later hard-delete (purge) of the listing, a moderator
/// account delete, or a report delete — an append-only audit trail must outlive
/// the things it references. The denormalized `slug` snapshot keeps the event
/// self-describing even once the listing row is gone. `before`/`after` carry the
/// structured state change (e.g. status approved->removed, or userId reassign).
///
/// DARK/INERT in PR1: no proc writes this table yet.
/// DB-only extra (in the migration .sql): action CHECK IN
/// (delist, relist, claim, purge, report-resolve, report-dismiss).
model AppListingModerationEvent {
id String @id // alme_<ULID>
// Nullable + SetNull so the audit event outlives a listing purge.
appListingId String? @map("app_listing_id")
appListing AppListing? @relation(fields: [appListingId], references: [id], onDelete: SetNull)
// Denormalized snapshot so the event is self-describing after a purge.
slug String
// The acting moderator. NULLABLE + SetNull so the audit trail SURVIVES a
// moderator-account delete (consistent with the report's resolvedBy) — the
// whole point of an append-only audit log is that it outlives the actor.
actorUserId Int? @map("actor_user_id")
actor User? @relation("AppListingModEventActor", fields: [actorUserId], references: [id], onDelete: SetNull)
// The report that triggered this event (if any). Nullable + SetNull: not every
// event comes from a report (e.g. a proactive delist), and the event outlives
// the report. Lets PR3 correlate a mod-event to its originating report.
reportId String? @map("report_id")
report AppListingReport? @relation(fields: [reportId], references: [id], onDelete: SetNull)
// Mod action (CHECK-constrained in the .sql).
action String
// Mod-supplied rationale / ownership-verification note.
reason String?
// Extra human-readable detail.
detail String?
// Structured before/after state, e.g. {"status":"approved"} / {"userId":123}.
before Json?
after Json?
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
// Per-listing moderation history.
@@index([appListingId, createdAt(sort: Desc)], map: "app_listing_mod_events_listing_idx")
// Per-moderator activity.
@@index([actorUserId, createdAt(sort: Desc)], map: "app_listing_mod_events_actor_idx")
// Correlate events back to the report that triggered them.
@@index([reportId], map: "app_listing_mod_events_report_idx")
@@map("app_listing_moderation_events")
}
/// App Blocks — agentic mod code-review report (P0 persistence layer).
///
/// One row per agent code-review of an app version, keyed to the app + version
/// so the NEXT version's review can diff against the prior one (the
/// `priorReportId` chain). Fronts BOTH on-site App Blocks (`appBlockId`) and
/// external / OAuth-connect apps (`oauthClientId`) — EXACTLY ONE of those is set.
///
/// P0 = data model ONLY, fully DARK/INERT: NOTHING in the running image reads or
/// writes this table (no router, no REST handler, no job) — it is additive and
/// safe to apply ahead of the code that will populate it. Provisioning, the
/// review UI, and the chat surface arrive in later phases.
///
/// Design notes:
/// - `publishRequestId`, `appBlockId`, `oauthClientId` are PLAIN INDEXED ids,
/// NOT Prisma relations. `publishRequestId` is dual-target by construction
/// (an on-site review is an AppBlockPublishRequest `pubreq_<ULID>`; a
/// connect review is an AppListingPublishRequest `alpr_<ULID>`), so it has
/// no single clean FK — it is resolved in the service by kind. Keeping the
/// app-key columns index-only (no back-relations on AppBlock/OauthClient)
/// keeps this dark P0 a self-contained additive island; the `(key, version)`
/// indexes drive the "latest prior report" lookup without relation traversal.
/// - `status` is a checked string (running|complete|failed|torn-down); the
/// CHECK lives ONLY in the migration .sql (Prisma can't express it), the
/// canonical code set is APP_REVIEW_AGENT_REPORT_STATUSES in the service.
/// - `costUsd` is Decimal(12,6): LLM costs are frequently sub-cent, so the
/// repo's Decimal(10,2) money precision would round them away — 6 dp keeps
/// per-review cost faithful. Deliberate deviation from the (10,2) precedent.
///
/// MANUAL-APPLY (datapacket-talos CLAUDE.md DB rule #8): the main civitai DB
/// (CNPG nvme0) does NOT run prisma migrate deploy; the committed SQL is
/// hand-applied per environment.
model AppReviewAgentReport {
id String @id // arar_<ULID>
// The review (publish request) this report was generated for. Dual-target
// (on-site = AppBlockPublishRequest, connect = AppListingPublishRequest) so it
// is stored as an indexed id, not a FK. Resolved by kind in the service.
publishRequestId String @map("publish_request_id")
// The STABLE app key (= blockId), present on EVERY publish request — including a
// first version, before any AppBlock row exists. Onsite reports are keyed by
// this (see the (slug, version) index); the prior-report chain is scoped by it.
slug String
// Report origin: 'onsite' | 'external' (CHECK in the .sql). External/connect is
// a later phase; onsite reports write 'onsite'.
kind String @default("onsite")
// App identity — INFORMATIONAL, nullable. `appBlockId` populated when the
// on-site AppBlock already exists (else null, e.g. a first version); reserved
// `oauthClientId` for the external / OAuth-connect app. No exactly-one invariant
// any more — `slug` is the key (the app_key XOR CHECK was dropped).
appBlockId String? @map("app_block_id")
oauthClientId String? @map("oauth_client_id")
// The reviewed bundle's version (semver) + integrity hash.
version String
bundleSha256 String @map("bundle_sha256")
// Agent-run lifecycle: running|complete|failed|torn-down (CHECK in the .sql).
status String @default("running")
// The LLM that produced the review (NULL until the run reports one).
model String?
startedAt DateTime @default(now()) @map("started_at") @db.Timestamptz(6)
completedAt DateTime? @map("completed_at") @db.Timestamptz(6)
// Structured agent outputs (NULLABLE — populated as the run progresses).
codeReview Json? @map("code_review")
securityAudit Json? @map("security_audit")
scopeVerdicts Json? @map("scope_verdicts")
summaryMd String? @map("summary_md")
// The prior version's report in the chain (self-relation) — lets the next
// version diff against it. SetNull so deleting an older report does not
// cascade-delete its successors (the chain head is preserved).
priorReportId String? @map("prior_report_id")
priorReport AppReviewAgentReport? @relation("AppReviewAgentReportChain", fields: [priorReportId], references: [id], onDelete: SetNull)
nextReports AppReviewAgentReport[] @relation("AppReviewAgentReportChain")
// LLM token accounting (provider-shaped Json) + total cost. See costUsd note
// in the model docstring for the Decimal(12,6) rationale.
tokenUsage Json? @map("token_usage")
costUsd Decimal? @map("cost_usd") @db.Decimal(12, 6)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
// "latest prior complete report for this app older than version X" lookups,
// scoped by the stable slug key.
@@index([slug, version], map: "app_review_agent_reports_slug_version_idx")
// By-review lookup (getAgentReport).
@@index([publishRequestId], map: "app_review_agent_reports_publish_request_idx")
// NB: the status CHECK ('running'|'complete'|'failed'|'torn-down'|'cost-capped'),
// the kind CHECK ('onsite'|'external'), and the PARTIAL UNIQUE index on
// (publish_request_id) WHERE status='running' live ONLY in the migration .sql —
// Prisma cannot express CHECKs or partial-unique indexes.
@@map("app_review_agent_reports")
}
/// Per-block-instance, per-user settings (viewer preferences).
/// FK CASCADE on subscription deletion and GDPR-user-delete.
///
/// The FK points at BlockUserSubscription.block_instance_id which is a
/// nullable + UNIQUE column populated for migrated per-model installs
/// (preserves the historical `bki_*` id). Future per-model pinned
/// subscriptions get a fresh `bki_*` id at upsert time. Blanket
/// subscriptions have NULL block_instance_id; their viewer overrides
/// aren't currently a thing (no UI surface), but if added later they'd
/// allocate a block_instance_id on first override write.
model BlockUserSettings {
blockInstanceId String @map("block_instance_id")
subscription BlockUserSubscription @relation(fields: [blockInstanceId], references: [blockInstanceId], onDelete: Cascade)
userId Int @map("user_id")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
settings Json @default("{}")
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
@@id([blockInstanceId, userId])
@@index([userId], map: "block_user_settings_user_idx")
@@map("block_user_settings")
}
/// Platform-default blocks (promoted to render on all eligible model pages).
model PlatformDefaultBlock {
appBlockId String @id @map("app_block_id")
appBlock AppBlock @relation(fields: [appBlockId], references: [id], onDelete: Restrict)
slotId String @map("slot_id")
targetModelTypes String[] @map("target_model_types")
minContentRating String? @map("min_content_rating")
maxContentRating String? @map("max_content_rating")
priority Int @default(500)
enabled Boolean @default(true)
promotedAt DateTime @default(now()) @map("promoted_at") @db.Timestamptz(6)
promotedBy Int? @map("promoted_by")
promoter User? @relation("PlatformDefaultBlockPromoter", fields: [promotedBy], references: [id], onDelete: SetNull)
@@index([slotId, enabled], map: "platform_default_blocks_slot_enabled_idx")
@@index([slotId, priority], map: "platform_default_blocks_slot_priority_idx")
@@index([promotedBy], map: "platform_default_blocks_promoted_by_idx")
@@map("platform_default_blocks")
}
/// User-controlled install row — the ONE install primitive (since the
/// 2026-05-30 kill_per_model_installs migration absorbed model_block
/// _installs into this table).
///
/// Two scopes:
/// - 'publisher_all_my_models': model owner. Joins on Model.userId
/// at listForModel time. Can be blanket (target_model_ids=[]) or
/// pinned to specific models (target_model_ids=[X,Y,...]).
/// - 'viewer_personal': any viewer. Joins on the current viewer's
/// userId. Always blanket in v0; per-model pinning UI is publisher
/// -only by design.
///
/// Pinning shape:
/// - blanket subscription: slot_id=NULL, target_model_ids=[]
/// - pinned subscription: slot_id=<slot>, target_model_ids=[X]
/// The pinned shape replaces what model_block_installs did — one row
/// per (user, app, scope, slot, model) tuple.
///
/// block_instance_id is NULL for blanket subs (listForModel synthesises
/// `bus_pub_<id>` / `bus_view_<id>` at SELECT time); non-NULL for the
/// pinned shape (the migration preserved the historical `bki_*` id, and
/// future pinned installs allocate a fresh one). Downstream tables
/// (block_buzz_attribution, block_scope_invocations, block_user_settings)
/// look up by this column.
///
/// Application-generated id (`bus_<ulid>`).
model BlockUserSubscription {
id String @id
userId Int @map("user_id")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
appBlockId String @map("app_block_id")
appBlock AppBlock @relation(fields: [appBlockId], references: [id], onDelete: Cascade)
scope String
targetModelTypes String[] @map("target_model_types")
targetBaseModels String[] @map("target_base_models")
/// NEW (kill_per_model_installs): pin this subscription to specific model
/// ids. Empty = blanket (applies to every model that passes the type +
/// base-model filters). Non-empty + non-NULL slot_id = the per-model
/// install path that model_block_installs used to carry.
targetModelIds Int[] @default([]) @map("target_model_ids")
/// NEW (kill_per_model_installs): when set, this subscription targets a
/// specific slot id (e.g. "model.sidebar_top"). When NULL, it applies to
/// every slot the manifest declares (the blanket-subscription shape).
slotId String? @map("slot_id")
/// NEW (kill_per_model_installs): copied from model_block_installs.pinned
/// _version. NULL = use the AppBlock's current approved manifest; semver
/// string = use that version's manifest from app_block_publish_requests.
pinnedVersion String? @map("pinned_version")
/// NEW (kill_per_model_installs): preserves the bki_* id from the migrated
/// install row so block_buzz_attribution, block_scope_invocations, and
/// block_user_settings continue to resolve. NULL for blanket
/// subscriptions (which synthesise bus_pub_* / bus_view_* on read).
blockInstanceId String? @unique @map("block_instance_id")
/// NEW (kill_per_model_installs): mirrors model_block_installs.installed
/// _by_user_id. For migrated rows this equals user_id by construction;
/// kept as a separate column so future "installed by mod / installed via
/// admin tooling" cases are expressible without schema change.
installedByUserId Int? @map("installed_by_user_id")
settings Json @default("{}")
enabled Boolean @default(true)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
viewerSettings BlockUserSettings[]
// Partial uniqueness — declared in the migration SQL (Prisma can't
// express partial unique indexes with array expressions inline). One
// blanket sub per (user, app, scope); one pinned sub per
// (user, app, scope, slot, target_model_ids[1]).
// Phase 0 author analytics: installs-over-time for an owned app block
// (app_block_id equality + created_at range). See migration
// 20260621120000_bus_app_block_created_analytics_idx.
@@index([appBlockId, createdAt(sort: Desc)], map: "bus_app_block_created_idx")
// NOTE: userId existence check for blocks.getNavSummary is already served by
// the live index bus_user_subscriptions_idx ON (user_id, updated_at DESC)
// (non-partial, leads on user_id) — verified on prod 2026-06-24. A dedicated
// @@index([userId]) would be redundant, so it is intentionally NOT declared.
@@map("block_user_subscriptions")
}
/// Buzz purchase originated from inside an App Block. One row per
/// (payment_transaction_id, app_block_id). Drives publisher revenue
/// share — `app_owner_share_cents` is paid out to `app_owner_user_id`
/// once `status` reaches `paid_out`. Rate card snapshot stored on the
/// row (`rate_card_version`) so past attributions pay out under their
/// own terms even if the active rate card later changes.
///
/// `blockInstanceId` is intentionally not an FK — see migration
/// comments. Resolve via BlockRegistry.resolveBlockInstance.
///
/// Application-generated id (`bba_<ulid>`).
model BlockBuzzAttribution {
id String @id
userId Int @map("user_id")
user User @relation("BlockBuzzAttributionPurchaser", fields: [userId], references: [id], onDelete: Restrict)
buzzAmount Int @map("buzz_amount")
usdAmountCents Int @map("usd_amount_cents")
buzzType String @default("yellow") @map("buzz_type")
paymentProvider String @map("payment_provider")
paymentTransactionId String @map("payment_transaction_id")
buzzTransactionId String? @map("buzz_transaction_id")
appId String @map("app_id")
app OauthClient @relation(fields: [appId], references: [id], onDelete: Restrict)
appBlockId String @map("app_block_id")
appBlock AppBlock @relation(fields: [appBlockId], references: [id], onDelete: Restrict)
blockInstanceId String @map("block_instance_id")
scope String
modelId Int? @map("model_id")
rateCardVersion String @map("rate_card_version")
appOwnerShareCents Int @map("app_owner_share_cents")
platformShareCents Int @map("platform_share_cents")
providerFeeCents Int @map("provider_fee_cents")
appOwnerUserId Int @map("app_owner_user_id")
appOwner User @relation("BlockBuzzAttributionAppOwner", fields: [appOwnerUserId], references: [id], onDelete: Restrict)
status String @default("pending")
voidedReason String? @map("voided_reason")
// 'velocity' (or future triggers) — set when the confirm-pending cron
// parks a row in status='held' for manual review instead of confirming
// it. NULL on every non-held row.
holdReason String? @map("hold_reason")
heldAt DateTime? @map("held_at") @db.Timestamptz(6)
// 'purchase' (the normal forward attribution) or 'clawback' (a negative
// carry-forward row written when a refund lands AFTER the original was
// paid out). Clawback rows carry negative app_owner_share_cents /
// usd_amount_cents so the payout aggregator nets them out automatically.
entryType String @default("purchase") @map("entry_type")
attributedAt DateTime @default(now()) @map("attributed_at") @db.Timestamptz(6)
confirmedAt DateTime? @map("confirmed_at") @db.Timestamptz(6)
voidedAt DateTime? @map("voided_at") @db.Timestamptz(6)
paidOutAt DateTime? @map("paid_out_at") @db.Timestamptz(6)
payoutId String? @map("payout_id")
@@unique([paymentTransactionId, appBlockId], map: "block_buzz_attribution_payment_app_uniq")
@@index([appOwnerUserId, attributedAt(sort: Desc)], map: "bba_publisher_dashboard_idx")
@@index([appBlockId, attributedAt(sort: Desc)], map: "bba_app_block_dashboard_idx")
@@index([paymentProvider, paymentTransactionId], map: "bba_payment_tx_idx")
@@map("block_buzz_attribution")
}
/// Idempotency ledger for publisher revenue-share payouts. One row per
/// (app_owner_user_id, period_key) — a publisher is paid at most once per
/// period. mintPayoutForOwner inserts here inside the same transaction
/// that flips the contributing block_buzz_attribution rows to paid_out;
/// the UNIQUE on (app_owner_user_id, period_key) is the no-double-pay
/// guard (a racing/retried mint hits P2002 and no-ops).
///
/// This row records that the ledger was minted — it does NOT mean money
/// moved. Actual disbursement (Tipalti / creator-program cash bank) is a
/// separate, leadership-gated step that reads these rows.
///
/// Application-generated id (`bba_payout_<ulid>`).
model BlockAttributionPayout {
id String @id
appOwnerUserId Int @map("app_owner_user_id")
appOwner User @relation("BlockAttributionPayoutOwner", fields: [appOwnerUserId], references: [id], onDelete: Restrict)
/// Caller-supplied period bucket, e.g. ISO week '2026-W22'. The
/// (owner, period) pair is the idempotency key.
periodKey String @map("period_key")
/// Net publisher share minted for this period (sum of contributing
/// confirmed rows' app_owner_share_cents, after clawbacks net out).
totalCents Int @map("total_cents")
/// Number of block_buzz_attribution rows flipped to paid_out by this mint.
rowCount Int @map("row_count")
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
@@unique([appOwnerUserId, periodKey], map: "block_attribution_payout_owner_period_uniq")
@@index([appOwnerUserId, createdAt(sort: Desc)], map: "bap_owner_created_idx")
@@map("block_attribution_payout")
}
/// W3 flow A — App Blocks buzz SPEND attribution. TRACK-ONLY: an audit
/// trail of the generation EVENT and its money BASIS. It pays nobody.
///
/// One row per block-initiated generation that spends the VIEWER's own
/// Buzz balance. Distinct from BlockBuzzAttribution (which covers card
/// PURCHASES from inside a block): a spend has no payment provider, no
/// provider fee, no refund/clawback lifecycle.
///
/// 🔴 THE AUTHOR BOUNTY THIS TABLE WAS BUILT FOR IS REMOVED. It was a
/// PLATFORM-FUNDED percentage bounty (`gross_value_cents × spend_share_pct`)
/// paid on top of the spend — never a slice of the viewer's Buzz — and it was
/// superseded by the additive, author-set, viewer-paid per-generation author
/// fee (`src/server/services/blocks/author-fee.ts`). Its compute and backpay
/// rails were deleted; nothing reads a rate card's `spendSharePct` any more.
///
/// WHAT THE ROWS CARRY NOW: `app_owner_share_cents = 0`, `spend_share_pct = 0`,
/// `rate_card_version = 'unrated'`. `status` is NOT uniformly 'tracked' — do not
/// read this summary as the write-time contract. Self-spend (spender == app
/// owner) and internal-owner apps write 'voided'; everything else writes
/// 'tracked'. That distinction is live and load-bearing (it is the marker the
/// analytics reader and any future payout rail would both need), and
/// `app-analytics.service.ts` applies NO status filter today. The authoritative,
/// complete statement is the doc block on `recordSpendAttribution` in
/// `src/server/services/blocks/buzz-attribution.service.ts` — read it there
/// rather than trusting a restatement here.
///
/// Measured in production 2026-09-18: a `GROUP BY rate_card_version` over the
/// whole table returned a single 'unrated' group. The grouping is
/// self-discriminating — any other stamped version would have been a second
/// group — so no row references a real card version. Re-measure before relying
/// on it. The money columns are retained at zero so the row shape and its CHECK
/// constraints are unchanged, NOT because anything computes them.
///
/// The LIVE consumer is reporting, not payout: `app-analytics.service.ts`
/// aggregates these rows for the app-owner dashboard.
///
/// ACCOUNTING (historical, for reading the constraints): because the bounty was
/// platform-funded rather than carved out of the spend, there is NO three-way
/// conservation invariant here (unlike the purchase table) — see the migration
/// for detail. That asymmetry is why the constraints look the way they do.
///
/// Idempotent on (workflow_id, app_block_id): a re-poll / retry /
/// re-submit of the same workflow is a no-op.
///
/// Application-generated id (`bsa_<ulid>`).
model BlockSpendAttribution {
id String @id
userId Int @map("user_id")
user User @relation("BlockSpendAttributionSpender", fields: [userId], references: [id], onDelete: Restrict)
buzzAmount Int @map("buzz_amount")
buzzType String @default("yellow") @map("buzz_type")
/// USD value of the Buzz burned (buzzDollarRatio 1000:1 -> cents).
/// Recorded for reporting — it is platform revenue, NOT a split pool.
grossValueCents Int @map("gross_value_cents")
/// Orchestrator workflow id — the idempotency anchor.
workflowId String @map("workflow_id")
appId String @map("app_id")
app OauthClient @relation("BlockSpendAttributionApp", fields: [appId], references: [id], onDelete: Restrict)
appBlockId String @map("app_block_id")
appBlock AppBlock @relation("BlockSpendAttributionAppBlock", fields: [appBlockId], references: [id], onDelete: Restrict)
blockInstanceId String @map("block_instance_id")
modelId Int? @map("model_id")
/// Always the 'unrated' sentinel — the write path hardcodes it. No rate card
/// is applied to a spend row, and nothing re-stamps it (the backpay that would
/// have is removed). Measured in production 2026-09-18: a
/// `GROUP BY rate_card_version` over the whole table returned a single
/// 'unrated' group — self-discriminating, since any other stamped version
/// would have been a second group. Re-measure before relying on it.
rateCardVersion String @map("rate_card_version")
/// Always 0. This was the spend rev-share percentage stamped at write time,
/// for the removed platform-funded bounty; the write path now hardcodes 0 and
/// the column is retained only to keep the row shape + CHECK constraints.
spendSharePct Int @map("spend_share_pct")
appOwnerShareCents Int @map("app_owner_share_cents")
appOwnerUserId Int @map("app_owner_user_id")
appOwner User @relation("BlockSpendAttributionAppOwner", fields: [appOwnerUserId], references: [id], onDelete: Restrict)
/// The USER who published the shared content this generation ran on behalf
/// of (the "content author") — the durable BASIS for a FUTURE creator
/// payout. Resolved SERVER-SIDE from `sharedContentKey` against the calling
/// app's own `app_<slug>.shared_kv`, never client-supplied. NULL when no
/// key was supplied, the row is missing/hidden, or the author is the
/// spender (self) or the app owner. FULLY GENERIC — any app that publishes
/// cross-user shared content can populate it — not tied to any one app kind.
/// TRACK-ONLY today: nothing pays out on it yet.
contentAuthorUserId Int? @map("content_author_user_id")
contentAuthor User? @relation("BlockSpendAttributionContentAuthor", fields: [contentAuthorUserId], references: [id], onDelete: SetNull)
/// The opaque shared-storage `key` the app supplied for this generation
/// (bounded, app-owned). NULL when the app supplied none.
sharedContentKey String? @map("shared_content_key")
/// The APP-FACING generation type this spend paid for, as `<coarse>` or
/// `<coarse>:<subtype>` — e.g. `textToImage:img2img-edit`,
/// `customComfy:seamless-pano-360`, `customComfy:inline`, a bare registered
/// STEP ID (`convert-image`, `chat-completion`), or `step:<orchestrator $type>`
/// on the PASS-THROUGH step arm.
///
/// 🔴 EVERY VALUE IS AN APP-FACING ID AND NEVER THE ORCHESTRATOR'S INTERNAL
/// `$type` — EXCEPT UNDER THE `step` COARSE KEY, WHICH IS EXACTLY THAT, AND IS
/// CALLER-SUPPLIED. The pass-through arm (`kind:'step'` with a bare `$type` and
/// no registry id) has no app-facing id at all: the `$type` IS the contract the
/// app wrote, it is validated against nothing but the platform-internal
/// denylist, and it is bounded here only by SHAPE (≤64 chars of
/// `[A-Za-z0-9._-]`). The `step:` prefix is what keeps that visible and keeps it
/// out of the other keys' namespaces — `textToImage` and `customComfy` are
/// themselves real orchestrator `$type`s, so a bare one would be indistinguishable
/// from a genuine image submit. Every other arm's value is server-owned.
///
/// 🔴 THE COARSE KEY IS EVERYTHING BEFORE THE FIRST COLON, and a value carries
/// at most one. A per-generation-type author fee keys on the coarse key, so
/// `split_part(generation_type, ':', 1)` is the stable grouping no matter how
/// the subtype axis grows — and on `step:` rows it is also the ONLY safe
/// grouping, because the subtype is app-chosen. Do not key a fee, a rate or a
/// payout on the full value. A registry step id implies `kind: 'step'` and
/// carries no subtype, so one column still covers the whole axis with no
/// companion `kind`.
///
/// Deliberately unconstrained TEXT — no CHECK, no enum. Both the step and
/// recipe registries are designed to grow additively (register an entry, not
/// a schema change), and a CHECK would make every new step or recipe a
/// migration. The bound is enforced in code against those registries
/// (`resolveBlockGenerationType` / `isBlockGenerationType`, in
/// `src/server/services/blocks/generation-type.ts`) and re-checked at the
/// write.
///
/// NULL on rows written before this column existed (no backfill is possible —
/// the type was never recorded) and on any submit whose type could not be
/// resolved. Resolution is fail-open: an unresolvable SUB-axis degrades to the
/// bare coarse key, and an unresolvable body to NULL, rather than throwing on
/// the fire-and-forget spend path.
generationType String? @map("generation_type")
// Must stay equal to the column default the committed migrations provision, and be allowed by
// their CHECK: Prisma supplies this itself on create, so a database built from history would
// otherwise disagree with the one this code runs against, with no error and no drift. Pinned by
// block-spend-attribution-status-default.test.ts.
//
// ⚠️ THE SECOND HALF OF THIS NOTE IS GONE. It used to add "and to what backpay selects … a value
// backpay does not read makes an omitted-status row invisible to payout forever". There is no
// spend backpay any more — the platform-funded percentage bounty was superseded by the
// per-generation author fee and its rail was removed — so no read exists for this value to agree
// with, and nothing pays out of this table. The guard's matching second assertion was dropped for
// the same reason rather than left to fail closed; the schema-vs-migrations half above is real,
// still asserted, and is the whole of what this comment now claims.
status String @default("tracked")
/// 'self_spend' / 'internal_owner' / 'manual_review'. Spend has no
/// refund path, so this is never 'refund'/'chargeback'.
voidedReason String? @map("voided_reason")
attributedAt DateTime @default(now()) @map("attributed_at") @db.Timestamptz(6)
confirmedAt DateTime? @map("confirmed_at") @db.Timestamptz(6)
voidedAt DateTime? @map("voided_at") @db.Timestamptz(6)
paidOutAt DateTime? @map("paid_out_at") @db.Timestamptz(6)
payoutId String? @map("payout_id")
@@unique([workflowId, appBlockId], map: "block_spend_attribution_workflow_app_uniq")
@@index([appOwnerUserId, attributedAt(sort: Desc)], map: "bsa_publisher_dashboard_idx")
@@index([appBlockId, attributedAt(sort: Desc)], map: "bsa_app_block_dashboard_idx")
@@index([contentAuthorUserId, attributedAt(sort: Desc)], map: "bsa_content_author_idx")
@@map("block_spend_attribution")
}
/// App Blocks per-generation AUTHOR FEE — the accrual ledger (slice 2).
///
/// One row = one generation on which an app charged its author fee. The viewer
/// has ALREADY been debited `feeBuzz` at submit; this row is the platform's
/// obligation to pay it onward to the app owner, settled in a daily batch.
/// Mirrors the model licensing fee: the orchestrator charges the viewer at
/// generation time and writes a per-resource fee row, and a daily job mints to
/// the creator. Same two hops, same dedup discipline, civitai-owned table.
///
/// 🔴 SEPARATE FROM BlockSpendAttribution ON PURPOSE — because of the WRITE
/// SEAM, not the row shape. `recordSpendAttribution` is invoked inside
/// `void (async () => { … })()` so that a failed attribution write can never
/// break the generation; it is droppable telemetry. An accrual is a money
/// obligation and must be AWAITED. Do not hang an awaited financial write onto a
/// deliberately fire-and-forget path.
///
/// ⚠️ An earlier revision said BlockSpendAttribution is "IMMUTABLE by design".
/// That is false — it carries `status`, `voidedReason`, `confirmedAt`,
/// `voidedAt`, `paidOutAt` and `payoutId`, the same lifecycle shape. Those
/// columns are dead today because the rail that wrote them was removed, which is
/// a different statement.
///
/// 🔴 `feeBuzz` IS AN INTEGER, NOT NUMERIC, AND THAT REVERSES THE "FRACTIONAL
/// ACCRUAL" THE DESIGN INHERITED FROM THE LICENSING RAIL. The licensing fee is
/// fractional because it is priced per-IMAGE at 0.01 buzz and the viewer pays
/// the CEILING of the sum, so the creator's share genuinely has sub-buzz
/// resolution. This fee does not: `max(flatBuzz, pct × base)` is FLOORED TO
/// WHOLE BUZZ before the viewer is shown or charged it (D7 requires the viewer
/// see the exact number before the run, and Buzz cannot express a fraction). The
/// author is credited exactly what the viewer paid — no more, because the
/// platform takes no cut; no less, because the platform funds nothing.
///
/// ⚠️ Consequence, and it is real: an author who sets a 0 flat leg and a low
/// percentage earns NOTHING on cheap generations, permanently —
/// `floor(4 × 500/10000) = 0` every time. Same shape as the $0.00 spend bounty
/// this arc replaced; the difference is that it is now the author's explicit
/// choice, and the platform default (flat 1 ⚡) avoids it. Slice 3's config UI
/// must say so at the point of setting it.
model BlockAuthorFeeAccrual {
id String @id
/// Orchestrator workflow id — the idempotency anchor. A resubmit of the same
/// workflow must never charge or accrue twice.
workflowId String @map("workflow_id")
appId String @map("app_id")
app OauthClient @relation("BlockAuthorFeeAccrualApp", fields: [appId], references: [id], onDelete: Restrict)
appBlockId String @map("app_block_id")
appBlock AppBlock @relation("BlockAuthorFeeAccrualAppBlock", fields: [appBlockId], references: [id], onDelete: Restrict)
/// 🔴 Resolved at WRITE time, never at settlement. An app that changes hands
/// must not retroactively move earnings already accrued to the previous owner
/// (`app-ownership-transfer.service.ts` is the precedent). Resolving the owner
/// in the settlement query would do exactly that.
appOwnerUserId Int @map("app_owner_user_id")
appOwner User @relation("BlockAuthorFeeAccrualAppOwner", fields: [appOwnerUserId], references: [id], onDelete: Restrict)
/// The viewer who paid. What the self-dealing exclusion is measured against,
/// and what slice 2b's refund path will join on to reverse a fee.
viewerUserId Int @map("viewer_user_id")
viewer User @relation("BlockAuthorFeeAccrualViewer", fields: [viewerUserId], references: [id], onDelete: Restrict)
/// 🔴 D6 — a viewer spending blue Buzz pays in blue and the author receives
/// blue (non-withdrawable). The settlement job groups by this and never
/// coerces to yellow; defaulting it would silently convert non-withdrawable
/// Buzz into withdrawable earnings. No `@default` for that reason.
buzzType String @map("buzz_type")
/// Whole Buzz owed to the author, always > 0 — pinned by a CHECK. There is no
/// negative row: the clawback was retired in round 0 (zero production callers,
/// and its carry-forward arm unreachable until something had settled). Slice 2b
/// adds it together with the refund path that drives it.
feeBuzz Int @map("fee_buzz")
/// The pricing inputs, kept so a disputed charge can be explained without
/// re-deriving it from a workflow that may no longer exist.
baseGenerationBuzz Int @map("base_generation_buzz")
flatLegBuzz Int @map("flat_leg_buzz")
pctLegBuzz Int @map("pct_leg_buzz")
governingLeg String @map("governing_leg")
/// The resolved '<coarse>' or '<coarse>:<subtype>' the fee was priced under.
/// NULL when the type could not be resolved — the fee still applies, falling
/// to the app's default (see `resolveBlockAuthorFeeParams`).
generationType String? @map("generation_type")
/// 'accrued' | 'settled'.
status String @default("accrued")
/// The externalTransactionId this row settled under, so a row traces to the
/// exact mint. NULL until settled; a CHECK keeps it and `settledAt` in step
/// with `status`.
settlementKey String? @map("settlement_key")
accruedAt DateTime @default(now()) @map("accrued_at") @db.Timestamptz(6)
settledAt DateTime? @map("settled_at") @db.Timestamptz(6)
@@unique([workflowId], map: "block_author_fee_accrual_workflow_key")
@@index([status, accruedAt], map: "block_author_fee_accrual_settlement_idx")
@@index([appOwnerUserId, accruedAt], map: "block_author_fee_accrual_owner_idx")
@@map("block_author_fee_accrual")
}
/// W3 flow C — App Blocks MEMBERSHIP / subscription attribution.
///
/// One row PER PAID INVOICE per app block: a block-initiated membership
/// (recurring subscription) purchase credits the app author a revenue
/// share on each paid invoice. The recurring-revenue sibling of
/// BlockBuzzAttribution (one-shot card purchase) and BlockSpendAttribution
/// (internal Buzz burn).
///
/// ACCOUNTING: a membership payment IS a real card transaction (gross USD,
/// real provider fee), so it uses the SAME three-way split as
/// BlockBuzzAttribution — author share is carved out of the net per the
/// active rate card's subscription percentage. The three-way conservation
/// invariant holds for entry_type='charge' rows (see the migration CHECK).
///
/// RENEWALS-PAY: one row per invoice_id means each renewal
/// (subscription_cycle) accrues a share by default. The service can gate
/// the write to billing_reason='subscription_create' for a first-only
/// policy without a schema change. ⚠️ FLAGGED for monetization sign-off.
///
/// Clawback (entry_type='clawback') carries negative shares for a refund /
/// proration of a previously paid-out period; nets out in the payout
/// aggregate (mirrors BlockBuzzAttribution's clawback carry-forward).
///
/// Idempotent on (invoice_id, app_block_id): a webhook retry for the same
/// invoice is a no-op.
///
/// Application-generated id (`bsu_<ulid>`).
model BlockSubscriptionAttribution {
id String @id
userId Int @map("user_id")
user User @relation("BlockSubscriptionAttributionPurchaser", fields: [userId], references: [id], onDelete: Restrict)
buzzAmount Int @default(0) @map("buzz_amount")
buzzType String @default("yellow") @map("buzz_type")
/// Gross USD value of the invoice, in cents.
grossValueCents Int @map("gross_value_cents")
paymentProvider String @map("payment_provider")
/// Per-period idempotency anchor — each renewal has its own invoice_id.
invoiceId String @map("invoice_id")
/// Groups the periods of one subscription.
subscriptionId String? @map("subscription_id")
/// subscription_create | subscription_cycle | subscription_update.
billingReason String? @map("billing_reason")
periodStart DateTime? @map("period_start") @db.Timestamptz(6)
periodEnd DateTime? @map("period_end") @db.Timestamptz(6)
appId String @map("app_id")
app OauthClient @relation("BlockSubscriptionAttributionApp", fields: [appId], references: [id], onDelete: Restrict)
appBlockId String @map("app_block_id")
appBlock AppBlock @relation("BlockSubscriptionAttributionAppBlock", fields: [appBlockId], references: [id], onDelete: Restrict)
blockInstanceId String @map("block_instance_id")
scope String
modelId Int? @map("model_id")
tier String?
/// TRACK-ONLY (#2629): no rate applied at write time. 'unrated' sentinel
/// until the payout-time backpay stamps the signed-off version.
rateCardVersion String @default("unrated") @map("rate_card_version")
/// 0 at write time; the payout-time backpay computes the real share.
subscriptionSharePct Int @default(0) @map("subscription_share_pct")
appOwnerShareCents Int @default(0) @map("app_owner_share_cents")
platformShareCents Int @map("platform_share_cents")
providerFeeCents Int @map("provider_fee_cents")
appOwnerUserId Int @map("app_owner_user_id")
appOwner User @relation("BlockSubscriptionAttributionAppOwner", fields: [appOwnerUserId], references: [id], onDelete: Restrict)
/// 'tracked' (track-only event) | pending | confirmed | voided | paid_out | held.
status String @default("tracked")
/// 'charge' (forward) | 'clawback' (negative carry-forward on refund/proration).
entryType String @default("charge") @map("entry_type")
/// 'refund' / 'chargeback' / 'proration' / 'self_purchase' / 'internal_owner' / 'manual_review'.
voidedReason String? @map("voided_reason")
attributedAt DateTime @default(now()) @map("attributed_at") @db.Timestamptz(6)
confirmedAt DateTime? @map("confirmed_at") @db.Timestamptz(6)
voidedAt DateTime? @map("voided_at") @db.Timestamptz(6)
paidOutAt DateTime? @map("paid_out_at") @db.Timestamptz(6)
payoutId String? @map("payout_id")
@@unique([invoiceId, appBlockId], map: "block_subscription_attribution_invoice_app_uniq")
@@index([appOwnerUserId, attributedAt(sort: Desc)], map: "bsu_publisher_dashboard_idx")
@@index([appBlockId, attributedAt(sort: Desc)], map: "bsu_app_block_dashboard_idx")
@@index([subscriptionId], map: "bsu_subscription_idx")
@@index([paymentProvider, invoiceId], map: "bsu_invoice_idx")
@@map("block_subscription_attribution")
}
/// W5 v0.5 audit log — one row per successful scope-gated API call from
/// block-scope.middleware.ts. Surfaced on /apps/installed Activity tab,
/// interleaved with BlockBuzzAttribution rows so the user sees paid and
/// free actions on one timeline.
///
/// `blockInstanceId` is NOT an FK on purpose — synthetic ids (pdb_*,
/// bus_pub_*, bus_view_*) have no model_block_installs row. Same
/// pattern as BlockBuzzAttribution; resolve at read time via
/// BlockRegistry.resolveBlockInstance.
model BlockScopeInvocation {
id BigInt @id @default(autoincrement())
userId Int @map("user_id")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
/// NULLABLE (App Dev Tunnel Phase 2): a PRE-APPROVAL dev-tunnel spend has NO
/// AppBlock row (approval is what creates it), so the token carries a SYNTHETIC
/// `ephemeral-<slug>` appBlockId that can never FK-resolve. For that case this
/// column is NULL and the synthetic ref is captured in `syntheticAppId`, so the
/// durable per-spend audit row persists instead of FK-failing + being swallowed
/// (the "only a log line" gap). An APPROVED app still sets this (real FK).
appBlockId String? @map("app_block_id")
appBlock AppBlock? @relation(fields: [appBlockId], references: [id], onDelete: Cascade)
/// The synthetic, non-resolving dev-app reference (`ephemeral-<slug>` /
/// `local-<slug>` / `pending-<pubreqId>`) for a PRE-APPROVAL dev-tunnel spend —
/// the forensic anchor when `appBlockId` is NULL. NULL for every approved-app row.
syntheticAppId String? @map("synthetic_app_id")
/// NULLABLE: an EXTERNAL OAuth invocation (`source = 'external-oauth'`) has no
/// block instance — the acting app is a pure OauthClient with no App Block. A
/// block-token row always sets this.
blockInstanceId String? @map("block_instance_id")
/// The acting OauthClient id for an EXTERNAL OAuth invocation (`source =
/// 'external-oauth'`) — the "which app" for external OAuth API usage, mirroring
/// what `appBlockId` is for a block-token row. NULL for a block-token row.
/// Intentionally FK-LESS text so the audit row SURVIVES deletion of the
/// OauthClient it references (an audit trail must outlive the app).
oauthClientId String? @map("oauth_client_id")
/// Discriminates the token population that made the call: `'app-block'` (an App
/// Block block-token, the historical default) vs `'external-oauth'` (a standard
/// external OAuth access token verified at `enforceTokenScope`). Additive:
/// existing rows backfill to `'app-block'`.
source String @default("app-block")
scope String
endpoint String
statusCode Int @map("status_code") @db.SmallInt
/// Structured per-action audit detail (W13). NULL for a passive read (whose
/// friendly label is derived from `scope` at render time) and for any row
/// written before this column existed. For an impactful MUTATION it carries a
/// stable `action` code + minimal subject refs (`{ action, amount?, toUserId?,
/// modelVersionId?, entityId?, entityType?, key?, outcome? }` — see
/// BlockActionDetail). Stores IDS, not display names — the view resolves them
/// via batch lookups, so the row never rots when a name changes. Nullable + no
/// default → additive, backwards-compatible, no backfill.
detail Json?
invokedAt DateTime @default(now()) @map("invoked_at") @db.Timestamptz(6)
@@index([userId, invokedAt(sort: Desc), id(sort: Desc)], map: "bsi_user_invoked_idx")
@@index([appBlockId, invokedAt(sort: Desc)], map: "bsi_app_block_invoked_idx")
@@index([syntheticAppId, invokedAt(sort: Desc)], map: "bsi_synthetic_app_invoked_idx")
@@index([oauthClientId, invokedAt(sort: Desc)], map: "bsi_oauth_client_invoked_idx")
@@map("block_scope_invocations")
}
/// A6 (audit HIGH / design-gaps C2) — per-user scope-grant consent ledger.
///
/// One row per (user, app_block). `granted_scopes` is the set of block-scope
/// strings the user has consented to for this app (same vocabulary as
/// app_blocks.approved_scopes / manifest.scopes). Token issuance intersects the
/// manifest/approved scope set with this row; any scope the app requests but
/// the user has NOT granted is withheld from the minted token and surfaced as a
/// `needs_consent` signal. Without this layer a version-bump that adds a scope
/// silently escalated every existing install (the C2 gap A6 closes).
///
/// A grant is written at install / subscribe time (implicit first-consent) and
/// extended on re-consent. `version` records the app version the grant was last
/// taken against (informational). `revoked_at` (NULL = active) lets a future
/// per-scope revoke flip the grant off without losing the audit row — a
/// non-NULL revoked_at makes the mint path treat granted_scopes as empty.
///
/// Application-generated id (`augr_<ulid>`).
model AppUserScopeGrant {
id String @id
userId Int @map("user_id")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
appBlockId String @map("app_block_id")
appBlock AppBlock @relation(fields: [appBlockId], references: [id], onDelete: Cascade)
version String
grantedScopes String[] @default([]) @map("granted_scopes")
grantedAt DateTime @default(now()) @map("granted_at") @db.Timestamptz(6)
revokedAt DateTime? @map("revoked_at") @db.Timestamptz(6)
/// The per-UTC-day Buzz ceiling the VIEWER set for THIS app at consent time.
/// NULL = the user set no budget, and the app spends under the platform's own
/// per-user daily ceiling (`BLOCK_BUZZ_CAP_PER_DAY`, 50,000) alone — which is
/// exactly the behaviour of every grant written before this column existed, so
/// no backfill is needed and nobody is silently tightened. Non-NULL adds a
/// SECOND reservation at spend time keyed on (user, app, UTC-day); both caps
/// apply and the tighter one binds. Meaningful only alongside the
/// `ai:write:budgeted` scope (nothing else in a grant can spend).
buzzBudgetPerDay Int? @map("buzz_budget_per_day")
@@unique([userId, appBlockId], map: "app_user_scope_grants_user_app_uniq")
@@map("app_user_scope_grants")
}
/// App Blocks Phase 3 (git-push self-service) — per-civitai-user Forgejo
/// identity. 1:1 with User (PK is the civitai userId), provisioned LAZILY the
/// first time a developer requests git access to one of their apps.
///
/// The Forgejo user is `restricted:true` and granted `write` ONLY on its own
/// civitai-apps/<slug> repo(s), so a push parks a pending review request but can
/// NEVER deploy without mod approval (the no-trust-on-push gate is unchanged).
///
/// `forgejo_token_encrypted` holds the user's own Forgejo PAT (sha1, scope
/// `write:repository`), AES-256-GCM-encrypted at rest keyed on NEXTAUTH_SECRET.
/// It is the SOURCE OF TRUTH for the token: Forgejo can't recover a user's
/// password, so the token is minted once at provision and re-read thereafter,
/// never re-minted. A GDPR delete of the User cascades this row away.
model AppDevForgejoIdentity {
userId Int @id @map("user_id")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
forgejoUsername String @map("forgejo_username")
forgejoTokenEncrypted String @map("forgejo_token_encrypted")
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
@@map("app_dev_forgejo_identity")
}
model OauthConsent {
id Int @id @default(autoincrement())
userId Int
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
clientId String
client OauthClient @relation(fields: [clientId], references: [id], onDelete: Cascade)
scope Int
buzzLimit Json?
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
@@unique([userId, clientId])
}
// First-party spoke domain registry for the OAuth cross-domain login flow (replaces AUTH_SPOKE_ORIGINS).
// The hub authorizes a login host against these rows (exact `domain`, or any subdomain when
// `includeSubdomains` — for ephemeral PR-preview hosts). Matched on the request hostname (no scheme/port),
// so a `localhost` row authorizes any localhost:<port>. Read with a short in-memory cache.
model TrustedSpokeDomain {
id Int @id @default(autoincrement())
domain String @unique // bare host, no scheme/port — e.g. civitai.com, civitaic.com, localhost
includeSubdomains Boolean @default(false) // also match *.domain (ephemeral PR-preview subdomains)
label String?
enabled Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
}
model AdToken {
id Int @id @default(autoincrement())
token String @unique
userId Int
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
expiresAt DateTime?
}
model Comment {
id Int @id @default(autoincrement())
content String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
nsfw Boolean @default(false)
tosViolation Boolean @default(false)
parent Comment? @relation("ParentComments", fields: [parentId], references: [id], onDelete: Cascade)
parentId Int?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
userId Int
model Model @relation(fields: [modelId], references: [id], onDelete: Cascade)
modelId Int
locked Boolean? @default(false)
hidden Boolean? @default(false)
pinnedAt DateTime?
comments Comment[] @relation("ParentComments")
reactions CommentReaction[]
reports CommentReport[]
@@index([modelId], type: Hash)
@@index([parentId], type: Hash)
@@index([userId])
}
model CommentReaction {
id Int @id @default(autoincrement())
commentId Int
comment Comment @relation(fields: [commentId], references: [id], onDelete: Cascade)
userId Int
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
reaction ReviewReactions
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([commentId, userId, reaction])
}
model UserNotificationSettings {
id Int @id @default(autoincrement())
userId Int
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
type String
disabledAt DateTime @default(now())
@@unique([userId, type])
}
model Webhook {
id Int @id @default(autoincrement())
url String
notifyOn String[] // Manually specified and managed since Prisma enums are not supported in arrays
active Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
userId Int
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([url, userId])
}
model Question {
id Int @id @default(autoincrement())
userId Int
user User @relation(fields: [userId], references: [id])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
title String @db.Citext
content String
selectedAnswerId Int? @unique
selectedAnswer Answer? @relation("SelectedAnswer", fields: [selectedAnswerId], references: [id])
tags TagsOnQuestions[]
reactions QuestionReaction[]
answers Answer[] @relation("Question")
metrics QuestionMetric[]
rank QuestionRank?
thread Thread?
}
model QuestionMetric {
questionId Int
question Question @relation(fields: [questionId], references: [id], onDelete: Cascade)
timeframe MetricTimeframe
heartCount Int @default(0)
commentCount Int @default(0)
answerCount Int @default(0)
@@id([questionId, timeframe])
}
model Answer {
id Int @id @default(autoincrement())
questionId Int
question Question @relation("Question", fields: [questionId], references: [id], onDelete: Cascade)
userId Int
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
content String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
reactions AnswerReaction[]
metrics AnswerMetric[]
answerFor Question? @relation("SelectedAnswer")
votes AnswerVote[]
rank AnswerRank?
thread Thread?
}
model AnswerVote {
answer Answer @relation(fields: [answerId], references: [id], onDelete: Cascade)
answerId Int
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
userId Int
vote Boolean?
createdAt DateTime @default(now())
@@id([answerId, userId])
}
model AnswerMetric {
answerId Int
answer Answer @relation(fields: [answerId], references: [id], onDelete: Cascade)
timeframe MetricTimeframe
checkCount Int
crossCount Int
heartCount Int
commentCount Int
@@id([answerId, timeframe])
}
model CommentV2 {
id Int @id @default(autoincrement())
content String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
nsfw Boolean @default(false)
tosViolation Boolean @default(false)
userId Int
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
threadId Int
thread Thread @relation("thread", fields: [threadId], references: [id], onDelete: Cascade)
childThread Thread? @relation("childThread")
metadata Json?
hidden Boolean? @default(false)
pinnedAt DateTime?
reactionCount Int @default(0)
reactions CommentV2Reaction[]
reports CommentV2Report[]
@@index([threadId], type: Hash)
@@index([reactionCount(sort: Desc), id(sort: Desc)])
@@index([userId])
}
model Thread {
id Int @id @default(autoincrement())
locked Boolean @default(false)
parentThreadId Int?
parentThread Thread? @relation("childThread", fields: [parentThreadId], references: [id], onDelete: Cascade)
rootThreadId Int?
rootThread Thread? @relation("root", fields: [rootThreadId], references: [id], onDelete: Cascade)
questionId Int? @unique
question Question? @relation(fields: [questionId], references: [id], onDelete: SetNull)
answerId Int? @unique
answer Answer? @relation(fields: [answerId], references: [id], onDelete: SetNull)
imageId Int? @unique
image Image? @relation(fields: [imageId], references: [id], onDelete: SetNull)
postId Int? @unique
post Post? @relation(fields: [postId], references: [id], onDelete: SetNull)
reviewId Int? @unique
review ResourceReview? @relation(fields: [reviewId], references: [id], onDelete: SetNull)
commentId Int? @unique
comment CommentV2? @relation("childThread", fields: [commentId], references: [id], onDelete: SetNull)
modelId Int? @unique
model Model? @relation(fields: [modelId], references: [id], onDelete: SetNull)
articleId Int? @unique
article Article? @relation(fields: [articleId], references: [id], onDelete: SetNull)
bountyId Int? @unique
bounty Bounty? @relation(fields: [bountyId], references: [id], onDelete: SetNull)
bountyEntryId Int? @unique
bountyEntry BountyEntry? @relation(fields: [bountyEntryId], references: [id], onDelete: SetNull)
clubPostId Int? @unique
clubPost ClubPost? @relation(fields: [clubPostId], references: [id], onDelete: SetNull)
comicProjectId Int?
comicChapterPosition Int?
comicChapter ComicChapter? @relation(fields: [comicProjectId, comicChapterPosition], references: [projectId, position], onDelete: SetNull, onUpdate: Cascade)
challengeId Int? @unique
challenge Challenge? @relation(fields: [challengeId], references: [id], onDelete: SetNull)
model3dId Int? @unique
model3d Model3D? @relation(fields: [model3dId], references: [id], onDelete: SetNull)
model3dReviewId Int? @unique
model3dReview Model3DReview? @relation(fields: [model3dReviewId], references: [id], onDelete: SetNull)
// App-store listing comments (W13). References the AppListing INTEGER surrogate
// (`serial_id`), NOT its TEXT ULID `id` — CommentsV2 is integer-keyed. `app_listings`
// is the same DB (main civitai nvme0), so this is a plain FK, not a cross-DB bridge.
appListingId Int? @unique
appListing AppListing? @relation("AppListingThread", fields: [appListingId], references: [serialId], onDelete: SetNull)
metadata Json @default("{}") // unused
commentCount Int @default(0)
comments CommentV2[] @relation("thread")
directChildren Thread[] @relation("childThread")
children Thread[] @relation("root")
mutes ThreadMute[]
@@unique([comicProjectId, comicChapterPosition])
@@index([reviewId], type: Hash)
@@index([postId], type: Hash)
@@index([questionId], type: Hash)
@@index([imageId], type: Hash)
@@index([articleId], type: Hash)
@@index([rootThreadId], type: Hash)
@@index([challengeId], type: Hash)
}
model ThreadMute {
userId Int
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
threadId Int
thread Thread @relation(fields: [threadId], references: [id], onDelete: Cascade)
mutedAt DateTime @default(now())
@@id([userId, threadId])
@@index([threadId])
}
model QuestionReaction {
id Int @id @default(autoincrement())
question Question @relation(fields: [questionId], references: [id], onDelete: Cascade)
questionId Int
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
userId Int
reaction ReviewReactions
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([questionId, userId, reaction])
}
model AnswerReaction {
id Int @id @default(autoincrement())
answer Answer @relation(fields: [answerId], references: [id], onDelete: Cascade)
answerId Int
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
userId Int
reaction ReviewReactions
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([answerId, userId, reaction])
}
model CommentV2Reaction {
id Int @id @default(autoincrement())
comment CommentV2 @relation(fields: [commentId], references: [id], onDelete: Cascade)
commentId Int
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
userId Int
reaction ReviewReactions
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([commentId, userId, reaction])
}
model ImageReaction {
id Int @id @default(autoincrement())
imageId Int
image Image @relation(fields: [imageId], references: [id], onDelete: Cascade)
userId Int
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
reaction ReviewReactions
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([imageId, userId, reaction])
}
model PostReaction {
id Int @id @default(autoincrement())
postId Int
post Post @relation(fields: [postId], references: [id], onDelete: Cascade)
userId Int
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
reaction ReviewReactions
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([postId, userId, reaction])
}
model ArticleReaction {
id Int @id @default(autoincrement())
articleId Int
article Article @relation(fields: [articleId], references: [id], onDelete: Cascade)
userId Int
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
reaction ReviewReactions
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([articleId, userId, reaction])
}
enum TagEngagementType {
Hide
Follow
// Depracated (don't use Allow)
Allow
}
model TagEngagement {
userId Int
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
tagId Int
tag Tag @relation(fields: [tagId], references: [id], onDelete: Cascade)
type TagEngagementType
createdAt DateTime @default(now())
@@id([userId, tagId])
}
enum DomainColor {
red
green
blue
all
}
model Announcement {
id Int @id @default(autoincrement())
title String
content String
emoji String?
color String @default("blue")
domain DomainColor[] @default([all])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
startsAt DateTime?
endsAt DateTime?
metadata Json?
disabled Boolean @default(false)
/// Author. Null is Civitai itself; the sitewide caches select on null, so a
/// non-null row can never reach the global banner.
userId Int?
user User? @relation("authoredAnnouncements", fields: [userId], references: [id], onDelete: Cascade)
coverId Int?
cover Image? @relation("AnnouncementCover", fields: [coverId], references: [id], onDelete: SetNull)
/// Profile-only rows never enter the announcements feed and never notify.
profileOnly Boolean @default(false)
targetUsers AnnouncementUser[]
dismissals AnnouncementDismissal[]
spends AnnouncementSpend[]
reports AnnouncementReport[]
@@index([userId, startsAt])
}
/// One spent announcement slot. Deliberately outlives the announcement it paid for:
/// counting live announcements would return the slot on delete, making the cap
/// refundable and therefore not a cap.
model AnnouncementSpend {
id Int @id @default(autoincrement())
userId Int
announcementId Int?
createdAt DateTime @default(now())
user User @relation("announcementSpends", fields: [userId], references: [id], onDelete: Cascade)
announcement Announcement? @relation(fields: [announcementId], references: [id], onDelete: SetNull)
/// The migration writes this as a PARTIAL unique (WHERE "announcementId" IS NOT NULL).
/// Same behaviour — Postgres already treats NULLs as distinct — so do not "correct"
/// either artifact to match the other.
@@unique([announcementId])
@@index([userId, createdAt])
}
/// A follower silencing one creator's announcements without unfollowing them.
model UserAnnouncementMute {
userId Int
creatorId Int
createdAt DateTime @default(now())
user User @relation("announcementMutesGiven", fields: [userId], references: [id], onDelete: Cascade)
creator User @relation("announcementMutesReceived", fields: [creatorId], references: [id], onDelete: Cascade)
@@id([userId, creatorId])
@@index([creatorId])
}
/// Allowlist of users an announcement targets. No rows = shown to everyone.
model AnnouncementUser {
announcementId Int
userId Int
announcement Announcement @relation(fields: [announcementId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@id([userId, announcementId])
@@index([announcementId])
}
/// A dismissal is meaningful only while its announcement can still be shown, so rows are read
/// scoped to currently-active announcements and a stale row is inert rather than wrong. That is
/// what lets cleanup be lazy: nothing user-visible depends on it having run.
///
/// Deleting a row for an announcement that is still live is the defect this table exists to fix —
/// see the client-side prune it replaces. Bound this by the announcement's death, never by a
/// per-user count.
model AnnouncementDismissal {
announcementId Int
userId Int
dismissedAt DateTime @default(now())
announcement Announcement @relation(fields: [announcementId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@id([userId, announcementId])
@@index([announcementId])
}
model RewardsBonusEvent {
id Int @id @default(autoincrement())
name String
description String?
multiplier Int /// Stored as multiplier * 10. e.g. 15 = 1.5x (50% MORE), 20 = 2x, 30 = 3x, 40 = 4x. Minimum effective value 10 (no bonus).
articleId Int?
article Article? @relation(fields: [articleId], references: [id], onDelete: SetNull)
bannerLabel String?
enabled Boolean @default(false)
startsAt DateTime?
endsAt DateTime?
createdById Int
createdBy User @relation("RewardsBonusEventCreator", fields: [createdById], references: [id], onDelete: Restrict)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([enabled, startsAt, endsAt])
}
enum CosmeticType {
Badge
NamePlate
ContentDecoration
ProfileDecoration
ProfileBackground
Sticker
// Applied, unused. Chat themes come with a membership; a cosmetic-granted
// theme is the shape a later pass would want, and an enum value cannot be
// dropped without recreating the type.
ChatTheme
}
enum CosmeticSource {
Trophy
Purchase
Event
Membership
Claim
}
enum CosmeticShopItemStatus {
Draft
PendingReview
Published
Rejected
RequestedChanges
Archived
}
model Cosmetic {
id Int @id @default(autoincrement())
name String
description String?
videoUrl String?
type CosmeticType
source CosmeticSource
permanentUnlock Boolean
data Json
createdAt DateTime? @default(now())
updatedAt DateTime? @updatedAt
availableStart DateTime?
availableEnd DateTime?
availableQuery String?
productId String?
leaderboardId String?
leaderboardPosition Int?
createdById Int?
pHash BigInt?
pHashUrl String?
pHashHex String?
pHashVersion String?
pHashFailedAt DateTime?
creator User? @relation("CosmeticCreator", fields: [createdById], references: [id], onDelete: SetNull)
UserCosmetic UserCosmetic[]
purchases UserCosmeticShopPurchases[]
purchaseComponents UserCosmeticShopPurchaseCosmetic[]
cosmeticShopItems CosmeticShopItem[]
packMemberships CosmeticShopItemCosmetic[]
@@index([createdById])
}
enum CosmeticEntity {
Model
Image
Article
Post
Model3D
}
model UserCosmetic {
userId Int
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
cosmeticId Int
cosmetic Cosmetic @relation(fields: [cosmeticId], references: [id], onDelete: Cascade)
obtainedAt DateTime @default(now()) // createdAt
equippedAt DateTime?
data Json?
claimKey String @default("claimed")
equippedToId Int?
equippedToType CosmeticEntity?
forId Int? // Locks the cosmetic to a specific entity
forType CosmeticEntity?
remaining Int? // Consumable balance; NULL = unlimited (every pre-existing row)
@@id([userId, cosmeticId, claimKey])
}
model CosmeticShopSection {
id Int @id @default(autoincrement())
addedById Int?
addedBy User? @relation(fields: [addedById], references: [id], onDelete: SetNull)
title String
description String?
placement Int @default(0)
meta Json @default("{}")
imageId Int?
image Image? @relation(fields: [imageId], references: [id], onDelete: SetNull)
published Boolean @default(true)
items CosmeticShopSectionItem[]
}
model CosmeticShopItem {
id Int @id @default(autoincrement())
// NULL on a pack, whose contents live in `members`. Deliberately nullable
// rather than pointed at a primary member: an unmigrated read path fails
// loudly instead of silently rendering and granting one cosmetic.
cosmeticId Int?
cosmetic Cosmetic? @relation(fields: [cosmeticId], references: [id], onDelete: Cascade)
unitAmount Int
addedById Int?
addedBy User? @relation(fields: [addedById], references: [id], onDelete: SetNull)
createdAt DateTime @default(now())
availableFrom DateTime?
availableTo DateTime?
availableQuantity Int?
meta Json @default("{}")
title String
description String?
archivedAt DateTime?
status CosmeticShopItemStatus @default(Published)
reviewedById Int?
reviewedAt DateTime?
rejectionReason String?
listed Boolean @default(true)
purchases UserCosmeticShopPurchases[]
sections CosmeticShopSectionItem[]
wishlists UserCosmeticShopItemWishlist[]
resales UserCosmeticShopItemResale[]
members CosmeticShopItemCosmetic[]
}
// Cross-creator resale: another creator listing this item in their own shop.
// `sellerShare` is captured when the row is created and never rewritten, so the
// original creator lowering (or withdrawing) the item's terms can't retroactively
// cut someone who already listed it.
model UserCosmeticShopItemResale {
userId Int
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
shopItemId Int
shopItem CosmeticShopItem @relation(fields: [shopItemId], references: [id], onDelete: Cascade)
sellerShare Int
index Int @default(0)
createdAt DateTime @default(now())
@@id([userId, shopItemId])
@@index([shopItemId])
}
model CosmeticShopItemCosmetic {
shopItemId Int
shopItem CosmeticShopItem @relation(fields: [shopItemId], references: [id], onDelete: Cascade)
cosmeticId Int
cosmetic Cosmetic @relation(fields: [cosmeticId], references: [id], onDelete: Cascade)
index Int @default(0)
// Snapshot of the member's list price when the pack was built. Live lookup
// would let a member's creator re-price every pack containing them.
floorAmount Int
@@id([shopItemId, cosmeticId])
@@index([cosmeticId])
}
model UserCosmeticShopItemWishlist {
userId Int
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
shopItemId Int
shopItem CosmeticShopItem @relation(fields: [shopItemId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
@@id([userId, shopItemId])
@@index([shopItemId])
}
model CosmeticShopSectionItem {
shopItemId Int
shopItem CosmeticShopItem @relation(fields: [shopItemId], references: [id], onDelete: Cascade)
shopSectionId Int
shopSection CosmeticShopSection @relation(fields: [shopSectionId], references: [id], onDelete: Cascade)
index Int @default(0)
createdAt DateTime @default(now())
@@id([shopItemId, shopSectionId])
}
model UserCosmeticShopPurchases {
userId Int
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
// NULL on a pack purchase: N cosmetics cannot share one transaction-keyed
// row, so the components live in `components`.
cosmeticId Int?
cosmetic Cosmetic? @relation(fields: [cosmeticId], references: [id], onDelete: Cascade)
shopItemId Int
shopItem CosmeticShopItem @relation(fields: [shopItemId], references: [id], onDelete: Cascade)
unitAmount Int
purchasedAt DateTime @default(now())
buzzTransactionId String @id @default(cuid())
refunded Boolean
// Who was actually paid for this sale, and in which Buzz color(s) — the split
// depends on the storefront it was bought through, so a takedown can't
// reconstruct it after the fact. See UserCosmeticShopPurchaseMeta.
meta Json?
components UserCosmeticShopPurchaseCosmetic[]
// Every displayed sold count is a `_count` over this FK, which Prisma resolves
// as one whole-table GROUP BY per query. Without the index a single-item read
// still scans the table to count one item's rows. Applied by hand — the
// migration beside this schema carries the plans and what the index does not fix.
@@index([shopItemId])
}
model UserCosmeticShopPurchaseCosmetic {
buzzTransactionId String
purchase UserCosmeticShopPurchases @relation(fields: [buzzTransactionId], references: [buzzTransactionId], onDelete: Cascade)
cosmeticId Int
cosmetic Cosmetic @relation(fields: [cosmeticId], references: [id], onDelete: Cascade)
// The member's snapshotted list price this component was attributed at; the
// pack creator's share is the remainder, so these need not sum to the price.
unitAmount Int
// Per-member payout record: each component pays a different creator, so the
// parent's meta cannot describe the split. See UserCosmeticShopPurchaseMeta.
meta Json?
@@id([buzzTransactionId, cosmeticId])
@@index([cosmeticId])
}
enum BuzzAccountType {
user
generation
club
green
fakered
}
model BuzzClaim {
key String @id
title String
description String
transactionIdQuery String
amount Int
availableStart DateTime?
availableEnd DateTime?
claimed Int @default(0)
limit Int?
accountType BuzzAccountType @default(user)
useMultiplier Boolean @default(false)
}
enum ArticleStatus {
Draft
Published
Unpublished
UnpublishedViolation
Processing
}
enum ArticleIngestionStatus {
Pending
Scanned
Blocked
Error
Rescan
}
model Article {
id Int @id @default(autoincrement())
createdAt DateTime? @default(now())
updatedAt DateTime? @updatedAt
nsfw Boolean @default(false)
tosViolation Boolean @default(false)
metadata Json?
title String
content String
cover String?
coverId Int? @unique
coverImage Image? @relation(fields: [coverId], references: [id], onDelete: SetNull)
publishedAt DateTime?
contentScannedAt DateTime? // Set when both image + text scans complete successfully
ingestion ArticleIngestionStatus @default(Pending)
scanRequestedAt DateTime?
userId Int
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
availability Availability @default(Public)
unlisted Boolean @default(false)
nsfwLevel Int @default(0)
userNsfwLevel Int @default(0)
moderatorNsfwLevel Int?
// Snapshot of the content-derived NSFW level (images + moderation floor) at
// the moment `moderatorNsfwLevel` was set. Lets the auto-approve gate tell
// "content genuinely dropped since the override" from "override was always
// above the images" — only the former may be auto-cleared. Null when no
// override is active (or a legacy override predating this column).
moderatorNsfwLevelBasis Int?
lockedProperties String[] @default([])
// Published by Civitai rather than by a community author. Mirrors Model.isOfficial:
// a column with a moderator-only setter, not a tag anyone can type.
isOfficial Boolean @default(false)
status ArticleStatus @default(Draft)
thread Thread?
reactions ArticleReaction[]
tags TagsOnArticle[]
reports ArticleReport[]
metrics ArticleMetric[]
rank ArticleRank?
stats ArticleStat?
engagements ArticleEngagement[]
associations ModelAssociations[]
collectionItems CollectionItem[]
rewardsBonusEvents RewardsBonusEvent[]
ratingReviews ArticleRatingReview[]
@@index([status, ingestion, nsfwLevel])
}
model PressMention {
id Int @id @default(autoincrement())
title String
url String
source String
publishedAt DateTime @default(now())
createdAt DateTime @default(now())
}
enum ArticleEngagementType {
Favorite
Hide
}
model ArticleEngagement {
userId Int
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
articleId Int
article Article @relation(fields: [articleId], references: [id], onDelete: Cascade)
type ArticleEngagementType
createdAt DateTime @default(now())
@@id([userId, articleId])
@@index([articleId], type: Hash)
}
model ArticleMetric {
article Article @relation(fields: [articleId], references: [id], onDelete: Cascade)
articleId Int
timeframe MetricTimeframe
likeCount Int @default(0)
dislikeCount Int @default(0)
laughCount Int @default(0)
cryCount Int @default(0)
heartCount Int @default(0)
commentCount Int @default(0)
viewCount Int @default(0)
favoriteCount Int @default(0)
hideCount Int @default(0)
collectedCount Int @default(0)
tippedCount Int @default(0)
tippedAmountCount Int @default(0)
updatedAt DateTime @default(now())
@@id([articleId, timeframe])
}
model Leaderboard {
id String @id
index Int
title String
description String
scoringDescription String
query String
active Boolean
public Boolean
domain DomainColor[] @default([all])
results LeaderboardResult[]
}
model LeaderboardResult {
leaderboardId String
leaderboard Leaderboard @relation(fields: [leaderboardId], references: [id], onDelete: Cascade)
date DateTime @db.Date
position Int
userId Int
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
score Int @default(0)
metrics Json @default("{}")
createdAt DateTime @default(now())
@@id([leaderboardId, date, position])
@@unique([leaderboardId, date, userId])
@@index([userId], type: Hash)
}
model ModelVersionExploration {
index Int
name String
prompt String
modelVersionId Int
modelVersion ModelVersion @relation(fields: [modelVersionId], references: [id], onDelete: Cascade)
@@id([modelVersionId, name])
}
enum GenerationSchedulers {
EulerA
Euler
LMS
Heun
DPM2
DPM2A
DPM2SA
DPM2M
DPMSDE
DPMFast
DPMAdaptive
LMSKarras
DPM2Karras
DPM2AKarras
DPM2SAKarras
DPM2MKarras
DPMSDEKarras
DDIM
}
model GenerationServiceProvider {
name String
schedulers GenerationSchedulers[]
@@id([name])
}
enum CollectionWriteConfiguration {
Private
Public
Review
}
enum CollectionReadConfiguration {
Private
Public
Unlisted
}
enum CollectionType {
Model
Article
Post
Image
Model3D
}
enum CollectionMode {
Contest
Bookmark
}
model Collection {
id Int @id @default(autoincrement())
createdAt DateTime? @default(now())
updatedAt DateTime? @updatedAt
name String
description String?
nsfw Boolean? @default(false)
userId Int
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
imageId Int?
image Image? @relation(fields: [imageId], references: [id], onDelete: SetNull)
write CollectionWriteConfiguration @default(Private)
read CollectionReadConfiguration @default(Private)
type CollectionType?
mode CollectionMode?
metadata Json @default("{}")
availability Availability @default(Public)
nsfwLevel Int @default(0)
collaborationDisabledAt DateTime?
items CollectionItem[]
contributors CollectionContributor[]
invites CollectionInvite[]
tags TagsOnCollection[]
post Post[]
reports CollectionReport[]
rank CollectionRank?
stats CollectionStat?
metrics CollectionMetric[]
challenges Challenge[]
@@index([userId])
@@index([type], type: Hash)
@@index([mode], type: Hash)
}
enum CollectionItemStatus {
ACCEPTED
REVIEW
REJECTED
}
enum CollectionItemRejectionReason {
OffTopic
WrongFormat
Duplicate
Quality
RulesViolation
Other
Automated
}
model CollectionItem {
id Int @id @default(autoincrement())
createdAt DateTime? @default(now())
updatedAt DateTime? @updatedAt
collectionId Int
collection Collection @relation(fields: [collectionId], references: [id], onDelete: Cascade)
articleId Int?
article Article? @relation(fields: [articleId], references: [id], onDelete: Cascade)
postId Int?
post Post? @relation(fields: [postId], references: [id], onDelete: Cascade)
imageId Int?
image Image? @relation(fields: [imageId], references: [id], onDelete: Cascade)
modelId Int?
model Model? @relation(fields: [modelId], references: [id], onDelete: Cascade)
model3dId Int?
model3d Model3D? @relation(fields: [model3dId], references: [id], onDelete: Cascade)
addedById Int?
addedBy User? @relation(fields: [addedById], references: [id], onDelete: SetNull)
reviewedById Int?
reviewedBy User? @relation("reviewedBy", fields: [reviewedById], references: [id], onDelete: SetNull)
reviewedAt DateTime?
note String?
status CollectionItemStatus @default(ACCEPTED)
rejectionReason CollectionItemRejectionReason?
rejectionDetail String?
tagId Int?
tag Tag? @relation(fields: [tagId], references: [id], onDelete: SetNull)
scores CollectionItemScore[]
@@unique([collectionId, articleId, postId, imageId, modelId, model3dId])
@@index([addedById], type: Hash)
@@index([imageId], type: Hash)
@@index([modelId], type: Hash)
@@index([model3dId], type: Hash)
@@index([collectionId], type: Hash)
@@index([collectionId, id(sort: Desc)])
}
model CollectionItemScore {
userId Int
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
collectionItemId Int
collectionItem CollectionItem @relation(fields: [collectionItemId], references: [id], onDelete: Cascade)
score Int
createdAt DateTime @default(now())
@@id([userId, collectionItemId])
}
enum CollectionContributorPermission {
VIEW
ADD
ADD_REVIEW
MANAGE
}
model CollectionContributor {
createdAt DateTime? @default(now())
updatedAt DateTime? @updatedAt
userId Int
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
collectionId Int
collection Collection @relation(fields: [collectionId], references: [id], onDelete: Cascade)
permissions CollectionContributorPermission[]
@@id([userId, collectionId])
@@index([userId], type: Hash)
}
enum CollectionCollaboratorRole {
Contributor
Manager
}
enum CollectionInviteStatus {
Pending
Accepted
Declined
}
model CollectionInvite {
id Int @id @default(autoincrement())
collectionId Int
collection Collection @relation(fields: [collectionId], references: [id], onDelete: Cascade)
userId Int
user User @relation("collectionInviteRecipient", fields: [userId], references: [id], onDelete: Cascade)
invitedById Int
invitedBy User @relation("collectionInviteSender", fields: [invitedById], references: [id], onDelete: Cascade)
role CollectionCollaboratorRole
status CollectionInviteStatus @default(Pending)
createdAt DateTime @default(now())
respondedAt DateTime?
@@unique([collectionId, userId])
@@index([userId, status])
}
model TagsOnCollection {
collectionId Int
tagId Int
createdAt DateTime? @default(now())
collection Collection @relation(fields: [collectionId], references: [id], onDelete: Cascade)
tag Tag @relation(fields: [tagId], references: [id], onDelete: Cascade)
filterableOnly Boolean @default(false)
@@id([tagId, collectionId])
@@index([collectionId], type: Hash)
}
enum HomeBlockType {
Collection
Announcement
Leaderboard
Social
Event
CosmeticShop
FeaturedModelVersion
FeaturedCollections
Feed
}
model HomeBlock {
id Int @id @default(autoincrement())
createdAt DateTime? @default(now())
updatedAt DateTime? @updatedAt
userId Int
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
metadata Json @default("{}")
index Int?
type HomeBlockType
permanent Boolean @default(false)
sourceId Int?
source HomeBlock? @relation("Clones", fields: [sourceId], references: [id], onDelete: SetNull)
clones HomeBlock[] @relation("Clones")
}
model BuzzTip {
entityType String
entityId Int
toUserId Int
fromUserId Int
amount Int
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@id([entityType, entityId, fromUserId])
@@index([toUserId])
}
enum Currency {
USD
BUZZ
USDC
}
enum BountyType {
ModelCreation
LoraCreation
EmbedCreation
DataSetCreation
DataSetCaption
ImageCreation
VideoCreation
Other
}
enum BountyMode {
Individual
Split
}
enum BountyEntryMode {
Open
BenefactorsOnly
}
model Bounty {
id Int @id @default(autoincrement())
userId Int?
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
name String // Locked after created
description String
startsAt DateTime @db.Date
expiresAt DateTime @db.Date // Locked after created
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
details Json?
mode BountyMode @default(Individual)
entryMode BountyEntryMode @default(Open)
type BountyType
minBenefactorUnitAmount Int
maxBenefactorUnitAmount Int? // Default to initial benefactor's entry
entryLimit Int @default(1)
nsfw Boolean @default(false)
poi Boolean @default(false)
complete Boolean @default(false)
refunded Boolean @default(false)
availability Availability @default(Public)
nsfwLevel Int @default(0)
lockedProperties String[] @default([])
tags TagsOnBounty[]
entries BountyEntry[]
benefactors BountyBenefactor[]
engagements BountyEngagement[]
thread Thread?
metrics BountyMetric[]
rank BountyRank?
stats BountyStat?
reports BountyReport[]
@@index([userId], type: Hash)
@@index([type])
@@index([mode])
}
model BountyEntry {
id Int @id @default(autoincrement())
userId Int?
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
bountyId Int
bounty Bounty @relation(fields: [bountyId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
locked Boolean @default(false)
description String?
nsfwLevel Int @default(0)
benefactors BountyBenefactor[]
thread Thread?
reactions BountyEntryReaction[]
metrics BountyEntryMetric[]
rank BountyEntryRank?
stats BountyEntryStat?
reports BountyEntryReport[]
@@index([bountyId], type: Hash)
}
model BountyEntryReaction {
bountyEntry BountyEntry @relation(fields: [bountyEntryId], references: [id], onDelete: Cascade)
bountyEntryId Int
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
userId Int
reaction ReviewReactions
createdAt DateTime @default(now())
@@id([bountyEntryId, userId, reaction])
@@index([bountyEntryId], type: Hash)
}
model BountyBenefactor {
userId Int
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
bountyId Int
bounty Bounty @relation(fields: [bountyId], references: [id], onDelete: Cascade)
unitAmount Int
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
awardedAt DateTime?
awardedToId Int?
awartedTo BountyEntry? @relation(fields: [awardedToId], references: [id], onDelete: SetNull)
currency Currency @default(BUZZ)
buzzTransactionId String[] @default([])
@@id([bountyId, userId])
@@index([bountyId], type: Hash)
@@index([userId], type: Hash)
}
enum BountyEngagementType {
Favorite
Track
}
model BountyEngagement {
userId Int
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
bountyId Int
bounty Bounty @relation(fields: [bountyId], references: [id], onDelete: Cascade)
type BountyEngagementType
createdAt DateTime @default(now())
@@id([type, bountyId, userId])
@@index([bountyId])
@@index([userId], type: Hash)
}
model TipConnection {
transactionId String // guid
entityId Int
entityType String
@@id([entityType, entityId, transactionId])
}
model BountyMetric {
bounty Bounty @relation(fields: [bountyId], references: [id], onDelete: Cascade)
bountyId Int
timeframe MetricTimeframe
favoriteCount Int @default(0)
trackCount Int @default(0)
entryCount Int @default(0)
benefactorCount Int @default(0)
unitAmountCount Int @default(0)
commentCount Int @default(0)
updatedAt DateTime @default(now())
@@id([bountyId, timeframe])
}
model BountyEntryMetric {
bountyEntry BountyEntry @relation(fields: [bountyEntryId], references: [id], onDelete: Cascade)
bountyEntryId Int
timeframe MetricTimeframe
likeCount Int @default(0)
dislikeCount Int @default(0)
laughCount Int @default(0)
cryCount Int @default(0)
heartCount Int @default(0)
unitAmountCount Int @default(0)
tippedCount Int @default(0)
tippedAmountCount Int @default(0)
updatedAt DateTime @default(now())
@@id([bountyEntryId, timeframe])
}
enum CsamReportType {
Image
TrainingData
GeneratedImage
ExternalLink
}
model CsamReport {
id Int @id @default(autoincrement())
userId Int?
createdAt DateTime @default(now())
reportedById Int
reportSentAt DateTime?
archivedAt DateTime?
contentRemovedAt DateTime?
reportId Int?
details Json @default("{}")
images Json @default("[]")
type CsamReportType @default(Image)
// modelVersionIds Int[] @default([])
}
model Link {
id Int @id @default(autoincrement())
url String
type LinkType
entityId Int
entityType String
}
enum Availability {
Public
Unsearchable // Public but Ignored from search results.
Private
EarlyAccess
}
model EntityAccess {
accessToId Int
accessToType String
accessorId Int
accessorType String
addedById Int
addedBy User @relation(fields: [addedById], references: [id])
addedAt DateTime @default(now())
permissions Int @default(0)
meta Json? @default("{}")
@@id([accessToId, accessToType, accessorId, accessorType])
}
enum PaidAccessEntityType {
ModelVersion
ComicChapter
}
// The config side of paid access (the purchase side is EntityAccess). Row exists => gated.
// endsAt IS NULL <=> permanent; a date => timed; a donation goal overwrites it to now() to end early.
// terms = PaidAccessTerms (bundle semantics). See docs/creator-studio/paid-access-schema.md.
model PaidAccess {
entityType PaidAccessEntityType
entityId Int
ownerId Int
endsAt DateTime?
// Timed-window length in days for a not-yet-published gate; endsAt is materialized to
// publishedAt + timeframeDays at publish. NULL = permanent (no timed window).
timeframeDays Int?
terms Json
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@id([entityType, entityId])
@@index([ownerId, entityType, endsAt]) // + a partial index (ownerId, entityType) WHERE endsAt IS NULL, added via raw SQL migration
@@index([entityType, endsAt]) // expiry-job + early-access-complete notification scan by end time
}
enum SaleDiscountType {
Fixed
Percent
}
// A scheduled discount laid OVER a version's PaidAccess price. The gate's own `terms` is never
// rewritten, so a creator editing their base price mid-sale cannot lose it, and "what did this cost
// on the day" stays answerable. Effective price is resolved at read time — see discountedTerms in
// @civitai/buzz. Sales cover permanent paid access only, never a timed early-access window.
model ModelVersionSale {
id Int @id @default(autoincrement())
userId Int
// Optional: naming a sale is a creator's choice, not a requirement.
name String?
discountType SaleDiscountType
// Buzz for Fixed, whole percent for Percent. Never a float — this reaches a price.
discountAmount Int
startsAt DateTime
endsAt DateTime
canceledAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
versions ModelVersionSaleItem[]
@@index([userId, startsAt])
}
model ModelVersionSaleItem {
saleId Int
modelVersionId Int
sale ModelVersionSale @relation(fields: [saleId], references: [id], onDelete: Cascade)
@@id([saleId, modelVersionId])
@@index([modelVersionId])
}
enum EntityCollaboratorStatus {
Pending
Approved
Rejected
}
model EntityCollaborator {
entityType EntityType
entityId Int
userId Int
user User @relation("entityCollaboratorParticipant", fields: [userId], references: [id])
status EntityCollaboratorStatus @default(Pending)
createdAt DateTime @default(now())
createdBy Int
creator User @relation("entityCollaboratorCreator", fields: [createdBy], references: [id])
lastMessageSentAt DateTime?
@@id([entityType, entityId, userId])
@@index([userId, entityType, entityId]) // Include status in the migration.
}
model EcosystemCheckpoints {
id Int @id
name String
}
model GenerationBaseModel {
baseModel String @id
}
model Club {
id Int @id @default(autoincrement())
userId Int
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
coverImageId Int?
coverImage Image? @relation(name: "coverImage", fields: [coverImageId], references: [id], onDelete: SetNull)
headerImageId Int?
headerImage Image? @relation(name: "headerImage", fields: [headerImageId], references: [id], onDelete: SetNull)
avatarId Int?
avatar Image? @relation(name: "avatarImage", fields: [avatarId], references: [id], onDelete: SetNull)
name String
description String
nsfw Boolean @default(false)
billing Boolean @default(true)
unlisted Boolean @default(false)
tiers ClubTier[]
memberships ClubMembership[]
posts ClubPost[]
adminInvites ClubAdminInvite[]
admins ClubAdmin[]
metrics ClubMetric[]
rank ClubRank?
stats ClubStat?
@@index([userId])
}
model ClubTier {
id Int @id @default(autoincrement())
clubId Int
club Club @relation(fields: [clubId], references: [id], onDelete: Cascade)
unitAmount Int
currency Currency @default(BUZZ)
name String
description String
coverImageId Int?
coverImage Image? @relation(fields: [coverImageId], references: [id], onDelete: SetNull)
// Whether or not this will be displayed in the common options for
// memberships.
unlisted Boolean @default(false)
// Can only be joined to via a club Admin adding the user.
joinable Boolean
memberships ClubMembership[] @relation("activeClubTier")
downgradeMemberships ClubMembership[] @relation("downgradeClubTier")
createdAt DateTime @default(now())
updatedAt DateTime? @updatedAt
memberLimit Int?
oneTimeFee Boolean @default(false)
}
enum ClubAdminPermission {
ManageMemberships
ManageTiers
ManagePosts
ManageClub
ManageResources
ViewRevenue
WithdrawRevenue
}
model ClubAdminInvite {
id String @id @default(cuid())
expiresAt DateTime?
clubId Int
club Club @relation(fields: [clubId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
permissions ClubAdminPermission[]
}
model ClubAdmin {
userId Int
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
clubId Int
club Club @relation(fields: [clubId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now()) // When they accepted the invite
permissions ClubAdminPermission[]
@@id([clubId, userId])
}
model ClubMembership {
id Int @id @default(autoincrement())
userId Int
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
clubId Int
club Club @relation(fields: [clubId], references: [id], onDelete: Cascade)
clubTierId Int
clubTier ClubTier @relation(name: "activeClubTier", fields: [clubTierId], references: [id], onDelete: Cascade)
startedAt DateTime
expiresAt DateTime?
cancelledAt DateTime?
nextBillingAt DateTime
unitAmount Int
currency Currency @default(BUZZ)
downgradeClubTierId Int?
downgradeClubTier ClubTier? @relation(name: "downgradeClubTier", fields: [downgradeClubTierId], references: [id], onDelete: Cascade)
billingPausedAt DateTime?
@@unique([userId, clubId])
@@index([userId])
@@index([clubId])
}
model ClubMembershipCharge {
id Int @id @default(autoincrement())
userId Int
clubId Int
clubTierId Int
chargedAt DateTime
status String?
invoiceId String?
unitAmount Int
unitAmountPurchased Int
currency Currency @default(BUZZ)
}
model ClubPost {
id Int @id @default(autoincrement())
clubId Int
club Club @relation(fields: [clubId], references: [id])
createdById Int
createdBy User @relation(fields: [createdById], references: [id])
createdAt DateTime @default(now())
membersOnly Boolean
title String?
description String?
coverImageId Int?
coverImage Image? @relation(fields: [coverImageId], references: [id], onDelete: SetNull)
entityId Int?
entityType String?
thread Thread?
reactions ClubPostReaction[]
metrics ClubPostMetric[]
}
model ClubPostReaction {
id Int @id @default(autoincrement())
clubPostId Int
clubPost ClubPost @relation(fields: [clubPostId], references: [id], onDelete: Cascade)
userId Int
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
reaction ReviewReactions
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([clubPostId, userId, reaction])
}
model ClubPostMetric {
clubPost ClubPost @relation(fields: [clubPostId], references: [id], onDelete: Cascade)
clubPostId Int
timeframe MetricTimeframe
likeCount Int @default(0)
dislikeCount Int @default(0)
laughCount Int @default(0)
cryCount Int @default(0)
heartCount Int @default(0)
@@id([clubPostId, timeframe])
}
model ClubMetric {
club Club @relation(fields: [clubId], references: [id], onDelete: Cascade)
clubId Int
timeframe MetricTimeframe
memberCount Int @default(0)
clubPostCount Int @default(0)
resourceCount Int @default(0)
@@id([clubId, timeframe])
}
model Chat {
id Int @id @default(autoincrement())
createdAt DateTime @default(now())
// Hash of asc-sorted userIds, and the dedupe key for 1:1 conversations. Null
// for groups: a group is identified by its row id, so two groups may hold the
// same people. Postgres treats nulls as distinct, so the unique index still
// holds for the 1:1 rows it applies to.
hash String? @unique
ownerId Int
isGroup Boolean @default(false)
name String?
owner User @relation(fields: [ownerId], references: [id])
chatMembers ChatMember[]
messages ChatMessage[]
reports ChatReport[]
}
enum ChatMemberStatus {
Invited
Joined
Ignored
Left
Kicked
}
enum ChatNotifyLevel {
All
Mentions
None
}
model ChatMember {
id Int @id @default(autoincrement())
createdAt DateTime @default(now()) // doubles as invited_at
userId Int
chatId Int
isOwner Boolean @default(false)
isMuted Boolean @default(false)
status ChatMemberStatus
lastViewedMessageId Int?
joinedAt DateTime?
ignoredAt DateTime?
leftAt DateTime? // do we need a rejoin option?
kickedAt DateTime?
unkickedAt DateTime? // TODO maybe remove
filteredAt DateTime? // reached this member via Requests rather than their inbox
notifyLevel ChatNotifyLevel @default(All)
pinnedAt DateTime?
clearedAt DateTime? // this member's clean-slate watermark; messages before it are hidden from them only
user User @relation(fields: [userId], references: [id])
chat Chat @relation(fields: [chatId], references: [id])
lastViewedMessage ChatMessage? @relation(fields: [lastViewedMessageId], references: [id])
@@index([userId, status, isMuted])
@@index([userId, status, filteredAt])
}
enum ChatMessageType {
Markdown
Image
Video
Audio
Embed
}
model ChatMessage {
id Int @id @default(autoincrement())
createdAt DateTime @default(now())
userId Int // if -1, isSystemMessage
chatId Int
content String
contentType ChatMessageType @default(Markdown)
referenceMessageId Int?
editedAt DateTime?
deletedAt DateTime? // hidden from both sides; the row is retained for moderation
user User @relation(fields: [userId], references: [id])
chat Chat @relation(fields: [chatId], references: [id])
referenceMessage ChatMessage? @relation(name: "referenceMessage", fields: [referenceMessageId], references: [id])
referenceMessages ChatMessage[] @relation("referenceMessage")
lastViewedMessages ChatMember[]
@@index([chatId, id])
}
model BuildGuide {
id Int @id @default(autoincrement())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
name String
message String
userId Int
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
components Json
capabilities Json
}
enum PurchasableRewardUsage {
SingleUse
MultiUse
}
model PurchasableReward {
id Int @id @default(autoincrement())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
title String
unitPrice Int
about String
redeemDetails String
termsOfUse String
usage PurchasableRewardUsage
codes String[]
archived Boolean @default(false)
availableFrom DateTime?
availableTo DateTime?
availableCount Int?
addedById Int?
addedBy User? @relation(fields: [addedById], references: [id])
coverImageId Int?
coverImage Image? @relation(fields: [coverImageId], references: [id], onDelete: SetNull)
purchases UserPurchasedRewards[]
}
model UserPurchasedRewards {
buzzTransactionId String @id
userId Int?
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
purchasableRewardId Int?
purchasableReward PurchasableReward? @relation(fields: [purchasableRewardId], references: [id], onDelete: SetNull)
createdAt DateTime @default(now())
// We should store the product name & details in the meta at the time or purchase in case the reward is later on deleted - will be a good safeguard
meta Json @default("{}")
code String
}
enum EntityType {
Image
Post
Article
Bounty
BountyEntry
ModelVersion
Model
Collection
Comment
CommentV2
User
UserProfile
ResourceReview
ChatMessage
Model3D
}
enum JobQueueType {
CleanUp
UpdateMetrics
UpdateNsfwLevel
UpdateSearchIndex
CleanIfEmpty
ModerationRequest
BlockedImageDelete
ImageScan
ReplacedImageDelete
}
model JobQueue {
type JobQueueType
entityType EntityType
entityId Int
createdAt DateTime @default(now())
@@id([entityType, entityId, type])
}
enum VaultItemStatus {
Pending
Stored
Failed
}
model VaultItem {
id Int @id @default(autoincrement())
vaultId Int
vault Vault @relation(fields: [vaultId], references: [userId], onDelete: Cascade)
status VaultItemStatus @default(Pending)
// We'll store some file details here as a JSON array of objects
files Json @default("[]")
// These are not FKs because they will tie to the downloadable file even after the model/version is deleted
modelVersionId Int
modelId Int
modelName String
versionName String
creatorId Int?
creator User? @relation(fields: [creatorId], references: [id], onDelete: SetNull)
creatorName String
type ModelType
baseModel String
category String
createdAt DateTime @default(now())
addedAt DateTime @default(now())
refreshedAt DateTime?
modelSizeKb Int
detailsSizeKb Int
imagesSizeKb Int
notes String?
meta Json @default("{}")
@@index([vaultId], type: Hash)
@@index([modelVersionId], type: Hash)
}
model Vault {
userId Int
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
storageKb Int
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
meta Json @default("{}")
items VaultItem[]
@@id([userId])
}
enum RedeemableCodeType {
Buzz
Membership
}
model RedeemableCode {
code String @id @default(cuid())
unitValue Int
userId Int?
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
createdAt DateTime @default(now())
type RedeemableCodeType
expiresAt DateTime?
redeemedAt DateTime?
transactionId String?
metadata Json?
priceId String?
price Price? @relation(fields: [priceId], references: [id], onDelete: SetNull)
@@index([userId, createdAt(sort: Desc)])
}
enum ToolType {
Image
Video
MotionCapture
Upscalers
Audio
Compute
GameEngines
Editor
LLM
}
model Tool {
id Int @id @default(autoincrement())
name String
icon String?
createdAt DateTime @default(now())
enabled Boolean @default(true)
unlisted Boolean @default(false)
type ToolType
domain String?
imageTools ImageTool[]
priority Int?
description String?
supported Boolean @default(false)
company String?
metadata Json @default("{}")
alias String?
}
model ImageTool {
imageId Int
image Image @relation(fields: [imageId], references: [id], onDelete: Cascade)
toolId Int
tool Tool @relation(fields: [toolId], references: [id], onDelete: Cascade)
notes String?
createdAt DateTime @default(now())
@@id([imageId, toolId])
@@index([toolId])
}
enum TechniqueType {
Image
Video
}
model Technique {
id Int @id @default(autoincrement())
name String
createdAt DateTime @default(now())
enabled Boolean @default(true)
type TechniqueType
imageTechniques ImageTechnique[]
}
model ImageTechnique {
imageId Int
image Image @relation(fields: [imageId], references: [id], onDelete: Cascade)
techniqueId Int
technique Technique @relation(fields: [techniqueId], references: [id], onDelete: Cascade)
notes String?
createdAt DateTime @default(now())
@@id([imageId, techniqueId])
@@index([techniqueId])
}
model DonationGoal {
id Int @id @default(autoincrement())
userId Int
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
title String
description String?
goalAmount Int
// Polymorphic target (mirrors PaidAccess). Nullable while modelVersionId is dual-written;
// becomes the sole target when modelVersionId is dropped (re-key migration).
entityType PaidAccessEntityType?
entityId Int?
modelVersionId Int?
modelVersion ModelVersion? @relation(fields: [modelVersionId], references: [id], onDelete: SetNull)
createdAt DateTime @default(now())
active Boolean @default(true)
donations Donation[]
@@index([entityType, entityId])
}
model Donation {
id Int @id @default(autoincrement())
userId Int
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
donationGoalId Int
donationGoal DonationGoal @relation(fields: [donationGoalId], references: [id], onDelete: Cascade)
amount Int
buzzTransactionId String
notes String?
createdAt DateTime @default(now())
}
model Blocklist {
id Int @id @default(autoincrement())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
type String
data String[]
}
enum AppealStatus {
Pending
Approved
Rejected
}
model Appeal {
id Int @id @default(autoincrement())
userId Int
user User @relation("submittedAppeals", fields: [userId], references: [id], onDelete: Cascade)
entityType EntityType
entityId Int
status AppealStatus @default(Pending)
appealMessage String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
resolvedAt DateTime?
resolvedBy Int?
resolvedByUser User? @relation("resolvedAppeals", fields: [resolvedBy], references: [id], onDelete: SetNull)
resolvedMessage String?
internalNotes String?
buzzTransactionId String?
@@unique([entityType, entityId, userId])
@@index([userId])
@@index([status])
}
enum AuctionType {
Model
Image
Collection
Article
}
model AuctionBase {
id Int @id @default(autoincrement())
type AuctionType
ecosystem String? // like Pony, SDXL, etc
name String
slug String
quantity Int // propagates to individual item
minPrice Int // propagates to individual item
// buyItNowPrices Json? // would probably need to be JSON to support different slot costs...
active Boolean @default(true)
runForDays Int @default(1)
validForDays Int @default(1)
description String?
auctions Auction[]
recurringBids BidRecurring[]
@@unique([type, ecosystem])
@@unique([name])
@@unique([slug])
}
model Auction {
id Int @id @default(autoincrement())
auctionBaseId Int
auctionBase AuctionBase @relation(fields: [auctionBaseId], references: [id], onDelete: Cascade)
startAt DateTime
endAt DateTime
quantity Int
minPrice Int
validFrom DateTime
validTo DateTime
finalized Boolean @default(false)
// buyItNowPrices Json? // would probably need to be JSON to support different slot costs...
// sold Int
bids Bid[]
@@unique([auctionBaseId, startAt])
}
model Bid {
id Int @id @default(autoincrement())
auctionId Int
auction Auction @relation(fields: [auctionId], references: [id], onDelete: Cascade)
userId Int
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
entityId Int
amount Int
createdAt DateTime @default(now())
deleted Boolean @default(false)
transactionIds String[] @default([])
isRefunded Boolean @default(false)
fromRecurring Boolean @default(false)
accountType String @default("yellow")
@@unique([auctionId, userId, entityId, accountType])
}
model BidRecurring {
id Int @id @default(autoincrement())
auctionBaseId Int
auctionBase AuctionBase @relation(fields: [auctionBaseId], references: [id], onDelete: Cascade)
userId Int
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
entityId Int
amount Int
createdAt DateTime @default(now())
startAt DateTime
endAt DateTime?
isPaused Boolean @default(false)
accountType String @default("yellow")
@@unique([auctionBaseId, userId, entityId, accountType])
}
model FeaturedModelVersion {
id Int @id @default(autoincrement())
modelVersionId Int
modelVersion ModelVersion @relation(fields: [modelVersionId], references: [id], onDelete: Cascade)
validFrom DateTime
validTo DateTime
position Int
}
model CoveredCheckpoint {
model_id Int
version_id Int
model Model @relation(fields: [model_id], references: [id], onDelete: Cascade)
modelVersion ModelVersion @relation(fields: [version_id], references: [id], onDelete: Cascade)
@@id([model_id, version_id])
}
enum ModerationRuleAction {
Approve
Block
Hold
}
model ModerationRule {
id Int @id @default(autoincrement())
entityType EntityType
definition Json
action ModerationRuleAction
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
enabled Boolean @default(true)
order Int?
reason String?
createdById Int
createdBy User @relation(fields: [createdById], references: [id], onDelete: Cascade)
}
enum ChangelogType {
Feature
Bugfix
Policy
Update
Incident
}
model Changelog {
id Int @id @default(autoincrement())
title String
content String
link String? // commit or article URL
cta String? // if feature, link to it
effectiveAt DateTime
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
type ChangelogType
tags String[] @default([])
disabled Boolean @default(false)
titleColor String?
sticky Boolean @default(false)
domain DomainColor[] @default([all])
}
model Bug {
id Int @id @default(autoincrement())
title String
summary String
content String?
status String @default("Open")
clickupUrl String?
firstSeenAt DateTime @default(now())
resolvedAt DateTime?
publishedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
disabled Boolean @default(false)
domain DomainColor[] @default([all])
tags String[] @default([])
feedback Feedback[]
@@index([status, publishedAt])
}
model NewOrderPlayer {
userId Int @id @unique
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
rankType NewOrderRankType
rank NewOrderRank @relation(fields: [rankType], references: [type], onDelete: Cascade)
startAt DateTime @default(now())
exp Int @default(0)
fervor Int @default(0)
smiteReceived NewOrderSmite[] @relation("smiteReceived")
smiteGiven NewOrderSmite[] @relation("smiteGiven")
}
enum NewOrderRankType {
Acolyte
Knight
Templar
}
model NewOrderRank {
type NewOrderRankType @id
name String
minExp Int
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
iconUrl String?
players NewOrderPlayer[]
}
model NewOrderSmite {
id Int @id @default(autoincrement())
targetPlayerId Int
targetPlayer NewOrderPlayer @relation("smiteReceived", fields: [targetPlayerId], references: [userId], onDelete: Cascade)
givenById Int
givenBy NewOrderPlayer @relation("smiteGiven", fields: [givenById], references: [userId], onDelete: Cascade)
size Int
remaining Int
reason String?
createdAt DateTime @default(now())
cleansedAt DateTime?
cleansedReason String?
}
model ReportAutomated {
id Int @id @default(autoincrement())
reportId Int @unique
report Report @relation(fields: [reportId], references: [id], onDelete: Cascade)
metadata Json @default("{}")
createdAt DateTime @default(now())
}
model RestrictedBaseModels {
baseModel String @id
}
// ============================================
// Challenge System
// ============================================
// Entries are stored as CollectionItems in the linked Contest Collection.
// This reuses existing collection infrastructure for submissions, scoring, and moderation.
enum ChallengeSource {
System // Auto-generated by system job
Mod // Created by moderator
User // Created by regular user (future)
}
enum ChallengeStatus {
Scheduled // Funded and waiting for startsAt
Active // Currently accepting submissions
Completing // Winner picking in progress (prevents duplicate processing)
Completed // Winners announced
Cancelled // Cancelled before completion
}
enum PrizeMode {
Fixed
Dynamic
}
enum PoolTrigger {
Entry
User
}
enum ChallengeReviewCostType {
None // No paid review
PerEntry // Cost per individual entry
Flat // Single flat rate for all entries
}
enum ChallengeIngestionStatus {
Pending // Awaiting text+cover scan; hidden from public feeds
Scanned // Passed scan, nsfwLevel assigned, publicly visible
Blocked // Hard-blocked (hate/CSAM/etc.); never shown
Error // Scan failed; needs retry
}
model Challenge {
id Int @id @default(autoincrement())
// Timing
startsAt DateTime // When submissions open
endsAt DateTime // When submissions close
visibleAt DateTime // When challenge appears in public feed
// Content
title String
description String? @db.Text
theme String? // Short theme (e.g., "Neon Dreams")
invitation String? // Tagline to invite participants
coverImageId Int?
coverImage Image? @relation("ChallengeCoverImage", fields: [coverImageId], references: [id], onDelete: SetNull)
nsfwLevel Int @default(1)
// Required model versions (optional - empty means any allowed)
modelVersionIds Int[] @default([]) // Array of allowed model version IDs (OR logic)
// Entry requirements
allowedNsfwLevel Int @default(1) // Bitwise NSFW levels allowed (1=PG, 2=PG13, 4=R, etc.)
// Judging Configuration
judgingPrompt String? @db.Text // Custom AI judging prompt (mod/system only)
judgingCategories Json? // User challenges: [{name, criteria}]; drives dynamic score schema + public display
reviewPercentage Int @default(100) // % of entries to score
maxReviews Int? // Hard cap on reviews (cost control)
judgingEngine String @default("legacy-absolute") @db.VarChar(50) // which engine ranks the field
// Entries - stored in a Contest Collection (auto-created if not provided)
collectionId Int? // Optional - auto-created on challenge creation
collection Collection? @relation(fields: [collectionId], references: [id], onDelete: SetNull)
maxEntriesPerUser Int @default(20) // Max submissions per participant
maxParticipants Int? // Max distinct participants (null = unlimited); bounds entry-fee judging cost
// Prizes & Costs
prizes Json @default("[]") // [{buzz: 5000, points: 150}, ...]
entryPrize Json? // {buzz: 200, points: 10} for participation
entryPrizeRequirement Int @default(10) // Min entries to qualify for entry prize
prizePool Int @default(0) // Total buzz for prizes
prizeMode PrizeMode @default(Fixed)
basePrizePool Int @default(0)
buzzPerAction Int @default(0)
poolTrigger PoolTrigger? // null for Fixed mode
maxPrizePool Int?
prizeDistribution Json? // null for Fixed mode; e.g. [50, 30, 20]
operationBudget Int @default(0) // Budget for AI review costs
operationSpent Int @default(0) // Actual spent (use atomic increment)
reviewCostType ChallengeReviewCostType @default(None) // Type of paid review pricing
reviewCost Int @default(0) // Buzz cost (per entry or flat, depending on reviewCostType)
entryFee Int @default(0) // Buzz charged per entry to participate (0 = free). Net to pool = entryFee - platform cut
buzzType String @default("yellow")
// Ownership & Source
createdById Int?
createdBy User? @relation(fields: [createdById], references: [id], onDelete: SetNull)
source ChallengeSource @default(System)
// Judge
judgeId Int?
judge ChallengeJudge? @relation(fields: [judgeId], references: [id], onDelete: SetNull)
// Lifecycle
status ChallengeStatus @default(Scheduled)
// Moderation / scan gating (user challenges hidden until Scanned). Default Scanned so
// existing + system/mod challenges stay visible without a backfill blackout. Named `ingestion`
// to match the other scanned entities (Image.ingestion, Article.ingestion).
ingestion ChallengeIngestionStatus @default(Scanned)
scannedAt DateTime?
// Metadata
metadata Json?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Relations
winners ChallengeWinner[]
threads Thread[]
reports ChallengeReport[]
engagements ChallengeEngagement[]
standings ChallengeEntryStanding[]
comparisons ChallengeEntryComparison[]
// Event grouping
eventId Int?
event ChallengeEvent? @relation(fields: [eventId], references: [id], onDelete: SetNull)
// Compound indexes for efficient feed queries
@@index([status, endsAt]) // "Ending Soon" feed
@@index([status, startsAt]) // "Just Started" feed
@@index([status, visibleAt]) // "Upcoming" feed
@@index([status, ingestion]) // public feed excludes non-Scanned challenges
@@index([createdById, status]) // User's challenges by status
@@index([judgeId])
@@index([eventId])
}
// Pairwise judging (Challenge.judgingEngine = "pairwise-ladder"). Owned entirely by that engine:
// the legacy absolute engine neither reads nor writes these tables, and nothing is migrated into
// them from CollectionItem.note.
//
// imageId / userId are deliberately PLAIN COLUMNS with no relation — do not add one. Image is huge
// and delete-heavy (moderation, ban removals); an FK would need indexes on imageId, imageIdA and
// imageIdB to keep those deletes off a sequential scan, and cascading would destroy the record of
// what the judge decided. Users are soft-deleted, so a userId FK enforces nothing. Orphans are
// inert — nothing outside the store reads standings, and the challenge cascade cleans both tables.
// Full reasoning in migrations/20260811090000_challenge_judging_engines/migration.sql.
model ChallengeEntryStanding {
challengeId Int
challenge Challenge @relation(fields: [challengeId], references: [id], onDelete: Cascade)
imageId Int
userId Int
rank Int // 1 = leader
comparisons Int @default(0) // bouts this entry has been in
winRate Float? // podium round-robin only; null outside the shortlist
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
@@id([challengeId, imageId])
@@index([challengeId, rank])
}
model ChallengeEntryComparison {
id Int @id @default(autoincrement())
challengeId Int
challenge Challenge @relation(fields: [challengeId], references: [id], onDelete: Cascade)
phase String @db.VarChar(20) // arrive | rerun | podium
// Pair columns are stored low-id first so one pair is one row whichever image was the
// challenger; firstSeatImageId is the seating that produced this verdict.
imageIdA Int
imageIdB Int
firstSeatImageId Int
winnerImageId Int? // null = tie
margin String? @db.VarChar(20)
model String @db.VarChar(200)
rerouted Boolean @default(false) // routed judge refused; answered by the permissive one
perCategory Json?
reason String? @db.Text
buzzCost Int @default(0)
createdAt DateTime @default(now())
@@unique([challengeId, phase, imageIdA, imageIdB, firstSeatImageId], map: "ChallengeEntryComparison_pair_key")
@@index([challengeId, phase])
}
model ChallengeReport {
challengeId Int
challenge Challenge @relation(fields: [challengeId], references: [id], onDelete: Cascade)
reportId Int @unique
report Report @relation(fields: [reportId], references: [id], onDelete: Cascade)
@@id([reportId, challengeId])
@@index([challengeId], type: Hash)
}
model AnnouncementReport {
announcementId Int
announcement Announcement @relation(fields: [announcementId], references: [id], onDelete: Cascade)
reportId Int @unique
report Report @relation(fields: [reportId], references: [id], onDelete: Cascade)
@@id([reportId, announcementId])
@@index([announcementId], type: Hash)
}
model ChallengeJudge {
id Int @id @default(autoincrement())
userId Int
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
name String @db.VarChar(255)
bio String? @db.Text
sourceCollectionId Int? // Collection to pick model resources from for this challenge type
systemPrompt String? @db.Text
collectionPrompt String? @db.Text
contentPrompt String? @db.Text
reviewPrompt String? @db.Text
reviewTemplate String? @db.Text // JSON message template (agent-workbench format)
winnerSelectionPrompt String? @db.Text
active Boolean @default(true)
userSelectable Boolean @default(false) // offer this judge to users in the create form
judgingEngine String @default("legacy-absolute") @db.VarChar(50) // copied onto Challenge.judgingEngine at creation
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
challenges Challenge[]
@@index([userId])
@@index([active])
}
// Judging-category library for challenges. label/group/criteria feed the client picker;
// rubric/rubricNsfw are server-only LLM prompt content, managed directly in the DB per
// environment (same handling as ChallengeJudge prompts).
model ChallengeCategory {
key String @id @db.VarChar(50)
label String @db.VarChar(100)
group String @db.VarChar(100)
criteria String @db.Text
rubric String? @db.Text
rubricNsfw String? @db.Text
sortOrder Int @default(0)
active Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([active])
}
enum ChallengeEngagementType {
Notify
}
model ChallengeEngagement {
userId Int
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
challengeId Int
challenge Challenge @relation(fields: [challengeId], references: [id], onDelete: Cascade)
type ChallengeEngagementType
createdAt DateTime @default(now())
@@id([type, challengeId, userId])
@@index([challengeId])
@@index([userId], type: Hash)
}
model ChallengeWinner {
id Int @id @default(autoincrement())
challengeId Int
challenge Challenge @relation(fields: [challengeId], references: [id], onDelete: Cascade)
userId Int
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
imageId Int?
image Image? @relation(fields: [imageId], references: [id], onDelete: SetNull)
place Int // 1, 2, 3, etc. (ties allowed - same place, different users)
buzzAwarded Int
pointsAwarded Int
reason String? @db.Text // AI explanation for placement
createdAt DateTime @default(now())
// Allow ties (multiple users can have same place), but user can only win once per challenge
@@unique([challengeId, userId])
@@index([challengeId])
@@index([userId])
@@index([createdAt])
}
model ChallengeEvent {
id Int @id @default(autoincrement())
title String
description String? @db.Text
titleColor String?
startDate DateTime
endDate DateTime
active Boolean @default(true)
winnerCooldownDays Int?
coverImageId Int?
coverImage Image? @relation("ChallengeEventCover", fields: [coverImageId], references: [id], onDelete: SetNull)
createdById Int?
createdBy User? @relation(fields: [createdById], references: [id], onDelete: SetNull)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
challenges Challenge[]
@@index([active, endDate])
}
/// @view
model QuestionRank {
questionId Int @id
question Question @relation(fields: [questionId], references: [id], onDelete: NoAction)
answerCountDay Int
answerCountWeek Int
answerCountMonth Int
answerCountYear Int
answerCountAllTime Int
heartCountDay Int
heartCountWeek Int
heartCountMonth Int
heartCountYear Int
heartCountAllTime Int
commentCountDay Int
commentCountWeek Int
commentCountMonth Int
commentCountYear Int
commentCountAllTime Int
answerCountDayRank Int
answerCountWeekRank Int
answerCountMonthRank Int
answerCountYearRank Int
answerCountAllTimeRank Int
heartCountDayRank Int
heartCountWeekRank Int
heartCountMonthRank Int
heartCountYearRank Int
heartCountAllTimeRank Int
commentCountDayRank Int
commentCountWeekRank Int
commentCountMonthRank Int
commentCountYearRank Int
commentCountAllTimeRank Int
}
/// @view
model AnswerRank {
answerId Int @id
answer Answer @relation(fields: [answerId], references: [id], onDelete: NoAction)
checkCountDay Int
checkCountWeek Int
checkCountMonth Int
checkCountYear Int
checkCountAllTime Int
crossCountDay Int
crossCountWeek Int
crossCountMonth Int
crossCountYear Int
crossCountAllTime Int
heartCountDay Int
heartCountWeek Int
heartCountMonth Int
heartCountYear Int
heartCountAllTime Int
commentCountDay Int
commentCountWeek Int
commentCountMonth Int
commentCountYear Int
commentCountAllTime Int
checkCountDayRank Int
checkCountWeekRank Int
checkCountMonthRank Int
checkCountYearRank Int
checkCountAllTimeRank Int
crossCountDayRank Int
crossCountWeekRank Int
crossCountMonthRank Int
crossCountYearRank Int
crossCountAllTimeRank Int
heartCountDayRank Int
heartCountWeekRank Int
heartCountMonthRank Int
heartCountYearRank Int
heartCountAllTimeRank Int
commentCountDayRank Int
commentCountWeekRank Int
commentCountMonthRank Int
commentCountYearRank Int
commentCountAllTimeRank Int
}
/// @view
model ModelReportStat {
model Model @relation(fields: [modelId], references: [id], onDelete: NoAction)
modelId Int @id
tosViolationPending Int
tosViolationUnactioned Int
tosViolationActioned Int
nsfwPending Int
nsfwUnactioned Int
nsfwActioned Int
ownershipPending Int
ownershipProcessing Int
ownershipActioned Int
ownershipUnactioned Int
adminAttentionPending Int
adminAttentionActioned Int
adminAttentionUnactioned Int
claimPending Int
claimActioned Int
claimUnactioned Int
}
/// @view
model ArticleStat {
articleId Int @id
article Article @relation(fields: [articleId], references: [id], onDelete: NoAction)
cryCountDay Int @default(0)
cryCountWeek Int @default(0)
cryCountMonth Int @default(0)
cryCountYear Int @default(0)
cryCountAllTime Int @default(0)
dislikeCountDay Int @default(0)
dislikeCountWeek Int @default(0)
dislikeCountMonth Int @default(0)
dislikeCountYear Int @default(0)
dislikeCountAllTime Int @default(0)
heartCountDay Int @default(0)
heartCountWeek Int @default(0)
heartCountMonth Int @default(0)
heartCountYear Int @default(0)
heartCountAllTime Int @default(0)
laughCountDay Int @default(0)
laughCountWeek Int @default(0)
laughCountMonth Int @default(0)
laughCountYear Int @default(0)
laughCountAllTime Int @default(0)
likeCountDay Int @default(0)
likeCountWeek Int @default(0)
likeCountMonth Int @default(0)
likeCountYear Int @default(0)
likeCountAllTime Int @default(0)
commentCountDay Int @default(0)
commentCountWeek Int @default(0)
commentCountMonth Int @default(0)
commentCountYear Int @default(0)
commentCountAllTime Int @default(0)
reactionCountDay Int @default(0)
reactionCountWeek Int @default(0)
reactionCountMonth Int @default(0)
reactionCountYear Int @default(0)
reactionCountAllTime Int @default(0)
viewCountDay Int @default(0)
viewCountWeek Int @default(0)
viewCountMonth Int @default(0)
viewCountYear Int @default(0)
viewCountAllTime Int @default(0)
favoriteCountDay Int @default(0)
favoriteCountWeek Int @default(0)
favoriteCountMonth Int @default(0)
favoriteCountYear Int @default(0)
favoriteCountAllTime Int @default(0)
collectedCountDay Int @default(0)
collectedCountWeek Int @default(0)
collectedCountMonth Int @default(0)
collectedCountYear Int @default(0)
collectedCountAllTime Int @default(0)
hideCountDay Int @default(0)
hideCountWeek Int @default(0)
hideCountMonth Int @default(0)
hideCountYear Int @default(0)
hideCountAllTime Int @default(0)
tippedCountDay Int @default(0)
tippedCountWeek Int @default(0)
tippedCountMonth Int @default(0)
tippedCountYear Int @default(0)
tippedCountAllTime Int @default(0)
tippedAmountCountDay Int @default(0)
tippedAmountCountWeek Int @default(0)
tippedAmountCountMonth Int @default(0)
tippedAmountCountYear Int @default(0)
tippedAmountCountAllTime Int @default(0)
}
/// @view
model ArticleRank {
articleId Int @id
article Article @relation(fields: [articleId], references: [id], onDelete: Cascade)
cryCountDayRank Int? @default(0)
cryCountWeekRank Int? @default(0)
cryCountMonthRank Int? @default(0)
cryCountYearRank Int? @default(0)
cryCountAllTimeRank Int? @default(0)
dislikeCountDayRank Int? @default(0)
dislikeCountWeekRank Int? @default(0)
dislikeCountMonthRank Int? @default(0)
dislikeCountYearRank Int? @default(0)
dislikeCountAllTimeRank Int? @default(0)
heartCountDayRank Int? @default(0)
heartCountWeekRank Int? @default(0)
heartCountMonthRank Int? @default(0)
heartCountYearRank Int? @default(0)
heartCountAllTimeRank Int? @default(0)
laughCountDayRank Int? @default(0)
laughCountWeekRank Int? @default(0)
laughCountMonthRank Int? @default(0)
laughCountYearRank Int? @default(0)
laughCountAllTimeRank Int? @default(0)
likeCountDayRank Int? @default(0)
likeCountWeekRank Int? @default(0)
likeCountMonthRank Int? @default(0)
likeCountYearRank Int? @default(0)
likeCountAllTimeRank Int? @default(0)
commentCountDayRank Int? @default(0)
commentCountWeekRank Int? @default(0)
commentCountMonthRank Int? @default(0)
commentCountYearRank Int? @default(0)
commentCountAllTimeRank Int? @default(0)
reactionCountDayRank Int? @default(0)
reactionCountWeekRank Int? @default(0)
reactionCountMonthRank Int? @default(0)
reactionCountYearRank Int? @default(0)
reactionCountAllTimeRank Int? @default(0)
viewCountDayRank Int? @default(0)
viewCountWeekRank Int? @default(0)
viewCountMonthRank Int? @default(0)
viewCountYearRank Int? @default(0)
viewCountAllTimeRank Int? @default(0)
favoriteCountDayRank Int? @default(0)
favoriteCountWeekRank Int? @default(0)
favoriteCountMonthRank Int? @default(0)
favoriteCountYearRank Int? @default(0)
favoriteCountAllTimeRank Int? @default(0)
hideCountDayRank Int? @default(0)
hideCountWeekRank Int? @default(0)
hideCountMonthRank Int? @default(0)
hideCountYearRank Int? @default(0)
hideCountAllTimeRank Int? @default(0)
collectedCountDayRank Int? @default(0)
collectedCountWeekRank Int? @default(0)
collectedCountMonthRank Int? @default(0)
collectedCountYearRank Int? @default(0)
collectedCountAllTimeRank Int? @default(0)
tippedCountDayRank Int? @default(0)
tippedCountWeekRank Int? @default(0)
tippedCountMonthRank Int? @default(0)
tippedCountYearRank Int? @default(0)
tippedCountAllTimeRank Int? @default(0)
tippedAmountCountDayRank Int? @default(0)
tippedAmountCountWeekRank Int? @default(0)
tippedAmountCountMonthRank Int? @default(0)
tippedAmountCountYearRank Int? @default(0)
tippedAmountCountAllTimeRank Int? @default(0)
}
/// @view
model UserStat {
user User @relation(fields: [userId], references: [id], onDelete: NoAction)
userId Int @id
uploadCountAllTime Int
reviewCountAllTime Int
downloadCountAllTime Int
generationCountAllTime Int
followingCountAllTime Int
followerCountAllTime Int
hiddenCountAllTime Int
answerCountAllTime Int
answerAcceptCountAllTime Int
thumbsUpCountAllTime Int
thumbsDownCountAllTime Int
reactionCountAllTime Int
}
/// @view
model UserRank {
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
userId Int @id
leaderboardRank Int?
leaderboardId String?
leaderboardTitle String?
leaderboardCosmetic String?
}
/// @view
model TagStat {
tag Tag @relation(fields: [tagId], references: [id], onDelete: NoAction)
tagId Int @id
followerCountDay Int
followerCountWeek Int
followerCountMonth Int
followerCountYear Int
followerCountAllTime Int
hiddenCountDay Int
hiddenCountWeek Int
hiddenCountMonth Int
hiddenCountYear Int
hiddenCountAllTime Int
modelCountDay Int
modelCountWeek Int
modelCountMonth Int
modelCountYear Int
modelCountAllTime Int
imageCountDay Int
imageCountWeek Int
imageCountMonth Int
imageCountYear Int
imageCountAllTime Int
postCountDay Int
postCountWeek Int
postCountMonth Int
postCountYear Int
postCountAllTime Int
}
/// @view
model TagRank {
tag Tag @relation(fields: [tagId], references: [id], onDelete: Cascade)
tagId Int @id
followerCountDayRank Int? @default(0)
followerCountWeekRank Int? @default(0)
followerCountMonthRank Int? @default(0)
followerCountYearRank Int? @default(0)
followerCountAllTimeRank Int? @default(0)
hiddenCountDayRank Int? @default(0)
hiddenCountWeekRank Int? @default(0)
hiddenCountMonthRank Int? @default(0)
hiddenCountYearRank Int? @default(0)
hiddenCountAllTimeRank Int? @default(0)
modelCountDayRank Int? @default(0)
modelCountWeekRank Int? @default(0)
modelCountMonthRank Int? @default(0)
modelCountYearRank Int? @default(0)
modelCountAllTimeRank Int? @default(0)
imageCountDayRank Int? @default(0)
imageCountWeekRank Int? @default(0)
imageCountMonthRank Int? @default(0)
imageCountYearRank Int? @default(0)
imageCountAllTimeRank Int? @default(0)
postCountDayRank Int? @default(0)
postCountWeekRank Int? @default(0)
postCountMonthRank Int? @default(0)
postCountYearRank Int? @default(0)
postCountAllTimeRank Int? @default(0)
articleCountDayRank Int? @default(0)
articleCountWeekRank Int? @default(0)
articleCountMonthRank Int? @default(0)
articleCountYearRank Int? @default(0)
articleCountAllTimeRank Int? @default(0)
}
/// @view
/// @view
model ImageModHelper {
imageId Int @id
image Image @relation(fields: [imageId], references: [id], onDelete: NoAction)
assessedNSFW Boolean? @default(false)
nsfwReportCount Int @default(0)
}
/// @view
model ModelHash {
modelId Int @id
model Model @relation(fields: [modelId], references: [id])
modelVersionId Int
modelVersion ModelVersion @relation(fields: [modelVersionId], references: [id])
hashType ModelHashType
fileType String
hash String
}
/// @view
model PostHelper {
postId Int @id
post Post @relation(fields: [postId], references: [id], onDelete: NoAction)
scanned Boolean
}
/// @view
model PostStat {
postId Int @id
post Post @relation(fields: [postId], references: [id], onDelete: NoAction)
cryCountDay Int @default(0)
cryCountWeek Int @default(0)
cryCountMonth Int @default(0)
cryCountYear Int @default(0)
cryCountAllTime Int @default(0)
dislikeCountDay Int @default(0)
dislikeCountWeek Int @default(0)
dislikeCountMonth Int @default(0)
dislikeCountYear Int @default(0)
dislikeCountAllTime Int @default(0)
heartCountDay Int @default(0)
heartCountWeek Int @default(0)
heartCountMonth Int @default(0)
heartCountYear Int @default(0)
heartCountAllTime Int @default(0)
laughCountDay Int @default(0)
laughCountWeek Int @default(0)
laughCountMonth Int @default(0)
laughCountYear Int @default(0)
laughCountAllTime Int @default(0)
likeCountDay Int @default(0)
likeCountWeek Int @default(0)
likeCountMonth Int @default(0)
likeCountYear Int @default(0)
likeCountAllTime Int @default(0)
commentCountDay Int @default(0)
commentCountWeek Int @default(0)
commentCountMonth Int @default(0)
commentCountYear Int @default(0)
commentCountAllTime Int @default(0)
reactionCountDay Int @default(0)
reactionCountWeek Int @default(0)
reactionCountMonth Int @default(0)
reactionCountYear Int @default(0)
reactionCountAllTime Int @default(0)
}
/// @view
model CollectionStat {
collection Collection @relation(fields: [collectionId], references: [id], onDelete: NoAction)
collectionId Int @id
followerCountDay Int
followerCountWeek Int
followerCountMonth Int
followerCountYear Int
followerCountAllTime Int
itemCountDay Int
itemCountWeek Int
itemCountMonth Int
itemCountYear Int
itemCountAllTime Int
contributorCountDay Int
contributorCountWeek Int
contributorCountMonth Int
contributorCountYear Int
contributorCountAllTime Int
}
/// @view
model CollectionRank {
collectionId Int @id
collection Collection @relation(fields: [collectionId], references: [id], onDelete: Cascade)
followerCountDayRank Int? @default(0)
followerCountWeekRank Int? @default(0)
followerCountMonthRank Int? @default(0)
followerCountYearRank Int? @default(0)
followerCountAllTimeRank Int? @default(0)
itemCountDayRank Int? @default(0)
itemCountWeekRank Int? @default(0)
itemCountMonthRank Int? @default(0)
itemCountYearRank Int? @default(0)
itemCountAllTimeRank Int? @default(0)
contributorCountDayRank Int? @default(0)
contributorCountWeekRank Int? @default(0)
contributorCountMonthRank Int? @default(0)
contributorCountYearRank Int? @default(0)
contributorCountAllTimeRank Int? @default(0)
}
/// @view
model ImageTag {
imageId Int
image Image @relation(fields: [imageId], references: [id], onDelete: Cascade)
tagId Int
tag Tag @relation(fields: [tagId], references: [id], onDelete: Cascade)
tagName String
tagType TagType
tagNsfw NsfwLevel
tagNsfwLevel Int
automated Boolean
confidence Int?
score Int
upVotes Int
downVotes Int
needsReview Boolean
concrete Boolean
lastUpvote DateTime?
source TagSource
@@id([imageId, tagId])
}
/// @view
model ModelTag {
modelId Int
model Model @relation(fields: [modelId], references: [id], onDelete: Cascade)
tagId Int
tag Tag @relation(fields: [tagId], references: [id], onDelete: Cascade)
tagName String
tagType TagType
score Int
upVotes Int
downVotes Int
needsReview Boolean
@@id([modelId, tagId])
}
/// @view
model ImageResourceHelper {
imageId Int
image Image @relation(fields: [imageId], references: [id])
reviewId Int?
reviewRating Int?
reviewDetails String?
reviewCreatedAt DateTime?
name String?
modelVersionId Int
modelVersionName String?
modelVersionCreatedAt DateTime?
modelId Int?
modelName String?
modelDownloadCount Int?
modelCommentCount Int?
modelThumbsUpCount Int?
modelThumbsDownCount Int?
modelType ModelType?
modelVersionBaseModel String?
detected Boolean?
@@id([imageId, modelVersionId])
}
/// @view
model PostResourceHelper {
postId Int
post Post @relation(fields: [postId], references: [id])
reviewId Int?
reviewRating Int?
reviewRecommended Boolean?
reviewDetails String?
reviewCreatedAt DateTime?
name String?
imageId Int
modelVersionId Int
modelVersionName String?
modelVersionCreatedAt DateTime?
modelId Int?
modelName String?
modelDownloadCount Int?
modelCommentCount Int?
modelThumbsUpCount Int?
modelThumbsDownCount Int?
modelType ModelType?
@@id([imageId, modelVersionId])
@@unique([postId, name, modelVersionId])
}
/// @view
model PostImageTag {
postId Int
post Post @relation(fields: [postId], references: [id], onDelete: NoAction)
tagId Int
@@id([postId, tagId])
}
/// @view
model PostTag {
postId Int
post Post @relation(fields: [postId], references: [id])
tagId Int
tag Tag @relation(fields: [tagId], references: [id], onDelete: NoAction)
tagName String
tagType TagType
score Int
upVotes Int
downVotes Int
@@id([postId, tagId])
}
/// @view
model ResourceReviewHelper {
resourceReviewId Int @id
resourceReview ResourceReview @relation(fields: [resourceReviewId], references: [id])
imageCount Int
}
/// @view
model GenerationCoverage {
modelId Int
model Model @relation(fields: [modelId], references: [id])
modelVersionId Int @unique
modelVersion ModelVersion @relation(fields: [modelVersionId], references: [id])
covered Boolean
@@id([modelId, modelVersionId])
}
model UserProfile {
userId Int
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
coverImageId Int?
coverImage Image? @relation(fields: [coverImageId], references: [id], onDelete: SetNull)
bio String?
message String?
messageAddedAt DateTime?
// SFW (green domain) overrides. Null means "inherit the field above"; a set
// value replaces it on civitai.com only.
sfwCoverImageId Int?
sfwCoverImage Image? @relation("UserProfileSfwCoverImage", fields: [sfwCoverImageId], references: [id], onDelete: SetNull)
sfwBio String?
sfwMessage String?
sfwMessageAddedAt DateTime?
location String?
nsfw Boolean @default(false)
privacySettings Json @default("{\"showFollowerCount\":true,\"showFollowingCount\":true,\"showReviewsRating\":true}")
profileSectionsSettings Json @default("[{\"key\":\"showcase\",\"enabled\":true},{\"key\":\"popularModels\",\"enabled\":true},{\"key\":\"popularArticles\",\"enabled\":true},{\"key\":\"modelsOverview\",\"enabled\":true},{\"key\":\"imagesOverview\",\"enabled\":true},{\"key\":\"recentReviews\",\"enabled\":true}]")
showcaseItems Json @default("[]")
@@id([userId])
// Serves the ON DELETE SET NULL referential-integrity trigger for the
// sfwCoverImage relation. Without it every Image delete sequentially scans this
// whole table. Prisma does not index relation scalars on its own.
@@index([sfwCoverImageId])
}
/// @view
model BountyStat {
bountyId Int @id
Bounty Bounty @relation(fields: [bountyId], references: [id], onDelete: NoAction)
favoriteCountDay Int
favoriteCountWeek Int
favoriteCountMonth Int
favoriteCountYear Int
favoriteCountAllTime Int
trackCountDay Int
trackCountWeek Int
trackCountMonth Int
trackCountYear Int
trackCountAllTime Int
entryCountDay Int
entryCountWeek Int
entryCountMonth Int
entryCountYear Int
entryCountAllTime Int
benefactorCountDay Int
benefactorCountWeek Int
benefactorCountMonth Int
benefactorCountYear Int
benefactorCountAllTime Int
unitAmountCountDay Int
unitAmountCountWeek Int
unitAmountCountMonth Int
unitAmountCountYear Int
unitAmountCountAllTime Int
commentCountDay Int
commentCountWeek Int
commentCountMonth Int
commentCountYear Int
commentCountAllTime Int
}
/// @view
model BountyRank {
bountyId Int @id
Bounty Bounty @relation(fields: [bountyId], references: [id], onDelete: Cascade)
favoriteCountDayRank Int? @default(0)
favoriteCountWeekRank Int? @default(0)
favoriteCountMonthRank Int? @default(0)
favoriteCountYearRank Int? @default(0)
favoriteCountAllTimeRank Int? @default(0)
trackCountDayRank Int? @default(0)
trackCountWeekRank Int? @default(0)
trackCountMonthRank Int? @default(0)
trackCountYearRank Int? @default(0)
trackCountAllTimeRank Int? @default(0)
entryCountDayRank Int? @default(0)
entryCountWeekRank Int? @default(0)
entryCountMonthRank Int? @default(0)
entryCountYearRank Int? @default(0)
entryCountAllTimeRank Int? @default(0)
benefactorCountDayRank Int? @default(0)
benefactorCountWeekRank Int? @default(0)
benefactorCountMonthRank Int? @default(0)
benefactorCountYearRank Int? @default(0)
benefactorCountAllTimeRank Int? @default(0)
unitAmountCountDayRank Int? @default(0)
unitAmountCountWeekRank Int? @default(0)
unitAmountCountMonthRank Int? @default(0)
unitAmountCountYearRank Int? @default(0)
unitAmountCountAllTimeRank Int? @default(0)
commentCountDayRank Int? @default(0)
commentCountWeekRank Int? @default(0)
commentCountMonthRank Int? @default(0)
commentCountYearRank Int? @default(0)
commentCountAllTimeRank Int? @default(0)
}
/// @view
model BountyEntryStat {
bountyEntryId Int @id
BountyEntry BountyEntry @relation(fields: [bountyEntryId], references: [id], onDelete: NoAction)
cryCountDay Int
cryCountWeek Int
cryCountMonth Int
cryCountYear Int
cryCountAllTime Int
dislikeCountDay Int
dislikeCountWeek Int
dislikeCountMonth Int
dislikeCountYear Int
dislikeCountAllTime Int
heartCountDay Int
heartCountWeek Int
heartCountMonth Int
heartCountYear Int
heartCountAllTime Int
laughCountDay Int
laughCountWeek Int
laughCountMonth Int
laughCountYear Int
laughCountAllTime Int
likeCountDay Int
likeCountWeek Int
likeCountMonth Int
likeCountYear Int
likeCountAllTime Int
reactionCountDay Int
reactionCountWeek Int
reactionCountMonth Int
reactionCountYear Int
reactionCountAllTime Int
unitAmountCountDay Int
unitAmountCountWeek Int
unitAmountCountMonth Int
unitAmountCountYear Int
unitAmountCountAllTime Int
tippedCountDay Int @default(0)
tippedCountWeek Int @default(0)
tippedCountMonth Int @default(0)
tippedCountYear Int @default(0)
tippedCountAllTime Int @default(0)
tippedAmountCountDay Int @default(0)
tippedAmountCountWeek Int @default(0)
tippedAmountCountMonth Int @default(0)
tippedAmountCountYear Int @default(0)
tippedAmountCountAllTime Int @default(0)
}
/// @view
model BountyEntryRank {
bountyEntryId Int @id
BountyEntry BountyEntry @relation(fields: [bountyEntryId], references: [id], onDelete: Cascade)
cryCountDayRank Int? @default(0)
cryCountWeekRank Int? @default(0)
cryCountMonthRank Int? @default(0)
cryCountYearRank Int? @default(0)
cryCountAllTimeRank Int? @default(0)
dislikeCountDayRank Int? @default(0)
dislikeCountWeekRank Int? @default(0)
dislikeCountMonthRank Int? @default(0)
dislikeCountYearRank Int? @default(0)
dislikeCountAllTimeRank Int? @default(0)
heartCountDayRank Int? @default(0)
heartCountWeekRank Int? @default(0)
heartCountMonthRank Int? @default(0)
heartCountYearRank Int? @default(0)
heartCountAllTimeRank Int? @default(0)
laughCountDayRank Int? @default(0)
laughCountWeekRank Int? @default(0)
laughCountMonthRank Int? @default(0)
laughCountYearRank Int? @default(0)
laughCountAllTimeRank Int? @default(0)
likeCountDayRank Int? @default(0)
likeCountWeekRank Int? @default(0)
likeCountMonthRank Int? @default(0)
likeCountYearRank Int? @default(0)
likeCountAllTimeRank Int? @default(0)
reactionCountDayRank Int? @default(0)
reactionCountWeekRank Int? @default(0)
reactionCountMonthRank Int? @default(0)
reactionCountYearRank Int? @default(0)
reactionCountAllTimeRank Int? @default(0)
unitAmountCountDayRank Int? @default(0)
unitAmountCountWeekRank Int? @default(0)
unitAmountCountMonthRank Int? @default(0)
unitAmountCountYearRank Int? @default(0)
unitAmountCountAllTimeRank Int? @default(0)
tippedCountDayRank Int? @default(0)
tippedCountWeekRank Int? @default(0)
tippedCountMonthRank Int? @default(0)
tippedCountYearRank Int? @default(0)
tippedCountAllTimeRank Int? @default(0)
tippedAmountCountDayRank Int? @default(0)
tippedAmountCountWeekRank Int? @default(0)
tippedAmountCountMonthRank Int? @default(0)
tippedAmountCountYearRank Int? @default(0)
tippedAmountCountAllTimeRank Int? @default(0)
}
/// @view
model ClubStat {
clubId Int @id
Club Club @relation(fields: [clubId], references: [id], onDelete: NoAction)
memberCountDay Int
memberCountWeek Int
memberCountMonth Int
memberCountYear Int
memberCountAllTime Int
resourceCountDay Int
resourceCountWeek Int
resourceCountMonth Int
resourceCountYear Int
resourceCountAllTime Int
clubPostCountDay Int
clubPostCountWeek Int
clubPostCountMonth Int
clubPostCountYear Int
clubPostCountAllTime Int
}
/// @view
model ClubRank {
clubId Int @id
Club Club @relation(fields: [clubId], references: [id], onDelete: Cascade)
memberCountDayRank Int? @default(0)
memberCountWeekRank Int? @default(0)
memberCountMonthRank Int? @default(0)
memberCountYearRank Int? @default(0)
memberCountAllTimeRank Int? @default(0)
resourceCountDayRank Int? @default(0)
resourceCountWeekRank Int? @default(0)
resourceCountMonthRank Int? @default(0)
resourceCountYearRank Int? @default(0)
resourceCountAllTimeRank Int? @default(0)
clubPostCountDayRank Int? @default(0)
clubPostCountWeekRank Int? @default(0)
clubPostCountMonthRank Int? @default(0)
clubPostCountYearRank Int? @default(0)
clubPostCountAllTimeRank Int? @default(0)
}
enum EntityMetric_EntityType_Type {
Image
}
enum EntityMetric_MetricType_Type {
ReactionLike
ReactionHeart
ReactionLaugh
ReactionCry
Comment
Collection
Buzz
}
model EntityMetric {
entityType EntityMetric_EntityType_Type
entityId Int
metricType EntityMetric_MetricType_Type
metricValue Int @default(0)
@@id([entityType, entityId, metricType])
}
/// @view
model EntityMetricImage {
imageId Int @id
reactionLike Int?
reactionHeart Int?
reactionLaugh Int?
reactionCry Int?
reactionTotal Int? // computed
comment Int?
collection Int?
buzz Int?
}
/// @view
model TagsOnImageDetails {
imageId Int
image Image @relation(fields: [imageId], references: [id])
tagId Int
tag Tag @relation(fields: [tagId], references: [id])
source TagSource
automated Boolean
disabled Boolean
needsReview Boolean
reserved_1 Boolean
reserved_2 Boolean
confidence Int
@@id([imageId, tagId])
}
// =============================================================================
// COMICS - Hackathon MVP
// =============================================================================
enum ComicProjectStatus {
Active
Deleted
}
enum ComicReferenceStatus {
Pending
Ready
Failed
}
enum ComicPanelStatus {
Pending
Enqueued
Generating
AwaitingSelection
RequireUnlock
Ready
Failed
}
enum ComicChapterStatus {
Draft
Published
Scheduled
}
enum ComicReferenceType {
Character
Location
Item
Style
}
enum ComicEngagementType {
None
Notify
Hide
}
enum ComicGenre {
Action
Adventure
Comedy
Drama
Fantasy
Horror
Mystery
Romance
SciFi
SliceOfLife
Thriller
Other
}
model ComicProject {
id Int @id @default(autoincrement())
userId Int
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
name String @db.VarChar(255)
description String? @db.Text
coverImageId Int?
coverImage Image? @relation("comicProjectCover", fields: [coverImageId], references: [id], onDelete: SetNull)
heroImageId Int?
heroImage Image? @relation("comicProjectHero", fields: [heroImageId], references: [id], onDelete: SetNull)
heroImagePosition Int @default(50)
status ComicProjectStatus @default(Active)
tosViolation Boolean @default(false)
meta Json?
baseModel String? @db.VarChar(50)
genre ComicGenre?
nsfwLevel Int @default(0)
publishedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
chapters ComicChapter[]
engagements ComicProjectEngagement[]
reports ComicProjectReport[]
projectReferences ComicProjectReference[]
metric ComicProjectMetric?
@@index([userId])
@@index([status])
@@index([coverImageId])
@@index([heroImageId])
}
// AllTime-only metric rollup for comics (single row per project, like
// Model3DMetric). Holds only STATE counters recomputed from their Postgres
// source of truth by the `comicProjectMetrics` cron (tips from BuzzTip,
// follows/hides from ComicProjectEngagement). readerCount/chapterReadCount are
// intentionally absent: readChapters[] is wiped on republish, so they can't be
// reconstructed from Postgres and remain on the ClickHouse event pipeline.
model ComicProjectMetric {
comicProjectId Int @id
comicProject ComicProject @relation(fields: [comicProjectId], references: [id], onDelete: Cascade)
updatedAt DateTime @default(now())
tippedCount Int @default(0)
tippedAmountCount Int @default(0)
followerCount Int @default(0)
hiddenCount Int @default(0)
readerCount Int @default(0)
chapterReadCount Int @default(0)
}
model ComicChapter {
id Int @default(autoincrement()) @unique
projectId Int
project ComicProject @relation(fields: [projectId], references: [id], onDelete: Cascade)
name String @db.VarChar(255) @default("Chapter 1")
position Int @default(0)
status ComicChapterStatus @default(Draft)
availability Availability @default(Public)
earlyAccessConfig Json?
earlyAccessEndsAt DateTime?
publishedAt DateTime?
initialPublishedAt DateTime?
nsfwLevel Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
panels ComicPanel[]
thread Thread?
reads ComicChapterRead[]
@@id([projectId, position])
}
// Per-(user, chapter) read record, keyed by the STABLE ComicChapter.id (not the
// mutable position), so reads survive reorder/republish — unlike the old
// position-array on ComicProjectEngagement. Drives readerCount/chapterReadCount
// in ComicProjectMetric. Soft-delete via `unread` (mirrors the engagement
// soft-delete): an un-read flips `unread = true` and bumps `updatedAt` so the
// `comicProjectMetrics` cron picks it up incrementally instead of silently
// drifting on dormant comics.
model ComicChapterRead {
userId Int
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
chapterId Int
chapter ComicChapter @relation(fields: [chapterId], references: [id], onDelete: Cascade)
unread Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
@@id([userId, chapterId])
@@index([chapterId])
@@index([updatedAt])
}
model ComicReference {
id Int @id @default(autoincrement())
userId Int
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
name String @db.VarChar(255)
type ComicReferenceType @default(Character)
description String? @db.Text
status ComicReferenceStatus @default(Pending)
errorMessage String? @db.Text
buzzCost Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
images ComicReferenceImage[]
panelReferences ComicPanelReference[]
projectReferences ComicProjectReference[]
@@index([userId])
@@index([status])
}
model ComicReferenceImage {
referenceId Int
reference ComicReference @relation(fields: [referenceId], references: [id], onDelete: Cascade)
imageId Int
image Image @relation(fields: [imageId], references: [id], onDelete: Cascade)
position Int @default(0)
createdAt DateTime @default(now())
@@id([referenceId, imageId])
@@index([imageId], type: Hash)
}
model ComicProjectReference {
projectId Int
referenceId Int
createdAt DateTime @default(now())
project ComicProject @relation(fields: [projectId], references: [id], onDelete: Cascade)
reference ComicReference @relation(fields: [referenceId], references: [id], onDelete: Cascade)
@@id([projectId, referenceId])
@@index([referenceId])
}
model ComicPanel {
id Int @id @default(autoincrement())
projectId Int
chapterPosition Int
chapter ComicChapter @relation(fields: [projectId, chapterPosition], references: [projectId, position], onDelete: Cascade, onUpdate: Cascade)
imageId Int?
image Image? @relation(fields: [imageId], references: [id], onDelete: SetNull)
prompt String @db.Text
enhancedPrompt String? @db.Text
imageUrl String? @db.VarChar(500)
position Int @default(0)
status ComicPanelStatus @default(Pending)
workflowId String?
civitaiJobId String? @db.VarChar(100)
errorMessage String? @db.Text
metadata Json?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
references ComicPanelReference[]
@@index([projectId, chapterPosition, position])
@@index([imageId])
@@index([status])
}
model ComicProjectEngagement {
userId Int
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
projectId Int
project ComicProject @relation(fields: [projectId], references: [id], onDelete: Cascade)
type ComicEngagementType @default(None)
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
@@id([userId, projectId])
@@index([projectId], type: Hash)
@@index([updatedAt])
}
model ComicPanelReference {
panelId Int
panel ComicPanel @relation(fields: [panelId], references: [id], onDelete: Cascade)
referenceId Int
reference ComicReference @relation(fields: [referenceId], references: [id], onDelete: Cascade)
@@id([panelId, referenceId])
@@index([referenceId], type: Hash)
}
// Generation restriction system — tracks restrictions issued for prohibited prompt violations
model UserRestriction {
id Int @id @default(autoincrement())
userId Int
user User @relation("userRestrictions", fields: [userId], references: [id], onDelete: Cascade)
type String @default("generation")
status UserRestrictionStatus @default(Pending)
triggers Json @default("[]")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
resolvedAt DateTime?
resolvedBy Int?
resolvedMessage String?
userMessage String?
userMessageAt DateTime?
@@index([userId])
@@index([status])
@@index([type, status])
}
enum UserRestrictionStatus {
Pending
Upheld
Overturned
}
// Moderator-curated allowlist for false-positive prompt triggers
// Strike system — graduated user enforcement with points-based escalation
enum StrikeReason {
BlockedContent
RealisticMinorContent
CSAMContent
TOSViolation
HarassmentContent
ProhibitedContent
ManualModAction
}
enum StrikeStatus {
Active
Expired
Voided
}
model UserStrike {
id Int @id @default(autoincrement())
userId Int
user User @relation("userStrikes", fields: [userId], references: [id], onDelete: Cascade)
reason StrikeReason
status StrikeStatus @default(Active)
points Int @default(1)
description String @db.VarChar(1000)
internalNotes String? @db.VarChar(2000)
entityType EntityType?
entityId Int?
reportId Int?
createdAt DateTime @default(now())
expiresAt DateTime
voidedAt DateTime?
voidedBy Int?
voidedByUser User? @relation("strikeVoidedBy", fields: [voidedBy], references: [id], onDelete: SetNull)
voidReason String? @db.VarChar(1000)
issuedBy Int?
issuedByUser User? @relation("strikeIssuedBy", fields: [issuedBy], references: [id], onDelete: SetNull)
@@index([userId, status])
@@index([userId, expiresAt])
@@index([status])
@@index([createdAt])
}
model GenerationPreset {
id Int @id @default(autoincrement())
userId Int
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
name String @db.Citext
description String? @db.VarChar(500)
ecosystem String
values Json
sortOrder Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([userId, ecosystem, name])
@@index([userId])
@@index([userId, ecosystem])
}
enum WildcardSetKind {
System
User
}
enum WildcardSetAuditStatus {
Pending
Clean
Mixed
Dirty
}
enum WildcardSetCategoryAuditStatus {
Pending
Clean
Dirty
}
model WildcardSet {
id Int @id @default(autoincrement())
kind WildcardSetKind
// System-kind only (null for User-kind)
modelVersionId Int? @unique
modelVersion ModelVersion? @relation(fields: [modelVersionId], references: [id], onDelete: Restrict)
// User-kind only (null for System-kind)
ownerUserId Int?
owner User? @relation("WildcardSetOwner", fields: [ownerUserId], references: [id], onDelete: Cascade)
// Display name. For System-kind, set at import to "${model.name} - ${modelVersion.name}".
// For User-kind, defaults to "My snippets" on first save (and is renameable by the owner).
// Drift on rename is acceptable — the picker reads `name` once, model renames are uncommon,
// and JOINing back through ModelVersion → Model on every read isn't worth saving the rare
// staleness window.
name String @db.Citext
auditStatus WildcardSetAuditStatus @default(Pending)
auditRuleVersion String?
auditedAt DateTime?
// Boolean OR of every non-Dirty category's `nsfw` flag. Lets a caller (e.g.
// the model detail page's "Generate" button) decide whether a wildcard set
// has any NSFW content at all (hidden on `.com`, visible on `.red`) without
// sub-querying categories. Maintained by the audit verdict path —
// recomputed whenever a category's `nsfw` or `auditStatus` changes.
//
// Deliberately a boolean, not the bitwise `nsfwLevel` bucket that images and
// models use: XGuard's text classifiers can't reliably distinguish PG / R /
// X for arbitrary text, so a boolean is the only honest representation of
// the signal we actually have.
nsfw Boolean @default(false)
// Phase 2 rollup: true iff at least one of this set's categories is usable
// (i.e. has been audited and isn't blocked). Powers the post-Phase-2 read
// path that gates the model detail page's Generate button without
// sub-querying categories. Maintained by `recomputeWildcardSetAuditStatus`
// alongside `nsfw`. See docs/wildcard-moderation-pipeline-cleanup.md §Phase 2.
usable Boolean @default(false)
isInvalidated Boolean @default(false)
invalidationReason String?
invalidatedAt DateTime?
// Open-ended container for set-scoped data we don't want to model as
// first-class columns yet. Shape today (see WildcardSetMetadata in the
// provisioning service) is `{ skippedEntries?: SkippedEntry[] }` —
// surface area can grow with the feature.
metadata Json?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
categories WildcardSetCategory[]
@@index([kind])
@@index([ownerUserId])
@@index([auditStatus])
@@index([isInvalidated])
@@index([nsfw])
@@index([usable])
}
model WildcardSetCategory {
id Int @id @default(autoincrement())
wildcardSetId Int
wildcardSet WildcardSet @relation(fields: [wildcardSetId], references: [id], onDelete: Cascade)
name String @db.Citext
values String[]
valueCount Int @default(0)
auditStatus WildcardSetCategoryAuditStatus @default(Pending)
auditRuleVersion String?
auditedAt DateTime?
auditNote String?
// Set to true iff the XGuard audit triggered any of `WILDCARD_AUDIT_LEVEL_LABELS`
// (currently just `nsfw`). See WildcardSet.nsfw for why this isn't bitwise.
nsfw Boolean @default(false)
// Phase 2 mirror of `EntityModeration.blocked` for this category — denormalized
// so the picker / read paths can hide Dirty content without joining EM. Kept
// in sync from `applyWildcardCategoryAuditSuccess` alongside `nsfw`. Default
// `false` so pre-Phase-2 rows backfill correctly (no row was ever Dirty until
// the audit verdict explicitly says so). See
// docs/wildcard-moderation-pipeline-cleanup.md §Phase 2.
blocked Boolean @default(false)
// Open-ended container for category-scoped audit data we don't want to model
// as columns yet. Shape today (see WildcardCategoryMetadata in
// wildcard-category-audit.service): `{ workflowId?, triggeredTerms?,
// triggeredLabels?, retryCount? }` — workflowId tracks in-flight audits;
// triggered* arrays survive after rollup so moderators can see what
// matched.
metadata Json?
displayOrder Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([wildcardSetId, name])
@@index([wildcardSetId])
@@index([wildcardSetId, auditStatus])
@@index([wildcardSetId, blocked])
@@index([auditStatus])
}
enum ReviewVerdict {
TruePositive
FalsePositive
TrueNegative
FalseNegative
Unsure
}
model ScannerLabelReview {
id Int @id @default(autoincrement())
contentHash String
version String
label String
reviewedBy Int
reviewedAt DateTime @default(now())
verdict ReviewVerdict
note String?
@@unique([contentHash, version, label, reviewedBy])
@@index([verdict, label])
@@index([reviewedAt])
}
// One row per unique scan input. Snapshotted when the first moderator commits
// a verdict, so the content survives the orchestrator's 30-day TTL and stays
// available for follow-up review / tuning analysis. Multi-mod safe: only the
// first verdict for a given contentHash creates the row.
//
// `content` is a JSON blob whose shape varies by scanner mode (see
// scanContentBody zod schema in scanner-review.schema.ts). Avoids schema
// migrations as new scanner modes and per-mode fields are added.
model ScannerContentSnapshot {
contentHash String @id
scanner String
content Json
createdAt DateTime @default(now())
@@index([scanner])
}
// ============================================================
// 3D Models — see docs/3d-models-plan.md (rev 9)
// v1 source = orchestrator PolyGen generation (Meshy via Fal).
// Schema is upload-ready so a future user-upload flow can write
// rows with workflowId = NULL.
// No versioning. No reactions on Model3D (react on thumbnail Image
// instead). No DownloadHistory in Postgres (ClickHouse events).
// Reviews own an optional Post for image attachments.
// ============================================================
enum Model3DStatus {
Draft
Published
Unpublished
Deleted
}
enum Model3DEngagementType {
Favorite
Hide
Notify
}
model Model3DLicense {
id Int @id @default(autoincrement())
name String @unique
description String
allowCommercialUse Boolean @default(false)
allowPrintFarm Boolean @default(false)
allowDerivatives Boolean @default(true)
allowRedistribution Boolean @default(false)
requireAttribution Boolean @default(true)
isCustom Boolean @default(false)
createdAt DateTime @default(now())
models Model3D[]
}
model Model3D {
id Int @id @default(autoincrement())
name String @db.Citext
description String?
userId Int
user User @relation("model3dCreator", fields: [userId], references: [id])
thumbnailImageId Int? @unique
thumbnailImage Image? @relation("model3dThumbnail", fields: [thumbnailImageId], references: [id], onDelete: SetNull)
licenseId Int
license Model3DLicense @relation(fields: [licenseId], references: [id])
licenseDetails String?
// Generation provenance — NULL for future user-uploaded rows.
workflowId String? @unique
sourceImageId Int?
sourceImage Image? @relation("model3dSource", fields: [sourceImageId], references: [id], onDelete: SetNull)
generationParams Json?
status Model3DStatus @default(Draft)
nsfw Boolean @default(false)
tosViolation Boolean @default(false)
poi Boolean @default(false)
minor Boolean @default(false)
unlisted Boolean @default(false)
lockedProperties String[] @default([])
availability Availability @default(Public)
nsfwLevel Int @default(0)
meta Json @default("{}")
// Per-Model3D gallery moderation (creator/mod hide image/user/tag). No
// version dimension — `images` is a flat list of hidden image ids.
gallerySettings Json @default("{\"users\":[],\"tags\":[],\"images\":[]}")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
publishedAt DateTime?
deletedAt DateTime?
deletedBy Int?
deletedByUser User? @relation("model3dDeletedBy", fields: [deletedBy], references: [id], onDelete: SetNull)
files Model3DFile[]
posts Post[]
tags TagsOnModel3D[]
engagements Model3DEngagement[]
reports Model3DReport[]
reviews Model3DReview[]
threads Thread[]
collectionItems CollectionItem[]
metric Model3DMetric?
@@index([userId, status, publishedAt(sort: Desc)])
@@index([status, publishedAt(sort: Desc)])
@@index([status, nsfwLevel, publishedAt(sort: Desc)])
@@index([name])
@@index([licenseId], type: Hash)
@@index([sourceImageId], type: Hash)
}
model Model3DFile {
id Int @id @default(autoincrement())
model3dId Int
model3d Model3D @relation(fields: [model3dId], references: [id], onDelete: Cascade)
name String
url String
sizeKB Float
format String
// Variant discriminator. Lets a single Model3D carry multiple glb/fbx
// exports for the same generation — base, rigged, animated, walking
// (with armature sibling), running (with armature sibling). Defaults to
// "primary" so existing rows + non-PolyGen ingest paths are unaffected.
variant String @default("primary")
isPrimary Boolean @default(false)
metadata Json?
virusScanResult ScanResultCode @default(Success)
virusScanMessage String?
rawScanResult Json?
scannedAt DateTime?
scanRequestedAt DateTime?
exists Boolean?
createdAt DateTime @default(now())
@@unique([model3dId, format, variant])
@@index([model3dId], type: Hash)
}
model TagsOnModel3D {
model3dId Int
model3d Model3D @relation(fields: [model3dId], references: [id], onDelete: Cascade)
tagId Int
tag Tag @relation(fields: [tagId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
@@id([model3dId, tagId])
@@index([model3dId], type: Hash)
@@index([tagId], type: Hash)
}
model Model3DEngagement {
userId Int
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
model3dId Int
model3d Model3D @relation(fields: [model3dId], references: [id], onDelete: Cascade)
type Model3DEngagementType
createdAt DateTime @default(now())
@@id([userId, model3dId])
@@index([model3dId], type: Hash)
}
model Model3DReport {
model3dId Int
model3d Model3D @relation(fields: [model3dId], references: [id], onDelete: Cascade)
reportId Int @unique
report Report @relation(fields: [reportId], references: [id], onDelete: Cascade)
@@id([reportId, model3dId])
@@index([model3dId], type: Hash)
}
model Model3DReview {
id Int @id @default(autoincrement())
model3dId Int
model3d Model3D @relation(fields: [model3dId], references: [id], onDelete: Cascade)
userId Int
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
recommended Boolean @default(true)
details String?
nsfw Boolean @default(false)
tosViolation Boolean @default(false)
exclude Boolean @default(false)
metadata Json?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
thread Thread?
post Post?
reports Model3DReviewReport[]
@@unique([model3dId, userId])
@@index([model3dId], type: Hash)
@@index([userId], type: Hash)
}
model Model3DReviewReport {
model3dReviewId Int
model3dReview Model3DReview @relation(fields: [model3dReviewId], references: [id], onDelete: Cascade)
reportId Int @unique
report Report @relation(fields: [reportId], references: [id], onDelete: Cascade)
@@id([reportId, model3dReviewId])
@@index([model3dReviewId], type: Hash)
}
model Model3DMetric {
model3dId Int @id
model3d Model3D @relation(fields: [model3dId], references: [id], onDelete: Cascade)
downloadCount Int @default(0)
commentCount Int @default(0)
collectedCount Int @default(0)
imageCount Int @default(0)
tippedCount Int @default(0)
tippedAmountCount Int @default(0)
ratingCount Int @default(0)
recommendedCount Int @default(0)
reactionCount Int @default(0)
earnedAmount Int @default(0)
updatedAt DateTime @default(now())
nsfwLevel Int @default(0)
userId Int @default(0)
status Model3DStatus @default(Draft)
availability Availability @default(Public)
poi Boolean @default(false)
minor Boolean @default(false)
}
enum ShopifyMerchOrderStatus {
Pending
Granted
}
model ShopifyCustomerLink {
id Int @id @default(autoincrement())
shopifyCustomerId String @unique
email String
userId Int
createdAt DateTime @default(now())
@@index([email])
@@index([userId])
}
model ShopifyMerchOrder {
id Int @id @default(autoincrement())
shopifyOrderId String @unique
email String
shopifyCustomerId String?
subtotal Decimal @db.Decimal(10, 2)
couponCodes String[]
buzzAmount Int
status ShopifyMerchOrderStatus @default(Pending)
userId Int?
grantedAt DateTime?
createdAt DateTime @default(now())
@@index([email])
@@index([userId])
}
enum OutboxEntity {
Article
Image
Model
Post
ModelVersion
}
/// Transactional outbox for entity lifecycle events, consumed by the event-engine app via Debezium CDC
/// and its reconciliation poller. Pre-existed in the DB (applied manually); modeled here. `attempts` backs
/// the poller's retry bookkeeping (park past a max-attempts cap; re-drive via redrive:outbox).
model Outbox {
id BigInt @id @default(autoincrement())
event String
entityType OutboxEntity
entityId BigInt
createdAt DateTime? @default(now())
details Json?
attempts Int?
}
/// Per-app, per-page list of the roles allowed to reach a page, keyed by the app's own path strings.
/// `roles` is authoritative wherever a row exists — an empty array means nobody, which is why this is one
/// row per page rather than one per grant. A missing row means the app has never been told about the page
/// and falls back to whatever its code declares. Roles are bare strings and `updatedById` has no FK on
/// purpose: any app on this database can use the table with its own role vocabulary.
model AppPageAccess {
app String
path String
roles String[]
updatedById Int?
updatedAt DateTime @default(now())
@@id([app, path])
}
/// A creator-owned space someone pays to occupy. `surface` and the entity columns are
/// TEXT rather than enums so a new surface is a code change instead of a DDL migration —
/// the same reasoning `entityMetricEvents.entityType` already runs on. Absent rows are
/// not "off": resolution cascades image -> post -> user -> the surface default.
model PlacementSpace {
id Int @id @default(autoincrement())
surface String
entityType String
entityId Int
mode String
/// What the owner asks. The charged price is min(price, cap) computed at read; a
/// stored effective price goes stale the moment a membership lapses.
price Int?
/// How many free placements this space accepts. NULL means the owner has never
/// chosen, which resolves to the surface's default (1) rather than to zero —
/// free capacity is opt-out. An explicit 0 is the owner taking none, which is
/// why this replaces an on/off toggle instead of sitting beside one.
///
/// Stored uncapped and ceilinged at read by the score/tier table, exactly like
/// `price`: a stored effective value goes stale the moment a membership lapses.
///
/// A column rather than a key in `settings`, because the foundation reads it
/// and `settings` is surface-owned by construction.
freeSlots Int?
/// Surface-owned settings, read only by the surface that wrote them — a max
/// sticker size means nothing to a remix gallery. Kept as JSON so this layer
/// does not grow a column per surface idea.
settings Json @default("{}")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([surface, entityType, entityId])
@@index([entityType, entityId])
}
/// One paid occupation of a space. `data` is opaque here on purpose — the sticker and
/// remix surfaces own their own payload shape, and this layer never reads inside it.
///
/// Money movements live in `PlacementTransaction`, not in columns here: a settlement
/// that got half way has to say which half, and a status column cannot.
///
/// The DB carries CHECK constraints Prisma cannot express, all in the migration —
/// `amount >= 0`, `status` confined to its five values, and `removedBy` tied to
/// `status = 'removed'`. A `prisma migrate diff` will read them as drift and offer to
/// drop them; don't let it.
model Placement {
id Int @id @default(autoincrement())
surface String
targetType String
targetId Int
ownerId Int
owner User @relation("PlacementOwner", fields: [ownerId], references: [id], onDelete: Cascade)
placerId Int
placer User @relation("PlacementPlacer", fields: [placerId], references: [id], onDelete: Cascade)
data Json @default("{}")
status String
/// 'owner' | 'moderator' | 'cosmeticTakedown', set with status 'removed'. The
/// removals refund different amounts, so the status alone cannot settle the
/// money. Constrained by "Placement_removedBy_check" — a new value needs a
/// migration even though the column is TEXT.
removedBy String?
/// What the placer paid into escrow, in Buzz. Kept per row rather than read back from
/// the space, whose price may move between placement and release.
///
/// Always 0 on a free placement, and nothing reads it there — the payout is
/// derived from receipted holds, of which a free placement has none.
amount Int
/// A placement made against the space's free capacity rather than paid for.
///
/// Escrow is bypassed entirely: zero-amount Buzz transactions are a landmine,
/// and the escrow's two-hold structure has neither a decline fee nor a
/// principal to hold. Settlement moves no money at all for one of these.
///
/// On the row rather than inferred from `amount = 0`, which is also what a paid
/// placement into a zero-priced space looks like. It also has to be immutable
/// and readable by a sweeper that never saw the request that made it.
free Boolean @default(false)
/// Which Buzz the placer paid in, so the settlement pays the same kind back out.
/// On the row for the same reason `amount` is: settlement is resumable, and the
/// domain that decided the currency is not visible to a sweeper.
///
/// NULL means a placement made before this column existed. Those were booked
/// into escrow as yellow whatever was spent, and settle as yellow to match the
/// ledger. Deliberately not backfilled — the bank is not balance-constrained,
/// so it is a choice about legacy rows, not a limit. See `settledSpendType`.
spendType String?
/// Who sold the thing being placed, when an approved placement owes them a cut.
/// On the row rather than passed in, because the settlement is resumable and a
/// sweeper that never saw the argument would strand the seller's share.
sellerId Int?
seller User? @relation("PlacementSeller", fields: [sellerId], references: [id], onDelete: SetNull)
/// Set when a block declined this placement. A block is the owner refusing to
/// give attention to anyone, and the decline fee is the price of that attention,
/// so no fee is taken. Stored rather than inferred: settlement is resumable and
/// must replay the same decision.
feeWaived Boolean @default(false)
createdAt DateTime @default(now())
expiresAt DateTime?
resolvedAt DateTime?
resolvedById Int?
/// A moderator takedown of an already-settled placement. Its own columns because
/// `resolvedAt`/`resolvedById` record who approved it, and overwriting them on the
/// one path whose purpose is a moderation record would destroy the approval trail.
takenDownAt DateTime?
takenDownById Int?
/// When this placement's Buzz reached the target's counter. The counter lives in
/// ClickHouse, which has no per-placement key to ask, so the fact that it was
/// counted is recorded here or nowhere. NULL on a placement that reached
/// `approved` is the reconcile sweep's work queue.
metricCountedAt DateTime?
/// When a sweep took this row to count it. Two columns rather than one because
/// the claim has to be atomic and the confirmation cannot be: two sweeps can
/// overlap (the job lock fails open when Redis is down), and without a claim
/// both read the same unstamped rows and both emit before either stamps. The
/// counter never reverses, so that over-count is permanent. A claim older than
/// the recovery window is retried, which is what stops a crash between the two
/// writes turning into the loss this whole feature exists to end.
metricClaimedAt DateTime?
/// How many times a sweep has taken this row. A row the tracker rejects fails
/// identically on every retry, and the claim orders by `resolvedAt`, so
/// without a ceiling one poisoned row sits at the head of the queue being
/// re-claimed forever and starves everything behind it.
metricAttempts Int @default(0)
transactions PlacementTransaction[]
@@index([surface, targetType, targetId, status])
@@index([ownerId, status])
/// The owner review queue's paging order. Without it each page rescans and
/// re-sorts every pending row the owner has, and a refetch of a walk N pages
/// deep pays that N+1 times.
///
/// `surface` is in it because both queues filter on it, and left out it cannot
/// be an index condition — an owner with 900 pending stickers would touch all
/// 900 heap tuples to fill one 50-row page of remix submissions. It sits after
/// `ownerId` rather than first so this does not become a strict prefix of
/// `[ownerId, status]` above, which the moderation lookups still want.
@@index([ownerId, surface, status, createdAt, id])
@@index([placerId, status])
/// Both free-placement entitlement checks, which run inside the claim
/// transaction and so are on the latency path of every free placement: the
/// daily allowance (this placer, free rows, since midnight UTC) and the
/// never-twice rule (this placer, free rows, this target). One index serves
/// both because the allowance bounds a placer to one free row per day, so
/// "every free row this placer has" is a handful of tuples either way.
///
/// Applied as a PARTIAL index (`WHERE free`) — see the migration. Prisma cannot
/// express the predicate, and without it this indexes every placement ever made
/// to answer questions only about the free ones.
@@index([placerId, free, createdAt])
@@index([status, expiresAt])
/// Applied as a PARTIAL index (`WHERE "metricCountedAt" IS NULL`) — see the
/// migration. Prisma can express neither the predicate nor the reason the key
/// is `resolvedAt` alone, so the declaration here is the shape and the
/// migration is the truth.
@@index([resolvedAt])
}
/// One row per movement of money for a placement. `UNIQUE (placementId, kind)` is the
/// idempotency guard: the insert is the lock, so a retried leg raises rather than paying
/// twice. `transactionId` is the Buzz service's receipt, derived from the row
/// (`placement-<id>-<kind>`) and never time-based, so the service's own dedupe is a real
/// second line rather than something a retry steps around.
model PlacementTransaction {
id Int @id @default(autoincrement())
placementId Int
placement Placement @relation(fields: [placementId], references: [id], onDelete: Cascade)
kind String
transactionId String?
amount Int
/// Failure accounting. Without it a leg that can never succeed is
/// indistinguishable from one that has not been tried yet, so it stays in the
/// recovery sweep forever and — past the batch limit — starves it, while the
/// sweep reports healthy numbers.
attempts Int @default(0)
lastAttemptAt DateTime?
lastError String?
createdAt DateTime @default(now())
@@unique([placementId, kind])
@@index([kind, createdAt])
}
/// A moderator suspending a user's placement privileges sitewide. Deliberately not
/// modelled as a block from a system account: that would inherit the block cache's
/// staleness and pollute the block lists real users see.
model PlacementSuspension {
userId Int @id
user User @relation("PlacementSuspensionUser", fields: [userId], references: [id], onDelete: Cascade)
reason String?
createdAt DateTime @default(now())
createdById Int?
@@index([createdAt])
}
enum UserHubSourceType {
User
Model
ModelVersion
Collection
Tag
}
/// A user-composed image feed: a named set of creators, models, versions and
/// collections merged into one stream. The source list is resolved server-side
/// from the hub id — a client-supplied id list would be an unbounded-cost query
/// anyone could post, which is why `image.getInfinite` takes `hubId` and not the
/// ids themselves.
model UserHub {
id Int @id @default(autoincrement())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
userId Int
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
name String
index Int @default(0)
sort String @default("Newest")
period MetricTimeframe @default(AllTime)
mediaTypes MediaType[]
metadata Json @default("{}")
availability Availability @default(Private)
// A browsing-level bitmask the hub's own feed is capped to. 0 means uncapped —
// the viewer's own level decides, as it did before hubs could be shared.
//
// Named like Collection's and Challenge's, NOT `nsfwLevel`: every `nsfwLevel`
// column in this schema is a DERIVED content level that nsfwLevels.service.ts
// owns and overwrites. This one is a setting its owner chose.
forcedBrowsingLevel Int @default(0)
sources UserHubSource[]
followers UserHubFollow[]
@@index([userId])
}
model UserHubFollow {
userId Int
hubId Int
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
hub UserHub @relation(fields: [hubId], references: [id], onDelete: Cascade)
@@unique([userId, hubId])
@@index([hubId])
}
model UserHubSource {
id Int @id @default(autoincrement())
hubId Int
hub UserHub @relation(fields: [hubId], references: [id], onDelete: Cascade)
type UserHubSourceType
targetId Int
alias String?
enabled Boolean @default(true)
// A NEGATIVE source: content matching it is kept OUT of the hub, instead of
// pulled in. The unique below is what makes the two mutually exclusive — one
// target is either something the hub collects or something it refuses.
exclude Boolean @default(false)
index Int @default(0)
// Tag sources sharing this key (within one hub and one `exclude` polarity) must ALL
// match. NULL is a group of one, which is what every pre-existing row means.
groupKey Int?
@@unique([hubId, type, targetId])
@@index([hubId])
@@index([type, targetId])
}
model Blurb {
id Int @id @default(autoincrement())
userId Int
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
name String @db.Citext
content String
contentHash String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime?
references BlurbReference[]
// RAW partial unique index (NOT expressible in Prisma @@unique — it carries a WHERE):
// "Blurb_userId_name_key" UNIQUE (userId, name) WHERE "deletedAt" IS NULL. Created in
// 20260825000000_add_blurbs (manual-apply). createBlurb relies on its P2002 to report a
// duplicate name.
@@index([updatedAt])
}
model BlurbReference {
blurbId Int
blurb Blurb @relation(fields: [blurbId], references: [id], onDelete: Cascade)
entityType String
entityId Int
materializedHash String
materializedAt DateTime
/// Set when the blurb is edited or soft-deleted, cleared once the entity is rewritten. The
/// fan-out selector filters on it because the cross-table hash inequality it replaced cannot be
/// indexed — see the note in 20260825000000_add_blurbs.
pendingSince DateTime?
@@id([blurbId, entityType, entityId])
@@index([entityType, entityId])
// RAW partial index (NOT expressible in Prisma — it carries a WHERE):
// "BlurbReference_pending_idx" ON ("materializedAt") WHERE "pendingSince" IS NOT NULL.
// Created in 20260825000000_add_blurbs (manual-apply).
}