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.
782 lines
28 KiB
Plaintext
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)])
|
|
}
|