- Neue Tabelle ReportDecision (nur anhängen, ohne Fremdschlüssel): jede Moderationsentscheidung zu einer Meldung (bestätigt/abgewiesen) mit Melder und gemeldeter Seite als Momentaufnahme. - Geschrieben von dismissReport, deletePostAsModerator, dem neuen upholdReport (Profil-Meldungen bestätigen) und petAdmin.delete. - Auswertung: Abzeichen und Zahlen in der Nutzerliste, im Tier-Detail und in der Warteschlange („Melder: häufige Fehlmeldungen“), Tab „Auswertung“ auf der Meldungen-Seite mit Ranglisten. - Datenschutz: Tier gelöscht → Tier-/Beitrags-IDs anonymisiert, Konto gelöscht → Owner-IDs anonymisiert, Zählung der Gegenseite bleibt. Teil der Art.-15-Auskunft (ohne Gegenseite), Abschnitt 4e in der Datenschutzerklärung. Braucht vor dem Deploy: prisma db push + prisma/enable_rls_report_decisions.sql. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1248 lines
51 KiB
Plaintext
1248 lines
51 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? // ALT (bis 10/2026): ein Gerät pro Konto. Wird nicht mehr geschrieben, beim Versand nur noch mitgelesen — neue Geräte stehen in PushToken
|
||
|
||
// Sponsoring (Gitea #22) — freiwilliger Beitrag/Abo gegen ein "Sponsor"-Badge.
|
||
stripeCustomerId String? @unique
|
||
sponsorSince DateTime? // permanent for one-time givers; for subscribers, valid only through sponsorPeriodEnd
|
||
sponsorPeriodEnd DateTime? // only set for an active subscription; controls how long the badge stays visible after cancellation
|
||
sponsorSubscriptionId String? @unique
|
||
sponsorSubscriptionStatus String? @db.VarChar(20) // mirrors Stripe subscription.status
|
||
contributions SponsorContribution[]
|
||
broadcastReceipts BroadcastReceipt[]
|
||
pushTokens PushToken[]
|
||
caretaking PetCaretaker[]
|
||
|
||
// Empfehlungsprogramm — Clerk-unabhängig: der Code wird erst nach der
|
||
// Registrierung im Onboarding eingelöst (src/lib/referrals.ts). invitedById
|
||
// oben ist dasselbe Feld wie im alten InviteCode-Ablauf.
|
||
referralCode String? @unique @db.VarChar(16) // persönlich, dauerhaft, unbegrenzt nutzbar, z. B. BUBI-7K3M
|
||
invitedAt DateTime? // Zeitpunkt der Code-Einlösung
|
||
referralQualifiedAt DateTime? // dieser (eingeladene) Owner hat ein Tier + ersten Beitrag → zählt beim Einladenden
|
||
referralFlaggedAt DateTime? // Qualifizierung lag über dem Tageslimit des Einladenden → wartet auf Admin-Freigabe, zählt noch nicht
|
||
referralRevokedAt DateTime? // vom Admin aberkannt → zählt nicht (mehr)
|
||
qualifiedReferralCount Int @default(0) // denormalisiert, nur in src/lib/referrals.ts gepflegt
|
||
referralRankId String? // Cache: höchster aktiver erreichter Rang, gepflegt von src/lib/referrals.ts
|
||
referralRank ReferralRank? @relation(fields: [referralRankId], references: [id], onDelete: SetNull)
|
||
referralPerks ReferralPerk[] @default([]) // Cache: Vereinigung der Vorteile aller erreichten aktiven Ränge, gepflegt von src/lib/referrals.ts
|
||
rankAchievements ReferralRankAchievement[]
|
||
acquisitionSource AcquisitionSource? // freiwillige Onboarding-Angabe "Wie hast du von PawFeed erfahren?"
|
||
acquisitionAnsweredAt DateTime?
|
||
|
||
@@index([createdAt(sort: Desc)])
|
||
@@index([invitedById])
|
||
}
|
||
|
||
enum AcquisitionSource {
|
||
TIKTOK
|
||
INSTAGRAM
|
||
YOUTUBE
|
||
FACEBOOK
|
||
FRIENDS
|
||
SHELTER_OR_PARTNER
|
||
STORE_SEARCH
|
||
PRESS
|
||
OTHER
|
||
}
|
||
|
||
// ─── Empfehlungsprogramm ─────────────────────────────────────────────────────
|
||
// Ränge sind Daten (im Admin-Panel unter /p/[secret]/referrals bearbeitbar);
|
||
// nur die Vorteile (ReferralPerk) brauchen Code. Vorteile sind kumulativ über
|
||
// alle erreichten Ränge. Erreichte Ränge bleiben erhalten, auch wenn eine
|
||
// Schwelle später erhöht wird (Bestandsschutz über ReferralRankAchievement).
|
||
|
||
enum ReferralPerk {
|
||
PROFILE_RING
|
||
NAME_BADGE
|
||
FEATURE_POOL
|
||
VERIFICATION_PRIORITY
|
||
CURATOR_SPOTLIGHT
|
||
EXPLORE_HIGHLIGHT // vorgesehen, noch nicht umgesetzt
|
||
HALL_OF_FAME // vorgesehen, noch nicht umgesetzt
|
||
EARLY_ACCESS // vorgesehen, noch nicht umgesetzt
|
||
VOUCHER // vorgesehen für Partner-Gutscheine, noch nicht umgesetzt
|
||
}
|
||
|
||
model ReferralRank {
|
||
id String @id @default(cuid())
|
||
key String @unique @db.VarChar(40) // stabil, nach Anlage nicht änderbar
|
||
threshold Int @unique // qualifizierte Einladungen
|
||
nameDe String @db.VarChar(40)
|
||
nameEn String @db.VarChar(40)
|
||
descriptionDe String @db.VarChar(200)
|
||
descriptionEn String @db.VarChar(200)
|
||
icon String @db.VarChar(30) // Schlüssel aus REFERRAL_ICON_KEYS (src/lib/referral-ranks.ts)
|
||
color String @db.VarChar(7) // #RRGGBB
|
||
perks ReferralPerk[]
|
||
isActive Boolean @default(true) // deaktivieren statt löschen — Achievements verweisen darauf
|
||
createdAt DateTime @default(now())
|
||
updatedAt DateTime @updatedAt
|
||
owners Owner[]
|
||
achievements ReferralRankAchievement[]
|
||
}
|
||
|
||
// Wer hat welchen Rang wann erreicht — Grundlage für Bestandsschutz,
|
||
// einmalige Rang-Benachrichtigungen und spätere Gutscheine.
|
||
model ReferralRankAchievement {
|
||
id String @id @default(cuid())
|
||
owner Owner @relation(fields: [ownerId], references: [id], onDelete: Cascade)
|
||
ownerId String
|
||
rank ReferralRank @relation(fields: [rankId], references: [id], onDelete: Restrict)
|
||
rankId String
|
||
achievedAt DateTime @default(now())
|
||
// Admin-Steuerung (Tierverwaltung): ein entzogener Rang bleibt als Zeile
|
||
// stehen, damit ihn awardRanks/recomputeReferralRanks nicht neu vergeben —
|
||
// er zählt erst wieder, wenn ein Admin ihn freigibt (revokedAt = null).
|
||
revokedAt DateTime?
|
||
revokedBy String? // ownerId des Admins
|
||
revokeReason String? @db.VarChar(500)
|
||
grantedManually Boolean @default(false) // vom Admin vergeben (besondere Leistung), nicht über den Zähler
|
||
grantedBy String? // ownerId des Admins
|
||
|
||
@@unique([ownerId, rankId])
|
||
@@index([rankId])
|
||
}
|
||
|
||
// Kurator-Konto „PawFeed Team“: Begrüßung neuer Tiere. Genau eine Zeile
|
||
// (id = 1), im Admin-Panel unter /p/[secret]/curator bearbeitbar.
|
||
// {name} im Text wird durch den Tiernamen ersetzt.
|
||
model CuratorSettings {
|
||
id Int @id @default(1)
|
||
welcomeEnabled Boolean @default(true) // Begrüßungs-Direktnachricht an jedes neue Tier
|
||
autoFollowEnabled Boolean @default(true) // Team-Tier folgt jedem neuen Tier
|
||
welcomeTextDe String @db.VarChar(1000)
|
||
welcomeTextEn String @db.VarChar(1000)
|
||
updatedAt DateTime @updatedAt
|
||
updatedBy String? // ownerId des Admins
|
||
}
|
||
|
||
// Genau eine Zeile (id = 1), im Admin-Panel bearbeitbar.
|
||
model ReferralSettings {
|
||
id Int @id @default(1)
|
||
programActive Boolean @default(true)
|
||
redeemWindowDays Int @default(7)
|
||
dailyQualifyCap Int @default(20)
|
||
autoMutualFollow Boolean @default(true)
|
||
updatedAt DateTime @updatedAt
|
||
updatedBy String? // ownerId des Admins
|
||
}
|
||
|
||
enum ThemePreference {
|
||
LIGHT
|
||
DARK
|
||
}
|
||
|
||
// Append-only moderation history — no delete/edit endpoint by design, mirrors
|
||
// the Gitea #10 permission-matrix spec's "Verwarnungssystem ... mit Historie".
|
||
model Warning {
|
||
id String @id @default(cuid())
|
||
owner Owner @relation(fields: [ownerId], references: [id], onDelete: Cascade)
|
||
ownerId String
|
||
reason String @db.VarChar(500)
|
||
issuedBy String // ownerId of the moderator who issued it
|
||
createdAt DateTime @default(now())
|
||
|
||
@@index([ownerId, createdAt(sort: Desc)])
|
||
}
|
||
|
||
enum VerificationRequestStatus {
|
||
PENDING
|
||
APPROVED
|
||
REJECTED
|
||
}
|
||
|
||
// Self-service verification request (Gitea #10). Reviewing is a fixed
|
||
// 4-item checklist, not free-form moderator judgment alone — all four must
|
||
// be true to approve; any false auto-rejects with an itemized note. The
|
||
// owner is notified via an automated "PawFeed Team" DM either way
|
||
// (src/lib/team-pet.ts) — see admin.reviewVerificationRequest.
|
||
model VerificationRequest {
|
||
id String @id @default(cuid())
|
||
pet Pet @relation(fields: [petId], references: [id], onDelete: Cascade)
|
||
petId String
|
||
message String @db.VarChar(1000) // requester's own justification
|
||
status VerificationRequestStatus @default(PENDING)
|
||
|
||
meetsAuthenticity Boolean? // real/notable pet — impersonation risk is the actual point of the badge
|
||
meetsCompleteProfile Boolean? // real name, bio, avatar set
|
||
meetsGoodStanding Boolean? // no active ban, no open warnings
|
||
meetsEstablishedPresence Boolean? // account has existed a while and actually posted
|
||
|
||
reviewNote String? @db.VarChar(1000)
|
||
reviewedBy String? // ownerId of the moderator who decided
|
||
reviewedAt DateTime?
|
||
createdAt DateTime @default(now())
|
||
|
||
@@index([status, createdAt(sort: Desc)])
|
||
@@index([petId])
|
||
}
|
||
|
||
model InviteCode {
|
||
id String @id @default(cuid())
|
||
code String @unique
|
||
issuedBy Owner? @relation("IssuedBy", fields: [issuedById], references: [id])
|
||
issuedById String? // null = admin-generated root invite
|
||
usedById String? // Clerk userId who redeemed this code (not a FK — owner may not exist yet)
|
||
usedAt DateTime?
|
||
revoked Boolean @default(false)
|
||
createdAt DateTime @default(now())
|
||
|
||
@@index([issuedById])
|
||
}
|
||
|
||
model Species {
|
||
id String @id @default(cuid())
|
||
name String @unique
|
||
breeds Breed[]
|
||
pets Pet[]
|
||
translations SpeciesTranslation[]
|
||
}
|
||
|
||
model SpeciesTranslation {
|
||
id String @id @default(cuid())
|
||
species Species @relation(fields: [speciesId], references: [id])
|
||
speciesId String
|
||
locale String
|
||
name String
|
||
|
||
@@unique([speciesId, locale])
|
||
}
|
||
|
||
model Breed {
|
||
id String @id @default(cuid())
|
||
name String
|
||
species Species @relation(fields: [speciesId], references: [id])
|
||
speciesId String
|
||
pets Pet[]
|
||
translations BreedTranslation[]
|
||
|
||
@@unique([speciesId, name])
|
||
}
|
||
|
||
model BreedTranslation {
|
||
id String @id @default(cuid())
|
||
breed Breed @relation(fields: [breedId], references: [id])
|
||
breedId String
|
||
locale String
|
||
name String
|
||
|
||
@@unique([breedId, locale])
|
||
}
|
||
|
||
model Pet {
|
||
id String @id @default(cuid())
|
||
owner Owner @relation(fields: [ownerId], references: [id])
|
||
ownerId String
|
||
species Species @relation(fields: [speciesId], references: [id])
|
||
speciesId String
|
||
breed Breed? @relation(fields: [breedId], references: [id])
|
||
breedId String?
|
||
name String
|
||
nickname String? @db.VarChar(30) // Rufname/Spitzname; shown after the real name, e.g. "Rex (Rexi)" — see src/lib/pet-display-name.ts
|
||
bio String?
|
||
adoptionStory String?
|
||
avatarKey String? // Supabase Storage object key, NOT a full URL
|
||
isVerified Boolean @default(false) // admin-granted verification checkmark (Gitea #10 permission matrix)
|
||
isFoundingMember Boolean @default(false) // eines der ersten 250 Tiere — einmalig in pets.create gesetzt (src/lib/new-pet-welcome.ts), nie wieder entzogen
|
||
dmPolicy DmPolicy @default(EVERYONE) // who can send this pet DMs
|
||
followerVisibility FollowerVisibility @default(EVERYONE) // who can view this pet's followers/following lists (Gitea #33)
|
||
birthday DateTime? @db.Date // actual birthday for anniversary notifications
|
||
adoptedAt DateTime? @db.Date // adoption date for anniversary notifications
|
||
// Gedenkmodus „In Erinnerung“ (Roadmap 04.10.): vom Besitzer gesetzt, rückgängig
|
||
// machbar. Verstorbene Tiere bekommen keine Geburtstags-/Impf-Mitteilungen und
|
||
// werden nicht vorgeschlagen; Abzeichen + Banner in Web und App.
|
||
deceasedAt DateTime? @db.Date
|
||
// Vermisst-Meldung (Roadmap 04.10.): Zeitpunkt der aktiven Meldung, sonst null.
|
||
// Cache von LostReport (foundAt = null) für das Abzeichen in Feed/Profil —
|
||
// nur src/trpc/routers/lost-reports.ts schreibt es.
|
||
lostSince DateTime?
|
||
createdAt DateTime @default(now())
|
||
updatedAt DateTime @updatedAt
|
||
|
||
// Explicit, un-pre-checked opt-in required before the Health-Tracking area
|
||
// (weight, vet visits, vaccines, emergency vet, feeding, Health Card) can be
|
||
// used for this pet — see datenschutz Abschnitt 3 (Art. 9 Abs. 2 lit. a
|
||
// DSGVO). Revoking sets this back to null and healthConsentRevokedAt to
|
||
// now(), and health.revokeConsent deletes every health record for this pet
|
||
// in the same transaction — there is no soft-delete/undo.
|
||
healthConsentGivenAt DateTime?
|
||
healthConsentRevokedAt DateTime?
|
||
|
||
// Phase 2 back-relations
|
||
posts Post[]
|
||
stories Story[]
|
||
storyViews StoryView[] @relation("StoryViewer")
|
||
storyReactions StoryReaction[] @relation("StoryReactions")
|
||
storyComments StoryComment[] @relation("StoryComments")
|
||
following Follow[] @relation("Follower")
|
||
followers Follow[] @relation("Followee")
|
||
blocking Block[] @relation("Blocker")
|
||
blockedBy Block[] @relation("Blocked")
|
||
reports Report[] @relation("Reporter")
|
||
|
||
// Phase 3 back-relations
|
||
reactions Reaction[] @relation("PetReactions")
|
||
comments Comment[] @relation("PetComments")
|
||
reposts Repost[] @relation("PetReposts")
|
||
|
||
// Phase 4 back-relations
|
||
receivedNotifications Notification[] @relation("ReceivedNotifications")
|
||
sentNotifications Notification[] @relation("SentNotifications")
|
||
|
||
// Phase 6 back-relations
|
||
conversationsAsA Conversation[] @relation("ConvPetA")
|
||
conversationsAsB Conversation[] @relation("ConvPetB")
|
||
sentMessages Message[] @relation("MessageSender")
|
||
|
||
// Phase 7 back-relations
|
||
weightLogs WeightLog[]
|
||
vetVisits VetVisit[]
|
||
vaccines Vaccine[]
|
||
healthCard HealthCard?
|
||
emergencyVet EmergencyVet?
|
||
feedingInfo PetFeedingInfo?
|
||
|
||
// Ad interaction back-relations
|
||
adReactions AdReaction[] @relation("AdReactions")
|
||
adComments AdComment[] @relation("AdComments")
|
||
adReposts AdRepost[] @relation("AdReposts")
|
||
|
||
verificationRequests VerificationRequest[]
|
||
|
||
// Story-Editor: Storys, in denen dieses Tier markiert wurde
|
||
storyMentions StoryMention[] @relation("StoryMentions")
|
||
|
||
// Vermisst-Meldungen (aktive: foundAt = null, höchstens eine)
|
||
lostReports LostReport[]
|
||
caretaker PetCaretaker?
|
||
|
||
// Gitea #18 — legal AI-content disclosure. Set once at avatar upload,
|
||
// owner-immutable afterward; only a moderator can correct it (see
|
||
// admin.setAiDisclosure). Overlay-badge only, never baked into pixels.
|
||
aiDisclosure AiDisclosure @default(NONE)
|
||
|
||
@@index([ownerId])
|
||
@@index([speciesId])
|
||
@@index([breedId])
|
||
@@index([createdAt(sort: Desc)])
|
||
}
|
||
|
||
// ─── Phase 6 Enums ───────────────────────────────────────────────────────────
|
||
|
||
enum DmPolicy {
|
||
EVERYONE // anyone can send a DM
|
||
FOLLOWERS_ONLY // only pets that this pet follows can send a DM
|
||
}
|
||
|
||
// Gitea #33 — who may view a pet's followers/following lists.
|
||
enum FollowerVisibility {
|
||
EVERYONE // anyone can view the lists
|
||
FOLLOWERS_ONLY // only pets that follow this pet can view the lists
|
||
NOBODY // only the owner can view the lists
|
||
}
|
||
|
||
// Gitea #18 — legal AI-content disclosure, shared across Post/Story/Pet.
|
||
enum AiDisclosure {
|
||
NONE
|
||
AI_MODIFIED
|
||
AI_GENERATED
|
||
}
|
||
|
||
// ─── Phase 2 Enums ───────────────────────────────────────────────────────────
|
||
|
||
enum PostType {
|
||
PHOTO
|
||
MILESTONE
|
||
REPOST
|
||
VIDEO
|
||
}
|
||
|
||
enum VideoStatus {
|
||
PROCESSING
|
||
READY
|
||
ERROR
|
||
}
|
||
|
||
enum MilestoneType {
|
||
BIRTHDAY
|
||
ADOPTION_DAY
|
||
FIRST_OUTING
|
||
VET_VISIT
|
||
LITTER
|
||
FOLLOWERS_500
|
||
FOLLOWERS_1K
|
||
FOLLOWERS_2_5K
|
||
FOLLOWERS_5K
|
||
FOLLOWERS_10K
|
||
FOLLOWERS_25K
|
||
FOLLOWERS_50K
|
||
FOLLOWERS_100K
|
||
FOLLOWERS_250K
|
||
FOLLOWERS_500K
|
||
FOLLOWERS_1M
|
||
}
|
||
|
||
enum ReportReason {
|
||
SPAM
|
||
HARASSMENT
|
||
INAPPROPRIATE
|
||
MISINFORMATION
|
||
OTHER
|
||
}
|
||
|
||
/// Ausgang einer Moderationsentscheidung zu einer Meldung (ReportDecision).
|
||
enum ReportOutcome {
|
||
UPHELD // bestätigt: Beitrag gelöscht, Tier gelöscht oder Profil-Meldung bestätigt
|
||
DISMISSED // abgewiesen
|
||
}
|
||
|
||
// ─── Phase 2 Models ──────────────────────────────────────────────────────────
|
||
|
||
model Post {
|
||
id String @id @default(cuid())
|
||
pet Pet @relation(fields: [petId], references: [id], onDelete: Cascade)
|
||
petId String
|
||
type PostType @default(PHOTO)
|
||
caption String? @db.VarChar(500)
|
||
images PostImage[]
|
||
milestone Milestone?
|
||
createdAt DateTime @default(now())
|
||
updatedAt DateTime @updatedAt
|
||
|
||
// Moderation "hide without delete" (Gitea #10 Phase 3) — set by an admin
|
||
// HIDE_POST action, cleared by UNHIDE_POST. Data stays intact for record
|
||
// purposes; every reader-facing query (feed/explore/profile/search/
|
||
// hashtag/milestones) filters hiddenAt: null. Admin views intentionally
|
||
// do NOT filter on this so moderators can still see + unhide.
|
||
hiddenAt DateTime?
|
||
|
||
// Admin curation (Gitea #10 Phase 4) — set by PIN_POST/UNPIN_POST. Pinned
|
||
// posts skip the 48h trending window in explore.getTrending and sort first.
|
||
pinnedAt DateTime?
|
||
|
||
// Denormalized engagement counters — kept in sync transactionally at each
|
||
// reaction/comment/repost create+delete site (reactions.ts, comments.ts,
|
||
// reposts.ts, admin.ts deleteComment). Avoids live _count aggregation on
|
||
// feed/explore reads. Backfilled from existing rows in the same migration.
|
||
reactionCount Int @default(0)
|
||
commentCount Int @default(0)
|
||
repostCount Int @default(0)
|
||
|
||
// Gitea #18 — legal AI-content disclosure, set once at creation, owner-
|
||
// immutable afterward. See admin.setAiDisclosure for moderator correction.
|
||
aiDisclosure AiDisclosure @default(NONE)
|
||
|
||
// Phase 3 back-relations
|
||
reactions Reaction[] @relation("PostReactions")
|
||
comments Comment[] @relation("PostComments")
|
||
repost Repost? @relation("RepostEntry")
|
||
originalReposts Repost[] @relation("OriginalReposts")
|
||
hashtags PostHashtag[] @relation("PostHashtags")
|
||
|
||
// Phase 4 back-relations
|
||
notifications Notification[]
|
||
|
||
// Phase 5 back-relation
|
||
videoPost VideoPost?
|
||
|
||
@@index([petId, createdAt(sort: Desc)])
|
||
}
|
||
|
||
model PostImage {
|
||
id String @id @default(cuid())
|
||
post Post @relation(fields: [postId], references: [id], onDelete: Cascade)
|
||
postId String
|
||
storageKey String // Supabase Storage object key
|
||
position Int // 0-based order in carousel
|
||
width Int?
|
||
height Int?
|
||
blurhash String? // computed client-side at upload (src/lib/blurhash.ts), decoded as a loading placeholder
|
||
|
||
@@unique([postId, position])
|
||
@@index([postId])
|
||
}
|
||
|
||
model Milestone {
|
||
id String @id @default(cuid())
|
||
post Post @relation(fields: [postId], references: [id], onDelete: Cascade)
|
||
postId String @unique
|
||
milestoneType MilestoneType
|
||
notes String? @db.VarChar(500) // free text for 'litter' type (D-09)
|
||
}
|
||
|
||
model Story {
|
||
id String @id @default(cuid())
|
||
pet Pet @relation(fields: [petId], references: [id], onDelete: Cascade)
|
||
petId String
|
||
mediaType StoryMediaType @default(IMAGE)
|
||
storageKey String? // Supabase Storage object key — IMAGE stories only
|
||
blurhash String? // computed client-side at upload (src/lib/blurhash.ts), decoded as a loading placeholder
|
||
expiresAt DateTime // set to NOW() + 24 hours on create (D-07)
|
||
views StoryView[]
|
||
createdAt DateTime @default(now())
|
||
|
||
// VIDEO stories — mirrors VideoPost. Typically 9:16 (phone-shot, portrait),
|
||
// unlike normal feed videos which skew 16:9 — aspectRatio drives rendering,
|
||
// never assume landscape. Duration capped at 30s (see
|
||
// MAX_STORY_VIDEO_DURATION_SECS in stories.ts), enforced client-side
|
||
// before upload and again server-side when the asset becomes ready.
|
||
muxUploadId String?
|
||
muxAssetId String?
|
||
muxPlaybackId String?
|
||
videoStatus VideoStatus?
|
||
durationSecs Float?
|
||
aspectRatio String? // e.g. "9:16" from Mux
|
||
|
||
// Gitea #18 — legal AI-content disclosure, set once at creation.
|
||
aiDisclosure AiDisclosure @default(NONE)
|
||
|
||
// Gitea #29 — Denormalized engagement counters, same rationale as Post
|
||
// (see Post model comment). Maintained transactionally in stories.ts.
|
||
reactionCount Int @default(0)
|
||
commentCount Int @default(0)
|
||
reactions StoryReaction[]
|
||
comments StoryComment[]
|
||
|
||
// Story music overlay — optional, admin-curated catalog only (no user
|
||
// uploads, licensing liability). onDelete: SetNull so retiring/deleting a
|
||
// MusicTrack never orphans historical stories that already used it.
|
||
musicTrack MusicTrack? @relation(fields: [musicTrackId], references: [id], onDelete: SetNull)
|
||
musicTrackId String?
|
||
musicOffsetSecs Float? @default(0) // start point within the track, since story display duration is fixed
|
||
musicMuteOriginal Boolean @default(false) // VIDEO stories only — mute the baked-in Mux audio in favor of the track
|
||
|
||
// Story-Editor (feature/story-editor): Text, Markierungen, GIFs und Sticker
|
||
// als Daten über dem Medium, nicht ins Bild gerechnet — validiert über
|
||
// storyOverlaysSchema (src/lib/story-overlays.ts). Positionen relativ zur
|
||
// Medienfläche, daher mediaWidth/-Height auch für Bilder.
|
||
overlays Json?
|
||
background String? @db.VarChar(30) // Farbverlauf-Schlüssel, nur TEXT-Storys
|
||
mediaWidth Int?
|
||
mediaHeight Int?
|
||
mentions StoryMention[]
|
||
notifications Notification[]
|
||
|
||
@@index([petId, expiresAt]) // supports lazy-delete filter query
|
||
@@index([muxUploadId])
|
||
@@index([muxAssetId])
|
||
@@index([musicTrackId])
|
||
}
|
||
|
||
// Admin-curated royalty-free music catalog for the Story music overlay.
|
||
// Deliberately no user-upload path — copyright liability stays with the
|
||
// admin who uploads and records `licenseNote` for each track.
|
||
model MusicTrack {
|
||
id String @id @default(cuid())
|
||
title String @db.VarChar(100)
|
||
artist String @db.VarChar(100)
|
||
licenseNote String @db.VarChar(500) // license/source reference, required for compliance
|
||
storageKey String // Supabase Storage object key, MUSIC_STORAGE_BUCKET
|
||
durationSecs Float
|
||
active Boolean @default(true) // soft-disable instead of deleting so past stories keep working
|
||
createdBy String // ownerId of the admin who uploaded it
|
||
createdAt DateTime @default(now())
|
||
|
||
stories Story[]
|
||
|
||
@@index([active])
|
||
}
|
||
|
||
enum StoryMediaType {
|
||
IMAGE
|
||
VIDEO
|
||
TEXT // reine Text-Story auf Farbverlauf (Story.background), ohne Foto/Video
|
||
}
|
||
|
||
// Markierte Tiere einer Story — eigene Tabelle (nicht nur im overlays-JSON),
|
||
// damit Block-/Sichtbarkeitsregeln, Benachrichtigungen und Löschungen sauber
|
||
// greifen. Cascade in beide Richtungen.
|
||
model StoryMention {
|
||
id String @id @default(cuid())
|
||
story Story @relation(fields: [storyId], references: [id], onDelete: Cascade)
|
||
storyId String
|
||
pet Pet @relation("StoryMentions", fields: [petId], references: [id], onDelete: Cascade)
|
||
petId String
|
||
createdAt DateTime @default(now())
|
||
|
||
@@unique([storyId, petId])
|
||
@@index([petId])
|
||
}
|
||
|
||
model StoryView {
|
||
story Story @relation(fields: [storyId], references: [id]) // NO onDelete:Cascade (D-08)
|
||
storyId String
|
||
viewerPet Pet @relation("StoryViewer", fields: [viewerPetId], references: [id])
|
||
viewerPetId String
|
||
viewedAt DateTime @default(now())
|
||
|
||
@@id([storyId, viewerPetId]) // deduplication: one view per viewer per story
|
||
@@index([viewedAt]) // supports 30-day cleanup job (D-08)
|
||
}
|
||
|
||
// Gitea #29 — story reactions/comments, mirrors Reaction/Comment (Post) and
|
||
// AdReaction/AdComment (Advertisement) rather than a shared postId/storyId
|
||
// polymorphic table, matching this codebase's existing per-target-type
|
||
// pattern (see AdReaction/AdComment above).
|
||
model StoryReaction {
|
||
id String @id @default(cuid())
|
||
story Story @relation(fields: [storyId], references: [id], onDelete: Cascade)
|
||
storyId String
|
||
pet Pet @relation("StoryReactions", fields: [petId], references: [id], onDelete: Cascade)
|
||
petId String
|
||
createdAt DateTime @default(now())
|
||
|
||
@@unique([storyId, petId])
|
||
@@index([storyId])
|
||
}
|
||
|
||
model StoryComment {
|
||
id String @id @default(cuid())
|
||
story Story @relation(fields: [storyId], references: [id], onDelete: Cascade)
|
||
storyId String
|
||
pet Pet @relation("StoryComments", fields: [petId], references: [id], onDelete: Cascade)
|
||
petId String
|
||
body String @db.VarChar(500)
|
||
createdAt DateTime @default(now())
|
||
|
||
@@index([storyId, createdAt(sort: Asc)])
|
||
@@index([petId])
|
||
}
|
||
|
||
model Follow {
|
||
follower Pet @relation("Follower", fields: [followerPetId], references: [id], onDelete: Cascade)
|
||
followerPetId String
|
||
followee Pet @relation("Followee", fields: [followeePetId], references: [id], onDelete: Cascade)
|
||
followeePetId String
|
||
createdAt DateTime @default(now())
|
||
|
||
@@id([followerPetId, followeePetId])
|
||
@@index([followerPetId])
|
||
@@index([followeePetId])
|
||
}
|
||
|
||
model Block {
|
||
blocker Pet @relation("Blocker", fields: [blockerPetId], references: [id], onDelete: Cascade)
|
||
blockerPetId String
|
||
blocked Pet @relation("Blocked", fields: [blockedPetId], references: [id], onDelete: Cascade)
|
||
blockedPetId String
|
||
createdAt DateTime @default(now())
|
||
|
||
@@id([blockerPetId, blockedPetId])
|
||
@@index([blockerPetId])
|
||
}
|
||
|
||
model Report {
|
||
id String @id @default(cuid())
|
||
reporter Pet @relation("Reporter", fields: [reporterPetId], references: [id])
|
||
reporterPetId String
|
||
targetPostId String? // null if reporting a profile
|
||
targetPetId String? // null if reporting a post
|
||
reason ReportReason
|
||
notes String? @db.VarChar(500)
|
||
createdAt DateTime @default(now())
|
||
|
||
@@unique([reporterPetId, targetPostId])
|
||
@@unique([reporterPetId, targetPetId])
|
||
@@index([reporterPetId])
|
||
@@index([targetPostId])
|
||
}
|
||
|
||
/// Melde-Statistik: eine Zeile pro entschiedener Meldung, nur anhängen, nie
|
||
/// ändern — außer Anonymisierung (src/lib/report-decisions.ts). Bewusst ohne
|
||
/// Fremdschlüssel, damit Zeilen gelöschte Beiträge/Tiere überleben: Beim
|
||
/// Löschen eines Tiers fallen seine Tier-/Beitrags-IDs weg, beim Löschen des
|
||
/// Kontos die Owner-IDs — die Zählung der jeweils anderen Seite bleibt gleich.
|
||
model ReportDecision {
|
||
id String @id @default(cuid())
|
||
reporterPetId String?
|
||
reporterOwnerId String?
|
||
/// Gemeldetes Tier bzw. das Tier, dem der gemeldete Beitrag gehörte (Momentaufnahme).
|
||
targetPetId String?
|
||
targetOwnerId String?
|
||
/// null bei Profil-Meldungen und nach Anonymisierung.
|
||
targetPostId String?
|
||
isProfileReport Boolean
|
||
reason ReportReason
|
||
outcome ReportOutcome
|
||
reportedAt DateTime
|
||
decidedAt DateTime @default(now())
|
||
decidedBy String
|
||
|
||
@@index([reporterPetId])
|
||
@@index([reporterOwnerId])
|
||
@@index([targetPetId])
|
||
@@index([targetOwnerId])
|
||
}
|
||
|
||
// ─── Phase 3 Models ──────────────────────────────────────────────────────────
|
||
|
||
model Reaction {
|
||
id String @id @default(cuid())
|
||
post Post @relation("PostReactions", fields: [postId], references: [id], onDelete: Cascade)
|
||
postId String
|
||
pet Pet @relation("PetReactions", fields: [petId], references: [id], onDelete: Cascade)
|
||
petId String
|
||
createdAt DateTime @default(now())
|
||
|
||
@@unique([postId, petId])
|
||
@@index([postId])
|
||
}
|
||
|
||
model Comment {
|
||
id String @id @default(cuid())
|
||
post Post @relation("PostComments", fields: [postId], references: [id], onDelete: Cascade)
|
||
postId String
|
||
pet Pet @relation("PetComments", fields: [petId], references: [id], onDelete: Cascade)
|
||
petId String
|
||
body String @db.VarChar(500)
|
||
createdAt DateTime @default(now())
|
||
updatedAt DateTime @updatedAt
|
||
|
||
// Phase 4 back-relation
|
||
notifications Notification[]
|
||
|
||
@@index([postId, createdAt(sort: Asc)])
|
||
@@index([petId])
|
||
}
|
||
|
||
model Repost {
|
||
post Post @relation("RepostEntry", fields: [postId], references: [id], onDelete: Cascade)
|
||
postId String @unique
|
||
originalPost Post @relation("OriginalReposts", fields: [originalPostId], references: [id])
|
||
originalPostId String
|
||
reposterPet Pet @relation("PetReposts", fields: [reposterPetId], references: [id], onDelete: Cascade)
|
||
reposterPetId String
|
||
createdAt DateTime @default(now())
|
||
|
||
@@id([reposterPetId, originalPostId])
|
||
@@index([originalPostId])
|
||
}
|
||
|
||
model Hashtag {
|
||
id String @id @default(cuid())
|
||
tag String @unique
|
||
posts PostHashtag[]
|
||
}
|
||
|
||
model PostHashtag {
|
||
post Post @relation("PostHashtags", fields: [postId], references: [id], onDelete: Cascade)
|
||
postId String
|
||
hashtag Hashtag @relation(fields: [hashtagId], references: [id], onDelete: Cascade)
|
||
hashtagId String
|
||
|
||
@@id([postId, hashtagId])
|
||
@@index([hashtagId])
|
||
}
|
||
|
||
// ─── Phase 5 Models ──────────────────────────────────────────────────────────
|
||
|
||
model VideoPost {
|
||
id String @id @default(cuid())
|
||
post Post @relation(fields: [postId], references: [id], onDelete: Cascade)
|
||
postId String @unique
|
||
muxUploadId String? // Mux direct upload ID — set on creation
|
||
muxAssetId String? // Mux asset ID — set by video.upload.asset_created webhook
|
||
muxPlaybackId String? // Mux playback ID — set by video.asset.ready webhook
|
||
status VideoStatus @default(PROCESSING)
|
||
durationSecs Float? // populated by webhook
|
||
aspectRatio String? // e.g. "16:9" from Mux
|
||
|
||
@@index([muxUploadId])
|
||
@@index([muxAssetId])
|
||
}
|
||
|
||
// ─── Phase 6 Models ──────────────────────────────────────────────────────────
|
||
|
||
model Conversation {
|
||
id String @id @default(cuid())
|
||
petA Pet @relation("ConvPetA", fields: [petAId], references: [id], onDelete: Cascade)
|
||
petAId String
|
||
petB Pet @relation("ConvPetB", fields: [petBId], references: [id], onDelete: Cascade)
|
||
petBId String
|
||
messages Message[]
|
||
createdAt DateTime @default(now())
|
||
updatedAt DateTime @updatedAt // updated on every new message for inbox ordering
|
||
|
||
@@unique([petAId, petBId]) // petAId is always < petBId (sorted on create) — deduplication
|
||
@@index([petAId])
|
||
@@index([petBId])
|
||
}
|
||
|
||
model Message {
|
||
id String @id @default(cuid())
|
||
conversation Conversation @relation(fields: [conversationId], references: [id], onDelete: Cascade)
|
||
conversationId String
|
||
senderPet Pet @relation("MessageSender", fields: [senderPetId], references: [id])
|
||
senderPetId String
|
||
body String @db.VarChar(1000)
|
||
readAt DateTime? // null = unread by recipient
|
||
|
||
createdAt DateTime @default(now())
|
||
|
||
@@index([conversationId, createdAt(sort: Asc)])
|
||
@@index([senderPetId])
|
||
}
|
||
|
||
// ─── Phase 7 Models ──────────────────────────────────────────────────────────
|
||
|
||
model WeightLog {
|
||
id String @id @default(cuid())
|
||
pet Pet @relation(fields: [petId], references: [id], onDelete: Cascade)
|
||
petId String
|
||
date DateTime @db.Date
|
||
weightGrams Int // stored in grams; display as kg
|
||
notes String? @db.VarChar(200)
|
||
createdAt DateTime @default(now())
|
||
|
||
@@index([petId, date(sort: Desc)])
|
||
}
|
||
|
||
model VetVisit {
|
||
id String @id @default(cuid())
|
||
pet Pet @relation(fields: [petId], references: [id], onDelete: Cascade)
|
||
petId String
|
||
date DateTime @db.Date
|
||
reason String @db.VarChar(100)
|
||
notes String? @db.VarChar(500)
|
||
vetName String? @db.VarChar(100)
|
||
createdAt DateTime @default(now())
|
||
|
||
@@index([petId, date(sort: Desc)])
|
||
}
|
||
|
||
model Vaccine {
|
||
id String @id @default(cuid())
|
||
pet Pet @relation(fields: [petId], references: [id], onDelete: Cascade)
|
||
petId String
|
||
name String @db.VarChar(100)
|
||
dateGiven DateTime @db.Date
|
||
nextDueDate DateTime? @db.Date
|
||
notes String? @db.VarChar(200)
|
||
createdAt DateTime @default(now())
|
||
// Gesetzt vom Cron /api/cron/vaccine-reminders, sobald die Erinnerung
|
||
// (30 Tage vor nextDueDate) raus ist — verhindert Doppel-Erinnerungen.
|
||
reminderSentAt DateTime?
|
||
notifications Notification[]
|
||
|
||
@@index([petId, dateGiven(sort: Desc)])
|
||
}
|
||
|
||
model EmergencyVet {
|
||
id String @id @default(cuid())
|
||
pet Pet @relation(fields: [petId], references: [id], onDelete: Cascade)
|
||
petId String @unique
|
||
name String @db.VarChar(150)
|
||
address String? @db.VarChar(300)
|
||
phone String? @db.VarChar(30)
|
||
website String? @db.VarChar(200)
|
||
lat Float?
|
||
lng Float?
|
||
createdAt DateTime @default(now())
|
||
updatedAt DateTime @updatedAt
|
||
}
|
||
|
||
// Vermisst-Meldung (Roadmap 04.10.). Öffentliche Teilen-Seite /vermisst/<code>
|
||
// ohne Login; Kontaktdaten und Auffindbarkeit wählt der Besitzer je Meldung.
|
||
// Nach dem Finden bleibt die Zeile (Seite zeigt „Wieder zu Hause“, ohne Kontakt).
|
||
model LostReport {
|
||
id String @id @default(cuid())
|
||
pet Pet @relation(fields: [petId], references: [id], onDelete: Cascade)
|
||
petId String
|
||
code String @unique
|
||
lastSeenAt DateTime
|
||
lastSeenPlace String @db.VarChar(120)
|
||
description String? @db.VarChar(1000)
|
||
contactPhone String? @db.VarChar(40)
|
||
contactEmail String? @db.VarChar(120)
|
||
allowIndexing Boolean @default(false) // Suchmaschinen dürfen die Seite finden
|
||
createdAt DateTime @default(now())
|
||
foundAt DateTime?
|
||
|
||
@@index([petId, createdAt])
|
||
}
|
||
|
||
model HealthCard {
|
||
id String @id @default(cuid())
|
||
pet Pet @relation(fields: [petId], references: [id], onDelete: Cascade)
|
||
petId String @unique
|
||
token String @unique @default(cuid())
|
||
expiresAt DateTime? // null = never expires; set on create/regenerate
|
||
createdAt DateTime @default(now())
|
||
updatedAt DateTime @updatedAt
|
||
}
|
||
|
||
// GDPR Art. 15 data-export download token (Gitea #6 follow-up). Deliberately
|
||
// a bespoke DB-backed token instead of emailing Supabase's native signed URL
|
||
// directly — the download link handed to the user must read pawfeed.org, not
|
||
// the storage backend. /api/gdpr-export/[token] validates against this table
|
||
// and streams the file server-side; storageKey/bucket stay an internal detail.
|
||
model DataExportToken {
|
||
id String @id @default(cuid())
|
||
owner Owner @relation(fields: [ownerId], references: [id], onDelete: Cascade)
|
||
ownerId String
|
||
token String @unique @default(cuid())
|
||
storageKey String
|
||
expiresAt DateTime
|
||
createdAt DateTime @default(now())
|
||
|
||
@@index([ownerId])
|
||
}
|
||
|
||
model PetFeedingInfo {
|
||
id String @id @default(cuid())
|
||
pet Pet @relation(fields: [petId], references: [id], onDelete: Cascade)
|
||
petId String @unique
|
||
foodType String? @db.VarChar(100)
|
||
feedingTimes Int?
|
||
dailyAmountG Int?
|
||
foodBrand String? @db.VarChar(100)
|
||
specialDiet String? @db.VarChar(500)
|
||
updatedAt DateTime @updatedAt
|
||
}
|
||
|
||
// ─── Phase 4 Enums ───────────────────────────────────────────────────────────
|
||
|
||
enum NotificationType {
|
||
FOLLOW
|
||
REACTION
|
||
COMMENT
|
||
MESSAGE
|
||
BIRTHDAY
|
||
ADOPTION_DAY
|
||
MENTION
|
||
PAW_BACK
|
||
PET_SUGGESTION
|
||
REFERRAL_JOINED // ein Tier ist über die eigene Einladung zu PawFeed gekommen
|
||
REFERRAL_RANK_UP // eigener Empfehlungsrang aufgestiegen (actorPetId = null)
|
||
VACCINE_DUE // Impfung wird in <= 30 Tagen fällig (vaccineId gesetzt, actorPetId = null)
|
||
STORY_MENTION // Tier wurde in einer Story markiert (storyId gesetzt)
|
||
STORY_COMMENT // Kommentar auf eine eigene Story (storyId gesetzt)
|
||
STORY_REACTION // Pfote auf eine eigene Story (storyId gesetzt)
|
||
PET_DECEASED // ein gefolgtes Tier ist verstorben (actorPetId = das Tier, kein Push)
|
||
PET_LOST // ein Tier aus dem Umfeld wird vermisst (actorPetId = das Tier, mit Push)
|
||
PET_FOUND // Entwarnung: das vermisste Tier ist wieder da (actorPetId = das Tier)
|
||
CARETAKER_REQUEST // jemand möchte das Tier mitbetreuen und wartet auf Freigabe (an das Tier selbst, nur Haupt-Besitzer handelt)
|
||
}
|
||
|
||
// ─── Phase 4 Models ──────────────────────────────────────────────────────────
|
||
|
||
model Notification {
|
||
id String @id @default(cuid())
|
||
recipientPet Pet @relation("ReceivedNotifications", fields: [recipientPetId], references: [id], onDelete: Cascade)
|
||
recipientPetId String
|
||
type NotificationType
|
||
actorPet Pet? @relation("SentNotifications", fields: [actorPetId], references: [id], onDelete: SetNull)
|
||
actorPetId String?
|
||
post Post? @relation(fields: [postId], references: [id], onDelete: Cascade)
|
||
postId String?
|
||
comment Comment? @relation(fields: [commentId], references: [id], onDelete: Cascade)
|
||
commentId String?
|
||
vaccine Vaccine? @relation(fields: [vaccineId], references: [id], onDelete: Cascade)
|
||
vaccineId String?
|
||
story Story? @relation(fields: [storyId], references: [id], onDelete: Cascade)
|
||
storyId String?
|
||
read Boolean @default(false)
|
||
createdAt DateTime @default(now())
|
||
|
||
@@index([recipientPetId, read, createdAt(sort: Desc)])
|
||
}
|
||
|
||
// ─── Admin / Moderation Models ────────────────────────────────────────────────
|
||
|
||
enum AdminRoleType {
|
||
SUPER_ADMIN
|
||
MODERATOR
|
||
// Full admin-panel access, same as MODERATOR today — awaiting the
|
||
// permission-matrix pass to scope this down to a narrower read/act set.
|
||
SUPPORT
|
||
}
|
||
|
||
model AdminRole {
|
||
id String @id @default(cuid())
|
||
owner Owner @relation(fields: [ownerId], references: [id], onDelete: Cascade)
|
||
ownerId String @unique
|
||
role AdminRoleType @default(MODERATOR)
|
||
grantedAt DateTime @default(now())
|
||
grantedBy String? // ownerId of the granting super admin
|
||
}
|
||
|
||
enum BlacklistEntryType {
|
||
WORD
|
||
LINK
|
||
HASHTAG
|
||
}
|
||
|
||
// Retroactive-capable content filter (Gitea #10 Phase 3) — adding an entry
|
||
// scans existing non-hidden posts for a match and auto-hides them via the
|
||
// same Post.hiddenAt mechanism a human moderator uses (admin.hidePost),
|
||
// stamped with a HIDE_POST ModerationLog entry so it's auditable exactly
|
||
// like a manual hide. New posts are checked at creation time too
|
||
// (posts.create) and hidden immediately if they match. Removing an entry
|
||
// does NOT auto-unhide already-hidden posts — that stays a manual review
|
||
// step via the existing hide/unhide UI.
|
||
model BlacklistEntry {
|
||
id String @id @default(cuid())
|
||
type BlacklistEntryType
|
||
value String @db.VarChar(200) // normalized lowercase: word, link domain, or hashtag (no #)
|
||
createdAt DateTime @default(now())
|
||
createdBy String // ownerId of the moderator who added it
|
||
|
||
@@unique([type, value])
|
||
@@index([type])
|
||
}
|
||
|
||
model UserBan {
|
||
id String @id @default(cuid())
|
||
owner Owner @relation(fields: [ownerId], references: [id], onDelete: Cascade)
|
||
ownerId String @unique
|
||
reason String? @db.VarChar(500)
|
||
bannedAt DateTime @default(now())
|
||
bannedBy String // ownerId of moderator who issued the ban
|
||
expiresAt DateTime? // null = permanent ban
|
||
}
|
||
|
||
// Stealth moderation (Gitea #10 Phase 3) — unlike UserBan, the owner is never
|
||
// told and can still use the app normally. Every reader-facing post query
|
||
// excludes posts from a shadowbanned owner's pets for everyone except that
|
||
// owner's own view (see feed/explore/posts/search/milestones routers), so
|
||
// the content quietly stops reaching anyone else without a visible signal.
|
||
model Shadowban {
|
||
id String @id @default(cuid())
|
||
owner Owner @relation(fields: [ownerId], references: [id], onDelete: Cascade)
|
||
ownerId String @unique
|
||
reason String @db.VarChar(500)
|
||
shadowbannedAt DateTime @default(now())
|
||
shadowbannedBy String // ownerId of moderator who issued it
|
||
expiresAt DateTime? // null = permanent
|
||
}
|
||
|
||
model Advertisement {
|
||
id String @id @default(cuid())
|
||
title String @db.VarChar(100)
|
||
caption String? @db.VarChar(500)
|
||
imageKey String?
|
||
targetUrl String? @db.VarChar(500)
|
||
isActive Boolean @default(true)
|
||
species String[] // targeted Species ids; empty = all species (no restriction)
|
||
breeds String[] // targeted Breed ids, narrows WITHIN the species selection above; empty = no breed restriction
|
||
startDate DateTime?
|
||
endDate DateTime?
|
||
createdBy String
|
||
createdAt DateTime @default(now())
|
||
updatedAt DateTime @updatedAt
|
||
|
||
// Denormalized engagement counters — see Post model comment for rationale.
|
||
// adViewCount is incremented once per ad-card impression (client fires
|
||
// ads.recordView the first time the card is ≥50% scrolled into view —
|
||
// see AdCard.tsx), not deduplicated per pet, matching standard ad-impression
|
||
// semantics rather than the per-pet-once pattern reactions/reposts use.
|
||
adViewCount Int @default(0)
|
||
adReactionCount Int @default(0)
|
||
adCommentCount Int @default(0)
|
||
adRepostCount Int @default(0)
|
||
|
||
reactions AdReaction[]
|
||
comments AdComment[]
|
||
reposts AdRepost[]
|
||
|
||
@@index([isActive, startDate, endDate])
|
||
}
|
||
|
||
model AdReaction {
|
||
id String @id @default(cuid())
|
||
ad Advertisement @relation(fields: [adId], references: [id], onDelete: Cascade)
|
||
adId String
|
||
pet Pet @relation("AdReactions", fields: [petId], references: [id], onDelete: Cascade)
|
||
petId String
|
||
createdAt DateTime @default(now())
|
||
|
||
@@unique([adId, petId])
|
||
@@index([adId])
|
||
}
|
||
|
||
model AdComment {
|
||
id String @id @default(cuid())
|
||
ad Advertisement @relation(fields: [adId], references: [id], onDelete: Cascade)
|
||
adId String
|
||
pet Pet @relation("AdComments", fields: [petId], references: [id], onDelete: Cascade)
|
||
petId String
|
||
body String @db.VarChar(500)
|
||
createdAt DateTime @default(now())
|
||
|
||
@@index([adId])
|
||
}
|
||
|
||
model AdRepost {
|
||
id String @id @default(cuid())
|
||
ad Advertisement @relation(fields: [adId], references: [id], onDelete: Cascade)
|
||
adId String
|
||
pet Pet @relation("AdReposts", fields: [petId], references: [id], onDelete: Cascade)
|
||
petId String
|
||
createdAt DateTime @default(now())
|
||
|
||
@@unique([adId, petId])
|
||
@@index([adId])
|
||
}
|
||
|
||
model ModerationLog {
|
||
id String @id @default(cuid())
|
||
action String @db.VarChar(50) // DELETE_POST | DELETE_COMMENT | BAN_USER | UNBAN_USER | DELETE_IMAGE | DISMISS_REPORT | CREATE_AD | UPDATE_AD | DELETE_AD | GRANT_ROLE | REVOKE_ROLE | WARN_USER | VERIFY_PET | UNVERIFY_PET | DELETE_ACCOUNT | APPROVE_VERIFICATION_REQUEST | REJECT_VERIFICATION_REQUEST | HIDE_POST | UNHIDE_POST | SHADOWBAN_USER | UNSHADOWBAN_USER | SET_AI_DISCLOSURE | ENABLE_MAINTENANCE_MODE | DISABLE_MAINTENANCE_MODE | PIN_POST | UNPIN_POST | SEND_BROADCAST | DELETE_BROADCAST | CREATE_MUSIC_TRACK | UPDATE_MUSIC_TRACK | DELETE_MUSIC_TRACK | CREATE_REFERRAL_RANK | UPDATE_REFERRAL_RANK | DEACTIVATE_REFERRAL_RANK | UPDATE_REFERRAL_SETTINGS | APPROVE_REFERRAL | REVOKE_REFERRAL | REVOKE_REFERRAL_RANK | UPDATE_CURATOR_SETTINGS | GRANT_REFERRAL_RANK | RESTORE_REFERRAL_RANK | DELETE_PET | SET_FOUNDING_MEMBER | CLEAR_PET_CONTENT | ENABLE_STORY_EDITOR | DISABLE_STORY_EDITOR | CLOSE_LOST_REPORT | DELETE_LOST_REPORT
|
||
targetType String @db.VarChar(20) // POST | COMMENT | USER | IMAGE | AD | REPORT | PET | SYSTEM | MUSIC_TRACK
|
||
targetId String
|
||
reason String? @db.VarChar(500)
|
||
moderatorId String // ownerId
|
||
// Both nullable: added after the table already had rows, so historical
|
||
// entries predate capture. Every new write always populates both — role is
|
||
// "BOOTSTRAP" for the env-configured ADMIN_OWNER_ID (no AdminRole row of
|
||
// its own), otherwise the AdminRoleType held at the time of the action.
|
||
moderatorRole String? @db.VarChar(20)
|
||
ipAddress String? @db.VarChar(45) // max IPv6 length; login-abuse / audit trail
|
||
createdAt DateTime @default(now())
|
||
|
||
@@index([moderatorId, createdAt(sort: Desc)])
|
||
@@index([targetType, targetId])
|
||
}
|
||
|
||
// ─── Legal Document Versioning ─────────────────────────────────────────────
|
||
// Pull-based, not push-based: publishing a version doesn't fan out a
|
||
// Notification row per pet (Notification is petId-scoped; ToS/Datenschutz
|
||
// acceptance is an Owner-level concern). Instead, each owner's session
|
||
// compares Owner.legalAcknowledgedAt against the latest publishedAt.
|
||
|
||
enum LegalDocType {
|
||
NUTZUNGSBEDINGUNGEN
|
||
DATENSCHUTZ
|
||
}
|
||
|
||
model LegalDocumentVersion {
|
||
id String @id @default(cuid())
|
||
docType LegalDocType
|
||
changeSummary String @db.VarChar(1000)
|
||
publishedAt DateTime @default(now())
|
||
publishedBy String // ownerId of the publishing admin
|
||
|
||
@@index([publishedAt(sort: Desc)])
|
||
}
|
||
|
||
// Gitea #22 — one row per successful Stripe payment (one-time or a
|
||
// subscription's recurring invoice). Kept as a durable record independent
|
||
// of Stripe's own dashboard, and as the source for the owner-facing
|
||
// contribution history on /unterstuetzen.
|
||
model SponsorContribution {
|
||
id String @id @default(cuid())
|
||
owner Owner @relation(fields: [ownerId], references: [id], onDelete: Cascade)
|
||
ownerId String
|
||
type String @db.VarChar(20) // ONE_TIME | SUBSCRIPTION
|
||
amountCents Int
|
||
currency String @default("eur") @db.VarChar(3)
|
||
stripeCheckoutSessionId String? @unique
|
||
stripeInvoiceId String? @unique
|
||
createdAt DateTime @default(now())
|
||
|
||
@@index([ownerId, createdAt(sort: Desc)])
|
||
}
|
||
|
||
// ─── Broadcast / System-Mitteilungen (Gitea #10, Phase 1) ──────────────────
|
||
// Phase 1 is in-app-banner-only, pull-based — same philosophy as Legal
|
||
// Document Versioning above. Unlike that single-timestamp pattern, this
|
||
// tracks a receipt PER broadcast (not one rolling Owner timestamp) so the
|
||
// admin can see delivered/read counts per message, and so a later Phase 2
|
||
// (email escalation, deferred until the user base passes ~100) can target
|
||
// exactly the owners who haven't seen a specific broadcast yet.
|
||
model Broadcast {
|
||
id String @id @default(cuid())
|
||
title String @db.VarChar(200)
|
||
body String @db.VarChar(2000)
|
||
createdAt DateTime @default(now())
|
||
createdBy String // ownerId of the publishing admin
|
||
receipts BroadcastReceipt[]
|
||
|
||
@@index([createdAt(sort: Desc)])
|
||
}
|
||
|
||
// One row per (broadcast, owner), created lazily the first time the
|
||
// owner's banner query surfaces the broadcast — no fan-out at send time,
|
||
// same reasoning as Notification/LegalDocumentVersion.
|
||
model BroadcastReceipt {
|
||
id String @id @default(cuid())
|
||
broadcast Broadcast @relation(fields: [broadcastId], references: [id], onDelete: Cascade)
|
||
broadcastId String
|
||
owner Owner @relation(fields: [ownerId], references: [id], onDelete: Cascade)
|
||
ownerId String
|
||
seenAt DateTime @default(now()) // first time shown in their banner
|
||
readAt DateTime? // set when they click "Gelesen"; null = not yet acknowledged
|
||
|
||
@@unique([broadcastId, ownerId])
|
||
@@index([ownerId, readAt])
|
||
}
|
||
|
||
// Push an mehrere Geräte (Roadmap 05.10.): ein Eintrag pro Gerät/App-Installation.
|
||
// Der Token ist eindeutig — meldet sich auf demselben Handy ein anderes Konto an,
|
||
// wandert der Eintrag zu diesem Konto. Tokens, die Expo als „DeviceNotRegistered“
|
||
// meldet (App deinstalliert), löscht src/lib/notify.ts beim Versand.
|
||
model PushToken {
|
||
id String @id @default(cuid())
|
||
owner Owner @relation(fields: [ownerId], references: [id], onDelete: Cascade)
|
||
ownerId String
|
||
token String @unique
|
||
platform String? @db.VarChar(10) // "ios" | "android"
|
||
createdAt DateTime @default(now())
|
||
lastSeenAt DateTime @default(now()) // bei jeder erneuten Registrierung (App-Start) aktualisiert
|
||
|
||
@@index([ownerId, lastSeenAt(sort: Desc)])
|
||
}
|
||
|
||
// Mitbetreuer (Roadmap 05.10., Notiz „Gemeinsame Tierhalter – Machbarkeit“):
|
||
// höchstens EIN zweiter Mensch pro Tier (petId eindeutig). Ablauf:
|
||
// INVITED – Haupt-Besitzer hat einen Code erzeugt (ownerId noch leer)
|
||
// PENDING – jemand hat den Code eingelöst, wartet auf Freigabe des Haupt-Besitzers
|
||
// ACTIVE – darf als das Tier handeln (src/lib/assert-pet-ownership.ts)
|
||
// Nur der Haupt-Besitzer (Pet.ownerId) löscht das Tier, ändert die Privatsphäre,
|
||
// den Gedenkmodus und verwaltet den Mitbetreuer. Löscht er sein Konto, übernimmt
|
||
// ein aktiver Mitbetreuer das Tier (src/lib/delete-account.ts).
|
||
enum PetCaretakerStatus {
|
||
INVITED
|
||
PENDING
|
||
ACTIVE
|
||
}
|
||
|
||
model PetCaretaker {
|
||
id String @id @default(cuid())
|
||
pet Pet @relation(fields: [petId], references: [id], onDelete: Cascade)
|
||
petId String @unique
|
||
owner Owner? @relation(fields: [ownerId], references: [id], onDelete: Cascade)
|
||
ownerId String?
|
||
status PetCaretakerStatus @default(INVITED)
|
||
inviteCode String? @unique @db.VarChar(16)
|
||
inviteExpiresAt DateTime?
|
||
createdAt DateTime @default(now())
|
||
requestedAt DateTime?
|
||
approvedAt DateTime?
|
||
|
||
@@index([ownerId, status])
|
||
}
|