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? 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[] @@index([createdAt(sort: Desc)]) } 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 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") @@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 // 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()) @@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 } 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 } 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 } 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 targetType String @db.VarChar(20) // POST | COMMENT | USER | IMAGE targetId String reason String? @db.VarChar(500) moderatorId String // ownerId 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)]) }