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/ // 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]) }