+ {t("titleWithPet", { name: inviterPet.name })} +
+{t("titleGeneric")}
+ )} + +{t("subtitle")}
+ + {code && inviterPet && ( +{t("codeLabel")}
+{code}
+{t("codeHint")}
+diff --git a/messages/de.json b/messages/de.json
index c1c0e1b..1870f9b 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -124,7 +124,8 @@
"addPhoto": "Profilfoto hinzufügen",
"addPhotoDesc": "Gib deinem Tier ein Gesicht auf PawFeed. Du kannst das jederzeit ändern.",
"done": "Fertig",
- "skipForNow": "Jetzt überspringen"
+ "skipForNow": "Jetzt überspringen",
+ "loading": "Wird geladen"
},
"Language": {
"switch": "Sprache",
@@ -477,7 +478,9 @@
"typePawBack": "hat deinen Post geteilt (Paw-Back)",
"typeBirthday": "🎂 Alles Gute zum Geburtstag! Erstelle einen Jubiläums-Post.",
"typeAdoptionDay": "🏠 Heute ist der Adoptionstag! Feiere den Jahrestag.",
- "typePetSuggestion": "könnte dir gefallen"
+ "typePetSuggestion": "könnte dir gefallen",
+ "typeReferralJoined": "ist über deine Einladung zu PawFeed gekommen",
+ "typeReferralRankUp": "Du hast einen neuen Empfehlungsrang erreicht. Schau ihn dir an!"
},
"PhotoPostForm": {
"captionPlaceholder": "Bildunterschrift schreiben…",
@@ -632,7 +635,8 @@
"statsFollowers": "Follower",
"statsFollowing": "Folgt",
"adoptionStory": "Adoptionsgeschichte",
- "verified": "Verifiziert"
+ "verified": "Verifiziert",
+ "invitedBy": "Eingeladen von {name}"
},
"EditPet": {
"title": "{name} bearbeiten",
@@ -765,5 +769,72 @@
"statSponsors": "Unterstützer:innen",
"ctaHeading": "Lust, dabei zu sein?",
"ctaButton": "Jetzt starten"
+ },
+ "Referrals": {
+ "title": "Freunde einladen",
+ "subtitle": "Teile deinen Link. Jedes Tier, das über deine Einladung kommt und seinen ersten Beitrag postet, bringt dich einen Rang weiter.",
+ "programPaused": "Das Empfehlungsprogramm ist gerade pausiert. Dein Link funktioniert, neue Einladungen werden aber im Moment nicht gezählt.",
+ "shareHeading": "Dein Einladungslink",
+ "shareSubtitle": "Unbegrenzt oft nutzbar. Wer sich ohne Link registriert, kann deinen Code auch im Onboarding eingeben.",
+ "yourCode": "Dein Code",
+ "share": "Teilen",
+ "copyLink": "Link kopieren",
+ "copied": "Kopiert",
+ "copiedToast": "Link kopiert",
+ "shareTitle": "Komm zu PawFeed",
+ "shareText": "Mein Tier ist auf PawFeed, dem sozialen Netzwerk für Haustiere. Mit meinem Code {code} sind wir direkt verbunden:",
+ "ranksHeading": "Deine Ränge",
+ "countedInvites": "{count, plural, =0 {Noch keine gezählte Einladung} one {# gezählte Einladung} other {# gezählte Einladungen}}",
+ "nextRank": "Noch {remaining, plural, one {# Einladung} other {# Einladungen}} bis {name}",
+ "allRanksReached": "Du hast alle Ränge erreicht. Danke!",
+ "achieved": "Erreicht",
+ "threshold": "{count, plural, one {# Einladung} other {# Einladungen}}",
+ "inviteesHeading": "Über dich gekommen",
+ "inviteesEmpty": "Noch niemand. Teile deinen Link mit Freunden, die selbst ein Tier haben.",
+ "inviteeNoPet": "Noch kein Tier angelegt",
+ "statusCounted": "Zählt",
+ "statusWaiting": "Wartet auf ersten Beitrag",
+ "statusInReview": "Wird geprüft",
+ "statusRevoked": "Aberkannt",
+ "redeemHeading": "Wurdest du eingeladen?",
+ "redeemSubtitle": "Gib den Code der Person ein, die dich eingeladen hat. Das geht nur in den ersten Tagen nach der Registrierung.",
+ "redeemLabel": "Einladungscode",
+ "redeemCta": "Einlösen",
+ "redeemedToast": "Eingelöst. Du und {name} folgt euch jetzt.",
+ "redeemedToastGeneric": "Code eingelöst.",
+ "errorPaused": "Das Empfehlungsprogramm ist gerade pausiert.",
+ "errorInvalid": "Diesen Code gibt es nicht. Prüf die Schreibweise, z. B. BUBI-7K3M.",
+ "errorOwnCode": "Das ist dein eigener Code. Gib den Code der Person ein, die dich eingeladen hat.",
+ "errorAlreadyRedeemed": "Du hast schon einen Einladungscode eingelöst.",
+ "errorWindowExpired": "Einladungscodes können nur in den ersten Tagen nach der Registrierung eingelöst werden.",
+ "errorNoPet": "Leg zuerst ein Tier an, dann kannst du den Code einlösen.",
+ "errorTooMany": "Zu viele Versuche. Bitte warte ein paar Minuten.",
+ "errorGeneric": "Das hat nicht geklappt. Bitte versuch es noch einmal.",
+ "rulesNote": "Eine Einladung zählt, sobald das eingeladene Tier angelegt ist und seinen ersten Beitrag veröffentlicht hat. Ränge sind digitale Auszeichnungen ohne Geldwert. Einmal erreichte Ränge bleiben erhalten, außer bei Missbrauch."
+ },
+ "ReferralOnboarding": {
+ "title": "Wie hast du von PawFeed erfahren?",
+ "subtitle": "Freiwillig. Es hilft uns zu verstehen, wo wir neue Tierfreunde erreichen.",
+ "source_FRIENDS": "Freunde oder Familie",
+ "source_TIKTOK": "TikTok",
+ "source_INSTAGRAM": "Instagram",
+ "source_YOUTUBE": "YouTube",
+ "source_FACEBOOK": "Facebook",
+ "source_SHELTER_OR_PARTNER": "Tierheim, Verein oder Laden",
+ "source_STORE_SEARCH": "App Store oder Google Play",
+ "source_PRESS": "Zeitung, Radio oder Blog",
+ "source_OTHER": "Anders",
+ "redeemed": "Eingelöst. Du und {name} folgt euch jetzt.",
+ "redeemedGeneric": "Code eingelöst.",
+ "continue": "Weiter",
+ "skip": "Überspringen"
+ },
+ "ReferralLanding": {
+ "titleWithPet": "{name} lädt dich zu PawFeed ein",
+ "titleGeneric": "Du bist zu PawFeed eingeladen",
+ "subtitle": "PawFeed ist das soziale Netzwerk, in dem dein Tier das Profil hat: Fotos, Kurzvideos, Storys und Impfpass an einem Ort.",
+ "codeLabel": "Einladungscode",
+ "codeHint": "Gib den Code nach der Registrierung im Onboarding ein, dann seid ihr direkt verbunden.",
+ "webSignUp": "Im Browser registrieren"
}
}
diff --git a/messages/en.json b/messages/en.json
index 92a8b4c..9dcb179 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -124,7 +124,8 @@
"addPhoto": "Add a profile photo",
"addPhotoDesc": "Give your pet a face on PawFeed. You can always change this later.",
"done": "Done",
- "skipForNow": "Skip for now"
+ "skipForNow": "Skip for now",
+ "loading": "Loading"
},
"Language": {
"switch": "Language",
@@ -477,7 +478,9 @@
"typePawBack": "shared your post (Paw-Back)",
"typeBirthday": "🎂 Happy Birthday! Create an anniversary post.",
"typeAdoptionDay": "🏠 Today is adoption day! Celebrate the anniversary.",
- "typePetSuggestion": "might be a good match"
+ "typePetSuggestion": "might be a good match",
+ "typeReferralJoined": "joined PawFeed through your invite",
+ "typeReferralRankUp": "You reached a new referral rank. Take a look!"
},
"PhotoPostForm": {
"captionPlaceholder": "Write a caption…",
@@ -632,7 +635,8 @@
"statsFollowers": "followers",
"statsFollowing": "following",
"adoptionStory": "Adoption story",
- "verified": "Verified"
+ "verified": "Verified",
+ "invitedBy": "Invited by {name}"
},
"EditPet": {
"title": "Edit {name}",
@@ -765,5 +769,72 @@
"statSponsors": "Supporters",
"ctaHeading": "Want to be part of it?",
"ctaButton": "Get started"
+ },
+ "Referrals": {
+ "title": "Invite friends",
+ "subtitle": "Share your link. Every pet that joins through your invite and publishes its first post moves you up a rank.",
+ "programPaused": "The referral program is paused right now. Your link still works, but new invites are not counted at the moment.",
+ "shareHeading": "Your invite link",
+ "shareSubtitle": "Use it as often as you like. People who sign up without the link can still enter your code during onboarding.",
+ "yourCode": "Your code",
+ "share": "Share",
+ "copyLink": "Copy link",
+ "copied": "Copied",
+ "copiedToast": "Link copied",
+ "shareTitle": "Join PawFeed",
+ "shareText": "My pet is on PawFeed, the social network for pets. Use my code {code} and we are connected right away:",
+ "ranksHeading": "Your ranks",
+ "countedInvites": "{count, plural, =0 {No counted invites yet} one {# counted invite} other {# counted invites}}",
+ "nextRank": "{remaining, plural, one {# more invite} other {# more invites}} until {name}",
+ "allRanksReached": "You reached every rank. Thank you!",
+ "achieved": "Reached",
+ "threshold": "{count, plural, one {# invite} other {# invites}}",
+ "inviteesHeading": "Joined through you",
+ "inviteesEmpty": "Nobody yet. Share your link with friends who have a pet.",
+ "inviteeNoPet": "No pet created yet",
+ "statusCounted": "Counts",
+ "statusWaiting": "Waiting for first post",
+ "statusInReview": "Under review",
+ "statusRevoked": "Revoked",
+ "redeemHeading": "Were you invited?",
+ "redeemSubtitle": "Enter the code of the person who invited you. This only works during the first days after signing up.",
+ "redeemLabel": "Invite code",
+ "redeemCta": "Redeem",
+ "redeemedToast": "Redeemed. You and {name} now follow each other.",
+ "redeemedToastGeneric": "Code redeemed.",
+ "errorPaused": "The referral program is paused right now.",
+ "errorInvalid": "That code does not exist. Check the spelling, e.g. BUBI-7K3M.",
+ "errorOwnCode": "That is your own code. Enter the code of the person who invited you.",
+ "errorAlreadyRedeemed": "You already redeemed an invite code.",
+ "errorWindowExpired": "Invite codes can only be redeemed during the first days after signing up.",
+ "errorNoPet": "Create a pet first, then you can redeem the code.",
+ "errorTooMany": "Too many attempts. Please wait a few minutes.",
+ "errorGeneric": "That did not work. Please try again.",
+ "rulesNote": "An invite counts once the invited pet is created and has published its first post. Ranks are digital awards without monetary value. Ranks you reach stay with you unless the program is abused."
+ },
+ "ReferralOnboarding": {
+ "title": "How did you hear about PawFeed?",
+ "subtitle": "Optional. It helps us understand where we reach new pet lovers.",
+ "source_FRIENDS": "Friends or family",
+ "source_TIKTOK": "TikTok",
+ "source_INSTAGRAM": "Instagram",
+ "source_YOUTUBE": "YouTube",
+ "source_FACEBOOK": "Facebook",
+ "source_SHELTER_OR_PARTNER": "Shelter, club or shop",
+ "source_STORE_SEARCH": "App Store or Google Play",
+ "source_PRESS": "Newspaper, radio or blog",
+ "source_OTHER": "Other",
+ "redeemed": "Redeemed. You and {name} now follow each other.",
+ "redeemedGeneric": "Code redeemed.",
+ "continue": "Continue",
+ "skip": "Skip"
+ },
+ "ReferralLanding": {
+ "titleWithPet": "{name} invites you to PawFeed",
+ "titleGeneric": "You are invited to PawFeed",
+ "subtitle": "PawFeed is the social network where your pet has the profile: photos, short videos, stories and vaccination record in one place.",
+ "codeLabel": "Invite code",
+ "codeHint": "Enter the code during onboarding after signing up and you are connected right away.",
+ "webSignUp": "Sign up in the browser"
}
}
diff --git a/prisma/enable_rls_referrals.sql b/prisma/enable_rls_referrals.sql
new file mode 100644
index 0000000..9d25162
--- /dev/null
+++ b/prisma/enable_rls_referrals.sql
@@ -0,0 +1,6 @@
+-- Empfehlungsprogramm (feat/referral-program): neue Tabellen für den
+-- PostgREST-Zugriff sperren, wie alle anderen Tabellen (siehe enable_rls.sql).
+-- Nach dem prisma db push einmal ausführen.
+ALTER TABLE public."ReferralRank" ENABLE ROW LEVEL SECURITY;
+ALTER TABLE public."ReferralRankAchievement" ENABLE ROW LEVEL SECURITY;
+ALTER TABLE public."ReferralSettings" ENABLE ROW LEVEL SECURITY;
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 60cd217..0e7b0bc 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -7,35 +7,125 @@ datasource db {
}
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
+ id String @id // = Clerk userId (e.g. "user_xxx")
+ createdAt DateTime @default(now())
+ invitedById String? // Clerk userId of the person who invited this owner
+ registrationIp String? @db.VarChar(45) // captured once at the earliest guaranteed owner touchpoint, see legal.acknowledgeUploadConsent
+ pets Pet[]
+ adminRole AdminRole?
+ ban UserBan?
+ shadowban Shadowban?
+ issuedInvites InviteCode[] @relation("IssuedBy")
+ legalAcknowledgedAt DateTime? // last time the owner dismissed the legal-update banner; null = never seen one
contentRightsConsentedAt DateTime? // first-pet onboarding: confirms the owner holds all rights to uploaded photos/videos
dataProcessingConsentedAt DateTime? // first-pet onboarding: Willenserklärung consenting to data transfer to Clerk/Cloudflare/Mux/Supabase
- dataExportTokens DataExportToken[]
- warnings Warning[]
- themePreference ThemePreference? // null = follows system preference, never explicitly toggled
- hideSpiders Boolean @default(false) // Arachnophobie-Helfer: hides Species="Spider" pets/posts from feed/explore/search when true
- petSuggestionsEnabled Boolean @default(true) // opt-out for the PET_SUGGESTION cron notification (src/app/api/cron/pet-suggestions)
- expoPushToken String? // mobile app's Expo push token, registered via owner.registerPushToken; single device only, overwritten on re-register
+ dataExportTokens DataExportToken[]
+ warnings Warning[]
+ themePreference ThemePreference? // null = follows system preference, never explicitly toggled
+ hideSpiders Boolean @default(false) // Arachnophobie-Helfer: hides Species="Spider" pets/posts from feed/explore/search when true
+ petSuggestionsEnabled Boolean @default(true) // opt-out for the PET_SUGGESTION cron notification (src/app/api/cron/pet-suggestions)
+ expoPushToken String? // mobile app's Expo push token, registered via owner.registerPushToken; single device only, overwritten on re-register
// Sponsoring (Gitea #22) — freiwilliger Beitrag/Abo gegen ein "Sponsor"-Badge.
- stripeCustomerId String? @unique
+ 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
+ sponsorSubscriptionId String? @unique
+ sponsorSubscriptionStatus String? @db.VarChar(20) // mirrors Stripe subscription.status
contributions SponsorContribution[]
- broadcastReceipts BroadcastReceipt[]
+ broadcastReceipts BroadcastReceipt[]
+
+ // Empfehlungsprogramm — Clerk-unabhängig: der Code wird erst nach der
+ // Registrierung im Onboarding eingelöst (src/lib/referrals.ts). invitedById
+ // oben ist dasselbe Feld wie im alten InviteCode-Ablauf.
+ referralCode String? @unique @db.VarChar(16) // persönlich, dauerhaft, unbegrenzt nutzbar, z. B. BUBI-7K3M
+ invitedAt DateTime? // Zeitpunkt der Code-Einlösung
+ referralQualifiedAt DateTime? // dieser (eingeladene) Owner hat ein Tier + ersten Beitrag → zählt beim Einladenden
+ referralFlaggedAt DateTime? // Qualifizierung lag über dem Tageslimit des Einladenden → wartet auf Admin-Freigabe, zählt noch nicht
+ referralRevokedAt DateTime? // vom Admin aberkannt → zählt nicht (mehr)
+ qualifiedReferralCount Int @default(0) // denormalisiert, nur in src/lib/referrals.ts gepflegt
+ referralRankId String? // Cache: höchster aktiver erreichter Rang, gepflegt von src/lib/referrals.ts
+ referralRank ReferralRank? @relation(fields: [referralRankId], references: [id], onDelete: SetNull)
+ referralPerks ReferralPerk[] @default([]) // Cache: Vereinigung der Vorteile aller erreichten aktiven Ränge, gepflegt von src/lib/referrals.ts
+ rankAchievements ReferralRankAchievement[]
+ acquisitionSource AcquisitionSource? // freiwillige Onboarding-Angabe "Wie hast du von PawFeed erfahren?"
+ acquisitionAnsweredAt DateTime?
@@index([createdAt(sort: Desc)])
+ @@index([invitedById])
+}
+
+enum AcquisitionSource {
+ TIKTOK
+ INSTAGRAM
+ YOUTUBE
+ FACEBOOK
+ FRIENDS
+ SHELTER_OR_PARTNER
+ STORE_SEARCH
+ PRESS
+ OTHER
+}
+
+// ─── Empfehlungsprogramm ─────────────────────────────────────────────────────
+// Ränge sind Daten (im Admin-Panel unter /p/[secret]/referrals bearbeitbar);
+// nur die Vorteile (ReferralPerk) brauchen Code. Vorteile sind kumulativ über
+// alle erreichten Ränge. Erreichte Ränge bleiben erhalten, auch wenn eine
+// Schwelle später erhöht wird (Bestandsschutz über ReferralRankAchievement).
+
+enum ReferralPerk {
+ PROFILE_RING
+ NAME_BADGE
+ FEATURE_POOL
+ VERIFICATION_PRIORITY
+ CURATOR_SPOTLIGHT
+ EXPLORE_HIGHLIGHT // vorgesehen, noch nicht umgesetzt
+ HALL_OF_FAME // vorgesehen, noch nicht umgesetzt
+ EARLY_ACCESS // vorgesehen, noch nicht umgesetzt
+ VOUCHER // vorgesehen für Partner-Gutscheine, noch nicht umgesetzt
+}
+
+model ReferralRank {
+ id String @id @default(cuid())
+ key String @unique @db.VarChar(40) // stabil, nach Anlage nicht änderbar
+ threshold Int @unique // qualifizierte Einladungen
+ nameDe String @db.VarChar(40)
+ nameEn String @db.VarChar(40)
+ descriptionDe String @db.VarChar(200)
+ descriptionEn String @db.VarChar(200)
+ icon String @db.VarChar(30) // Schlüssel aus REFERRAL_ICON_KEYS (src/lib/referral-ranks.ts)
+ color String @db.VarChar(7) // #RRGGBB
+ perks ReferralPerk[]
+ isActive Boolean @default(true) // deaktivieren statt löschen — Achievements verweisen darauf
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+ owners Owner[]
+ achievements ReferralRankAchievement[]
+}
+
+// Wer hat welchen Rang wann erreicht — Grundlage für Bestandsschutz,
+// einmalige Rang-Benachrichtigungen und spätere Gutscheine.
+model ReferralRankAchievement {
+ id String @id @default(cuid())
+ owner Owner @relation(fields: [ownerId], references: [id], onDelete: Cascade)
+ ownerId String
+ rank ReferralRank @relation(fields: [rankId], references: [id], onDelete: Restrict)
+ rankId String
+ achievedAt DateTime @default(now())
+
+ @@unique([ownerId, rankId])
+ @@index([rankId])
+}
+
+// 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 {
@@ -50,7 +140,7 @@ model Warning {
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
+ issuedBy String // ownerId of the moderator who issued it
createdAt DateTime @default(now())
@@index([ownerId, createdAt(sort: Desc)])
@@ -80,7 +170,7 @@ model VerificationRequest {
meetsEstablishedPresence Boolean? // account has existed a while and actually posted
reviewNote String? @db.VarChar(1000)
- reviewedBy String? // ownerId of the moderator who decided
+ reviewedBy String? // ownerId of the moderator who decided
reviewedAt DateTime?
createdAt DateTime @default(now())
@@ -92,8 +182,8 @@ 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)
+ 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())
@@ -141,25 +231,25 @@ model BreedTranslation {
}
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)
- dmPolicy DmPolicy @default(EVERYONE) // who can send this pet DMs
+ 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)
+ dmPolicy DmPolicy @default(EVERYONE) // who can send this pet DMs
followerVisibility FollowerVisibility @default(EVERYONE) // who can view this pet's followers/following lists (Gitea #33)
- birthday DateTime? @db.Date // actual birthday for anniversary notifications
- adoptedAt DateTime? @db.Date // adoption date for anniversary notifications
- createdAt DateTime @default(now())
- updatedAt DateTime @updatedAt
+ birthday DateTime? @db.Date // actual birthday for anniversary notifications
+ adoptedAt DateTime? @db.Date // adoption date for anniversary notifications
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
// Explicit, un-pre-checked opt-in required before the Health-Tracking area
// (weight, vet visits, vaccines, emergency vet, feeding, Health Card) can be
@@ -171,16 +261,16 @@ model Pet {
healthConsentRevokedAt DateTime?
// Phase 2 back-relations
- posts Post[]
- stories Story[]
- storyViews StoryView[] @relation("StoryViewer")
+ 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")
+ 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")
@@ -225,15 +315,15 @@ model Pet {
// ─── Phase 6 Enums ───────────────────────────────────────────────────────────
enum DmPolicy {
- EVERYONE // anyone can send a DM
+ 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
+ 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
+ NOBODY // only the owner can view the lists
}
// Gitea #18 — legal AI-content disclosure, shared across Post/Story/Pet.
@@ -303,11 +393,11 @@ model Post {
// 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?
+ 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?
+ pinnedAt DateTime?
// Denormalized engagement counters — kept in sync transactionally at each
// reaction/comment/repost create+delete site (reactions.ts, comments.ts,
@@ -322,17 +412,17 @@ model Post {
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")
+ 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[]
+ notifications Notification[]
// Phase 5 back-relation
- videoPost VideoPost?
+ videoPost VideoPost?
@@index([petId, createdAt(sort: Desc)])
}
@@ -387,18 +477,18 @@ model Story {
// 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)
+ 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
+ musicTrack MusicTrack? @relation(fields: [musicTrackId], references: [id], onDelete: SetNull)
+ musicTrackId String?
+ musicOffsetSecs Float? @default(0) // start point within the track, since story display duration is fixed
+ musicMuteOriginal Boolean @default(false) // VIDEO stories only — mute the baked-in Mux audio in favor of the track
@@index([petId, expiresAt]) // supports lazy-delete filter query
@@index([muxUploadId])
@@ -410,15 +500,15 @@ model Story {
// 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
+ 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())
+ 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[]
@@ -572,15 +662,15 @@ model PostHashtag {
// ─── 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
+ 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])
@@ -610,7 +700,7 @@ model Message {
senderPet Pet @relation("MessageSender", fields: [senderPetId], references: [id])
senderPetId String
body String @db.VarChar(1000)
- readAt DateTime? // null = unread by recipient
+ readAt DateTime? // null = unread by recipient
createdAt DateTime @default(now())
@@ -625,7 +715,7 @@ model WeightLog {
pet Pet @relation(fields: [petId], references: [id], onDelete: Cascade)
petId String
date DateTime @db.Date
- weightGrams Int // stored in grams; display as kg
+ weightGrams Int // stored in grams; display as kg
notes String? @db.VarChar(200)
createdAt DateTime @default(now())
@@ -700,14 +790,14 @@ model DataExportToken {
}
model PetFeedingInfo {
- id String @id @default(cuid())
- pet Pet @relation(fields: [petId], references: [id], onDelete: Cascade)
- petId String @unique
- foodType String? @db.VarChar(100)
+ 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)
+ foodBrand String? @db.VarChar(100)
+ specialDiet String? @db.VarChar(500)
updatedAt DateTime @updatedAt
}
@@ -723,6 +813,8 @@ enum NotificationType {
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)
}
// ─── Phase 4 Models ──────────────────────────────────────────────────────────
@@ -755,12 +847,12 @@ enum AdminRoleType {
}
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
+ 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 {
@@ -782,7 +874,7 @@ model BlacklistEntry {
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
+ createdBy String // ownerId of the moderator who added it
@@unique([type, value])
@@index([type])
@@ -794,7 +886,7 @@ model UserBan {
ownerId String @unique
reason String? @db.VarChar(500)
bannedAt DateTime @default(now())
- bannedBy String // ownerId of moderator who issued the ban
+ bannedBy String // ownerId of moderator who issued the ban
expiresAt DateTime? // null = permanent ban
}
@@ -809,7 +901,7 @@ model Shadowban {
ownerId String @unique
reason String @db.VarChar(500)
shadowbannedAt DateTime @default(now())
- shadowbannedBy String // ownerId of moderator who issued it
+ shadowbannedBy String // ownerId of moderator who issued it
expiresAt DateTime? // null = permanent
}
@@ -882,19 +974,19 @@ model AdRepost {
}
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
- 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
+ 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
+ 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())
+ 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])
@@ -916,7 +1008,7 @@ model LegalDocumentVersion {
docType LegalDocType
changeSummary String @db.VarChar(1000)
publishedAt DateTime @default(now())
- publishedBy String // ownerId of the publishing admin
+ publishedBy String // ownerId of the publishing admin
@@index([publishedAt(sort: Desc)])
}
@@ -931,7 +1023,7 @@ model SponsorContribution {
ownerId String
type String @db.VarChar(20) // ONE_TIME | SUBSCRIPTION
amountCents Int
- currency String @db.VarChar(3) @default("eur")
+ currency String @default("eur") @db.VarChar(3)
stripeCheckoutSessionId String? @unique
stripeInvoiceId String? @unique
createdAt DateTime @default(now())
@@ -951,7 +1043,7 @@ model Broadcast {
title String @db.VarChar(200)
body String @db.VarChar(2000)
createdAt DateTime @default(now())
- createdBy String // ownerId of the publishing admin
+ createdBy String // ownerId of the publishing admin
receipts BroadcastReceipt[]
@@index([createdAt(sort: Desc)])
diff --git a/prisma/scripts/referral-setup.ts b/prisma/scripts/referral-setup.ts
new file mode 100644
index 0000000..a295e0c
--- /dev/null
+++ b/prisma/scripts/referral-setup.ts
@@ -0,0 +1,174 @@
+import { PrismaClient, Prisma, type ReferralPerk } from "@prisma/client";
+import { PrismaPg } from "@prisma/adapter-pg";
+import { Pool } from "pg";
+import { generateReferralCode } from "../../src/lib/referral-code";
+import { recomputeReferralRanks } from "../../src/lib/referral-recompute";
+
+/**
+ * Einmaliges Setup für das Empfehlungsprogramm — idempotent, mehrfach ausführbar.
+ *
+ * 1. Legt die 7 Start-Ränge an, aber nur, wenn es den key noch nicht gibt —
+ * im Admin bearbeitete Ränge werden nie überschrieben.
+ * 2. Legt die Einstellungen (ReferralSettings, id = 1) an, falls sie fehlen.
+ * 3. Erzeugt einen persönlichen Code für jeden Owner ohne Code.
+ * 4. Nur mit --credit-existing: rechnet Einladungen aus dem alten
+ * InviteCode-System an (Owner mit invitedById und eigenem Beitrag) und
+ * vergibt die passenden Ränge. Ohne Benachrichtigungen.
+ *
+ * Ausführen (auf dem NAS im Wegwerf-Container, nach dem db push):
+ * npx tsx prisma/scripts/referral-setup.ts
+ * npx tsx prisma/scripts/referral-setup.ts --credit-existing
+ */
+
+const databaseUrl = process.env.DATABASE_URL;
+if (!databaseUrl) throw new Error("DATABASE_URL is not set.");
+
+const prisma = new PrismaClient({ adapter: new PrismaPg(new Pool({ connectionString: databaseUrl })) });
+
+type DefaultRank = {
+ key: string;
+ threshold: number;
+ nameDe: string;
+ nameEn: string;
+ descriptionDe: string;
+ descriptionEn: string;
+ icon: string;
+ color: string;
+ perks: ReferralPerk[];
+};
+
+// Vorteile sind kumulativ (src/lib/referral-ranks.ts) — jeder Rang listet nur, was neu dazukommt.
+const DEFAULT_RANKS: DefaultRank[] = [
+ {
+ key: "pfotenfreund", threshold: 1, nameDe: "Pfotenfreund", nameEn: "Paw Pal",
+ descriptionDe: "Der erste Freund ist über deine Einladung da.",
+ descriptionEn: "Your first friend joined through your invite.",
+ icon: "paw-print", color: "#b8734a", perks: ["PROFILE_RING"],
+ },
+ {
+ key: "botschafter", threshold: 3, nameDe: "Botschafter", nameEn: "Ambassador",
+ descriptionDe: "Drei Freunde eingeladen. Abzeichen neben dem Namen.",
+ descriptionEn: "Three friends invited. Badge next to the name.",
+ icon: "heart", color: "#e8620f", perks: ["NAME_BADGE"],
+ },
+ {
+ key: "rudelfuehrer", threshold: 5, nameDe: "Rudelführer", nameEn: "Pack Leader",
+ descriptionDe: "In der Auswahl für das Tier der Woche.",
+ descriptionEn: "In the running for Pet of the Week.",
+ icon: "shield", color: "#2f8a45", perks: ["FEATURE_POOL"],
+ },
+ {
+ key: "leitpfote", threshold: 10, nameDe: "Leitpfote", nameEn: "Lead Paw",
+ descriptionDe: "Bevorzugte Verifizierung und eine Vorstellung durch das PawFeed-Team.",
+ descriptionEn: "Priority verification and a shout-out from the PawFeed team.",
+ icon: "star", color: "#2f5f9e", perks: ["VERIFICATION_PRIORITY", "CURATOR_SPOTLIGHT"],
+ },
+ {
+ key: "goldene-pfote", threshold: 25, nameDe: "Goldene Pfote", nameEn: "Golden Paw",
+ descriptionDe: "Goldenes Abzeichen und goldener Ring.",
+ descriptionEn: "Golden badge and golden ring.",
+ icon: "award", color: "#c7951f", perks: ["EXPLORE_HIGHLIGHT"],
+ },
+ {
+ key: "pfotenlegende", threshold: 50, nameDe: "Pfoten-Legende", nameEn: "Paw Legend",
+ descriptionDe: "Ein dauerhafter Platz in der Ruhmeshalle.",
+ descriptionEn: "A permanent place in the Hall of Fame.",
+ icon: "crown", color: "#7b4fc9", perks: ["HALL_OF_FAME"],
+ },
+ {
+ key: "pawfeed-ikone", threshold: 100, nameDe: "PawFeed-Ikone", nameEn: "PawFeed Icon",
+ descriptionDe: "Neue Funktionen vor allen anderen testen.",
+ descriptionEn: "Try new features before everyone else.",
+ icon: "gem", color: "#0f7f86", perks: ["EARLY_ACCESS"],
+ },
+];
+
+async function seedRanks() {
+ let created = 0;
+ for (const rank of DEFAULT_RANKS) {
+ const exists = await prisma.referralRank.findUnique({ where: { key: rank.key } });
+ if (exists) continue;
+ const thresholdTaken = await prisma.referralRank.findUnique({ where: { threshold: rank.threshold } });
+ if (thresholdTaken) {
+ console.warn(` ↷ ${rank.key}: Schwelle ${rank.threshold} ist schon vergeben, übersprungen`);
+ continue;
+ }
+ await prisma.referralRank.create({ data: rank });
+ created++;
+ }
+ console.log(`Ränge: ${created} neu angelegt, ${DEFAULT_RANKS.length - created} vorhanden oder übersprungen`);
+}
+
+async function seedSettings() {
+ await prisma.referralSettings.upsert({ where: { id: 1 }, create: { id: 1 }, update: {} });
+ console.log("Einstellungen: vorhanden");
+}
+
+async function backfillCodes() {
+ const owners = await prisma.owner.findMany({
+ where: { referralCode: null },
+ select: { id: true, pets: { orderBy: { createdAt: "asc" }, take: 1, select: { name: true } } },
+ });
+ let created = 0;
+ for (const owner of owners) {
+ for (let attempt = 0; attempt < 5; attempt++) {
+ try {
+ await prisma.owner.update({
+ where: { id: owner.id },
+ data: { referralCode: generateReferralCode(owner.pets[0]?.name) },
+ });
+ created++;
+ break;
+ } catch (err) {
+ const isCollision = err instanceof Prisma.PrismaClientKnownRequestError && err.code === "P2002";
+ if (!isCollision) throw err;
+ }
+ }
+ }
+ console.log(`Codes: ${created} von ${owners.length} Ownern ohne Code erzeugt`);
+}
+
+async function creditExistingInvites() {
+ const now = new Date();
+ const qualified = await prisma.owner.updateMany({
+ where: {
+ invitedById: { not: null },
+ referralQualifiedAt: null,
+ referralRevokedAt: null,
+ pets: {
+ some: {
+ posts: { some: { OR: [{ type: "PHOTO" }, { type: "VIDEO", videoPost: { status: "READY" } }] } },
+ },
+ },
+ },
+ data: { referralQualifiedAt: now },
+ });
+ // invitedAt für Alt-Einladungen nachtragen, damit Auswertungen nicht leer bleiben.
+ await prisma.$executeRaw`UPDATE "Owner" SET "invitedAt" = "createdAt" WHERE "invitedById" IS NOT NULL AND "invitedAt" IS NULL`;
+
+ // Zähler komplett aus den Einladungen neu berechnen (idempotent).
+ await prisma.$executeRaw`
+ UPDATE "Owner" o SET "qualifiedReferralCount" = COALESCE(c.n, 0)
+ FROM (SELECT o2."id", (
+ SELECT count(*)::int FROM "Owner" i
+ WHERE i."invitedById" = o2."id" AND i."referralQualifiedAt" IS NOT NULL AND i."referralRevokedAt" IS NULL
+ ) AS n FROM "Owner" o2) c
+ WHERE o."id" = c."id" AND o."qualifiedReferralCount" IS DISTINCT FROM COALESCE(c.n, 0)`;
+
+ await recomputeReferralRanks(prisma);
+ console.log(`Alt-Einladungen: ${qualified.count} neu angerechnet, Zähler und Ränge neu berechnet`);
+}
+
+async function main() {
+ await seedRanks();
+ await seedSettings();
+ await backfillCodes();
+ if (process.argv.includes("--credit-existing")) await creditExistingInvites();
+}
+
+main()
+ .catch((err) => {
+ console.error(err);
+ process.exitCode = 1;
+ })
+ .finally(() => prisma.$disconnect());
diff --git a/src/__tests__/helpers/prisma-mock.ts b/src/__tests__/helpers/prisma-mock.ts
index 3a517ce..35e2468 100644
--- a/src/__tests__/helpers/prisma-mock.ts
+++ b/src/__tests__/helpers/prisma-mock.ts
@@ -325,6 +325,28 @@ interface MockMusicTrackClient {
delete: MockFn;
}
+// ─── Empfehlungsprogramm ─────────────────────────────────────────────────────
+
+interface MockReferralRankClient {
+ findMany: MockFn;
+ findUnique: MockFn;
+ count: MockFn;
+ create: MockFn;
+ update: MockFn;
+}
+
+interface MockReferralRankAchievementClient {
+ findMany: MockFn;
+ createMany: MockFn;
+ deleteMany: MockFn;
+ groupBy: MockFn;
+}
+
+interface MockReferralSettingsClient {
+ findUnique: MockFn;
+ upsert: MockFn;
+}
+
interface MockPrismaClient {
pet: MockPetClient;
owner: MockOwnerClient;
@@ -371,7 +393,11 @@ interface MockPrismaClient {
broadcast: MockBroadcastClient;
broadcastReceipt: MockBroadcastReceiptClient;
musicTrack: MockMusicTrackClient;
+ referralRank: MockReferralRankClient;
+ referralRankAchievement: MockReferralRankAchievementClient;
+ referralSettings: MockReferralSettingsClient;
$transaction: MockFn;
+ $executeRaw: MockFn;
$disconnect: MockFn;
}
@@ -648,7 +674,26 @@ export function createMockPrisma(): MockPrismaClient {
update: vi.fn(),
delete: vi.fn(),
},
+ referralRank: {
+ findMany: vi.fn().mockResolvedValue([]),
+ findUnique: vi.fn(),
+ count: vi.fn(),
+ create: vi.fn(),
+ update: vi.fn(),
+ },
+ referralRankAchievement: {
+ findMany: vi.fn().mockResolvedValue([]),
+ createMany: vi.fn().mockResolvedValue({ count: 0 }),
+ deleteMany: vi.fn(),
+ groupBy: vi.fn().mockResolvedValue([]),
+ },
+ // Ohne Settings-Zeile greifen die Standardwerte (DEFAULT_REFERRAL_SETTINGS).
+ referralSettings: {
+ findUnique: vi.fn().mockResolvedValue(null),
+ upsert: vi.fn(),
+ },
$transaction: vi.fn(),
+ $executeRaw: vi.fn().mockResolvedValue(0),
$disconnect: vi.fn(),
};
}
diff --git a/src/__tests__/referrals-router.test.ts b/src/__tests__/referrals-router.test.ts
new file mode 100644
index 0000000..4f6089e
--- /dev/null
+++ b/src/__tests__/referrals-router.test.ts
@@ -0,0 +1,211 @@
+/**
+ * Empfehlungsprogramm — Router (referrals.ts für Nutzer, referral-admin.ts
+ * fürs Admin-Panel). Kernlogik ist in referrals.test.ts abgedeckt.
+ */
+
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { Prisma } from "@prisma/client";
+
+vi.mock("server-only", () => ({}));
+vi.mock("@clerk/nextjs/server", () => ({ auth: vi.fn().mockResolvedValue({ userId: "owner-1" }) }));
+vi.mock("@/lib/prisma", () => ({ prisma: {} }));
+vi.mock("@/lib/redis", () => ({ redis: {} }));
+vi.mock("@/lib/rate-limit", () => ({ checkRateLimit: vi.fn().mockResolvedValue({ success: true }) }));
+vi.mock("@/lib/feed-helpers", () => ({ backfillOnFollow: vi.fn().mockResolvedValue(undefined) }));
+vi.mock("@/lib/notify", () => ({ notify: vi.fn() }));
+
+const mockAssertAdmin = vi.fn();
+vi.mock("@/trpc/routers/admin", () => ({
+ assertAdmin: (...args: unknown[]) => mockAssertAdmin(...args),
+}));
+
+import { TRPCError } from "@trpc/server";
+import { createMockPrisma } from "@/__tests__/helpers/prisma-mock";
+
+type MockPrisma = ReturnType {t("subtitle")}
+ {t("programPaused")}
+ {t("redeemSubtitle")} {t("rulesNote")}
+
+ {t("title")}
+
+
+ {t("redeemHeading")}
+
+
diff --git a/src/app/(app)/pets/[petId]/page.tsx b/src/app/(app)/pets/[petId]/page.tsx index 9601b9d..dbbdb77 100644 --- a/src/app/(app)/pets/[petId]/page.tsx +++ b/src/app/(app)/pets/[petId]/page.tsx @@ -12,6 +12,7 @@ import { getAvatarUrl } from "@/lib/avatar-url"; import { ProfileActions } from "@/app/(app)/pets/[petId]/ProfileActions"; import { ProfileTabs } from "@/components/profile/ProfileTabs"; import { PetAvatarZoom } from "@/components/pet/PetAvatarZoom"; +import { ReferralAvatarRing } from "@/components/referrals/ReferralAvatarRing"; /** * Pet profile view — /pets/[petId] @@ -63,24 +64,40 @@ export default async function PetProfilePage({ params }: PageProps) {
+ {t.rich("invitedBy", { + name: pet.invitedByPet.name, + link: (chunks) => ( + + {chunks} + + ), + })} +
+ )} + {/* Owner attribution — only shown on own pet */} {isOwnPet && ownerUsername && (
diff --git a/src/app/api/webhooks/mux/route.ts b/src/app/api/webhooks/mux/route.ts
index 8f38b63..be03074 100644
--- a/src/app/api/webhooks/mux/route.ts
+++ b/src/app/api/webhooks/mux/route.ts
@@ -4,6 +4,7 @@ import { mux, MUX_WEBHOOK_SECRET } from "@/lib/mux";
import { redis } from "@/lib/redis";
import { fanOutPost } from "@/lib/feed-helpers";
import { MAX_STORY_VIDEO_DURATION_SECS } from "@/lib/story-video-limits";
+import { maybeQualifyReferral } from "@/lib/referrals";
/**
* POST /api/webhooks/mux
@@ -73,7 +74,7 @@ export async function POST(req: NextRequest) {
const videoPost = await prisma.videoPost.findFirst({
where: { muxAssetId: assetId },
- include: { post: { select: { id: true, petId: true, createdAt: true } } },
+ include: { post: { select: { id: true, petId: true, createdAt: true, pet: { select: { ownerId: true } } } } },
});
if (videoPost) {
@@ -95,6 +96,11 @@ export async function POST(req: NextRequest) {
videoPost.post.createdAt,
prisma
).catch((err) => console.error("[mux-webhook] fanOutPost failed", err));
+
+ // Empfehlungsprogramm: ein fertig verarbeitetes Video zählt als erster Beitrag
+ await maybeQualifyReferral(prisma, videoPost.post.pet.ownerId).catch((err) =>
+ console.error("[mux-webhook] referral qualification failed", err)
+ );
break;
}
diff --git a/src/app/onboarding/pet/page.tsx b/src/app/onboarding/pet/page.tsx
index 643f5c2..fb4d347 100644
--- a/src/app/onboarding/pet/page.tsx
+++ b/src/app/onboarding/pet/page.tsx
@@ -11,6 +11,10 @@ import { OnboardingGuidelines } from "@/components/legal/OnboardingGuidelines";
import { UploadConsent } from "@/components/legal/UploadConsent";
import { ArachnophobiaOptIn } from "@/components/legal/ArachnophobiaOptIn";
import { LanguageSwitcher } from "@/components/i18n/LanguageSwitcher";
+import { AcquisitionStep } from "@/components/referrals/AcquisitionStep";
+import { useQuery } from "@tanstack/react-query";
+import { useTRPC } from "@/trpc/client";
+import { Loader2 } from "lucide-react";
/**
* Onboarding pet page — /onboarding/pet
@@ -24,6 +28,8 @@ import { LanguageSwitcher } from "@/components/i18n/LanguageSwitcher";
* Step 2: Arachnophobie-Helfer opt-in (non-blocking, shown once — see
* ArachnophobiaOptIn; always re-toggleable later on /pets)
* Step 3: Create pet profile (name, species, etc.)
+ * Step 3b: "Wie hast du von PawFeed erfahren?" + optional referral code
+ * (Empfehlungsprogramm — once per owner, see referrals.onboardingState)
* Step 4: Upload avatar photo (now that petId exists)
*
* Lives OUTSIDE the (app) layout group to avoid redirect loop.
@@ -36,6 +42,15 @@ export default function OnboardingPetPage() {
const [guidelinesAcknowledged, setGuidelinesAcknowledged] = useState(false);
const [uploadConsentGiven, setUploadConsentGiven] = useState(false);
const [arachnophobiaStepDone, setArachnophobiaStepDone] = useState(false);
+ const [acquisitionStepDone, setAcquisitionStepDone] = useState(false);
+ const trpc = useTRPC();
+ // Erst nach dem Anlegen des Tiers abfragen — vorher existiert die Owner-Zeile ggf. noch nicht.
+ const { data: referralState } = useQuery({
+ ...trpc.referrals.onboardingState.queryOptions(),
+ enabled: !!createdPetId,
+ });
+ const isReferralStateLoading = !!createdPetId && !acquisitionStepDone && referralState === undefined;
+ const showAcquisitionStep = !!createdPetId && !acquisitionStepDone && !!referralState?.showStep;
let content: React.ReactNode;
@@ -45,6 +60,12 @@ export default function OnboardingPetPage() {
content = Ränge, Einstellungen und Auswertung des Empfehlungsprogramms. {t("subtitle")} {t("codeLabel")} {code} {t("codeHint")} {hint} {label} {hint}
+ Erreichte Ränge bleiben erhalten, auch wenn du eine Schwelle später erhöhst. Senkst du eine Schwelle, bekommen
+ alle passenden Nutzer den Rang sofort, ohne Benachrichtigung.
+
{formatPetName(pet)}
-
{formatRelativeTime(createdAt)}
diff --git a/src/components/layout/MobileNav.tsx b/src/components/layout/MobileNav.tsx
index 03a3b64..0637fe3 100644
--- a/src/components/layout/MobileNav.tsx
+++ b/src/components/layout/MobileNav.tsx
@@ -14,6 +14,7 @@ import {
Moon,
Sun,
Sparkles,
+ Gift,
} from "lucide-react";
import { useTranslations } from "next-intl";
import { NotificationBell } from "@/components/notifications/NotificationBell";
@@ -79,6 +80,7 @@ export function MobileNav({ pets }: MobileNavProps) {
const isNotificationsPage = pathname.startsWith("/notifications");
const isMessagesPage = pathname.startsWith("/messages");
const isSponsorPage = pathname.startsWith("/unterstuetzen");
+ const isInvitePage = pathname.startsWith("/einladen");
return (
{t("subtitle")}
+
+ {inviterName ? t("redeemed", { name: inviterName }) : t("redeemedGeneric")}
+ {t("inviteesEmpty")}
+ {t("nextRank", { remaining: Math.max(0, next.threshold - qualifiedCount), name: next.name })}
+ {t("allRanksReached")} {rank.name} {rank.description} {t("shareSubtitle")} {t("yourCode")} {code}Empfehlungen
+
+ {t("titleWithPet", { name: inviterPet.name })}
+
+ {t("titleGeneric")}
+ )}
+
+
+ {tiles.map((tile) => (
+
+
+ {data.flagged.length > 0 && (
+
+ {data.flagged.map((o) => (
+
+
+ {data.sources.filter((s) => s.source).sort((a, b) => b.count - a.count).map((s) => (
+
+
+ {data.recent.map((o) => (
+
+
+ {data.topInviters.map((o, i) => (
+
+
+ {owners.flatMap((o) => o.pets.map((pet) => (
+
+ {title}
+ {hint &&
+
+
+
+
+
+
+ {ranks.map((rank) => {
+ return (
+ Schwelle
+ Rang
+ Vorteile
+ Träger
+
+
+
+ );
+ })}
+
+ {rank.threshold}
+
+
+
+ {rank.nameDe}
+ {!rank.isActive &&
+ {rank.perks.join(", ") || "–"}
+ {rank.holderCount}
+
+
+
+ kam). Überspringbar.
+ */
+export function AcquisitionStep({ canRedeem, onDone }: { canRedeem: boolean; onDone: () => void }) {
+ const trpc = useTRPC();
+ const t = useTranslations("ReferralOnboarding");
+ const [rememberedCode] = useState(readRememberedReferralCode);
+ const [source, setSource] = useState{t("title")}
+
+ {t("inviteesHeading")}
+
+ {invitees.length === 0 ? (
+
+ {invitees.map((invitee, index) => (
+
+ )}
+
+ {t("ranksHeading")}
+
+
+ {t("countedInvites", { count: qualifiedCount })}
+
+
+ {activeRanks.map((rank) => {
+ const isAchieved = achievedRankIds.has(rank.id);
+ return (
+
+
+ {t("shareHeading")}
+
+