Files
adminandClaude Opus 5.5 77cdebec0f feat(admin): Melde-Statistik — wer oft gemeldet wird und wer oft falsch meldet
- Neue Tabelle ReportDecision (nur anhängen, ohne Fremdschlüssel): jede
  Moderationsentscheidung zu einer Meldung (bestätigt/abgewiesen) mit
  Melder und gemeldeter Seite als Momentaufnahme.
- Geschrieben von dismissReport, deletePostAsModerator, dem neuen
  upholdReport (Profil-Meldungen bestätigen) und petAdmin.delete.
- Auswertung: Abzeichen und Zahlen in der Nutzerliste, im Tier-Detail und
  in der Warteschlange („Melder: häufige Fehlmeldungen“), Tab „Auswertung“
  auf der Meldungen-Seite mit Ranglisten.
- Datenschutz: Tier gelöscht → Tier-/Beitrags-IDs anonymisiert, Konto
  gelöscht → Owner-IDs anonymisiert, Zählung der Gegenseite bleibt. Teil der
  Art.-15-Auskunft (ohne Gegenseite), Abschnitt 4e in der
  Datenschutzerklärung.

Braucht vor dem Deploy: prisma db push + prisma/enable_rls_report_decisions.sql.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 07:16:08 +02:00

1248 lines
51 KiB
Plaintext
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
registrationIp String? @db.VarChar(45) // captured once at the earliest guaranteed owner touchpoint, see legal.acknowledgeUploadConsent
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
hideSpiders Boolean @default(false) // Arachnophobie-Helfer: hides Species="Spider" pets/posts from feed/explore/search when true
petSuggestionsEnabled Boolean @default(true) // opt-out for the PET_SUGGESTION cron notification (src/app/api/cron/pet-suggestions)
expoPushToken String? // ALT (bis 10/2026): ein Gerät pro Konto. Wird nicht mehr geschrieben, beim Versand nur noch mitgelesen — neue Geräte stehen in PushToken
// Sponsoring (Gitea #22) — freiwilliger Beitrag/Abo gegen ein "Sponsor"-Badge.
stripeCustomerId String? @unique
sponsorSince DateTime? // permanent for one-time givers; for subscribers, valid only through sponsorPeriodEnd
sponsorPeriodEnd DateTime? // only set for an active subscription; controls how long the badge stays visible after cancellation
sponsorSubscriptionId String? @unique
sponsorSubscriptionStatus String? @db.VarChar(20) // mirrors Stripe subscription.status
contributions SponsorContribution[]
broadcastReceipts BroadcastReceipt[]
pushTokens PushToken[]
caretaking PetCaretaker[]
// Empfehlungsprogramm — Clerk-unabhängig: der Code wird erst nach der
// Registrierung im Onboarding eingelöst (src/lib/referrals.ts). invitedById
// oben ist dasselbe Feld wie im alten InviteCode-Ablauf.
referralCode String? @unique @db.VarChar(16) // persönlich, dauerhaft, unbegrenzt nutzbar, z. B. BUBI-7K3M
invitedAt DateTime? // Zeitpunkt der Code-Einlösung
referralQualifiedAt DateTime? // dieser (eingeladene) Owner hat ein Tier + ersten Beitrag → zählt beim Einladenden
referralFlaggedAt DateTime? // Qualifizierung lag über dem Tageslimit des Einladenden → wartet auf Admin-Freigabe, zählt noch nicht
referralRevokedAt DateTime? // vom Admin aberkannt → zählt nicht (mehr)
qualifiedReferralCount Int @default(0) // denormalisiert, nur in src/lib/referrals.ts gepflegt
referralRankId String? // Cache: höchster aktiver erreichter Rang, gepflegt von src/lib/referrals.ts
referralRank ReferralRank? @relation(fields: [referralRankId], references: [id], onDelete: SetNull)
referralPerks ReferralPerk[] @default([]) // Cache: Vereinigung der Vorteile aller erreichten aktiven Ränge, gepflegt von src/lib/referrals.ts
rankAchievements ReferralRankAchievement[]
acquisitionSource AcquisitionSource? // freiwillige Onboarding-Angabe "Wie hast du von PawFeed erfahren?"
acquisitionAnsweredAt DateTime?
@@index([createdAt(sort: Desc)])
@@index([invitedById])
}
enum AcquisitionSource {
TIKTOK
INSTAGRAM
YOUTUBE
FACEBOOK
FRIENDS
SHELTER_OR_PARTNER
STORE_SEARCH
PRESS
OTHER
}
// ─── Empfehlungsprogramm ─────────────────────────────────────────────────────
// Ränge sind Daten (im Admin-Panel unter /p/[secret]/referrals bearbeitbar);
// nur die Vorteile (ReferralPerk) brauchen Code. Vorteile sind kumulativ über
// alle erreichten Ränge. Erreichte Ränge bleiben erhalten, auch wenn eine
// Schwelle später erhöht wird (Bestandsschutz über ReferralRankAchievement).
enum ReferralPerk {
PROFILE_RING
NAME_BADGE
FEATURE_POOL
VERIFICATION_PRIORITY
CURATOR_SPOTLIGHT
EXPLORE_HIGHLIGHT // vorgesehen, noch nicht umgesetzt
HALL_OF_FAME // vorgesehen, noch nicht umgesetzt
EARLY_ACCESS // vorgesehen, noch nicht umgesetzt
VOUCHER // vorgesehen für Partner-Gutscheine, noch nicht umgesetzt
}
model ReferralRank {
id String @id @default(cuid())
key String @unique @db.VarChar(40) // stabil, nach Anlage nicht änderbar
threshold Int @unique // qualifizierte Einladungen
nameDe String @db.VarChar(40)
nameEn String @db.VarChar(40)
descriptionDe String @db.VarChar(200)
descriptionEn String @db.VarChar(200)
icon String @db.VarChar(30) // Schlüssel aus REFERRAL_ICON_KEYS (src/lib/referral-ranks.ts)
color String @db.VarChar(7) // #RRGGBB
perks ReferralPerk[]
isActive Boolean @default(true) // deaktivieren statt löschen — Achievements verweisen darauf
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
owners Owner[]
achievements ReferralRankAchievement[]
}
// Wer hat welchen Rang wann erreicht — Grundlage für Bestandsschutz,
// einmalige Rang-Benachrichtigungen und spätere Gutscheine.
model ReferralRankAchievement {
id String @id @default(cuid())
owner Owner @relation(fields: [ownerId], references: [id], onDelete: Cascade)
ownerId String
rank ReferralRank @relation(fields: [rankId], references: [id], onDelete: Restrict)
rankId String
achievedAt DateTime @default(now())
// Admin-Steuerung (Tierverwaltung): ein entzogener Rang bleibt als Zeile
// stehen, damit ihn awardRanks/recomputeReferralRanks nicht neu vergeben —
// er zählt erst wieder, wenn ein Admin ihn freigibt (revokedAt = null).
revokedAt DateTime?
revokedBy String? // ownerId des Admins
revokeReason String? @db.VarChar(500)
grantedManually Boolean @default(false) // vom Admin vergeben (besondere Leistung), nicht über den Zähler
grantedBy String? // ownerId des Admins
@@unique([ownerId, rankId])
@@index([rankId])
}
// Kurator-Konto „PawFeed Team“: Begrüßung neuer Tiere. Genau eine Zeile
// (id = 1), im Admin-Panel unter /p/[secret]/curator bearbeitbar.
// {name} im Text wird durch den Tiernamen ersetzt.
model CuratorSettings {
id Int @id @default(1)
welcomeEnabled Boolean @default(true) // Begrüßungs-Direktnachricht an jedes neue Tier
autoFollowEnabled Boolean @default(true) // Team-Tier folgt jedem neuen Tier
welcomeTextDe String @db.VarChar(1000)
welcomeTextEn String @db.VarChar(1000)
updatedAt DateTime @updatedAt
updatedBy String? // ownerId des Admins
}
// Genau eine Zeile (id = 1), im Admin-Panel bearbeitbar.
model ReferralSettings {
id Int @id @default(1)
programActive Boolean @default(true)
redeemWindowDays Int @default(7)
dailyQualifyCap Int @default(20)
autoMutualFollow Boolean @default(true)
updatedAt DateTime @updatedAt
updatedBy String? // ownerId des Admins
}
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
nickname String? @db.VarChar(30) // Rufname/Spitzname; shown after the real name, e.g. "Rex (Rexi)" — see src/lib/pet-display-name.ts
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)
isFoundingMember Boolean @default(false) // eines der ersten 250 Tiere — einmalig in pets.create gesetzt (src/lib/new-pet-welcome.ts), nie wieder entzogen
dmPolicy DmPolicy @default(EVERYONE) // who can send this pet DMs
followerVisibility FollowerVisibility @default(EVERYONE) // who can view this pet's followers/following lists (Gitea #33)
birthday DateTime? @db.Date // actual birthday for anniversary notifications
adoptedAt DateTime? @db.Date // adoption date for anniversary notifications
// Gedenkmodus „In Erinnerung“ (Roadmap 04.10.): vom Besitzer gesetzt, rückgängig
// machbar. Verstorbene Tiere bekommen keine Geburtstags-/Impf-Mitteilungen und
// werden nicht vorgeschlagen; Abzeichen + Banner in Web und App.
deceasedAt DateTime? @db.Date
// Vermisst-Meldung (Roadmap 04.10.): Zeitpunkt der aktiven Meldung, sonst null.
// Cache von LostReport (foundAt = null) für das Abzeichen in Feed/Profil —
// nur src/trpc/routers/lost-reports.ts schreibt es.
lostSince DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Explicit, un-pre-checked opt-in required before the Health-Tracking area
// (weight, vet visits, vaccines, emergency vet, feeding, Health Card) can be
// used for this pet — see datenschutz Abschnitt 3 (Art. 9 Abs. 2 lit. a
// DSGVO). Revoking sets this back to null and healthConsentRevokedAt to
// now(), and health.revokeConsent deletes every health record for this pet
// in the same transaction — there is no soft-delete/undo.
healthConsentGivenAt DateTime?
healthConsentRevokedAt DateTime?
// Phase 2 back-relations
posts Post[]
stories Story[]
storyViews StoryView[] @relation("StoryViewer")
storyReactions StoryReaction[] @relation("StoryReactions")
storyComments StoryComment[] @relation("StoryComments")
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[]
// Story-Editor: Storys, in denen dieses Tier markiert wurde
storyMentions StoryMention[] @relation("StoryMentions")
// Vermisst-Meldungen (aktive: foundAt = null, höchstens eine)
lostReports LostReport[]
caretaker PetCaretaker?
// Gitea #18 — legal AI-content disclosure. Set once at avatar upload,
// owner-immutable afterward; only a moderator can correct it (see
// admin.setAiDisclosure). Overlay-badge only, never baked into pixels.
aiDisclosure AiDisclosure @default(NONE)
@@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
}
// Gitea #33 — who may view a pet's followers/following lists.
enum FollowerVisibility {
EVERYONE // anyone can view the lists
FOLLOWERS_ONLY // only pets that follow this pet can view the lists
NOBODY // only the owner can view the lists
}
// Gitea #18 — legal AI-content disclosure, shared across Post/Story/Pet.
enum AiDisclosure {
NONE
AI_MODIFIED
AI_GENERATED
}
// ─── 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
}
/// Ausgang einer Moderationsentscheidung zu einer Meldung (ReportDecision).
enum ReportOutcome {
UPHELD // bestätigt: Beitrag gelöscht, Tier gelöscht oder Profil-Meldung bestätigt
DISMISSED // abgewiesen
}
// ─── 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?
// Admin curation (Gitea #10 Phase 4) — set by PIN_POST/UNPIN_POST. Pinned
// posts skip the 48h trending window in explore.getTrending and sort first.
pinnedAt 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)
// Gitea #18 — legal AI-content disclosure, set once at creation, owner-
// immutable afterward. See admin.setAiDisclosure for moderator correction.
aiDisclosure AiDisclosure @default(NONE)
// 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
mediaType StoryMediaType @default(IMAGE)
storageKey String? // Supabase Storage object key — IMAGE stories only
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())
// VIDEO stories — mirrors VideoPost. Typically 9:16 (phone-shot, portrait),
// unlike normal feed videos which skew 16:9 — aspectRatio drives rendering,
// never assume landscape. Duration capped at 30s (see
// MAX_STORY_VIDEO_DURATION_SECS in stories.ts), enforced client-side
// before upload and again server-side when the asset becomes ready.
muxUploadId String?
muxAssetId String?
muxPlaybackId String?
videoStatus VideoStatus?
durationSecs Float?
aspectRatio String? // e.g. "9:16" from Mux
// Gitea #18 — legal AI-content disclosure, set once at creation.
aiDisclosure AiDisclosure @default(NONE)
// Gitea #29 — Denormalized engagement counters, same rationale as Post
// (see Post model comment). Maintained transactionally in stories.ts.
reactionCount Int @default(0)
commentCount Int @default(0)
reactions StoryReaction[]
comments StoryComment[]
// Story music overlay — optional, admin-curated catalog only (no user
// uploads, licensing liability). onDelete: SetNull so retiring/deleting a
// MusicTrack never orphans historical stories that already used it.
musicTrack MusicTrack? @relation(fields: [musicTrackId], references: [id], onDelete: SetNull)
musicTrackId String?
musicOffsetSecs Float? @default(0) // start point within the track, since story display duration is fixed
musicMuteOriginal Boolean @default(false) // VIDEO stories only — mute the baked-in Mux audio in favor of the track
// Story-Editor (feature/story-editor): Text, Markierungen, GIFs und Sticker
// als Daten über dem Medium, nicht ins Bild gerechnet — validiert über
// storyOverlaysSchema (src/lib/story-overlays.ts). Positionen relativ zur
// Medienfläche, daher mediaWidth/-Height auch für Bilder.
overlays Json?
background String? @db.VarChar(30) // Farbverlauf-Schlüssel, nur TEXT-Storys
mediaWidth Int?
mediaHeight Int?
mentions StoryMention[]
notifications Notification[]
@@index([petId, expiresAt]) // supports lazy-delete filter query
@@index([muxUploadId])
@@index([muxAssetId])
@@index([musicTrackId])
}
// Admin-curated royalty-free music catalog for the Story music overlay.
// Deliberately no user-upload path — copyright liability stays with the
// admin who uploads and records `licenseNote` for each track.
model MusicTrack {
id String @id @default(cuid())
title String @db.VarChar(100)
artist String @db.VarChar(100)
licenseNote String @db.VarChar(500) // license/source reference, required for compliance
storageKey String // Supabase Storage object key, MUSIC_STORAGE_BUCKET
durationSecs Float
active Boolean @default(true) // soft-disable instead of deleting so past stories keep working
createdBy String // ownerId of the admin who uploaded it
createdAt DateTime @default(now())
stories Story[]
@@index([active])
}
enum StoryMediaType {
IMAGE
VIDEO
TEXT // reine Text-Story auf Farbverlauf (Story.background), ohne Foto/Video
}
// Markierte Tiere einer Story — eigene Tabelle (nicht nur im overlays-JSON),
// damit Block-/Sichtbarkeitsregeln, Benachrichtigungen und Löschungen sauber
// greifen. Cascade in beide Richtungen.
model StoryMention {
id String @id @default(cuid())
story Story @relation(fields: [storyId], references: [id], onDelete: Cascade)
storyId String
pet Pet @relation("StoryMentions", fields: [petId], references: [id], onDelete: Cascade)
petId String
createdAt DateTime @default(now())
@@unique([storyId, petId])
@@index([petId])
}
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)
}
// Gitea #29 — story reactions/comments, mirrors Reaction/Comment (Post) and
// AdReaction/AdComment (Advertisement) rather than a shared postId/storyId
// polymorphic table, matching this codebase's existing per-target-type
// pattern (see AdReaction/AdComment above).
model StoryReaction {
id String @id @default(cuid())
story Story @relation(fields: [storyId], references: [id], onDelete: Cascade)
storyId String
pet Pet @relation("StoryReactions", fields: [petId], references: [id], onDelete: Cascade)
petId String
createdAt DateTime @default(now())
@@unique([storyId, petId])
@@index([storyId])
}
model StoryComment {
id String @id @default(cuid())
story Story @relation(fields: [storyId], references: [id], onDelete: Cascade)
storyId String
pet Pet @relation("StoryComments", fields: [petId], references: [id], onDelete: Cascade)
petId String
body String @db.VarChar(500)
createdAt DateTime @default(now())
@@index([storyId, createdAt(sort: Asc)])
@@index([petId])
}
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])
}
/// Melde-Statistik: eine Zeile pro entschiedener Meldung, nur anhängen, nie
/// ändern — außer Anonymisierung (src/lib/report-decisions.ts). Bewusst ohne
/// Fremdschlüssel, damit Zeilen gelöschte Beiträge/Tiere überleben: Beim
/// Löschen eines Tiers fallen seine Tier-/Beitrags-IDs weg, beim Löschen des
/// Kontos die Owner-IDs — die Zählung der jeweils anderen Seite bleibt gleich.
model ReportDecision {
id String @id @default(cuid())
reporterPetId String?
reporterOwnerId String?
/// Gemeldetes Tier bzw. das Tier, dem der gemeldete Beitrag gehörte (Momentaufnahme).
targetPetId String?
targetOwnerId String?
/// null bei Profil-Meldungen und nach Anonymisierung.
targetPostId String?
isProfileReport Boolean
reason ReportReason
outcome ReportOutcome
reportedAt DateTime
decidedAt DateTime @default(now())
decidedBy String
@@index([reporterPetId])
@@index([reporterOwnerId])
@@index([targetPetId])
@@index([targetOwnerId])
}
// ─── 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())
// Gesetzt vom Cron /api/cron/vaccine-reminders, sobald die Erinnerung
// (30 Tage vor nextDueDate) raus ist — verhindert Doppel-Erinnerungen.
reminderSentAt DateTime?
notifications Notification[]
@@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
}
// Vermisst-Meldung (Roadmap 04.10.). Öffentliche Teilen-Seite /vermisst/<code>
// ohne Login; Kontaktdaten und Auffindbarkeit wählt der Besitzer je Meldung.
// Nach dem Finden bleibt die Zeile (Seite zeigt „Wieder zu Hause“, ohne Kontakt).
model LostReport {
id String @id @default(cuid())
pet Pet @relation(fields: [petId], references: [id], onDelete: Cascade)
petId String
code String @unique
lastSeenAt DateTime
lastSeenPlace String @db.VarChar(120)
description String? @db.VarChar(1000)
contactPhone String? @db.VarChar(40)
contactEmail String? @db.VarChar(120)
allowIndexing Boolean @default(false) // Suchmaschinen dürfen die Seite finden
createdAt DateTime @default(now())
foundAt DateTime?
@@index([petId, createdAt])
}
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
PET_SUGGESTION
REFERRAL_JOINED // ein Tier ist über die eigene Einladung zu PawFeed gekommen
REFERRAL_RANK_UP // eigener Empfehlungsrang aufgestiegen (actorPetId = null)
VACCINE_DUE // Impfung wird in <= 30 Tagen fällig (vaccineId gesetzt, actorPetId = null)
STORY_MENTION // Tier wurde in einer Story markiert (storyId gesetzt)
STORY_COMMENT // Kommentar auf eine eigene Story (storyId gesetzt)
STORY_REACTION // Pfote auf eine eigene Story (storyId gesetzt)
PET_DECEASED // ein gefolgtes Tier ist verstorben (actorPetId = das Tier, kein Push)
PET_LOST // ein Tier aus dem Umfeld wird vermisst (actorPetId = das Tier, mit Push)
PET_FOUND // Entwarnung: das vermisste Tier ist wieder da (actorPetId = das Tier)
CARETAKER_REQUEST // jemand möchte das Tier mitbetreuen und wartet auf Freigabe (an das Tier selbst, nur Haupt-Besitzer handelt)
}
// ─── 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?
vaccine Vaccine? @relation(fields: [vaccineId], references: [id], onDelete: Cascade)
vaccineId String?
story Story? @relation(fields: [storyId], references: [id], onDelete: Cascade)
storyId 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 | SET_AI_DISCLOSURE | ENABLE_MAINTENANCE_MODE | DISABLE_MAINTENANCE_MODE | PIN_POST | UNPIN_POST | SEND_BROADCAST | DELETE_BROADCAST | CREATE_MUSIC_TRACK | UPDATE_MUSIC_TRACK | DELETE_MUSIC_TRACK | CREATE_REFERRAL_RANK | UPDATE_REFERRAL_RANK | DEACTIVATE_REFERRAL_RANK | UPDATE_REFERRAL_SETTINGS | APPROVE_REFERRAL | REVOKE_REFERRAL | REVOKE_REFERRAL_RANK | UPDATE_CURATOR_SETTINGS | GRANT_REFERRAL_RANK | RESTORE_REFERRAL_RANK | DELETE_PET | SET_FOUNDING_MEMBER | CLEAR_PET_CONTENT | ENABLE_STORY_EDITOR | DISABLE_STORY_EDITOR | CLOSE_LOST_REPORT | DELETE_LOST_REPORT
targetType String @db.VarChar(20) // POST | COMMENT | USER | IMAGE | AD | REPORT | PET | SYSTEM | MUSIC_TRACK
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)])
}
// Gitea #22 — one row per successful Stripe payment (one-time or a
// subscription's recurring invoice). Kept as a durable record independent
// of Stripe's own dashboard, and as the source for the owner-facing
// contribution history on /unterstuetzen.
model SponsorContribution {
id String @id @default(cuid())
owner Owner @relation(fields: [ownerId], references: [id], onDelete: Cascade)
ownerId String
type String @db.VarChar(20) // ONE_TIME | SUBSCRIPTION
amountCents Int
currency String @default("eur") @db.VarChar(3)
stripeCheckoutSessionId String? @unique
stripeInvoiceId String? @unique
createdAt DateTime @default(now())
@@index([ownerId, createdAt(sort: Desc)])
}
// ─── Broadcast / System-Mitteilungen (Gitea #10, Phase 1) ──────────────────
// Phase 1 is in-app-banner-only, pull-based — same philosophy as Legal
// Document Versioning above. Unlike that single-timestamp pattern, this
// tracks a receipt PER broadcast (not one rolling Owner timestamp) so the
// admin can see delivered/read counts per message, and so a later Phase 2
// (email escalation, deferred until the user base passes ~100) can target
// exactly the owners who haven't seen a specific broadcast yet.
model Broadcast {
id String @id @default(cuid())
title String @db.VarChar(200)
body String @db.VarChar(2000)
createdAt DateTime @default(now())
createdBy String // ownerId of the publishing admin
receipts BroadcastReceipt[]
@@index([createdAt(sort: Desc)])
}
// One row per (broadcast, owner), created lazily the first time the
// owner's banner query surfaces the broadcast — no fan-out at send time,
// same reasoning as Notification/LegalDocumentVersion.
model BroadcastReceipt {
id String @id @default(cuid())
broadcast Broadcast @relation(fields: [broadcastId], references: [id], onDelete: Cascade)
broadcastId String
owner Owner @relation(fields: [ownerId], references: [id], onDelete: Cascade)
ownerId String
seenAt DateTime @default(now()) // first time shown in their banner
readAt DateTime? // set when they click "Gelesen"; null = not yet acknowledged
@@unique([broadcastId, ownerId])
@@index([ownerId, readAt])
}
// Push an mehrere Geräte (Roadmap 05.10.): ein Eintrag pro Gerät/App-Installation.
// Der Token ist eindeutig — meldet sich auf demselben Handy ein anderes Konto an,
// wandert der Eintrag zu diesem Konto. Tokens, die Expo als „DeviceNotRegistered“
// meldet (App deinstalliert), löscht src/lib/notify.ts beim Versand.
model PushToken {
id String @id @default(cuid())
owner Owner @relation(fields: [ownerId], references: [id], onDelete: Cascade)
ownerId String
token String @unique
platform String? @db.VarChar(10) // "ios" | "android"
createdAt DateTime @default(now())
lastSeenAt DateTime @default(now()) // bei jeder erneuten Registrierung (App-Start) aktualisiert
@@index([ownerId, lastSeenAt(sort: Desc)])
}
// Mitbetreuer (Roadmap 05.10., Notiz „Gemeinsame Tierhalter – Machbarkeit“):
// höchstens EIN zweiter Mensch pro Tier (petId eindeutig). Ablauf:
// INVITED – Haupt-Besitzer hat einen Code erzeugt (ownerId noch leer)
// PENDING – jemand hat den Code eingelöst, wartet auf Freigabe des Haupt-Besitzers
// ACTIVE – darf als das Tier handeln (src/lib/assert-pet-ownership.ts)
// Nur der Haupt-Besitzer (Pet.ownerId) löscht das Tier, ändert die Privatsphäre,
// den Gedenkmodus und verwaltet den Mitbetreuer. Löscht er sein Konto, übernimmt
// ein aktiver Mitbetreuer das Tier (src/lib/delete-account.ts).
enum PetCaretakerStatus {
INVITED
PENDING
ACTIVE
}
model PetCaretaker {
id String @id @default(cuid())
pet Pet @relation(fields: [petId], references: [id], onDelete: Cascade)
petId String @unique
owner Owner? @relation(fields: [ownerId], references: [id], onDelete: Cascade)
ownerId String?
status PetCaretakerStatus @default(INVITED)
inviteCode String? @unique @db.VarChar(16)
inviteExpiresAt DateTime?
createdAt DateTime @default(now())
requestedAt DateTime?
approvedAt DateTime?
@@index([ownerId, status])
}