Files
petfeed/prisma/schema.prisma
T
adminandClaude Opus 5.5 c74bbffccb feat: Datumseingabe TT.MM.JJJJ (App) + Erinnerung an Folgeimpfung
Datum (found.md, iOS-Feedback): neue DateField-Komponente mit Ziffern-
tastatur und automatisch eingefügten Punkten (TT.MM.JJJJ) für Geburtstag,
Adoptionstag, Gewicht, Tierarzt und Impfungen. Parser akzeptiert weiter
"-" und "/" sowie einstellige Tage/Monate. Wheel-Picker bleibt in
PENDING-NATIVE-BUILD.md (natives Modul).

Impf-Erinnerung (found.md, iOS-Feedback): täglicher Cron
/api/cron/vaccine-reminders erinnert einmalig 30 Tage vor
Vaccine.nextDueDate per Mitteilung (VACCINE_DUE) + Push mit Impfname und
Datum. Schema: NotificationType.VACCINE_DUE, Notification.vaccineId
(Cascade), Vaccine.reminderSentAt (Claim gegen Doppelversand, wird bei
Fehler wieder freigegeben). Web + App zeigen die Mitteilung an, Tippen
öffnet die Gesundheitsseite. App: Schnellauswahl +1/+3 Jahre für den
Folgetermin plus Hinweis auf die Erinnerung.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 12:42:20 +02:00

1096 lines
44 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
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? // mobile app's Expo push token, registered via owner.registerPushToken; single device only, overwritten on re-register
// 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[]
// 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
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[]
// 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
}
// ─── 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
@@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
}
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])
}
// ─── 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
}
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)
}
// ─── 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?
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
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])
}