Files
petfeed/prisma/schema.prisma
T
admin b4a49f8ed3 fix: address findings from full security/functionality audit (Gitea #17)
Rate-limiting on messages.send, reports.create, reactions.toggle,
follows.create, reposts.create. Unique constraints on Report to stop
report-flooding. Mux webhook now fails hard in production without a
signing secret instead of skipping verification. Admin-panel outline
buttons fixed on 4 more pages (same white-on-dark bug as the Users page).
Timing-safe comparison for ADMIN_SECRET/CRON_SECRET. Fixed duplicate HSTS
header (Cloudflare's stronger one was being shadowed by the app's own).
tRPC now returns clean JSON 401s instead of HTML redirects when
unauthenticated, and no longer blocks public procedures for logged-out
visitors. 14 previously-silent catch blocks now log to GlitchTip.
invite/page.tsx and EmergencyVetSection.tsx fully localized (were 100%
hardcoded German). Stale TODO comments removed, auth.test.ts scaffold
rewritten to match the actual lazy-upsert pattern instead of a Clerk
webhook flow that was never built.
2026-08-13 12:42:49 +02:00

782 lines
28 KiB
Plaintext

generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
}
model Owner {
id String @id // = Clerk userId (e.g. "user_xxx")
createdAt DateTime @default(now())
invitedById String? // Clerk userId of the person who invited this owner
pets Pet[]
adminRole AdminRole?
ban UserBan?
shadowban Shadowban?
issuedInvites InviteCode[] @relation("IssuedBy")
legalAcknowledgedAt DateTime? // last time the owner dismissed the legal-update banner; null = never seen one
contentRightsConsentedAt DateTime? // first-pet onboarding: confirms the owner holds all rights to uploaded photos/videos
dataProcessingConsentedAt DateTime? // first-pet onboarding: Willenserklärung consenting to data transfer to Clerk/Cloudflare/Mux/Supabase
dataExportTokens DataExportToken[]
warnings Warning[]
themePreference ThemePreference? // null = follows system preference, never explicitly toggled
@@index([createdAt(sort: Desc)])
}
enum ThemePreference {
LIGHT
DARK
}
// Append-only moderation history — no delete/edit endpoint by design, mirrors
// the Gitea #10 permission-matrix spec's "Verwarnungssystem ... mit Historie".
model Warning {
id String @id @default(cuid())
owner Owner @relation(fields: [ownerId], references: [id], onDelete: Cascade)
ownerId String
reason String @db.VarChar(500)
issuedBy String // ownerId of the moderator who issued it
createdAt DateTime @default(now())
@@index([ownerId, createdAt(sort: Desc)])
}
enum VerificationRequestStatus {
PENDING
APPROVED
REJECTED
}
// Self-service verification request (Gitea #10). Reviewing is a fixed
// 4-item checklist, not free-form moderator judgment alone — all four must
// be true to approve; any false auto-rejects with an itemized note. The
// owner is notified via an automated "PawFeed Team" DM either way
// (src/lib/team-pet.ts) — see admin.reviewVerificationRequest.
model VerificationRequest {
id String @id @default(cuid())
pet Pet @relation(fields: [petId], references: [id], onDelete: Cascade)
petId String
message String @db.VarChar(1000) // requester's own justification
status VerificationRequestStatus @default(PENDING)
meetsAuthenticity Boolean? // real/notable pet — impersonation risk is the actual point of the badge
meetsCompleteProfile Boolean? // real name, bio, avatar set
meetsGoodStanding Boolean? // no active ban, no open warnings
meetsEstablishedPresence Boolean? // account has existed a while and actually posted
reviewNote String? @db.VarChar(1000)
reviewedBy String? // ownerId of the moderator who decided
reviewedAt DateTime?
createdAt DateTime @default(now())
@@index([status, createdAt(sort: Desc)])
@@index([petId])
}
model InviteCode {
id String @id @default(cuid())
code String @unique
issuedBy Owner? @relation("IssuedBy", fields: [issuedById], references: [id])
issuedById String? // null = admin-generated root invite
usedById String? // Clerk userId who redeemed this code (not a FK — owner may not exist yet)
usedAt DateTime?
revoked Boolean @default(false)
createdAt DateTime @default(now())
@@index([issuedById])
}
model Species {
id String @id @default(cuid())
name String @unique
breeds Breed[]
pets Pet[]
translations SpeciesTranslation[]
}
model SpeciesTranslation {
id String @id @default(cuid())
species Species @relation(fields: [speciesId], references: [id])
speciesId String
locale String
name String
@@unique([speciesId, locale])
}
model Breed {
id String @id @default(cuid())
name String
species Species @relation(fields: [speciesId], references: [id])
speciesId String
pets Pet[]
translations BreedTranslation[]
@@unique([speciesId, name])
}
model BreedTranslation {
id String @id @default(cuid())
breed Breed @relation(fields: [breedId], references: [id])
breedId String
locale String
name String
@@unique([breedId, locale])
}
model Pet {
id String @id @default(cuid())
owner Owner @relation(fields: [ownerId], references: [id])
ownerId String
species Species @relation(fields: [speciesId], references: [id])
speciesId String
breed Breed? @relation(fields: [breedId], references: [id])
breedId String?
name String
bio String?
adoptionStory String?
avatarKey String? // Supabase Storage object key, NOT a full URL
isVerified Boolean @default(false) // admin-granted verification checkmark (Gitea #10 permission matrix)
dmPolicy DmPolicy @default(EVERYONE) // who can send this pet DMs
birthday DateTime? @db.Date // actual birthday for anniversary notifications
adoptedAt DateTime? @db.Date // adoption date for anniversary notifications
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Phase 2 back-relations
posts Post[]
stories Story[]
storyViews StoryView[] @relation("StoryViewer")
following Follow[] @relation("Follower")
followers Follow[] @relation("Followee")
blocking Block[] @relation("Blocker")
blockedBy Block[] @relation("Blocked")
reports Report[] @relation("Reporter")
// Phase 3 back-relations
reactions Reaction[] @relation("PetReactions")
comments Comment[] @relation("PetComments")
reposts Repost[] @relation("PetReposts")
// Phase 4 back-relations
receivedNotifications Notification[] @relation("ReceivedNotifications")
sentNotifications Notification[] @relation("SentNotifications")
// Phase 6 back-relations
conversationsAsA Conversation[] @relation("ConvPetA")
conversationsAsB Conversation[] @relation("ConvPetB")
sentMessages Message[] @relation("MessageSender")
// Phase 7 back-relations
weightLogs WeightLog[]
vetVisits VetVisit[]
vaccines Vaccine[]
healthCard HealthCard?
emergencyVet EmergencyVet?
feedingInfo PetFeedingInfo?
// Ad interaction back-relations
adReactions AdReaction[] @relation("AdReactions")
adComments AdComment[] @relation("AdComments")
adReposts AdRepost[] @relation("AdReposts")
verificationRequests VerificationRequest[]
@@index([ownerId])
@@index([speciesId])
@@index([breedId])
@@index([createdAt(sort: Desc)])
}
// ─── Phase 6 Enums ───────────────────────────────────────────────────────────
enum DmPolicy {
EVERYONE // anyone can send a DM
FOLLOWERS_ONLY // only pets that this pet follows can send a DM
}
// ─── Phase 2 Enums ───────────────────────────────────────────────────────────
enum PostType {
PHOTO
MILESTONE
REPOST
VIDEO
}
enum VideoStatus {
PROCESSING
READY
ERROR
}
enum MilestoneType {
BIRTHDAY
ADOPTION_DAY
FIRST_OUTING
VET_VISIT
LITTER
FOLLOWERS_500
FOLLOWERS_1K
FOLLOWERS_2_5K
FOLLOWERS_5K
FOLLOWERS_10K
FOLLOWERS_25K
FOLLOWERS_50K
FOLLOWERS_100K
FOLLOWERS_250K
FOLLOWERS_500K
FOLLOWERS_1M
}
enum ReportReason {
SPAM
HARASSMENT
INAPPROPRIATE
MISINFORMATION
OTHER
}
// ─── Phase 2 Models ──────────────────────────────────────────────────────────
model Post {
id String @id @default(cuid())
pet Pet @relation(fields: [petId], references: [id], onDelete: Cascade)
petId String
type PostType @default(PHOTO)
caption String? @db.VarChar(500)
images PostImage[]
milestone Milestone?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Moderation "hide without delete" (Gitea #10 Phase 3) — set by an admin
// HIDE_POST action, cleared by UNHIDE_POST. Data stays intact for record
// purposes; every reader-facing query (feed/explore/profile/search/
// hashtag/milestones) filters hiddenAt: null. Admin views intentionally
// do NOT filter on this so moderators can still see + unhide.
hiddenAt DateTime?
// Denormalized engagement counters — kept in sync transactionally at each
// reaction/comment/repost create+delete site (reactions.ts, comments.ts,
// reposts.ts, admin.ts deleteComment). Avoids live _count aggregation on
// feed/explore reads. Backfilled from existing rows in the same migration.
reactionCount Int @default(0)
commentCount Int @default(0)
repostCount Int @default(0)
// Phase 3 back-relations
reactions Reaction[] @relation("PostReactions")
comments Comment[] @relation("PostComments")
repost Repost? @relation("RepostEntry")
originalReposts Repost[] @relation("OriginalReposts")
hashtags PostHashtag[] @relation("PostHashtags")
// Phase 4 back-relations
notifications Notification[]
// Phase 5 back-relation
videoPost VideoPost?
@@index([petId, createdAt(sort: Desc)])
}
model PostImage {
id String @id @default(cuid())
post Post @relation(fields: [postId], references: [id], onDelete: Cascade)
postId String
storageKey String // Supabase Storage object key
position Int // 0-based order in carousel
width Int?
height Int?
blurhash String? // computed client-side at upload (src/lib/blurhash.ts), decoded as a loading placeholder
@@unique([postId, position])
@@index([postId])
}
model Milestone {
id String @id @default(cuid())
post Post @relation(fields: [postId], references: [id], onDelete: Cascade)
postId String @unique
milestoneType MilestoneType
notes String? @db.VarChar(500) // free text for 'litter' type (D-09)
}
model Story {
id String @id @default(cuid())
pet Pet @relation(fields: [petId], references: [id], onDelete: Cascade)
petId String
storageKey String // Supabase Storage object key
blurhash String? // computed client-side at upload (src/lib/blurhash.ts), decoded as a loading placeholder
expiresAt DateTime // set to NOW() + 24 hours on create (D-07)
views StoryView[]
createdAt DateTime @default(now())
@@index([petId, expiresAt]) // supports lazy-delete filter query
}
model StoryView {
story Story @relation(fields: [storyId], references: [id]) // NO onDelete:Cascade (D-08)
storyId String
viewerPet Pet @relation("StoryViewer", fields: [viewerPetId], references: [id])
viewerPetId String
viewedAt DateTime @default(now())
@@id([storyId, viewerPetId]) // deduplication: one view per viewer per story
@@index([viewedAt]) // supports 30-day cleanup job (D-08)
}
model Follow {
follower Pet @relation("Follower", fields: [followerPetId], references: [id], onDelete: Cascade)
followerPetId String
followee Pet @relation("Followee", fields: [followeePetId], references: [id], onDelete: Cascade)
followeePetId String
createdAt DateTime @default(now())
@@id([followerPetId, followeePetId])
@@index([followerPetId])
@@index([followeePetId])
}
model Block {
blocker Pet @relation("Blocker", fields: [blockerPetId], references: [id], onDelete: Cascade)
blockerPetId String
blocked Pet @relation("Blocked", fields: [blockedPetId], references: [id], onDelete: Cascade)
blockedPetId String
createdAt DateTime @default(now())
@@id([blockerPetId, blockedPetId])
@@index([blockerPetId])
}
model Report {
id String @id @default(cuid())
reporter Pet @relation("Reporter", fields: [reporterPetId], references: [id])
reporterPetId String
targetPostId String? // null if reporting a profile
targetPetId String? // null if reporting a post
reason ReportReason
notes String? @db.VarChar(500)
createdAt DateTime @default(now())
@@unique([reporterPetId, targetPostId])
@@unique([reporterPetId, targetPetId])
@@index([reporterPetId])
@@index([targetPostId])
}
// ─── Phase 3 Models ──────────────────────────────────────────────────────────
model Reaction {
id String @id @default(cuid())
post Post @relation("PostReactions", fields: [postId], references: [id], onDelete: Cascade)
postId String
pet Pet @relation("PetReactions", fields: [petId], references: [id], onDelete: Cascade)
petId String
createdAt DateTime @default(now())
@@unique([postId, petId])
@@index([postId])
}
model Comment {
id String @id @default(cuid())
post Post @relation("PostComments", fields: [postId], references: [id], onDelete: Cascade)
postId String
pet Pet @relation("PetComments", fields: [petId], references: [id], onDelete: Cascade)
petId String
body String @db.VarChar(500)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Phase 4 back-relation
notifications Notification[]
@@index([postId, createdAt(sort: Asc)])
@@index([petId])
}
model Repost {
post Post @relation("RepostEntry", fields: [postId], references: [id], onDelete: Cascade)
postId String @unique
originalPost Post @relation("OriginalReposts", fields: [originalPostId], references: [id])
originalPostId String
reposterPet Pet @relation("PetReposts", fields: [reposterPetId], references: [id], onDelete: Cascade)
reposterPetId String
createdAt DateTime @default(now())
@@id([reposterPetId, originalPostId])
@@index([originalPostId])
}
model Hashtag {
id String @id @default(cuid())
tag String @unique
posts PostHashtag[]
}
model PostHashtag {
post Post @relation("PostHashtags", fields: [postId], references: [id], onDelete: Cascade)
postId String
hashtag Hashtag @relation(fields: [hashtagId], references: [id], onDelete: Cascade)
hashtagId String
@@id([postId, hashtagId])
@@index([hashtagId])
}
// ─── Phase 5 Models ──────────────────────────────────────────────────────────
model VideoPost {
id String @id @default(cuid())
post Post @relation(fields: [postId], references: [id], onDelete: Cascade)
postId String @unique
muxUploadId String? // Mux direct upload ID — set on creation
muxAssetId String? // Mux asset ID — set by video.upload.asset_created webhook
muxPlaybackId String? // Mux playback ID — set by video.asset.ready webhook
status VideoStatus @default(PROCESSING)
durationSecs Float? // populated by webhook
aspectRatio String? // e.g. "16:9" from Mux
@@index([muxUploadId])
@@index([muxAssetId])
}
// ─── Phase 6 Models ──────────────────────────────────────────────────────────
model Conversation {
id String @id @default(cuid())
petA Pet @relation("ConvPetA", fields: [petAId], references: [id], onDelete: Cascade)
petAId String
petB Pet @relation("ConvPetB", fields: [petBId], references: [id], onDelete: Cascade)
petBId String
messages Message[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt // updated on every new message for inbox ordering
@@unique([petAId, petBId]) // petAId is always < petBId (sorted on create) — deduplication
@@index([petAId])
@@index([petBId])
}
model Message {
id String @id @default(cuid())
conversation Conversation @relation(fields: [conversationId], references: [id], onDelete: Cascade)
conversationId String
senderPet Pet @relation("MessageSender", fields: [senderPetId], references: [id])
senderPetId String
body String @db.VarChar(1000)
readAt DateTime? // null = unread by recipient
createdAt DateTime @default(now())
@@index([conversationId, createdAt(sort: Asc)])
@@index([senderPetId])
}
// ─── Phase 7 Models ──────────────────────────────────────────────────────────
model WeightLog {
id String @id @default(cuid())
pet Pet @relation(fields: [petId], references: [id], onDelete: Cascade)
petId String
date DateTime @db.Date
weightGrams Int // stored in grams; display as kg
notes String? @db.VarChar(200)
createdAt DateTime @default(now())
@@index([petId, date(sort: Desc)])
}
model VetVisit {
id String @id @default(cuid())
pet Pet @relation(fields: [petId], references: [id], onDelete: Cascade)
petId String
date DateTime @db.Date
reason String @db.VarChar(100)
notes String? @db.VarChar(500)
vetName String? @db.VarChar(100)
createdAt DateTime @default(now())
@@index([petId, date(sort: Desc)])
}
model Vaccine {
id String @id @default(cuid())
pet Pet @relation(fields: [petId], references: [id], onDelete: Cascade)
petId String
name String @db.VarChar(100)
dateGiven DateTime @db.Date
nextDueDate DateTime? @db.Date
notes String? @db.VarChar(200)
createdAt DateTime @default(now())
@@index([petId, dateGiven(sort: Desc)])
}
model EmergencyVet {
id String @id @default(cuid())
pet Pet @relation(fields: [petId], references: [id], onDelete: Cascade)
petId String @unique
name String @db.VarChar(150)
address String? @db.VarChar(300)
phone String? @db.VarChar(30)
website String? @db.VarChar(200)
lat Float?
lng Float?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model HealthCard {
id String @id @default(cuid())
pet Pet @relation(fields: [petId], references: [id], onDelete: Cascade)
petId String @unique
token String @unique @default(cuid())
expiresAt DateTime? // null = never expires; set on create/regenerate
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
// GDPR Art. 15 data-export download token (Gitea #6 follow-up). Deliberately
// a bespoke DB-backed token instead of emailing Supabase's native signed URL
// directly — the download link handed to the user must read pawfeed.org, not
// the storage backend. /api/gdpr-export/[token] validates against this table
// and streams the file server-side; storageKey/bucket stay an internal detail.
model DataExportToken {
id String @id @default(cuid())
owner Owner @relation(fields: [ownerId], references: [id], onDelete: Cascade)
ownerId String
token String @unique @default(cuid())
storageKey String
expiresAt DateTime
createdAt DateTime @default(now())
@@index([ownerId])
}
model PetFeedingInfo {
id String @id @default(cuid())
pet Pet @relation(fields: [petId], references: [id], onDelete: Cascade)
petId String @unique
foodType String? @db.VarChar(100)
feedingTimes Int?
dailyAmountG Int?
foodBrand String? @db.VarChar(100)
specialDiet String? @db.VarChar(500)
updatedAt DateTime @updatedAt
}
// ─── Phase 4 Enums ───────────────────────────────────────────────────────────
enum NotificationType {
FOLLOW
REACTION
COMMENT
MESSAGE
BIRTHDAY
ADOPTION_DAY
MENTION
PAW_BACK
}
// ─── Phase 4 Models ──────────────────────────────────────────────────────────
model Notification {
id String @id @default(cuid())
recipientPet Pet @relation("ReceivedNotifications", fields: [recipientPetId], references: [id], onDelete: Cascade)
recipientPetId String
type NotificationType
actorPet Pet? @relation("SentNotifications", fields: [actorPetId], references: [id], onDelete: SetNull)
actorPetId String?
post Post? @relation(fields: [postId], references: [id], onDelete: Cascade)
postId String?
comment Comment? @relation(fields: [commentId], references: [id], onDelete: Cascade)
commentId String?
read Boolean @default(false)
createdAt DateTime @default(now())
@@index([recipientPetId, read, createdAt(sort: Desc)])
}
// ─── Admin / Moderation Models ────────────────────────────────────────────────
enum AdminRoleType {
SUPER_ADMIN
MODERATOR
// Full admin-panel access, same as MODERATOR today — awaiting the
// permission-matrix pass to scope this down to a narrower read/act set.
SUPPORT
}
model AdminRole {
id String @id @default(cuid())
owner Owner @relation(fields: [ownerId], references: [id], onDelete: Cascade)
ownerId String @unique
role AdminRoleType @default(MODERATOR)
grantedAt DateTime @default(now())
grantedBy String? // ownerId of the granting super admin
}
enum BlacklistEntryType {
WORD
LINK
HASHTAG
}
// Retroactive-capable content filter (Gitea #10 Phase 3) — adding an entry
// scans existing non-hidden posts for a match and auto-hides them via the
// same Post.hiddenAt mechanism a human moderator uses (admin.hidePost),
// stamped with a HIDE_POST ModerationLog entry so it's auditable exactly
// like a manual hide. New posts are checked at creation time too
// (posts.create) and hidden immediately if they match. Removing an entry
// does NOT auto-unhide already-hidden posts — that stays a manual review
// step via the existing hide/unhide UI.
model BlacklistEntry {
id String @id @default(cuid())
type BlacklistEntryType
value String @db.VarChar(200) // normalized lowercase: word, link domain, or hashtag (no #)
createdAt DateTime @default(now())
createdBy String // ownerId of the moderator who added it
@@unique([type, value])
@@index([type])
}
model UserBan {
id String @id @default(cuid())
owner Owner @relation(fields: [ownerId], references: [id], onDelete: Cascade)
ownerId String @unique
reason String? @db.VarChar(500)
bannedAt DateTime @default(now())
bannedBy String // ownerId of moderator who issued the ban
expiresAt DateTime? // null = permanent ban
}
// Stealth moderation (Gitea #10 Phase 3) — unlike UserBan, the owner is never
// told and can still use the app normally. Every reader-facing post query
// excludes posts from a shadowbanned owner's pets for everyone except that
// owner's own view (see feed/explore/posts/search/milestones routers), so
// the content quietly stops reaching anyone else without a visible signal.
model Shadowban {
id String @id @default(cuid())
owner Owner @relation(fields: [ownerId], references: [id], onDelete: Cascade)
ownerId String @unique
reason String @db.VarChar(500)
shadowbannedAt DateTime @default(now())
shadowbannedBy String // ownerId of moderator who issued it
expiresAt DateTime? // null = permanent
}
model Advertisement {
id String @id @default(cuid())
title String @db.VarChar(100)
caption String? @db.VarChar(500)
imageKey String?
targetUrl String? @db.VarChar(500)
isActive Boolean @default(true)
species String[] // targeted Species ids; empty = all species (no restriction)
breeds String[] // targeted Breed ids, narrows WITHIN the species selection above; empty = no breed restriction
startDate DateTime?
endDate DateTime?
createdBy String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Denormalized engagement counters — see Post model comment for rationale.
// adViewCount is incremented once per ad-card impression (client fires
// ads.recordView the first time the card is ≥50% scrolled into view —
// see AdCard.tsx), not deduplicated per pet, matching standard ad-impression
// semantics rather than the per-pet-once pattern reactions/reposts use.
adViewCount Int @default(0)
adReactionCount Int @default(0)
adCommentCount Int @default(0)
adRepostCount Int @default(0)
reactions AdReaction[]
comments AdComment[]
reposts AdRepost[]
@@index([isActive, startDate, endDate])
}
model AdReaction {
id String @id @default(cuid())
ad Advertisement @relation(fields: [adId], references: [id], onDelete: Cascade)
adId String
pet Pet @relation("AdReactions", fields: [petId], references: [id], onDelete: Cascade)
petId String
createdAt DateTime @default(now())
@@unique([adId, petId])
@@index([adId])
}
model AdComment {
id String @id @default(cuid())
ad Advertisement @relation(fields: [adId], references: [id], onDelete: Cascade)
adId String
pet Pet @relation("AdComments", fields: [petId], references: [id], onDelete: Cascade)
petId String
body String @db.VarChar(500)
createdAt DateTime @default(now())
@@index([adId])
}
model AdRepost {
id String @id @default(cuid())
ad Advertisement @relation(fields: [adId], references: [id], onDelete: Cascade)
adId String
pet Pet @relation("AdReposts", fields: [petId], references: [id], onDelete: Cascade)
petId String
createdAt DateTime @default(now())
@@unique([adId, petId])
@@index([adId])
}
model ModerationLog {
id String @id @default(cuid())
action String @db.VarChar(50) // DELETE_POST | DELETE_COMMENT | BAN_USER | UNBAN_USER | DELETE_IMAGE | DISMISS_REPORT | CREATE_AD | UPDATE_AD | DELETE_AD | GRANT_ROLE | REVOKE_ROLE | WARN_USER | VERIFY_PET | UNVERIFY_PET | DELETE_ACCOUNT | APPROVE_VERIFICATION_REQUEST | REJECT_VERIFICATION_REQUEST | HIDE_POST | UNHIDE_POST | SHADOWBAN_USER | UNSHADOWBAN_USER
targetType String @db.VarChar(20) // POST | COMMENT | USER | IMAGE | AD | REPORT | PET
targetId String
reason String? @db.VarChar(500)
moderatorId String // ownerId
// Both nullable: added after the table already had rows, so historical
// entries predate capture. Every new write always populates both — role is
// "BOOTSTRAP" for the env-configured ADMIN_OWNER_ID (no AdminRole row of
// its own), otherwise the AdminRoleType held at the time of the action.
moderatorRole String? @db.VarChar(20)
ipAddress String? @db.VarChar(45) // max IPv6 length; login-abuse / audit trail
createdAt DateTime @default(now())
@@index([moderatorId, createdAt(sort: Desc)])
@@index([targetType, targetId])
}
// ─── Legal Document Versioning ─────────────────────────────────────────────
// Pull-based, not push-based: publishing a version doesn't fan out a
// Notification row per pet (Notification is petId-scoped; ToS/Datenschutz
// acceptance is an Owner-level concern). Instead, each owner's session
// compares Owner.legalAcknowledgedAt against the latest publishedAt.
enum LegalDocType {
NUTZUNGSBEDINGUNGEN
DATENSCHUTZ
}
model LegalDocumentVersion {
id String @id @default(cuid())
docType LegalDocType
changeSummary String @db.VarChar(1000)
publishedAt DateTime @default(now())
publishedBy String // ownerId of the publishing admin
@@index([publishedAt(sort: Desc)])
}