mirror of
https://github.com/civitai/civitai.git
synced 2026-09-20 22:08:18 +08:00
9282fd5deb
* 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>
8602 lines
334 KiB
Plaintext
8602 lines
334 KiB
Plaintext
// 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).
|
||
}
|