feat: Clerk-unabhängiges Empfehlungsprogramm mit einstellbaren Rängen (Web + Admin)

Persönliche, unbegrenzt nutzbare Empfehlungscodes (z. B. BUBI-7K3M), die erst
nach der Registrierung im Onboarding eingelöst werden — keine Kopplung an den
Clerk-Sign-up-Ablauf mehr. Registrierung ohne Code bleibt möglich.

- Schema (rein additiv): Owner-Felder für Code, Einladungsstatus, Zähler und
  Rang-Cache, ReferralRank / ReferralRankAchievement / ReferralSettings,
  AcquisitionSource, NotificationType REFERRAL_JOINED / REFERRAL_RANK_UP
- Kernlogik src/lib/referrals.ts: Einlösen (7-Tage-Fenster, kein eigener Code,
  keine Kreis-Einladungen, gesperrte Einladende ungültig), gegenseitiges
  Folgen, Qualifizierung erst nach Tier + erstem Beitrag (Foto oder fertiges
  Video), Tageslimit mit Prüf-Markierung, Ränge mit Bestandsschutz
- Web: /einladen, öffentliche Seite /r/[code], Onboarding-Schritt
  "Wie hast du von PawFeed erfahren?", Rang-Abzeichen, Profilring,
  "Eingeladen von …", neue Benachrichtigungen
- Admin /p/[secret]/referrals: Ränge anlegen/bearbeiten/deaktivieren mit
  Live-Vorschau, Einstellungen, Übersicht mit Prüfung und Aberkennung;
  bevorzugte Verifizierung als Vorteil
- prisma/scripts/referral-setup.ts: Start-Ränge, Settings, Code-Backfill,
  optional --credit-existing für Alt-Einladungen

Noch offen: Mobile-App, Abbau des alten InviteCode-Ablaufs, Rechtstexte,
db push + Deploy.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-01 06:25:13 +02:00
1 parent 02662528cf
commit 4d0aa7ab9e
47 files changed
+3564 -139

No files matched your search

+74 -3
View File
@@ -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 <link>{name}</link>"
},
"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"
}
}
+74 -3
View File
@@ -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 <link>{name}</link>"
},
"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"
}
}
+6
View File
@@ -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;
+206 -114
View File
@@ -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)])
+174
View File
@@ -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());
+45
View File
@@ -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(),
};
}
+211
View File
@@ -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<typeof createMockPrisma>;
async function createCaller(prisma: MockPrisma = createMockPrisma()) {
const { referralsRouter } = await import("@/trpc/routers/referrals");
const { referralAdminRouter } = await import("@/trpc/routers/referral-admin");
const { initTRPC } = await import("@trpc/server");
const t = initTRPC.context<{ userId: string | null; prisma: MockPrisma; ip: string }>().create();
const testRouter = t.router({ referrals: referralsRouter, referralAdmin: referralAdminRouter });
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const caller = testRouter.createCaller({ userId: "owner-1", prisma: prisma as any, ip: "127.0.0.1" } as any);
return { caller, prisma };
}
const VALID_RANK = {
key: "rudelgruender",
nameDe: "Rudelgründer",
nameEn: "Pack Founder",
descriptionDe: "",
descriptionEn: "",
threshold: 1000,
icon: "crown" as const,
color: "#123abc",
perks: ["NAME_BADGE" as const],
};
beforeEach(() => {
mockAssertAdmin.mockReset();
mockAssertAdmin.mockResolvedValue("SUPER_ADMIN");
});
// ─── Nutzer ──────────────────────────────────────────────────────────────────
describe("referrals.setAcquisitionSource", () => {
it("only stores the first answer", async () => {
const { caller, prisma } = await createCaller();
prisma.owner.updateMany.mockResolvedValue({ count: 1 });
await caller.referrals.setAcquisitionSource({ source: "TIKTOK" });
expect(prisma.owner.updateMany).toHaveBeenCalledWith({
where: { id: "owner-1", acquisitionSource: null },
data: { acquisitionSource: "TIKTOK", acquisitionAnsweredAt: expect.any(Date) },
});
});
it("rejects unknown sources", async () => {
const { caller } = await createCaller();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await expect(caller.referrals.setAcquisitionSource({ source: "MYSPACE" as any })).rejects.toThrow();
});
});
describe("referrals.onboardingState", () => {
it("shows the step once, inside the redeem window", async () => {
const { caller, prisma } = await createCaller();
prisma.owner.findUnique.mockResolvedValue({ createdAt: new Date(), invitedById: null, acquisitionAnsweredAt: null });
expect(await caller.referrals.onboardingState()).toEqual({ showStep: true, canRedeem: true });
prisma.owner.findUnique.mockResolvedValue({ createdAt: new Date(), invitedById: null, acquisitionAnsweredAt: new Date() });
expect(await caller.referrals.onboardingState()).toEqual({ showStep: false, canRedeem: true });
});
it("hides everything for owners older than the window", async () => {
const { caller, prisma } = await createCaller();
const old = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
prisma.owner.findUnique.mockResolvedValue({ createdAt: old, invitedById: null, acquisitionAnsweredAt: null });
expect(await caller.referrals.onboardingState()).toEqual({ showStep: false, canRedeem: false });
});
});
describe("referrals.me", () => {
it("maps invitee states and never exposes owner ids", async () => {
const { caller, prisma } = await createCaller();
prisma.owner.findUnique.mockResolvedValue({
referralCode: "BUBI-7K3M", createdAt: new Date(), invitedById: null,
qualifiedReferralCount: 1, referralRankId: "r1", referralPerks: ["PROFILE_RING"],
});
prisma.referralRank.findMany.mockResolvedValue([
{ id: "r1", threshold: 1, isActive: true, perks: ["PROFILE_RING"] },
{ id: "r3", threshold: 3, isActive: true, perks: ["NAME_BADGE"] },
]);
prisma.referralRankAchievement.findMany.mockResolvedValue([{ rankId: "r1", achievedAt: new Date() }]);
prisma.owner.findMany.mockResolvedValue([
{ invitedAt: new Date(), referralQualifiedAt: new Date(), referralFlaggedAt: null, referralRevokedAt: null, pets: [{ id: "p1", name: "Luna", avatarKey: null }] },
{ invitedAt: new Date(), referralQualifiedAt: null, referralFlaggedAt: null, referralRevokedAt: null, pets: [] },
]);
const me = await caller.referrals.me();
expect(me.code).toBe("BUBI-7K3M");
expect(me.nextRankId).toBe("r3");
expect(me.invitees.map((i) => i.status)).toEqual(["counted", "waiting"]);
expect(JSON.stringify(me)).not.toContain("owner-");
});
});
// ─── Admin ───────────────────────────────────────────────────────────────────
describe("referralAdmin permissions", () => {
it("requires MODERATOR to read and SUPER_ADMIN to change ranks", async () => {
const { caller, prisma } = await createCaller();
await caller.referralAdmin.listRanks();
expect(mockAssertAdmin).toHaveBeenLastCalledWith(expect.anything(), "owner-1", "MODERATOR");
mockAssertAdmin.mockRejectedValue(new TRPCError({ code: "FORBIDDEN" }));
await expect(caller.referralAdmin.createRank(VALID_RANK)).rejects.toThrow("FORBIDDEN");
expect(mockAssertAdmin).toHaveBeenLastCalledWith(expect.anything(), "owner-1", "SUPER_ADMIN");
});
});
describe("referralAdmin.createRank", () => {
function setupTx() {
const prisma = createMockPrisma();
prisma.referralRank.count.mockResolvedValue(7);
prisma.referralRank.create.mockResolvedValue({ id: "rank-new", ...VALID_RANK });
prisma.moderationLog.create.mockResolvedValue({});
prisma.$transaction.mockImplementation(async (fn: (tx: unknown) => unknown) => fn(prisma));
return prisma;
}
it("creates the rank, recomputes everyone and writes the audit log", async () => {
const { caller, prisma } = await createCaller(setupTx());
await caller.referralAdmin.createRank(VALID_RANK);
expect(prisma.referralRank.create).toHaveBeenCalledWith({ data: VALID_RANK });
expect(prisma.$executeRaw).toHaveBeenCalledTimes(3);
expect(prisma.moderationLog.create).toHaveBeenCalledWith({
data: expect.objectContaining({ action: "CREATE_REFERRAL_RANK", moderatorId: "owner-1" }),
});
});
it("rejects invalid colors, unknown icons and a zero threshold", async () => {
const { caller } = await createCaller(setupTx());
await expect(caller.referralAdmin.createRank({ ...VALID_RANK, color: "red" })).rejects.toThrow();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await expect(caller.referralAdmin.createRank({ ...VALID_RANK, icon: "sparkles" as any })).rejects.toThrow();
await expect(caller.referralAdmin.createRank({ ...VALID_RANK, threshold: 0 })).rejects.toThrow();
});
it("reports a taken threshold as CONFLICT", async () => {
const prisma = setupTx();
prisma.referralRank.create.mockRejectedValue(
new Prisma.PrismaClientKnownRequestError("Unique constraint failed", { code: "P2002", clientVersion: "test" })
);
const { caller } = await createCaller(prisma);
await expect(caller.referralAdmin.createRank(VALID_RANK)).rejects.toMatchObject({ code: "CONFLICT" });
});
it("refuses more than 20 ranks", async () => {
const prisma = setupTx();
prisma.referralRank.count.mockResolvedValue(20);
const { caller } = await createCaller(prisma);
await expect(caller.referralAdmin.createRank(VALID_RANK)).rejects.toMatchObject({ code: "BAD_REQUEST" });
});
});
describe("referralAdmin.revokeReferral", () => {
function setup(invitee: Record<string, unknown> | null) {
const prisma = createMockPrisma();
prisma.owner.findUnique.mockResolvedValue(invitee);
prisma.owner.update.mockResolvedValue({});
prisma.owner.updateMany.mockResolvedValue({ count: 1 });
prisma.moderationLog.create.mockResolvedValue({});
prisma.$transaction.mockImplementation(async (fn: (tx: unknown) => unknown) => fn(prisma));
return prisma;
}
it("decrements the inviter only when the invite had counted", async () => {
const counted = setup({ invitedById: "inviter", referralQualifiedAt: new Date(), referralRevokedAt: null });
await (await createCaller(counted)).caller.referralAdmin.revokeReferral({ ownerId: "invitee", reason: "Fake" });
expect(counted.owner.updateMany).toHaveBeenCalledWith({
where: { id: "inviter", qualifiedReferralCount: { gt: 0 } },
data: { qualifiedReferralCount: { decrement: 1 } },
});
const waiting = setup({ invitedById: "inviter", referralQualifiedAt: null, referralRevokedAt: null });
await (await createCaller(waiting)).caller.referralAdmin.revokeReferral({ ownerId: "invitee", reason: "Fake" });
expect(waiting.owner.updateMany).not.toHaveBeenCalled();
});
it("refuses to revoke twice", async () => {
const prisma = setup({ invitedById: "inviter", referralQualifiedAt: new Date(), referralRevokedAt: new Date() });
const { caller } = await createCaller(prisma);
await expect(caller.referralAdmin.revokeReferral({ ownerId: "invitee", reason: "Fake" })).rejects.toMatchObject({ code: "NOT_FOUND" });
});
});
+374
View File
@@ -0,0 +1,374 @@
/**
* Empfehlungsprogramm — Kernlogik (src/lib/referral-code.ts, referral-ranks.ts,
* referrals.ts). Router-Tests folgen in referrals-router.test.ts.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("server-only", () => ({}));
vi.mock("@/lib/prisma", () => ({ prisma: {} }));
vi.mock("@/lib/redis", () => ({ redis: {} }));
vi.mock("@/lib/feed-helpers", () => ({ backfillOnFollow: vi.fn().mockResolvedValue(undefined) }));
vi.mock("@/lib/notify", () => ({ notify: vi.fn() }));
import { createMockPrisma } from "@/__tests__/helpers/prisma-mock";
import { notify } from "@/lib/notify";
import { generateReferralCode, normalizeReferralCode } from "@/lib/referral-code";
import { collectPerks, pickDisplayRank, pickNextRank, ranksToAward } from "@/lib/referral-ranks";
import {
REFERRAL_ERRORS,
awardRanks,
maybeQualifyReferral,
recomputeReferralRanks,
redeemReferralCode,
} from "@/lib/referrals";
type MockPrisma = ReturnType<typeof createMockPrisma>;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const asDb = (m: MockPrisma) => m as any;
const NOW = new Date("2026-10-01T12:00:00Z");
const DAY = 24 * 60 * 60 * 1000;
function rank(id: string, threshold: number, perks: string[] = [], isActive = true) {
return { id, threshold, perks, isActive };
}
const RANKS = [
rank("r1", 1, ["PROFILE_RING"]),
rank("r3", 3, ["NAME_BADGE"]),
rank("r5", 5, ["FEATURE_POOL"]),
rank("r10", 10, ["VERIFICATION_PRIORITY", "CURATOR_SPOTLIGHT"]),
];
// ─── Codes ───────────────────────────────────────────────────────────────────
describe("generateReferralCode", () => {
const fixedRandom = () => 0; // erstes Zeichen des Zeichensatzes: "2"
it("uses up to six letters of the pet name as prefix", () => {
expect(generateReferralCode("Bubi", fixedRandom)).toBe("BUBI-2222");
expect(generateReferralCode("Prinzessin Bianca", fixedRandom)).toBe("PRINZE-2222");
});
it("transliterates German umlauts and ß", () => {
expect(generateReferralCode("Müsli", fixedRandom)).toBe("MUESLI-2222");
expect(generateReferralCode("Strauß", fixedRandom)).toBe("STRAUS-2222");
});
it("falls back to PAWS when the name has fewer than two latin letters", () => {
expect(generateReferralCode("猫", fixedRandom)).toBe("PAWS-2222");
expect(generateReferralCode("X", fixedRandom)).toBe("PAWS-2222");
expect(generateReferralCode(null, fixedRandom)).toBe("PAWS-2222");
});
it("always produces a code that passes normalization", () => {
for (let i = 0; i < 50; i++) {
const code = generateReferralCode("Bianca");
expect(normalizeReferralCode(code)).toBe(code);
}
});
});
describe("normalizeReferralCode", () => {
it("accepts lower case and surrounding whitespace", () => {
expect(normalizeReferralCode(" bubi-7k3m ")).toBe("BUBI-7K3M");
});
it("rejects malformed codes and confusable characters", () => {
expect(normalizeReferralCode("")).toBeNull();
expect(normalizeReferralCode("BUBI7K3M")).toBeNull();
expect(normalizeReferralCode("BUBI-0K3M")).toBeNull();
expect(normalizeReferralCode("B-7K3M")).toBeNull();
expect(normalizeReferralCode("BUBIBUBI-7K3M")).toBeNull();
});
});
// ─── Rang-Regeln ─────────────────────────────────────────────────────────────
describe("rank rules", () => {
it("displays the highest achieved active rank", () => {
expect(pickDisplayRank(RANKS, new Set(["r1", "r3"]))?.id).toBe("r3");
});
it("ignores deactivated ranks for display and perks", () => {
const ranks = [rank("r1", 1, ["PROFILE_RING"]), rank("r3", 3, ["NAME_BADGE"], false)];
const achieved = new Set(["r1", "r3"]);
expect(pickDisplayRank(ranks, achieved)?.id).toBe("r1");
expect([...collectPerks(ranks, achieved)]).toEqual(["PROFILE_RING"]);
});
it("collects perks cumulatively across achieved ranks", () => {
const perks = collectPerks(RANKS, new Set(["r1", "r3", "r5"]));
expect([...perks].sort()).toEqual(["FEATURE_POOL", "NAME_BADGE", "PROFILE_RING"]);
});
it("awards only reached, active, not yet achieved ranks", () => {
expect(ranksToAward(RANKS, 5, new Set(["r1"])).map((r) => r.id)).toEqual(["r3", "r5"]);
});
it("picks the lowest unachieved rank as next goal", () => {
expect(pickNextRank(RANKS, new Set(["r1", "r3"]))?.id).toBe("r5");
expect(pickNextRank(RANKS, new Set(["r1", "r3", "r5", "r10"]))).toBeNull();
});
});
// ─── Einlösen ────────────────────────────────────────────────────────────────
interface RedeemSetup {
invitee?: Record<string, unknown> | null;
inviter?: Record<string, unknown> | null;
}
function setupRedeem({ invitee, inviter }: RedeemSetup = {}) {
const mockPrisma = createMockPrisma();
const inviteeRow = invitee === undefined ? { createdAt: new Date(NOW.getTime() - DAY), invitedById: null } : invitee;
const inviterRow = inviter === undefined ? { id: "owner-inviter", invitedById: null, ban: null } : inviter;
mockPrisma.owner.findUnique.mockImplementation(async ({ where }: { where: Record<string, string> }) => {
if (where.referralCode) return inviterRow;
// maybeQualifyReferral nach dem Einlösen: noch kein Beitrag-Status relevant
if (where.id === "owner-invitee") return inviteeRow && { ...inviteeRow, referralQualifiedAt: null, referralFlaggedAt: null, referralRevokedAt: null, ban: null, shadowban: null };
return null;
});
mockPrisma.pet.findFirst.mockImplementation(async ({ where }: { where: { ownerId: string } }) =>
where.ownerId === "owner-inviter" ? { id: "pet-bubi", name: "Bubi" } : { id: "pet-luna", name: "Luna" }
);
mockPrisma.owner.updateMany.mockResolvedValue({ count: 1 });
mockPrisma.block.findFirst.mockResolvedValue(null);
mockPrisma.follow.upsert.mockResolvedValue({});
mockPrisma.post.count.mockResolvedValue(0);
return mockPrisma;
}
describe("redeemReferralCode", () => {
beforeEach(() => vi.mocked(notify).mockClear());
it("links the invitee, makes both pets follow each other and notifies the inviter", async () => {
const mockPrisma = setupRedeem();
const result = await redeemReferralCode(asDb(mockPrisma), "owner-invitee", "bubi-7k3m", NOW);
expect(result).toEqual({ inviterPetName: "Bubi" });
expect(mockPrisma.owner.updateMany).toHaveBeenCalledWith({
where: { id: "owner-invitee", invitedById: null },
data: { invitedById: "owner-inviter", invitedAt: NOW },
});
expect(mockPrisma.follow.upsert).toHaveBeenCalledTimes(2);
expect(notify).toHaveBeenCalledWith(expect.anything(), {
recipientPetId: "pet-bubi",
type: "REFERRAL_JOINED",
actorPetId: "pet-luna",
});
});
it("rejects when the program is paused", async () => {
const mockPrisma = setupRedeem();
mockPrisma.referralSettings.findUnique.mockResolvedValue({
programActive: false, redeemWindowDays: 7, dailyQualifyCap: 20, autoMutualFollow: true,
});
await expect(redeemReferralCode(asDb(mockPrisma), "owner-invitee", "BUBI-7K3M", NOW)).rejects.toThrow(
REFERRAL_ERRORS.PROGRAM_PAUSED
);
});
it("rejects malformed and unknown codes the same way", async () => {
const mockPrisma = setupRedeem({ inviter: null });
await expect(redeemReferralCode(asDb(mockPrisma), "owner-invitee", "nope", NOW)).rejects.toThrow(
REFERRAL_ERRORS.CODE_INVALID
);
await expect(redeemReferralCode(asDb(mockPrisma), "owner-invitee", "ABCD-7K3M", NOW)).rejects.toThrow(
REFERRAL_ERRORS.CODE_INVALID
);
});
it("rejects the owner's own code", async () => {
const mockPrisma = setupRedeem({ inviter: { id: "owner-invitee", invitedById: null, ban: null } });
await expect(redeemReferralCode(asDb(mockPrisma), "owner-invitee", "LUNA-7K3M", NOW)).rejects.toThrow(
REFERRAL_ERRORS.OWN_CODE
);
});
it("rejects a second redemption", async () => {
const mockPrisma = setupRedeem({ invitee: { createdAt: NOW, invitedById: "someone" } });
await expect(redeemReferralCode(asDb(mockPrisma), "owner-invitee", "BUBI-7K3M", NOW)).rejects.toThrow(
REFERRAL_ERRORS.ALREADY_REDEEMED
);
});
it("lets only one of two concurrent redemptions win", async () => {
const mockPrisma = setupRedeem();
mockPrisma.owner.updateMany.mockResolvedValue({ count: 0 });
await expect(redeemReferralCode(asDb(mockPrisma), "owner-invitee", "BUBI-7K3M", NOW)).rejects.toThrow(
REFERRAL_ERRORS.ALREADY_REDEEMED
);
expect(mockPrisma.follow.upsert).not.toHaveBeenCalled();
});
it("rejects after the redeem window (7 days) has passed", async () => {
const mockPrisma = setupRedeem({ invitee: { createdAt: new Date(NOW.getTime() - 8 * DAY), invitedById: null } });
await expect(redeemReferralCode(asDb(mockPrisma), "owner-invitee", "BUBI-7K3M", NOW)).rejects.toThrow(
REFERRAL_ERRORS.WINDOW_EXPIRED
);
});
it("treats an actively banned inviter as an invalid code, but not an expired ban", async () => {
const banned = setupRedeem({ inviter: { id: "owner-inviter", invitedById: null, ban: { expiresAt: null } } });
await expect(redeemReferralCode(asDb(banned), "owner-invitee", "BUBI-7K3M", NOW)).rejects.toThrow(
REFERRAL_ERRORS.CODE_INVALID
);
const expired = setupRedeem({
inviter: { id: "owner-inviter", invitedById: null, ban: { expiresAt: new Date(NOW.getTime() - DAY) } },
});
await expect(redeemReferralCode(asDb(expired), "owner-invitee", "BUBI-7K3M", NOW)).resolves.toEqual({
inviterPetName: "Bubi",
});
});
it("rejects circular invites (A invited B, B tries A's code)", async () => {
const mockPrisma = setupRedeem({ inviter: { id: "owner-inviter", invitedById: "owner-invitee", ban: null } });
await expect(redeemReferralCode(asDb(mockPrisma), "owner-invitee", "BUBI-7K3M", NOW)).rejects.toThrow(
REFERRAL_ERRORS.CODE_INVALID
);
});
it("skips the mutual follow when either pet blocked the other", async () => {
const mockPrisma = setupRedeem();
mockPrisma.block.findFirst.mockResolvedValue({ id: "block-1" });
await redeemReferralCode(asDb(mockPrisma), "owner-invitee", "BUBI-7K3M", NOW);
expect(mockPrisma.follow.upsert).not.toHaveBeenCalled();
});
it("skips the mutual follow when it is switched off in the settings", async () => {
const mockPrisma = setupRedeem();
mockPrisma.referralSettings.findUnique.mockResolvedValue({
programActive: true, redeemWindowDays: 7, dailyQualifyCap: 20, autoMutualFollow: false,
});
await redeemReferralCode(asDb(mockPrisma), "owner-invitee", "BUBI-7K3M", NOW);
expect(mockPrisma.follow.upsert).not.toHaveBeenCalled();
});
});
// ─── Qualifizieren ───────────────────────────────────────────────────────────
function setupQualify(owner: Record<string, unknown> | null, opts: { posts?: number; qualifiedToday?: number } = {}) {
const mockPrisma = createMockPrisma();
mockPrisma.owner.findUnique.mockImplementation(async ({ where }: { where: { id: string } }) => {
if (where.id === "owner-invitee") return owner;
if (where.id === "owner-inviter") return { qualifiedReferralCount: 3 };
return null;
});
mockPrisma.post.count.mockResolvedValue(opts.posts ?? 1);
mockPrisma.owner.count.mockResolvedValue(opts.qualifiedToday ?? 0);
mockPrisma.owner.updateMany.mockResolvedValue({ count: 1 });
mockPrisma.owner.update.mockResolvedValue({});
mockPrisma.referralRank.findMany.mockResolvedValue(RANKS);
mockPrisma.referralRankAchievement.findMany.mockResolvedValue([{ rankId: "r1" }]);
mockPrisma.pet.findFirst.mockResolvedValue({ id: "pet-bubi", name: "Bubi" });
mockPrisma.$transaction.mockImplementation(async (fn: (tx: unknown) => unknown) => fn(mockPrisma));
return mockPrisma;
}
const INVITED = {
invitedById: "owner-inviter",
referralQualifiedAt: null,
referralFlaggedAt: null,
referralRevokedAt: null,
ban: null,
shadowban: null,
};
describe("maybeQualifyReferral", () => {
beforeEach(() => vi.mocked(notify).mockClear());
it("does nothing for owners who were not invited", async () => {
const mockPrisma = setupQualify({ ...INVITED, invitedById: null });
expect(await maybeQualifyReferral(asDb(mockPrisma), "owner-invitee", NOW)).toBe("not-invited");
expect(mockPrisma.$transaction).not.toHaveBeenCalled();
});
it("waits until the invitee has a real post", async () => {
const mockPrisma = setupQualify(INVITED, { posts: 0 });
expect(await maybeQualifyReferral(asDb(mockPrisma), "owner-invitee", NOW)).toBe("no-post");
});
it("qualifies once, credits the inviter and awards the newly reached rank", async () => {
const mockPrisma = setupQualify(INVITED);
expect(await maybeQualifyReferral(asDb(mockPrisma), "owner-invitee", NOW)).toBe("qualified");
expect(mockPrisma.owner.updateMany).toHaveBeenCalledWith({
where: { id: "owner-invitee", referralQualifiedAt: null, referralFlaggedAt: null, referralRevokedAt: null },
data: { referralQualifiedAt: NOW },
});
expect(mockPrisma.owner.updateMany).toHaveBeenCalledWith({
where: { id: "owner-inviter" },
data: { qualifiedReferralCount: { increment: 1 } },
});
// Zähler 3 → r3 (Botschafter) neu, r1 war schon erreicht
expect(mockPrisma.referralRankAchievement.createMany).toHaveBeenCalledWith({
data: [{ ownerId: "owner-inviter", rankId: "r3" }],
skipDuplicates: true,
});
expect(mockPrisma.owner.update).toHaveBeenCalledWith({
where: { id: "owner-inviter" },
data: { referralRankId: "r3", referralPerks: expect.arrayContaining(["PROFILE_RING", "NAME_BADGE"]) },
});
expect(notify).toHaveBeenCalledWith(expect.anything(), { recipientPetId: "pet-bubi", type: "REFERRAL_RANK_UP" });
});
it("flags instead of counting once the inviter hit the daily cap", async () => {
const mockPrisma = setupQualify(INVITED, { qualifiedToday: 20 });
expect(await maybeQualifyReferral(asDb(mockPrisma), "owner-invitee", NOW)).toBe("flagged");
expect(mockPrisma.owner.updateMany).toHaveBeenCalledWith(
expect.objectContaining({ data: { referralFlaggedAt: NOW } })
);
expect(mockPrisma.owner.updateMany).not.toHaveBeenCalledWith(
expect.objectContaining({ data: { qualifiedReferralCount: { increment: 1 } } })
);
});
it("never counts the same invitee twice", async () => {
const mockPrisma = setupQualify({ ...INVITED, referralQualifiedAt: new Date() });
expect(await maybeQualifyReferral(asDb(mockPrisma), "owner-invitee", NOW)).toBe("already-done");
});
it("does not count shadowbanned or revoked invitees", async () => {
const shadow = setupQualify({ ...INVITED, shadowban: { expiresAt: null } });
expect(await maybeQualifyReferral(asDb(shadow), "owner-invitee", NOW)).toBe("already-done");
const revoked = setupQualify({ ...INVITED, referralRevokedAt: new Date() });
expect(await maybeQualifyReferral(asDb(revoked), "owner-invitee", NOW)).toBe("already-done");
});
});
// ─── Ränge vergeben ──────────────────────────────────────────────────────────
describe("awardRanks", () => {
it("keeps a rank whose threshold was raised later (grandfathering)", async () => {
const mockPrisma = createMockPrisma();
// Botschafter wurde bei 3 erreicht, Schwelle danach auf 10 erhöht; Zähler steht bei 4.
mockPrisma.referralRank.findMany.mockResolvedValue([rank("r1", 1, ["PROFILE_RING"]), rank("rB", 10, ["NAME_BADGE"])]);
mockPrisma.referralRankAchievement.findMany.mockResolvedValue([{ rankId: "r1" }, { rankId: "rB" }]);
mockPrisma.owner.update.mockResolvedValue({});
const newRanks = await awardRanks(asDb(mockPrisma), "owner-1", 4);
expect(newRanks).toEqual([]);
expect(mockPrisma.owner.update).toHaveBeenCalledWith({
where: { id: "owner-1" },
data: { referralRankId: "rB", referralPerks: ["PROFILE_RING", "NAME_BADGE"] },
});
});
});
describe("recomputeReferralRanks", () => {
it("runs the set-based SQL (award, refresh cache, clear stale cache)", async () => {
const mockPrisma = createMockPrisma();
await recomputeReferralRanks(asDb(mockPrisma));
expect(mockPrisma.$executeRaw).toHaveBeenCalledTimes(3);
});
});
+77
View File
@@ -0,0 +1,77 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { useTranslations } from "next-intl";
import { Gift, Loader2 } from "lucide-react";
import { toast } from "sonner";
import { useTRPC } from "@/trpc/client";
import { useReferralRanks } from "@/hooks/useReferralRanks";
import { ShareInviteCard } from "@/components/referrals/ShareInviteCard";
import { RankLadder } from "@/components/referrals/RankLadder";
import { InviteeList } from "@/components/referrals/InviteeList";
import { RedeemCodeForm } from "@/components/referrals/RedeemCodeForm";
/**
* „Freunde einladen“ — Empfehlungsprogramm. Ersetzt /invite (alte Einmal-Codes).
* Eigener Link, Rangfortschritt, eingeladene Tiere und — innerhalb des
* Einlösefensters — die nachträgliche Eingabe eines fremden Codes.
*/
export default function InviteFriendsPage() {
const trpc = useTRPC();
const t = useTranslations("Referrals");
const { ranks } = useReferralRanks();
const { data, isLoading } = useQuery(trpc.referrals.me.queryOptions());
if (isLoading || !data) {
return (
<div className="flex justify-center py-24">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
);
}
const achieved = new Set(data.achievedRankIds);
return (
<div className="max-w-lg mx-auto px-4 py-10 space-y-6">
<header className="space-y-1">
<h1 className="flex items-center gap-2 text-xl font-semibold">
<Gift className="h-5 w-5 text-orange-500" aria-hidden="true" />
{t("title")}
</h1>
<p className="text-sm text-muted-foreground">{t("subtitle")}</p>
</header>
{!data.programActive && (
<p className="rounded-xl border border-amber-300 bg-amber-50 px-4 py-3 text-sm text-amber-900 dark:border-amber-800 dark:bg-amber-950 dark:text-amber-200">
{t("programPaused")}
</p>
)}
<ShareInviteCard code={data.code} />
<RankLadder
ranks={ranks}
achievedRankIds={achieved}
qualifiedCount={data.qualifiedCount}
nextRankId={data.nextRankId}
/>
<InviteeList invitees={data.invitees} />
{data.canRedeem && (
<section className="rounded-2xl border bg-card p-5 space-y-2" aria-labelledby="redeem-heading">
<h2 id="redeem-heading" className="text-base font-semibold">
{t("redeemHeading")}
</h2>
<p className="text-sm text-muted-foreground">{t("redeemSubtitle")}</p>
<RedeemCodeForm
onRedeemed={(name) => toast.success(name ? t("redeemedToast", { name }) : t("redeemedToastGeneric"))}
/>
</section>
)}
<p className="text-xs text-muted-foreground">{t("rulesNote")}</p>
</div>
);
}
+9 -2
View File
@@ -3,7 +3,7 @@
import { useEffect, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { Bell, UserPlus, Heart, MessageCircle, Cake, Home, AtSign, Repeat2, Sparkles } from "lucide-react";
import { Bell, UserPlus, Heart, MessageCircle, Cake, Home, AtSign, Repeat2, Sparkles, Gift, Award } from "lucide-react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useTRPC } from "@/trpc/client";
import { useActivePet } from "@/context/ActivePetContext";
@@ -16,7 +16,7 @@ import type { PostCardPost } from "@/components/feed/PostCard";
import { useTranslations } from "next-intl";
import { formatRelativeCompact } from "@/lib/format-relative-time";
type NotificationType = "FOLLOW" | "REACTION" | "COMMENT" | "MESSAGE" | "BIRTHDAY" | "ADOPTION_DAY" | "MENTION" | "PAW_BACK" | "PET_SUGGESTION";
type NotificationType = "FOLLOW" | "REACTION" | "COMMENT" | "MESSAGE" | "BIRTHDAY" | "ADOPTION_DAY" | "MENTION" | "PAW_BACK" | "PET_SUGGESTION" | "REFERRAL_JOINED" | "REFERRAL_RANK_UP";
function notificationIcon(type: NotificationType) {
switch (type) {
@@ -29,6 +29,8 @@ function notificationIcon(type: NotificationType) {
case "MENTION": return <AtSign className="h-4 w-4 text-violet-500" />;
case "PAW_BACK": return <Repeat2 className="h-4 w-4 text-orange-500" />;
case "PET_SUGGESTION": return <Sparkles className="h-4 w-4 text-amber-500" />;
case "REFERRAL_JOINED": return <Gift className="h-4 w-4 text-orange-500" />;
case "REFERRAL_RANK_UP": return <Award className="h-4 w-4 text-orange-500" />;
}
}
@@ -91,7 +93,10 @@ export default function NotificationsPage() {
switch (notif.type) {
case "FOLLOW":
case "PET_SUGGESTION":
case "REFERRAL_JOINED":
return notif.actorPet ? `/pets/${notif.actorPet.id}` : null;
case "REFERRAL_RANK_UP":
return "/einladen";
case "BIRTHDAY":
case "ADOPTION_DAY":
return notif.recipientPetId ? `/pets/${notif.recipientPetId}/health` : null;
@@ -226,6 +231,8 @@ export default function NotificationsPage() {
{notif.type === "BIRTHDAY" && t("typeBirthday")}
{notif.type === "ADOPTION_DAY" && t("typeAdoptionDay")}
{notif.type === "PET_SUGGESTION" && t("typePetSuggestion")}
{notif.type === "REFERRAL_JOINED" && t("typeReferralJoined")}
{notif.type === "REFERRAL_RANK_UP" && t("typeReferralRankUp")}
</span>
</p>
<p className="text-xs text-muted-foreground mt-0.5">
+23 -6
View File
@@ -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) {
<div className="container max-w-[640px] mx-auto px-6 py-8">
{/* Pet identity */}
<div className="flex flex-col items-center gap-3 mb-8">
<PetAvatarZoom
avatarUrl={pet.avatarKey ? getAvatarUrl(pet.avatarKey) : null}
petName={pet.name}
initials={getInitials(pet.name)}
aiDisclosure={pet.aiDisclosure}
/>
<ReferralAvatarRing owner={pet.owner}>
<PetAvatarZoom
avatarUrl={pet.avatarKey ? getAvatarUrl(pet.avatarKey) : null}
petName={pet.name}
initials={getInitials(pet.name)}
aiDisclosure={pet.aiDisclosure}
/>
</ReferralAvatarRing>
<h1 className="flex items-center gap-1.5 text-xl font-semibold text-center">
{formatPetName(pet)}
<PetIdentityBadges
isVerified={pet.isVerified}
sponsor={pet.owner}
referral={pet.owner}
size="lg"
verifiedLabel={t("verified")}
/>
</h1>
<Badge variant="secondary">{pet.species.name}</Badge>
{pet.invitedByPet && (
<p className="text-xs text-muted-foreground">
{t.rich("invitedBy", {
name: pet.invitedByPet.name,
link: (chunks) => (
<Link href={`/pets/${pet.invitedByPet?.id}`} className="font-medium text-foreground hover:underline">
{chunks}
</Link>
),
})}
</p>
)}
{/* Owner attribution — only shown on own pet */}
{isOwnPet && ownerUsername && (
<p className="text-xs text-muted-foreground">
+7 -1
View File
@@ -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;
}
+21
View File
@@ -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 = <UploadConsent onContinue={() => setUploadConsentGiven(true)} />;
} else if (!arachnophobiaStepDone) {
content = <ArachnophobiaOptIn onContinue={() => setArachnophobiaStepDone(true)} />;
} else if (isReferralStateLoading) {
content = <Loader2 className="h-5 w-5 animate-spin text-muted-foreground" aria-label={t("loading")} />;
} else if (showAcquisitionStep) {
content = (
<AcquisitionStep canRedeem={referralState?.canRedeem ?? false} onDone={() => setAcquisitionStepDone(true)} />
);
} else if (createdPetId) {
content = (
<div className="w-full max-w-[512px] p-8 flex flex-col items-center gap-6">
+2
View File
@@ -21,6 +21,7 @@ import {
ArrowLeft,
Radio,
Music,
Gift,
} from "lucide-react";
interface AdminLayoutProps {
@@ -71,6 +72,7 @@ export default async function AdminLayout({ children, params }: AdminLayoutProps
{ href: `${base}/broadcasts`, label: "Mitteilungen", Icon: Radio },
{ href: `${base}/log`, label: "Audit Log", Icon: ScrollText },
{ href: `${base}/verification`, label: "Verification", Icon: BadgeCheck },
{ href: `${base}/referrals`, label: "Empfehlungen", Icon: Gift },
{ href: `${base}/blacklist`, label: "Blacklist", Icon: Ban },
]
: []),
+32
View File
@@ -0,0 +1,32 @@
"use client";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { RanksTab } from "@/components/admin/referrals/RanksTab";
import { SettingsTab } from "@/components/admin/referrals/SettingsTab";
import { OverviewTab } from "@/components/admin/referrals/OverviewTab";
/**
* Empfehlungsprogramm verwalten: Ränge (Schwellen, Namen, Farben, Vorteile),
* Einstellungen und Übersicht. Ändern dürfen nur SUPER_ADMINs — die Prüfung
* passiert in referral-admin.ts, nicht hier.
*/
export default function AdminReferralsPage() {
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold">Empfehlungen</h1>
<p className="text-sm text-muted-foreground">Ränge, Einstellungen und Auswertung des Empfehlungsprogramms.</p>
</div>
<Tabs defaultValue="overview">
<TabsList>
<TabsTrigger value="overview">Übersicht</TabsTrigger>
<TabsTrigger value="ranks">Ränge</TabsTrigger>
<TabsTrigger value="settings">Einstellungen</TabsTrigger>
</TabsList>
<TabsContent value="overview" className="pt-4"><OverviewTab /></TabsContent>
<TabsContent value="ranks" className="pt-4"><RanksTab /></TabsContent>
<TabsContent value="settings" className="pt-4"><SettingsTab /></TabsContent>
</Tabs>
</div>
);
}
+3
View File
@@ -212,6 +212,9 @@ export default function AdminVerificationPage() {
Already verified
</Badge>
)}
{req.pet.owner.referralPerks.includes("VERIFICATION_PRIORITY") && (
<Badge className="bg-orange-700 text-orange-100 text-[10px]">Bevorzugt (Empfehlungsrang)</Badge>
)}
<span className="text-xs text-zinc-600 font-mono">{req.pet.ownerId}</span>
</div>
+86
View File
@@ -0,0 +1,86 @@
import type { Metadata } from "next";
import Link from "next/link";
import { headers } from "next/headers";
import { getTranslations } from "next-intl/server";
import { prisma } from "@/lib/prisma";
import { checkRateLimit } from "@/lib/rate-limit";
import { normalizeReferralCode } from "@/lib/referral-code";
import { buttonVariants } from "@/components/ui/button";
import { PetAvatar } from "@/components/ui/pet-avatar";
import { LandingHeader } from "@/components/landing/LandingHeader";
import { LandingFooter } from "@/components/landing/LandingFooter";
import { PlayStoreBadge } from "@/components/landing/PlayStoreBadge";
import { RememberReferralCode } from "@/components/referrals/RememberReferralCode";
export const metadata: Metadata = {
title: "Einladung zu PawFeed",
robots: { index: false, follow: false },
};
const LOOKUPS_PER_MINUTE = 30;
/**
* Öffentliche Einladungsseite des Empfehlungsprogramms (/r/BUBI-7K3M).
* Zeigt nur Name und Bild des ersten Tiers des Einladenden — der Einladende
* hat den Link selbst geteilt. Unbekannte Codes bekommen dieselbe Seite ohne
* Tier, damit sich nicht durchprobieren lässt, welche Codes existieren.
* Der Code wird nur für das spätere Vorausfüllen gemerkt (Cookie pf_ref);
* eingelöst wird er erst im Onboarding nach der Registrierung.
*/
export default async function ReferralLandingPage({ params }: { params: Promise<{ code: string }> }) {
const { code: rawCode } = await params;
const t = await getTranslations("ReferralLanding");
const code = normalizeReferralCode(decodeURIComponent(rawCode));
const headerList = await headers();
const ip = headerList.get("cf-connecting-ip") ?? headerList.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown";
const { success } = await checkRateLimit({ key: `referral-landing:${ip}`, limit: LOOKUPS_PER_MINUTE, windowSeconds: 60 });
const inviterPet =
code && success
? await prisma.pet.findFirst({
where: { owner: { referralCode: code } },
orderBy: { createdAt: "asc" },
select: { name: true, avatarKey: true },
})
: null;
return (
<div className="min-h-screen bg-background">
<LandingHeader />
<main className="px-4 py-16 sm:py-24">
<div className="mx-auto max-w-md space-y-8 text-center">
{inviterPet ? (
<div className="flex flex-col items-center gap-4">
<PetAvatar name={inviterPet.name} avatarKey={inviterPet.avatarKey} size="lg" className="h-24 w-24" />
<h1 className="text-3xl font-bold tracking-tight text-balance">
{t("titleWithPet", { name: inviterPet.name })}
</h1>
</div>
) : (
<h1 className="text-3xl font-bold tracking-tight text-balance">{t("titleGeneric")}</h1>
)}
<p className="text-muted-foreground text-balance">{t("subtitle")}</p>
{code && inviterPet && (
<div className="rounded-2xl border bg-card px-5 py-4">
<p className="text-xs uppercase tracking-wide text-muted-foreground">{t("codeLabel")}</p>
<p className="font-mono text-2xl font-semibold tracking-wider select-all">{code}</p>
<p className="mt-1 text-xs text-muted-foreground">{t("codeHint")}</p>
<RememberReferralCode code={code} />
</div>
)}
<div className="flex flex-col items-center gap-3">
<PlayStoreBadge className="h-14" />
<Link href="/sign-up" className={buttonVariants({ variant: "outline", size: "lg" })}>
{t("webSignUp")}
</Link>
</div>
</div>
</main>
<LandingFooter />
</div>
);
}
@@ -0,0 +1,155 @@
"use client";
import Link from "next/link";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { AlertTriangle, Loader2 } from "lucide-react";
import { toast } from "sonner";
import { useTRPC } from "@/trpc/client";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
const SOURCE_LABELS: Record<string, string> = {
FRIENDS: "Freunde/Familie", TIKTOK: "TikTok", INSTAGRAM: "Instagram", YOUTUBE: "YouTube", FACEBOOK: "Facebook",
SHELTER_OR_PARTNER: "Tierheim/Partner", STORE_SEARCH: "Store-Suche", PRESS: "Presse", OTHER: "Anders",
};
type PetRef = { id: string; name: string } | null;
function PetLink({ pet }: { pet: PetRef }) {
if (!pet) return <span className="text-muted-foreground">ohne Tier</span>;
return <Link href={`/pets/${pet.id}`} className="font-medium hover:underline">{pet.name}</Link>;
}
type MaybeDate = Date | string | null;
function statusOf(o: { referralRevokedAt: MaybeDate; referralQualifiedAt: MaybeDate; referralFlaggedAt: MaybeDate }) {
if (o.referralRevokedAt) return <Badge variant="secondary">aberkannt</Badge>;
if (o.referralQualifiedAt) return <Badge>zählt</Badge>;
if (o.referralFlaggedAt) return <Badge variant="destructive">Prüfung</Badge>;
return <Badge variant="outline">wartet</Badge>;
}
export function OverviewTab() {
const trpc = useTRPC();
const qc = useQueryClient();
const { data, isLoading } = useQuery(trpc.referralAdmin.overview.queryOptions());
const { data: featurePool = [] } = useQuery(trpc.referralAdmin.perkHolders.queryOptions({ perk: "FEATURE_POOL" }));
const { data: spotlight = [] } = useQuery(trpc.referralAdmin.perkHolders.queryOptions({ perk: "CURATOR_SPOTLIGHT" }));
const refresh = () => void qc.invalidateQueries(trpc.referralAdmin.overview.queryFilter());
const approve = useMutation(trpc.referralAdmin.approveFlagged.mutationOptions({ onSuccess: () => { toast.success("Freigegeben"); refresh(); }, onError: (e) => toast.error(e.message) }));
const revoke = useMutation(trpc.referralAdmin.revokeReferral.mutationOptions({ onSuccess: () => { toast.success("Aberkannt"); refresh(); }, onError: (e) => toast.error(e.message) }));
if (isLoading || !data) return <Loader2 className="mx-auto my-12 h-5 w-5 animate-spin text-muted-foreground" />;
const tiles = [
{ label: "Eingelöst (7 / 30 Tage)", value: `${data.redeemed7} / ${data.redeemed30}` },
{ label: "Gezählt (7 / 30 Tage)", value: `${data.qualified7} / ${data.qualified30}` },
{ label: "Einladungsfaktor (30 Tage)", value: data.inviteFactor30.toFixed(2) },
{ label: "Owner mit Tier", value: String(data.ownersWithPet) },
];
const answered = data.sources.filter((s) => s.source).reduce((sum, s) => sum + s.count, 0);
return (
<div className="space-y-8">
<dl className="grid grid-cols-2 gap-3 lg:grid-cols-4">
{tiles.map((tile) => (
<div key={tile.label} className="rounded-xl border p-4">
<dt className="text-xs text-muted-foreground">{tile.label}</dt>
<dd className="mt-1 font-mono text-xl font-semibold tabular-nums">{tile.value}</dd>
</div>
))}
</dl>
{data.flagged.length > 0 && (
<Section title={`Zur Prüfung (${data.flagged.length})`} hint="Über dem Tageslimit des Einladenden. Freigeben zählt die Einladung, Aberkennen nicht.">
<ul className="divide-y rounded-xl border">
{data.flagged.map((o) => (
<li key={o.id} className="flex flex-wrap items-center gap-3 px-4 py-2.5 text-sm">
<AlertTriangle className="h-4 w-4 text-amber-600" aria-hidden="true" />
<span className="flex-1"><PetLink pet={o.pet} /> eingeladen von <PetLink pet={o.inviterPet} /></span>
<Button size="sm" onClick={() => approve.mutate({ ownerId: o.id })}>Freigeben</Button>
<Button size="sm" variant="outline" onClick={() => revoke.mutate({ ownerId: o.id, reason: "Tageslimit-Prüfung" })}>Aberkennen</Button>
</li>
))}
</ul>
</Section>
)}
<Section title="Herkunft (Onboarding-Frage)" hint={`${answered} Antworten`}>
<ul className="grid gap-1.5 sm:grid-cols-2">
{data.sources.filter((s) => s.source).sort((a, b) => b.count - a.count).map((s) => (
<li key={s.source} className="flex items-center gap-3 text-sm">
<span className="w-36 shrink-0">{SOURCE_LABELS[s.source ?? ""] ?? s.source}</span>
<span className="h-2 rounded-full bg-orange-500" style={{ width: `${answered ? (s.count / answered) * 100 : 0}%`, minWidth: 4 }} />
<span className="font-mono tabular-nums text-muted-foreground">{s.count}</span>
</li>
))}
</ul>
</Section>
<Section title="Letzte Einlösungen">
<ul className="divide-y rounded-xl border">
{data.recent.map((o) => (
<li key={o.id} className="flex flex-wrap items-center gap-3 px-4 py-2.5 text-sm">
<span className="flex-1"><PetLink pet={o.pet} /> ← <PetLink pet={o.inviterPet} /></span>
{o.sameIpAsInviter && <Badge variant="destructive" title="Gleiche Registrierungs-IP wie der Einladende">gleiche IP</Badge>}
{statusOf(o)}
{!o.referralRevokedAt && (
<Button size="sm" variant="ghost" onClick={() => revoke.mutate({ ownerId: o.id, reason: "Manuell aberkannt" })}>Aberkennen</Button>
)}
</li>
))}
{data.recent.length === 0 && <li className="px-4 py-6 text-center text-sm text-muted-foreground">Noch keine Einlösungen.</li>}
</ul>
</Section>
<Section title="Top-Einladende">
<ol className="divide-y rounded-xl border">
{data.topInviters.map((o, i) => (
<li key={o.id} className="flex items-center gap-3 px-4 py-2 text-sm">
<span className="w-6 font-mono text-muted-foreground">{i + 1}</span>
<span className="flex-1"><PetLink pet={o.pet} /></span>
<span className="font-mono tabular-nums">{o.qualifiedReferralCount}</span>
</li>
))}
</ol>
</Section>
<div className="grid gap-8 lg:grid-cols-2">
<PerkList title="Kandidaten „Tier der Woche“" owners={featurePool} />
<PerkList title="Noch vorzustellen (Team-Profil)" owners={spotlight} />
</div>
</div>
);
}
type PerkOwner = { id: string; qualifiedReferralCount: number; pets: { id: string; name: string; _count: { followers: number; posts: number } }[] };
function PerkList({ title, owners }: { title: string; owners: PerkOwner[] }) {
return (
<Section title={title}>
<ul className="divide-y rounded-xl border">
{owners.flatMap((o) => o.pets.map((pet) => (
<li key={pet.id} className="flex items-center gap-3 px-4 py-2 text-sm">
<span className="flex-1"><PetLink pet={pet} /></span>
<span className="text-xs text-muted-foreground">{pet._count.followers} Follower · {pet._count.posts} Beiträge</span>
</li>
)))}
{owners.length === 0 && <li className="px-4 py-6 text-center text-sm text-muted-foreground">Noch niemand.</li>}
</ul>
</Section>
);
}
function Section({ title, hint, children }: { title: string; hint?: string; children: React.ReactNode }) {
return (
<section className="space-y-3">
<div>
<h2 className="text-base font-semibold">{title}</h2>
{hint && <p className="text-xs text-muted-foreground">{hint}</p>}
</div>
{children}
</section>
);
}
@@ -0,0 +1,202 @@
"use client";
import { useState } from "react";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { ReferralIcon } from "@/components/referrals/ReferralRankBadge";
import {
IMPLEMENTED_REFERRAL_PERKS,
REFERRAL_COLOR_PATTERN,
REFERRAL_ICON_KEYS,
REFERRAL_PERKS,
type ReferralIconKey,
type ReferralPerkKey,
} from "@/lib/referral-ranks";
import { cn } from "@/lib/utils";
export type RankFormValues = {
key: string;
nameDe: string;
nameEn: string;
descriptionDe: string;
descriptionEn: string;
threshold: number;
icon: ReferralIconKey;
color: string;
perks: ReferralPerkKey[];
isActive: boolean;
};
export const EMPTY_RANK: RankFormValues = {
key: "", nameDe: "", nameEn: "", descriptionDe: "", descriptionEn: "",
threshold: 1, icon: "paw-print", color: "#e8620f", perks: ["NAME_BADGE"], isActive: true,
};
const PERK_LABELS: Record<ReferralPerkKey, string> = {
PROFILE_RING: "Profilring",
NAME_BADGE: "Abzeichen neben dem Namen",
FEATURE_POOL: "Auswahl „Tier der Woche“",
VERIFICATION_PRIORITY: "Verifizierung bevorzugt",
CURATOR_SPOTLIGHT: "Vorstellung durch das Team",
EXPLORE_HIGHLIGHT: "Explore-Hervorhebung",
HALL_OF_FAME: "Ruhmeshalle",
EARLY_ACCESS: "Früher Zugang",
VOUCHER: "Partner-Gutschein",
};
// Hintergründe der App in Hell und Dunkel — für Vorschau und Kontrastprüfung.
const LIGHT_BG = "#ffffff";
const DARK_BG = "#0a0a0a";
const MIN_CONTRAST = 3; // WCAG-Mindestwert für Symbole und große Grafiken
function luminance(hex: string): number {
const channels = [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16) / 255);
const [r, g, b] = channels.map((c) => (c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4));
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
}
function contrast(a: string, b: string): number {
const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x);
return (hi + 0.05) / (lo + 0.05);
}
type Props = {
open: boolean;
mode: "create" | "edit";
initial: RankFormValues;
isSaving: boolean;
onClose: () => void;
onSave: (values: RankFormValues) => void;
};
export function RankEditorDialog({ open, mode, initial, isSaving, onClose, onSave }: Props) {
const [form, setForm] = useState<RankFormValues>(initial);
const set = <K extends keyof RankFormValues>(key: K, value: RankFormValues[K]) => setForm((f) => ({ ...f, [key]: value }));
const isColorValid = REFERRAL_COLOR_PATTERN.test(form.color);
const isValid =
isColorValid && form.nameDe.trim() && form.nameEn.trim() && form.threshold >= 1 &&
(mode === "edit" || /^[a-z0-9-]{2,40}$/.test(form.key));
const lowContrast = isColorValid && Math.min(contrast(form.color, LIGHT_BG), contrast(form.color, DARK_BG)) < MIN_CONTRAST;
function togglePerk(perk: ReferralPerkKey) {
set("perks", form.perks.includes(perk) ? form.perks.filter((p) => p !== perk) : [...form.perks, perk]);
}
return (
<Dialog open={open} onOpenChange={(next) => !next && onClose()}>
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-xl">
<DialogHeader>
<DialogTitle>{mode === "create" ? "Rang hinzufügen" : `Rang bearbeiten: ${initial.nameDe}`}</DialogTitle>
</DialogHeader>
<div className="space-y-4">
{/* Vorschau in Hell und Dunkel */}
<div className="grid grid-cols-2 gap-2">
{[LIGHT_BG, DARK_BG].map((bg) => (
<div key={bg} className="flex items-center gap-3 rounded-lg border p-3" style={{ background: bg, color: bg === LIGHT_BG ? "#111" : "#eee" }}>
<span className="rounded-full p-[3px]" style={{ background: isColorValid ? form.color : "transparent" }}>
<span className="block h-9 w-9 rounded-full" style={{ background: bg === LIGHT_BG ? "#e5e5e5" : "#333" }} />
</span>
<span className="flex items-center gap-1 text-sm font-semibold">
Bubi
<ReferralIcon icon={form.icon} className="h-4 w-4" style={{ color: form.color, fill: form.color }} aria-hidden="true" />
</span>
</div>
))}
</div>
{lowContrast && (
<p className="text-xs text-amber-700 dark:text-amber-400">
Die Farbe hat auf hellem oder dunklem Hintergrund wenig Kontrast. Abzeichen und Ring sind dort schlecht zu sehen.
</p>
)}
{mode === "create" && (
<Field label="Schlüssel (nicht änderbar)" hint="Kleinbuchstaben, Ziffern und Bindestrich, z. B. rudelgruender">
<Input value={form.key} onChange={(e) => set("key", e.target.value.toLowerCase())} className="font-mono" />
</Field>
)}
<div className="grid grid-cols-2 gap-3">
<Field label="Name (DE)"><Input value={form.nameDe} maxLength={40} onChange={(e) => set("nameDe", e.target.value)} /></Field>
<Field label="Name (EN)"><Input value={form.nameEn} maxLength={40} onChange={(e) => set("nameEn", e.target.value)} /></Field>
<Field label="Beschreibung (DE)"><Input value={form.descriptionDe} maxLength={200} onChange={(e) => set("descriptionDe", e.target.value)} /></Field>
<Field label="Beschreibung (EN)"><Input value={form.descriptionEn} maxLength={200} onChange={(e) => set("descriptionEn", e.target.value)} /></Field>
<Field label="Schwelle (gezählte Einladungen)">
<Input type="number" min={1} max={100000} value={form.threshold} onChange={(e) => set("threshold", Number(e.target.value))} />
</Field>
<Field label="Farbe">
<div className="flex gap-2">
<input type="color" aria-label="Farbe wählen" value={isColorValid ? form.color : "#000000"} onChange={(e) => set("color", e.target.value)} className="h-9 w-12 cursor-pointer rounded border bg-transparent" />
<Input value={form.color} onChange={(e) => set("color", e.target.value)} className="font-mono" aria-invalid={!isColorValid} />
</div>
</Field>
</div>
<Field label="Symbol">
<div className="flex flex-wrap gap-1.5">
{REFERRAL_ICON_KEYS.map((key) => {
return (
<button
key={key}
type="button"
title={key}
aria-label={key}
aria-pressed={form.icon === key}
onClick={() => set("icon", key)}
className={cn("grid h-9 w-9 place-items-center rounded-lg border", form.icon === key && "border-orange-500 bg-orange-50 dark:bg-orange-950")}
>
<ReferralIcon icon={key} className="h-4 w-4" />
</button>
);
})}
</div>
</Field>
<Field label="Vorteile" hint="Vorteile sind kumulativ: Ein Rang erhält alle Vorteile der darunterliegenden erreichten Ränge.">
<div className="grid gap-1.5 sm:grid-cols-2">
{REFERRAL_PERKS.map((perk) => (
<label key={perk} className="flex items-start gap-2 text-sm">
<input type="checkbox" checked={form.perks.includes(perk)} onChange={() => togglePerk(perk)} className="mt-1 accent-orange-500" />
<span>
{PERK_LABELS[perk]}
{!IMPLEMENTED_REFERRAL_PERKS.has(perk) && <span className="block text-xs text-muted-foreground">noch ohne Wirkung</span>}
</span>
</label>
))}
</div>
</Field>
{mode === "edit" && (
<label className="flex items-center justify-between gap-3 rounded-lg border p-3 text-sm">
<span>
Aktiv
<span className="block text-xs text-muted-foreground">Deaktivierte Ränge werden nicht mehr vergeben und nicht angezeigt. Erreichte Ränge bleiben gespeichert.</span>
</span>
<Switch checked={form.isActive} onCheckedChange={(checked) => set("isActive", checked)} />
</label>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}>Abbrechen</Button>
<Button disabled={!isValid || isSaving} onClick={() => onSave(form)}>
{isSaving ? "Wird gespeichert…" : "Speichern und neu berechnen"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
function Field({ label, hint, children }: { label: string; hint?: string; children: React.ReactNode }) {
return (
<div className="space-y-1.5">
<p className="text-sm font-medium">{label}</p>
{children}
{hint && <p className="text-xs text-muted-foreground">{hint}</p>}
</div>
);
}
+115
View File
@@ -0,0 +1,115 @@
"use client";
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Loader2, Pencil, Plus } from "lucide-react";
import { toast } from "sonner";
import { useTRPC } from "@/trpc/client";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { ReferralIcon } from "@/components/referrals/ReferralRankBadge";
import { EMPTY_RANK, RankEditorDialog, type RankFormValues } from "@/components/admin/referrals/RankEditorDialog";
import type { ReferralIconKey, ReferralPerkKey } from "@/lib/referral-ranks";
type EditorState = { mode: "create" | "edit"; id: string | null; initial: RankFormValues } | null;
export function RanksTab() {
const trpc = useTRPC();
const qc = useQueryClient();
const [editor, setEditor] = useState<EditorState>(null);
const { data: ranks = [], isLoading } = useQuery(trpc.referralAdmin.listRanks.queryOptions());
function onSaved() {
toast.success("Gespeichert. Ränge aller Nutzer wurden neu berechnet.");
setEditor(null);
void qc.invalidateQueries(trpc.referralAdmin.listRanks.queryFilter());
void qc.invalidateQueries(trpc.referrals.ranks.queryFilter());
}
const onError = (err: { message: string }) => toast.error(err.message);
const create = useMutation(trpc.referralAdmin.createRank.mutationOptions({ onSuccess: onSaved, onError }));
const update = useMutation(trpc.referralAdmin.updateRank.mutationOptions({ onSuccess: onSaved, onError }));
function handleSave(values: RankFormValues) {
const { key, isActive, ...fields } = values;
if (editor?.mode === "create") create.mutate({ key, ...fields });
else if (editor?.id) update.mutate({ id: editor.id, isActive, ...fields });
}
if (isLoading) return <Loader2 className="mx-auto my-12 h-5 w-5 animate-spin text-muted-foreground" />;
return (
<div className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<p className="max-w-prose text-sm text-muted-foreground">
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.
</p>
<Button onClick={() => setEditor({ mode: "create", id: null, initial: EMPTY_RANK })}>
<Plus className="h-4 w-4" /> Rang hinzufügen
</Button>
</div>
<div className="overflow-x-auto rounded-xl border">
<table className="w-full text-sm">
<thead className="bg-muted/50 text-left text-xs uppercase tracking-wide text-muted-foreground">
<tr>
<th className="px-3 py-2 text-right">Schwelle</th>
<th className="px-3 py-2">Rang</th>
<th className="px-3 py-2">Vorteile</th>
<th className="px-3 py-2 text-right">Träger</th>
<th className="px-3 py-2" />
</tr>
</thead>
<tbody>
{ranks.map((rank) => {
return (
<tr key={rank.id} className={rank.isActive ? "border-t" : "border-t opacity-50"}>
<td className="px-3 py-2.5 text-right font-mono tabular-nums">{rank.threshold}</td>
<td className="px-3 py-2.5">
<span className="flex items-center gap-2 font-medium">
<ReferralIcon icon={rank.icon} className="h-4 w-4" style={{ color: rank.color, fill: rank.color }} aria-hidden="true" />
{rank.nameDe}
{!rank.isActive && <Badge variant="secondary">inaktiv</Badge>}
</span>
<span className="block text-xs text-muted-foreground">{rank.nameEn} · {rank.key}</span>
</td>
<td className="px-3 py-2.5 text-xs text-muted-foreground">{rank.perks.join(", ") || "–"}</td>
<td className="px-3 py-2.5 text-right font-mono tabular-nums">{rank.holderCount}</td>
<td className="px-3 py-2.5 text-right">
<Button
variant="ghost"
size="sm"
aria-label={`${rank.nameDe} bearbeiten`}
onClick={() =>
setEditor({
mode: "edit",
id: rank.id,
initial: { ...rank, icon: rank.icon as ReferralIconKey, perks: rank.perks as ReferralPerkKey[] },
})
}
>
<Pencil className="h-4 w-4" />
</Button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
{editor && (
<RankEditorDialog
key={editor.id ?? "new"}
open
mode={editor.mode}
initial={editor.initial}
isSaving={create.isPending || update.isPending}
onClose={() => setEditor(null)}
onSave={handleSave}
/>
)}
</div>
);
}
@@ -0,0 +1,110 @@
"use client";
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Loader2 } from "lucide-react";
import { toast } from "sonner";
import { useTRPC } from "@/trpc/client";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
type SettingsValues = {
programActive: boolean;
redeemWindowDays: number;
dailyQualifyCap: number;
autoMutualFollow: boolean;
};
export function SettingsTab() {
const trpc = useTRPC();
const { data, isLoading } = useQuery(trpc.referralAdmin.getSettings.queryOptions());
if (isLoading || !data) return <Loader2 className="mx-auto my-12 h-5 w-5 animate-spin text-muted-foreground" />;
return <SettingsForm key={data.updatedAt.toString()} initial={data} />;
}
function SettingsForm({ initial }: { initial: SettingsValues }) {
const trpc = useTRPC();
const qc = useQueryClient();
const [form, setForm] = useState<SettingsValues>({
programActive: initial.programActive,
redeemWindowDays: initial.redeemWindowDays,
dailyQualifyCap: initial.dailyQualifyCap,
autoMutualFollow: initial.autoMutualFollow,
});
const set = <K extends keyof SettingsValues>(key: K, value: SettingsValues[K]) => setForm((f) => ({ ...f, [key]: value }));
const save = useMutation(
trpc.referralAdmin.updateSettings.mutationOptions({
onSuccess: () => {
toast.success("Einstellungen gespeichert");
void qc.invalidateQueries(trpc.referralAdmin.getSettings.queryFilter());
},
onError: (err) => toast.error(err.message),
})
);
return (
<div className="max-w-xl space-y-3">
<ToggleRow
title="Programm aktiv"
hint="Aus: Codes können nicht mehr eingelöst werden. Bereits eingelöste Einladungen zählen weiter, Ränge bleiben sichtbar."
checked={form.programActive}
onChange={(v) => set("programActive", v)}
/>
<ToggleRow
title="Gegenseitig folgen"
hint="Beim Einlösen folgen sich das Tier des Einladenden und das neue Tier automatisch."
checked={form.autoMutualFollow}
onChange={(v) => set("autoMutualFollow", v)}
/>
<NumberRow
id="redeem-window"
title="Einlösefenster in Tagen"
hint="So lange nach der Registrierung kann ein neuer Nutzer einen Code eingeben (1 bis 90)."
value={form.redeemWindowDays}
min={1}
max={90}
onChange={(v) => set("redeemWindowDays", v)}
/>
<NumberRow
id="daily-cap"
title="Tageslimit pro Einladendem"
hint="Mehr gezählte Einladungen innerhalb von 24 Stunden landen zur Prüfung in der Übersicht, statt sofort zu zählen."
value={form.dailyQualifyCap}
min={1}
max={1000}
onChange={(v) => set("dailyQualifyCap", v)}
/>
<Button onClick={() => save.mutate(form)} disabled={save.isPending}>
{save.isPending ? "Wird gespeichert…" : "Einstellungen speichern"}
</Button>
</div>
);
}
function ToggleRow({ title, hint, checked, onChange }: { title: string; hint: string; checked: boolean; onChange: (v: boolean) => void }) {
return (
<label className="flex items-center justify-between gap-4 rounded-xl border p-4">
<span className="text-sm">
<span className="font-medium">{title}</span>
<span className="block text-xs text-muted-foreground">{hint}</span>
</span>
<Switch checked={checked} onCheckedChange={onChange} />
</label>
);
}
type NumberRowProps = { id: string; title: string; hint: string; value: number; min: number; max: number; onChange: (v: number) => void };
function NumberRow({ id, title, hint, value, min, max, onChange }: NumberRowProps) {
return (
<div className="flex items-center justify-between gap-4 rounded-xl border p-4">
<label htmlFor={id} className="text-sm">
<span className="font-medium">{title}</span>
<span className="block text-xs text-muted-foreground">{hint}</span>
</label>
<Input id={id} type="number" min={min} max={max} value={value} onChange={(e) => onChange(Number(e.target.value))} className="w-24 text-right font-mono" />
</div>
);
}
+7 -2
View File
@@ -81,7 +81,12 @@ type PostPet = {
name: string;
avatarKey: string | null;
isVerified?: boolean;
owner?: { sponsorSince: Date | string | null; sponsorPeriodEnd: Date | string | null } | null;
owner?: {
sponsorSince: Date | string | null;
sponsorPeriodEnd: Date | string | null;
referralRankId?: string | null;
referralPerks?: string[] | null;
} | null;
};
type PostMilestone = { milestoneType: import("@prisma/client").MilestoneType; notes: string | null };
@@ -248,7 +253,7 @@ function PostCardImpl({ post, onDeleted, variant = "feed" }: PostCardProps) {
<div className="flex-1 min-w-0">
<p className="flex items-center gap-1 text-base font-semibold text-foreground leading-tight truncate">
{formatPetName(pet)}
<PetIdentityBadges isVerified={pet.isVerified} sponsor={pet.owner} />
<PetIdentityBadges isVerified={pet.isVerified} sponsor={pet.owner} referral={pet.owner} />
</p>
<p className="text-xs text-muted-foreground">
{formatRelativeTime(createdAt)}
+9
View File
@@ -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 (
<div className="lg:hidden fixed bottom-0 left-0 right-0 z-50">
@@ -147,6 +149,13 @@ export function MobileNav({ pets }: MobileNavProps) {
<span className="text-orange-500">+</span>
<span>{t("addPet")}</span>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => router.push("/einladen")}
className={`flex items-center gap-2 cursor-pointer ${isInvitePage ? "text-orange-500" : ""}`}
>
<Gift className="h-3.5 w-3.5 shrink-0" aria-hidden="true" />
<span>{t("invite")}</span>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => router.push("/unterstuetzen")}
className={`flex items-center gap-2 cursor-pointer ${isSponsorPage ? "text-orange-500" : ""}`}
+2
View File
@@ -17,6 +17,7 @@ import {
ChevronRight,
Plus,
Sparkles,
Gift,
} from "lucide-react";
import { useTranslations } from "next-intl";
import { PostTypeSheet } from "@/components/post-creation/PostTypeSheet";
@@ -55,6 +56,7 @@ const navLinks = [
{ href: "/search", key: "search", icon: Search, active: true, special: false },
{ href: "/notifications", key: "notifications", icon: Bell, active: true, special: true },
{ href: "/messages", key: "messages", icon: MessageCircle, active: true, special: false },
{ href: "/einladen", key: "invite", icon: Gift, active: true, special: false },
{ href: "/unterstuetzen", key: "sponsor", icon: Sparkles, active: true, special: false },
] as const;
@@ -0,0 +1,102 @@
"use client";
import { useState } from "react";
import { useMutation } from "@tanstack/react-query";
import { useTranslations } from "next-intl";
import { CheckCircle2 } from "lucide-react";
import { useTRPC } from "@/trpc/client";
import { Button } from "@/components/ui/button";
import { RedeemCodeForm } from "@/components/referrals/RedeemCodeForm";
import { forgetReferralCode, readRememberedReferralCode } from "@/components/referrals/RememberReferralCode";
import { cn } from "@/lib/utils";
const SOURCES = [
"FRIENDS",
"TIKTOK",
"INSTAGRAM",
"YOUTUBE",
"FACEBOOK",
"SHELTER_OR_PARTNER",
"STORE_SEARCH",
"PRESS",
"OTHER",
] as const;
type Source = (typeof SOURCES)[number];
/**
* Onboarding-Schritt nach dem ersten Tier: freiwillige Herkunftsangabe und,
* bei „Freunde“, die Eingabe eines Empfehlungscodes (vorausgefüllt, wenn der
* neue Nutzer über /r/<code> 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<Source | null>(rememberedCode ? "FRIENDS" : null);
const [inviterName, setInviterName] = useState<string | null | undefined>(undefined);
const saveSource = useMutation(trpc.referrals.setAcquisitionSource.mutationOptions({ onSettled: onDone }));
function handleContinue() {
if (source) saveSource.mutate({ source });
else onDone();
}
function handleRedeemed(name: string | null) {
forgetReferralCode();
setInviterName(name);
}
const isRedeemed = inviterName !== undefined;
return (
<div className="w-full max-w-[512px] p-8 space-y-6">
<div>
<h1 className="text-xl font-semibold mb-2">{t("title")}</h1>
<p className="text-sm text-muted-foreground">{t("subtitle")}</p>
</div>
<div role="radiogroup" aria-label={t("title")} className="flex flex-wrap gap-2">
{SOURCES.map((option) => (
<button
key={option}
type="button"
role="radio"
aria-checked={source === option}
onClick={() => setSource(option)}
className={cn(
"rounded-full border px-4 py-2 text-sm transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-orange-500",
source === option
? "border-orange-500 bg-orange-50 text-orange-700 dark:bg-orange-950 dark:text-orange-300"
: "hover:bg-muted"
)}
>
{t(`source_${option}`)}
</button>
))}
</div>
{source === "FRIENDS" && canRedeem && (
<div className="rounded-xl border bg-card p-4">
{isRedeemed ? (
<p className="flex items-center gap-2 text-sm">
<CheckCircle2 className="h-4 w-4 text-green-600" aria-hidden="true" />
{inviterName ? t("redeemed", { name: inviterName }) : t("redeemedGeneric")}
</p>
) : (
<RedeemCodeForm initialCode={rememberedCode} onRedeemed={handleRedeemed} />
)}
</div>
)}
<div className="flex flex-col gap-2">
<Button className="w-full" onClick={handleContinue} disabled={saveSource.isPending}>
{t("continue")}
</Button>
<Button variant="ghost" className="w-full text-muted-foreground" onClick={onDone}>
{t("skip")}
</Button>
</div>
</div>
);
}
+65
View File
@@ -0,0 +1,65 @@
"use client";
import Link from "next/link";
import { useLocale, useTranslations } from "next-intl";
import { PetAvatar } from "@/components/ui/pet-avatar";
type Invitee = {
pet: { id: string; name: string; avatarKey: string | null } | null;
invitedAt: Date | string | null;
status: "counted" | "waiting" | "in-review" | "revoked";
};
const STATUS_CLASS: Record<Invitee["status"], string> = {
counted: "text-green-700 dark:text-green-400",
waiting: "text-muted-foreground",
"in-review": "text-amber-700 dark:text-amber-400",
revoked: "text-muted-foreground line-through",
};
const STATUS_KEY: Record<Invitee["status"], string> = {
counted: "statusCounted",
waiting: "statusWaiting",
"in-review": "statusInReview",
revoked: "statusRevoked",
};
/** Tiere, die über den eigenen Code gekommen sind, mit Status. */
export function InviteeList({ invitees }: { invitees: Invitee[] }) {
const t = useTranslations("Referrals");
const locale = useLocale();
return (
<section className="rounded-2xl border bg-card p-5 space-y-3" aria-labelledby="invitees-heading">
<h2 id="invitees-heading" className="text-base font-semibold">
{t("inviteesHeading")}
</h2>
{invitees.length === 0 ? (
<p className="text-sm text-muted-foreground">{t("inviteesEmpty")}</p>
) : (
<ul className="divide-y">
{invitees.map((invitee, index) => (
<li key={invitee.pet?.id ?? `invitee-${index}`} className="flex items-center gap-3 py-2.5">
{invitee.pet ? (
<Link href={`/pets/${invitee.pet.id}`} className="flex min-w-0 flex-1 items-center gap-3 hover:underline">
<PetAvatar avatarKey={invitee.pet.avatarKey} name={invitee.pet.name} size="sm" />
<span className="truncate text-sm font-medium">{invitee.pet.name}</span>
</Link>
) : (
<span className="flex-1 text-sm text-muted-foreground">{t("inviteeNoPet")}</span>
)}
<span className="shrink-0 text-right text-xs">
<span className={STATUS_CLASS[invitee.status]}>{t(STATUS_KEY[invitee.status])}</span>
{invitee.invitedAt && (
<span className="block text-muted-foreground">
{new Date(invitee.invitedAt).toLocaleDateString(locale)}
</span>
)}
</span>
</li>
))}
</ul>
)}
</section>
);
}
+89
View File
@@ -0,0 +1,89 @@
"use client";
import { useTranslations } from "next-intl";
import { Check } from "lucide-react";
import { ReferralIcon } from "@/components/referrals/ReferralRankBadge";
import type { ReferralRankView } from "@/hooks/useReferralRanks";
import { cn } from "@/lib/utils";
type Props = {
ranks: ReferralRankView[];
achievedRankIds: ReadonlySet<string>;
qualifiedCount: number;
nextRankId: string | null;
};
/** Fortschritt zum nächsten Rang plus alle aktiven Ränge, erreichte markiert. */
export function RankLadder({ ranks, achievedRankIds, qualifiedCount, nextRankId }: Props) {
const t = useTranslations("Referrals");
const activeRanks = ranks.filter((rank) => rank.isActive);
const next = activeRanks.find((rank) => rank.id === nextRankId) ?? null;
const progress = next ? Math.min(100, Math.round((qualifiedCount / next.threshold) * 100)) : 100;
return (
<section className="rounded-2xl border bg-card p-5 space-y-5" aria-labelledby="ranks-heading">
<div className="space-y-2">
<div className="flex items-baseline justify-between gap-3">
<h2 id="ranks-heading" className="text-base font-semibold">
{t("ranksHeading")}
</h2>
<span className="text-sm tabular-nums text-muted-foreground">
{t("countedInvites", { count: qualifiedCount })}
</span>
</div>
{next ? (
<>
<div
className="h-2 rounded-full bg-muted overflow-hidden"
role="progressbar"
aria-valuemin={0}
aria-valuemax={next.threshold}
aria-valuenow={qualifiedCount}
>
<div className="h-full rounded-full transition-[width]" style={{ width: `${progress}%`, background: next.color }} />
</div>
<p className="text-sm text-muted-foreground">
{t("nextRank", { remaining: Math.max(0, next.threshold - qualifiedCount), name: next.name })}
</p>
</>
) : (
<p className="text-sm text-muted-foreground">{t("allRanksReached")}</p>
)}
</div>
<ol className="space-y-2">
{activeRanks.map((rank) => {
const isAchieved = achievedRankIds.has(rank.id);
return (
<li
key={rank.id}
className={cn(
"flex items-center gap-3 rounded-xl border px-3 py-2.5",
isAchieved ? "bg-background" : "bg-muted/30 border-dashed"
)}
style={isAchieved ? { borderColor: rank.color } : undefined}
>
<span
className="grid h-8 w-8 shrink-0 place-items-center rounded-full"
style={{ background: isAchieved ? rank.color : "transparent", border: `2px solid ${rank.color}` }}
>
<ReferralIcon icon={rank.icon} className="h-4 w-4" style={{ color: isAchieved ? "white" : rank.color }} aria-hidden="true" />
</span>
<div className="min-w-0 flex-1">
<p className="text-sm font-semibold">{rank.name}</p>
<p className="text-xs text-muted-foreground">{rank.description}</p>
</div>
<span className="shrink-0 text-xs tabular-nums text-muted-foreground">
{isAchieved ? (
<Check className="h-4 w-4 text-green-600" aria-label={t("achieved")} />
) : (
t("threshold", { count: rank.threshold })
)}
</span>
</li>
);
})}
</ol>
</section>
);
}
@@ -0,0 +1,82 @@
"use client";
import { useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useTranslations } from "next-intl";
import { Loader2 } from "lucide-react";
import { useTRPC } from "@/trpc/client";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
/** Fehler-Schlüssel aus REFERRAL_ERRORS (src/lib/referrals.ts) → Übersetzungsschlüssel. */
const ERROR_KEYS: Record<string, string> = {
REFERRAL_PROGRAM_PAUSED: "errorPaused",
REFERRAL_CODE_INVALID: "errorInvalid",
REFERRAL_OWN_CODE: "errorOwnCode",
REFERRAL_ALREADY_REDEEMED: "errorAlreadyRedeemed",
REFERRAL_WINDOW_EXPIRED: "errorWindowExpired",
REFERRAL_NO_PET: "errorNoPet",
};
type Props = {
initialCode?: string;
onRedeemed?: (inviterPetName: string | null) => void;
};
export function RedeemCodeForm({ initialCode = "", onRedeemed }: Props) {
const trpc = useTRPC();
const queryClient = useQueryClient();
const t = useTranslations("Referrals");
const [code, setCode] = useState(initialCode);
const [errorKey, setErrorKey] = useState<string | null>(null);
const redeem = useMutation(
trpc.referrals.redeem.mutationOptions({
onSuccess: (result) => {
setErrorKey(null);
void queryClient.invalidateQueries(trpc.referrals.me.queryFilter());
onRedeemed?.(result.inviterPetName);
},
onError: (err) => {
const isRateLimited = err.data?.code === "TOO_MANY_REQUESTS";
setErrorKey(isRateLimited ? "errorTooMany" : (ERROR_KEYS[err.message] ?? "errorGeneric"));
},
})
);
function handleSubmit(event: React.FormEvent) {
event.preventDefault();
if (code.trim()) redeem.mutate({ code: code.trim() });
}
return (
<form onSubmit={handleSubmit} className="space-y-2">
<label htmlFor="referral-code" className="text-sm font-medium">
{t("redeemLabel")}
</label>
<div className="flex gap-2">
<Input
id="referral-code"
value={code}
onChange={(e) => setCode(e.target.value.toUpperCase())}
placeholder="BUBI-7K3M"
autoComplete="off"
autoCapitalize="characters"
spellCheck={false}
maxLength={32}
className="font-mono uppercase"
aria-invalid={!!errorKey}
aria-describedby={errorKey ? "referral-code-error" : undefined}
/>
<Button type="submit" disabled={!code.trim() || redeem.isPending}>
{redeem.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : t("redeemCta")}
</Button>
</div>
{errorKey && (
<p id="referral-code-error" className="text-sm text-destructive">
{t(errorKey)}
</p>
)}
</form>
);
}
@@ -0,0 +1,18 @@
"use client";
import type { ReactNode } from "react";
import { useReferralRingColor, type ReferralOwnerFields } from "@/components/referrals/ReferralRankBadge";
/**
* Ring in der Rangfarbe um das Tierbild im Profilkopf (Vorteil PROFILE_RING).
* Bewusst nur im Profil, nicht im Feed — dort steht der Ring für Storys.
*/
export function ReferralAvatarRing({ owner, children }: { owner: ReferralOwnerFields | null | undefined; children: ReactNode }) {
const color = useReferralRingColor(owner);
if (!color) return <>{children}</>;
return (
<div className="rounded-full p-[3px]" style={{ background: color }}>
<div className="rounded-full bg-background p-[2px]">{children}</div>
</div>
);
}
@@ -0,0 +1,65 @@
"use client";
import { createElement, type ComponentProps } from "react";
import { Award, Crown, Flame, Gem, Heart, Leaf, Medal, PawPrint, Rocket, Shield, Star, Trophy, type LucideIcon } from "lucide-react";
import { useReferralRanks } from "@/hooks/useReferralRanks";
import { cn } from "@/lib/utils";
/** Schlüssel aus REFERRAL_ICON_KEYS (src/lib/referral-ranks.ts) → lucide-react. */
const ICONS: Record<string, LucideIcon> = {
"paw-print": PawPrint,
heart: Heart,
shield: Shield,
star: Star,
award: Award,
crown: Crown,
gem: Gem,
medal: Medal,
trophy: Trophy,
flame: Flame,
rocket: Rocket,
leaf: Leaf,
};
/** Rang-Symbol per Schlüssel; unbekannte Schlüssel fallen auf die Pfote zurück. */
export function ReferralIcon({ icon, ...props }: { icon: string } & ComponentProps<LucideIcon>) {
return createElement(ICONS[icon] ?? PawPrint, props);
}
export type ReferralOwnerFields = {
referralRankId?: string | null;
referralPerks?: readonly string[] | null;
};
const ICON_SIZE = { sm: "h-3 w-3", md: "h-3.5 w-3.5", lg: "h-4 w-4" } as const;
/**
* Rang-Abzeichen neben dem Tiernamen. Sichtbar, sobald der Owner den Vorteil
* NAME_BADGE hat (kumulativ, siehe Owner.referralPerks); Farbe und Symbol
* kommen vom höchsten erreichten Rang.
*/
export function ReferralRankBadge({
owner,
size = "md",
}: {
owner: ReferralOwnerFields | null | undefined;
size?: keyof typeof ICON_SIZE;
}) {
const { byId } = useReferralRanks();
if (!owner?.referralRankId || !owner.referralPerks?.includes("NAME_BADGE")) return null;
const rank = byId.get(owner.referralRankId);
if (!rank) return null;
return (
<span title={`${rank.name} · ${rank.description}`} className="inline-flex shrink-0">
<ReferralIcon icon={rank.icon} className={cn(ICON_SIZE[size])} style={{ color: rank.color, fill: rank.color }} aria-label={rank.name} />
</span>
);
}
/** Farbe des Profilrings (Vorteil PROFILE_RING) oder null. */
export function useReferralRingColor(owner: ReferralOwnerFields | null | undefined): string | null {
const { byId } = useReferralRanks();
if (!owner?.referralRankId || !owner.referralPerks?.includes("PROFILE_RING")) return null;
return byId.get(owner.referralRankId)?.color ?? null;
}
@@ -0,0 +1,29 @@
"use client";
import { useEffect } from "react";
export const REFERRAL_COOKIE = "pf_ref";
const REMEMBER_DAYS = 7; // entspricht dem Standard-Einlösefenster
/**
* Merkt sich den Code einer Einladungsseite, damit das Onboarding ihn
* vorausfüllen kann. Reine Komfortfunktion: geht das Cookie verloren (andere
* Browser, App-Registrierung), tippt der neue Nutzer den Code einfach ab.
*/
export function RememberReferralCode({ code }: { code: string }) {
useEffect(() => {
const maxAge = REMEMBER_DAYS * 24 * 60 * 60;
document.cookie = `${REFERRAL_COOKIE}=${encodeURIComponent(code)}; Max-Age=${maxAge}; Path=/; SameSite=Lax`;
}, [code]);
return null;
}
export function readRememberedReferralCode(): string {
if (typeof document === "undefined") return "";
const match = document.cookie.match(new RegExp(`(?:^|; )${REFERRAL_COOKIE}=([^;]*)`));
return match ? decodeURIComponent(match[1]) : "";
}
export function forgetReferralCode() {
document.cookie = `${REFERRAL_COOKIE}=; Max-Age=0; Path=/; SameSite=Lax`;
}
@@ -0,0 +1,65 @@
"use client";
import { useState } from "react";
import { useTranslations } from "next-intl";
import { Check, Copy, Share2 } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
const COPIED_RESET_MS = 2000;
export function referralLink(code: string): string {
return `${window.location.origin}/r/${code}`;
}
/** Eigener Einladungslink mit Teilen (wo verfügbar) und Kopieren. */
export function ShareInviteCard({ code }: { code: string }) {
const t = useTranslations("Referrals");
const [isCopied, setIsCopied] = useState(false);
const canShare = typeof navigator !== "undefined" && typeof navigator.share === "function";
function handleCopy() {
void navigator.clipboard.writeText(referralLink(code)).then(() => {
setIsCopied(true);
toast.success(t("copiedToast"));
setTimeout(() => setIsCopied(false), COPIED_RESET_MS);
});
}
async function handleShare() {
try {
await navigator.share({ title: t("shareTitle"), text: t("shareText", { code }), url: referralLink(code) });
} catch {
// Teilen-Dialog abgebrochen — nichts zu tun
}
}
return (
<section className="rounded-2xl border bg-card p-5 space-y-4" aria-labelledby="share-heading">
<div className="space-y-1">
<h2 id="share-heading" className="text-base font-semibold">
{t("shareHeading")}
</h2>
<p className="text-sm text-muted-foreground">{t("shareSubtitle")}</p>
</div>
<div className="rounded-xl bg-muted/60 px-4 py-3 text-center">
<p className="text-xs uppercase tracking-wide text-muted-foreground">{t("yourCode")}</p>
<p className="font-mono text-2xl font-semibold tracking-wider select-all">{code}</p>
</div>
<div className="flex flex-wrap gap-2">
{canShare && (
<Button onClick={() => void handleShare()} className="flex-1 min-w-[140px]">
<Share2 className="h-4 w-4" />
{t("share")}
</Button>
)}
<Button variant="outline" onClick={handleCopy} className="flex-1 min-w-[140px]">
{isCopied ? <Check className="h-4 w-4 text-green-600" /> : <Copy className="h-4 w-4" />}
{isCopied ? t("copied") : t("copyLink")}
</Button>
</div>
</section>
);
}
+8 -3
View File
@@ -1,6 +1,7 @@
import { BadgeCheck, Sparkles } from "lucide-react";
import { isActiveSponsor } from "@/lib/sponsor";
import { cn } from "@/lib/utils";
import { ReferralRankBadge, type ReferralOwnerFields } from "@/components/referrals/ReferralRankBadge";
type SponsorWindow = {
sponsorSince: Date | string | null;
@@ -11,6 +12,8 @@ type Props = {
isVerified?: boolean;
/** Omit entirely (rather than passing null) when the caller has no owner/sponsor data to show. */
sponsor?: SponsorWindow | null;
/** Owner-Felder referralRankId/referralPerks (petCardSelect.owner) — Rang-Abzeichen des Empfehlungsprogramms. */
referral?: ReferralOwnerFields | null;
size?: "sm" | "md" | "lg";
verifiedLabel?: string;
sponsorLabel?: string;
@@ -30,13 +33,14 @@ function isSponsorActive(sponsor: SponsorWindow): boolean {
}
/**
* Verified checkmark + sponsor sparkle, shown inline next to a pet's name.
* Renders nothing when neither applies. sponsor is optional — pass it only
* Verified checkmark + sponsor sparkle + referral rank badge, shown inline
* next to a pet's name. Renders nothing when none applies. sponsor is optional — pass it only
* where the caller actually has owner sponsor data (see pet-repository.ts).
*/
export function PetIdentityBadges({
isVerified,
sponsor,
referral,
size = "md",
verifiedLabel = "Verified",
sponsorLabel = "Sponsor",
@@ -44,7 +48,7 @@ export function PetIdentityBadges({
const iconClass = ICON_SIZE[size];
const showSponsor = sponsor != null && isSponsorActive(sponsor);
if (!isVerified && !showSponsor) return null;
if (!isVerified && !showSponsor && !referral?.referralRankId) return null;
return (
<>
@@ -60,6 +64,7 @@ export function PetIdentityBadges({
aria-label={sponsorLabel}
/>
)}
<ReferralRankBadge owner={referral} size={size} />
</>
);
}
+39
View File
@@ -0,0 +1,39 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { useLocale } from "next-intl";
import { useTRPC } from "@/trpc/client";
const RANKS_STALE_MS = 30 * 60 * 1000; // Ränge ändern sich selten — einmal laden, lange halten
export type ReferralRankView = {
id: string;
key: string;
threshold: number;
name: string;
description: string;
icon: string;
color: string;
perks: string[];
isActive: boolean;
};
/** Rangliste des Empfehlungsprogramms in der aktuellen Sprache, für Abzeichen und Einladen-Seite. */
export function useReferralRanks(): { ranks: ReferralRankView[]; byId: Map<string, ReferralRankView> } {
const trpc = useTRPC();
const locale = useLocale();
const { data = [] } = useQuery({ ...trpc.referrals.ranks.queryOptions(), staleTime: RANKS_STALE_MS });
const ranks = data.map((rank) => ({
id: rank.id,
key: rank.key,
threshold: rank.threshold,
name: locale === "de" ? rank.nameDe : rank.nameEn,
description: locale === "de" ? rank.descriptionDe : rank.descriptionEn,
icon: rank.icon,
color: rank.color,
perks: rank.perks,
isActive: rank.isActive,
}));
return { ranks, byId: new Map(ranks.map((rank) => [rank.id, rank])) };
}
+2
View File
@@ -39,6 +39,8 @@ const PUSH_COPY: Record<
MENTION: { title: "Erwähnung", genericBody: "Du wurdest in einem Beitrag erwähnt.", actorBody: (a) => `${a} hat dich erwähnt.` },
PAW_BACK: { title: "Paw-Back", genericBody: "Jemand hat deinen Beitrag geteilt.", actorBody: (a) => `${a} hat deinen Beitrag geteilt.` },
PET_SUGGESTION: { title: "Vorschlag für dich", genericBody: "Ein Tier könnte dir gefallen.", actorBody: (a) => `${a} könnte dir gefallen!` },
REFERRAL_JOINED: { title: "Deine Einladung hat geklappt", genericBody: "Ein neues Tier ist über deine Einladung zu PawFeed gekommen.", actorBody: (a) => `${a} ist über deine Einladung zu PawFeed gekommen.` },
REFERRAL_RANK_UP: { title: "Neuer Rang", genericBody: "Du hast einen neuen Empfehlungsrang erreicht.", actorBody: () => "Du hast einen neuen Empfehlungsrang erreicht." },
};
/**
+46
View File
@@ -0,0 +1,46 @@
/**
* Persönliche Empfehlungscodes (Empfehlungsprogramm): bis zu 6 Buchstaben aus
* dem Namen des ersten Tiers, Bindestrich, 4 Zeichen — z. B. "BUBI-7K3M".
* Pure, ohne Server-Abhängigkeiten, damit Web-Client und Tests sie nutzen können.
*/
// Gleicher Zeichensatz wie src/lib/invite-codes.ts — ohne verwechselbare 0/1/I/O.
const SUFFIX_CHARS = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ";
const SUFFIX_LENGTH = 4;
const MAX_PREFIX_LENGTH = 6;
const MIN_PREFIX_LENGTH = 2;
const FALLBACK_PREFIX = "PAWS";
export const REFERRAL_CODE_PATTERN = /^[A-Z]{2,6}-[2-9A-HJ-NP-Z]{4}$/;
const UMLAUTS: Record<string, string> = { Ä: "AE", Ö: "OE", Ü: "UE", ẞ: "SS" };
function toPrefix(petName: string | null | undefined): string {
if (!petName) return FALLBACK_PREFIX;
const ascii = petName
.toUpperCase()
.replace(/ß/g, "SS")
.replace(/[ÄÖÜẞ]/g, (ch) => UMLAUTS[ch] ?? "")
.normalize("NFD")
.replace(/[^A-Z]/g, "")
.slice(0, MAX_PREFIX_LENGTH);
return ascii.length >= MIN_PREFIX_LENGTH ? ascii : FALLBACK_PREFIX;
}
export function generateReferralCode(
petName: string | null | undefined,
random: () => number = Math.random
): string {
const suffix = Array.from(
{ length: SUFFIX_LENGTH },
() => SUFFIX_CHARS[Math.floor(random() * SUFFIX_CHARS.length)]
).join("");
return `${toPrefix(petName)}-${suffix}`;
}
/** Normalisiert Nutzereingaben (" bubi-7k3m ") und gibt null zurück, wenn das Format nicht passt. */
export function normalizeReferralCode(input: string | null | undefined): string | null {
if (!input) return null;
const code = input.trim().toUpperCase().replace(/\s+/g, "");
return REFERRAL_CODE_PATTERN.test(code) ? code : null;
}
+113
View File
@@ -0,0 +1,113 @@
/**
* Reine Rang-Logik des Empfehlungsprogramms — ohne Prisma, damit Server,
* Web-Client und Tests dieselben Regeln nutzen (die Mobile-App hat ein
* Gegenstück in mobile/src/lib/referral-ranks.ts).
*
* Regeln:
* - Vorteile sind kumulativ über alle erreichten, aktiven Ränge.
* - Erreichte Ränge bleiben erhalten, auch wenn eine Schwelle später erhöht
* wird (Bestandsschutz) — "erreicht" kommt aus ReferralRankAchievement,
* nicht aus dem aktuellen Zähler.
* - Angezeigt wird der erreichte aktive Rang mit der höchsten Schwelle.
*/
export const REFERRAL_PERKS = [
"PROFILE_RING",
"NAME_BADGE",
"FEATURE_POOL",
"VERIFICATION_PRIORITY",
"CURATOR_SPOTLIGHT",
"EXPLORE_HIGHLIGHT",
"HALL_OF_FAME",
"EARLY_ACCESS",
"VOUCHER",
] as const;
export type ReferralPerkKey = (typeof REFERRAL_PERKS)[number];
/** Vorteile, die schon Code haben. Die übrigen sind im Admin auswählbar, wirken aber noch nicht. */
export const IMPLEMENTED_REFERRAL_PERKS: ReadonlySet<ReferralPerkKey> = new Set([
"PROFILE_RING",
"NAME_BADGE",
"FEATURE_POOL",
"VERIFICATION_PRIORITY",
"CURATOR_SPOTLIGHT",
]);
/**
* Symbole, die es in lucide-react UND lucide-react-native gibt. Sparkles und
* BadgeCheck fehlen absichtlich — die stehen für Sponsor und Verifiziert.
*/
export const REFERRAL_ICON_KEYS = [
"paw-print",
"heart",
"shield",
"star",
"award",
"crown",
"gem",
"medal",
"trophy",
"flame",
"rocket",
"leaf",
] as const;
export type ReferralIconKey = (typeof REFERRAL_ICON_KEYS)[number];
export const MAX_REFERRAL_RANKS = 20;
export const REFERRAL_COLOR_PATTERN = /^#[0-9a-fA-F]{6}$/;
export interface ReferralRankLike {
id: string;
threshold: number;
isActive: boolean;
perks: readonly string[];
}
function activeAchieved<T extends ReferralRankLike>(
ranks: readonly T[],
achievedRankIds: ReadonlySet<string>
): T[] {
return ranks.filter((rank) => rank.isActive && achievedRankIds.has(rank.id));
}
/** Höchster erreichter aktiver Rang — das, was neben dem Namen angezeigt wird. */
export function pickDisplayRank<T extends ReferralRankLike>(
ranks: readonly T[],
achievedRankIds: ReadonlySet<string>
): T | null {
return activeAchieved(ranks, achievedRankIds).reduce<T | null>(
(best, rank) => (best === null || rank.threshold > best.threshold ? rank : best),
null
);
}
/** Vereinigung der Vorteile aller erreichten aktiven Ränge. */
export function collectPerks(
ranks: readonly ReferralRankLike[],
achievedRankIds: ReadonlySet<string>
): Set<string> {
return new Set(activeAchieved(ranks, achievedRankIds).flatMap((rank) => rank.perks));
}
/** Ränge, die bei diesem Zähler neu dazukommen (aktiv, Schwelle erreicht, noch nicht erreicht). */
export function ranksToAward<T extends ReferralRankLike>(
ranks: readonly T[],
count: number,
achievedRankIds: ReadonlySet<string>
): T[] {
return ranks.filter(
(rank) => rank.isActive && rank.threshold <= count && !achievedRankIds.has(rank.id)
);
}
/** Nächster noch nicht erreichter aktiver Rang, für die Fortschrittsanzeige. */
export function pickNextRank<T extends ReferralRankLike>(
ranks: readonly T[],
achievedRankIds: ReadonlySet<string>
): T | null {
return (
ranks
.filter((rank) => rank.isActive && !achievedRankIds.has(rank.id))
.sort((a, b) => a.threshold - b.threshold)[0] ?? null
);
}
+45
View File
@@ -0,0 +1,45 @@
import type { Prisma } from "@prisma/client";
// Bewusst ohne "server-only": wird auch von prisma/scripts/referral-setup.ts
// (plain Node via tsx) genutzt. Enthält nur Datenbankzugriffe, keine Geheimnisse.
/**
* Nach jeder Rang-Änderung im Admin: fehlende Achievements für alle Owner
* anlegen (Bestandsschutz: nichts wird entfernt) und beide Caches neu setzen.
* Mengenbasiert in SQL, damit es auch bei 100.000 Ownern eine Abfrage bleibt.
* Verschickt bewusst keine Benachrichtigungen (keine Push-Welle).
*/
export async function recomputeReferralRanks(tx: Pick<Prisma.TransactionClient, "$executeRaw">): Promise<void> {
await tx.$executeRaw`
INSERT INTO "ReferralRankAchievement" ("id", "ownerId", "rankId", "achievedAt")
SELECT gen_random_uuid()::text, o."id", r."id", now()
FROM "Owner" o
JOIN "ReferralRank" r ON r."isActive" AND o."qualifiedReferralCount" >= r."threshold"
WHERE o."qualifiedReferralCount" > 0
ON CONFLICT ("ownerId", "rankId") DO NOTHING`;
await tx.$executeRaw`
UPDATE "Owner" o
SET "referralRankId" = x."rankId", "referralPerks" = x."perks"
FROM (
SELECT a."ownerId",
(array_agg(r."id" ORDER BY r."threshold" DESC))[1] AS "rankId",
COALESCE(array_agg(DISTINCT p) FILTER (WHERE p IS NOT NULL), '{}') AS "perks"
FROM "ReferralRankAchievement" a
JOIN "ReferralRank" r ON r."id" = a."rankId" AND r."isActive"
LEFT JOIN LATERAL unnest(r."perks") AS p ON true
GROUP BY a."ownerId"
) x
WHERE o."id" = x."ownerId"`;
// Owner, deren erreichte Ränge alle deaktiviert wurden, verlieren nur den Cache — nicht die Achievements.
await tx.$executeRaw`
UPDATE "Owner" o
SET "referralRankId" = NULL, "referralPerks" = '{}'
WHERE (o."referralRankId" IS NOT NULL OR cardinality(o."referralPerks") > 0)
AND NOT EXISTS (
SELECT 1 FROM "ReferralRankAchievement" a
JOIN "ReferralRank" r ON r."id" = a."rankId" AND r."isActive"
WHERE a."ownerId" = o."id"
)`;
}
+321
View File
@@ -0,0 +1,321 @@
import "server-only";
import { Prisma, type PrismaClient, type ReferralPerk } from "@prisma/client";
import { TRPCError } from "@trpc/server";
import { redis } from "@/lib/redis";
import { backfillOnFollow } from "@/lib/feed-helpers";
import { notify } from "@/lib/notify";
import { generateReferralCode, normalizeReferralCode } from "@/lib/referral-code";
import { collectPerks, pickDisplayRank, ranksToAward } from "@/lib/referral-ranks";
/**
* Empfehlungsprogramm — Server-Logik. Bewusst unabhängig von Clerk: ein Code
* wird erst eingelöst, wenn der Owner schon angemeldet ist und ein Tier hat
* (Onboarding-Schritt bzw. /einladen). Clerk liefert nur die userId.
*
* Ablauf: redeem → (erster Beitrag) → maybeQualifyReferral → Zähler +1 beim
* Einladenden → awardRanks → ggf. REFERRAL_RANK_UP.
*/
type Db = PrismaClient | Prisma.TransactionClient;
export const REFERRAL_ERRORS = {
PROGRAM_PAUSED: "REFERRAL_PROGRAM_PAUSED",
CODE_INVALID: "REFERRAL_CODE_INVALID",
OWN_CODE: "REFERRAL_OWN_CODE",
ALREADY_REDEEMED: "REFERRAL_ALREADY_REDEEMED",
WINDOW_EXPIRED: "REFERRAL_WINDOW_EXPIRED",
NO_PET: "REFERRAL_NO_PET",
} as const;
export const DEFAULT_REFERRAL_SETTINGS = {
programActive: true,
redeemWindowDays: 7,
dailyQualifyCap: 20,
autoMutualFollow: true,
} as const;
export type ReferralSettingsValues = {
programActive: boolean;
redeemWindowDays: number;
dailyQualifyCap: number;
autoMutualFollow: boolean;
};
const DAY_MS = 24 * 60 * 60 * 1000;
const CODE_GENERATION_ATTEMPTS = 5;
/** Bann oder Shadowban zählt, solange er nicht abgelaufen ist (expiresAt null = dauerhaft). */
function isSanctionActive(sanction: { expiresAt: Date | null } | null, now: Date): boolean {
return !!sanction && (sanction.expiresAt === null || sanction.expiresAt > now);
}
export async function getReferralSettings(prisma: Db): Promise<ReferralSettingsValues> {
const row = await prisma.referralSettings.findUnique({ where: { id: 1 } });
return row ?? { ...DEFAULT_REFERRAL_SETTINGS };
}
/** Ältestes Tier eines Owners — Absender/Empfänger für Empfehlungs-Benachrichtigungen. */
async function firstPetOf(prisma: Db, ownerId: string) {
return prisma.pet.findFirst({
where: { ownerId },
orderBy: { createdAt: "asc" },
select: { id: true, name: true },
});
}
// ─── Codes ───────────────────────────────────────────────────────────────────
/** Gibt den Code des Owners zurück und legt ihn bei Bedarf an (Präfix aus dem ersten Tiernamen). */
export async function ensureReferralCode(prisma: PrismaClient, ownerId: string): Promise<string> {
const owner = await prisma.owner.findUnique({ where: { id: ownerId }, select: { referralCode: true } });
if (owner?.referralCode) return owner.referralCode;
const pet = await firstPetOf(prisma, ownerId);
for (let attempt = 0; attempt < CODE_GENERATION_ATTEMPTS; attempt++) {
try {
// count 0 heißt: parallel schon gesetzt oder Owner fehlt — beides klärt das Neulesen unten.
await prisma.owner.updateMany({
where: { id: ownerId, referralCode: null },
data: { referralCode: generateReferralCode(pet?.name) },
});
break;
} catch (err) {
const isCollision = err instanceof Prisma.PrismaClientKnownRequestError && err.code === "P2002";
if (!isCollision) throw err;
}
}
const fresh = await prisma.owner.findUnique({ where: { id: ownerId }, select: { referralCode: true } });
if (!fresh?.referralCode) {
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Could not create a referral code." });
}
return fresh.referralCode;
}
// ─── Einlösen ────────────────────────────────────────────────────────────────
function fail(code: TRPCError["code"], message: string): never {
throw new TRPCError({ code, message });
}
async function followIfAllowed(prisma: PrismaClient, followerPetId: string, followeePetId: string) {
await prisma.follow.upsert({
where: { followerPetId_followeePetId: { followerPetId, followeePetId } },
create: { followerPetId, followeePetId },
update: {},
});
await backfillOnFollow(redis, followerPetId, followeePetId, prisma).catch((err) =>
console.error("[referrals] feed backfill failed", err)
);
}
/** Gegenseitiges Folgen nach dem Einlösen — nie blockierend, Blockaden werden respektiert. */
async function connectPets(prisma: PrismaClient, inviterPetId: string, inviteePetId: string) {
const block = await prisma.block.findFirst({
where: {
OR: [
{ blockerPetId: inviterPetId, blockedPetId: inviteePetId },
{ blockerPetId: inviteePetId, blockedPetId: inviterPetId },
],
},
});
if (block) return;
await followIfAllowed(prisma, inviteePetId, inviterPetId);
await followIfAllowed(prisma, inviterPetId, inviteePetId);
// Der Eingeladene erfährt es als normalen Follow; der Einladende bekommt REFERRAL_JOINED (unten).
notify(prisma, { recipientPetId: inviteePetId, type: "FOLLOW", actorPetId: inviterPetId });
}
export async function redeemReferralCode(
prisma: PrismaClient,
ownerId: string,
rawCode: string,
now: Date = new Date()
): Promise<{ inviterPetName: string | null }> {
const settings = await getReferralSettings(prisma);
if (!settings.programActive) fail("PRECONDITION_FAILED", REFERRAL_ERRORS.PROGRAM_PAUSED);
const code = normalizeReferralCode(rawCode);
if (!code) fail("NOT_FOUND", REFERRAL_ERRORS.CODE_INVALID);
const owner = await prisma.owner.findUnique({
where: { id: ownerId },
select: { createdAt: true, invitedById: true },
});
if (!owner) fail("PRECONDITION_FAILED", REFERRAL_ERRORS.NO_PET);
if (owner.invitedById) fail("CONFLICT", REFERRAL_ERRORS.ALREADY_REDEEMED);
if (now.getTime() - owner.createdAt.getTime() > settings.redeemWindowDays * DAY_MS) {
fail("PRECONDITION_FAILED", REFERRAL_ERRORS.WINDOW_EXPIRED);
}
const inviter = await prisma.owner.findUnique({
where: { referralCode: code },
select: { id: true, invitedById: true, ban: { select: { expiresAt: true } } },
});
// Gebannte Einladende und direkte Kreis-Einladungen (A lädt B, B lädt A) wirken wie ein ungültiger Code.
if (!inviter || isSanctionActive(inviter.ban, now) || inviter.invitedById === ownerId) fail("NOT_FOUND", REFERRAL_ERRORS.CODE_INVALID);
if (inviter.id === ownerId) fail("BAD_REQUEST", REFERRAL_ERRORS.OWN_CODE);
const inviteePet = await firstPetOf(prisma, ownerId);
if (!inviteePet) fail("PRECONDITION_FAILED", REFERRAL_ERRORS.NO_PET);
// Bewacht mit invitedById: null — zwei parallele Einlösungen können nicht beide gewinnen.
const claimed = await prisma.owner.updateMany({
where: { id: ownerId, invitedById: null },
data: { invitedById: inviter.id, invitedAt: now },
});
if (claimed.count === 0) fail("CONFLICT", REFERRAL_ERRORS.ALREADY_REDEEMED);
const inviterPet = await firstPetOf(prisma, inviter.id);
if (inviterPet) {
if (settings.autoMutualFollow) {
await connectPets(prisma, inviterPet.id, inviteePet.id).catch((err) =>
console.error("[referrals] mutual follow failed", err)
);
}
notify(prisma, { recipientPetId: inviterPet.id, type: "REFERRAL_JOINED", actorPetId: inviteePet.id });
}
// Wer erst nach dem ersten Beitrag einlöst (z. B. später über /einladen), zählt sofort.
await maybeQualifyReferral(prisma, ownerId, now).catch((err) =>
console.error("[referrals] qualification after redeem failed", err)
);
return { inviterPetName: inviterPet?.name ?? null };
}
// ─── Qualifizieren ───────────────────────────────────────────────────────────
/** Ein echter eigener Beitrag: Foto, oder Video, das bei Mux fertig verarbeitet ist. */
async function hasQualifyingPost(prisma: Db, ownerId: string): Promise<boolean> {
const count = await prisma.post.count({
where: {
pet: { ownerId },
OR: [{ type: "PHOTO" }, { type: "VIDEO", videoPost: { status: "READY" } }],
},
});
return count > 0;
}
export type QualifyResult = "not-invited" | "already-done" | "no-post" | "flagged" | "qualified";
/**
* Nach jedem eigenen Beitrag aufrufen (posts.create, Mux-Webhook READY).
* Billig im Normalfall: ein findUnique, dann früher Abbruch.
*/
export async function maybeQualifyReferral(
prisma: PrismaClient,
ownerId: string,
now: Date = new Date()
): Promise<QualifyResult> {
const owner = await prisma.owner.findUnique({
where: { id: ownerId },
select: {
invitedById: true,
referralQualifiedAt: true,
referralFlaggedAt: true,
referralRevokedAt: true,
ban: { select: { expiresAt: true } },
shadowban: { select: { expiresAt: true } },
},
});
if (!owner?.invitedById) return "not-invited";
if (owner.referralQualifiedAt || owner.referralFlaggedAt || owner.referralRevokedAt) return "already-done";
if (isSanctionActive(owner.ban, now) || isSanctionActive(owner.shadowban, now)) return "already-done";
if (!(await hasQualifyingPost(prisma, ownerId))) return "no-post";
const inviterId = owner.invitedById;
const settings = await getReferralSettings(prisma);
const capWindowStart = new Date(now.getTime() - DAY_MS); // gleitende 24 Stunden
const outcome = await prisma.$transaction(async (tx) => {
const qualifiedToday = await tx.owner.count({
where: { invitedById: inviterId, referralQualifiedAt: { gte: capWindowStart } },
});
const pending = { id: ownerId, referralQualifiedAt: null, referralFlaggedAt: null, referralRevokedAt: null };
if (qualifiedToday >= settings.dailyQualifyCap) {
await tx.owner.updateMany({ where: pending, data: { referralFlaggedAt: now } });
return { status: "flagged" as const, newRanks: [] };
}
const claimed = await tx.owner.updateMany({ where: pending, data: { referralQualifiedAt: now } });
if (claimed.count === 0) return { status: "already-done" as const, newRanks: [] };
const newRanks = await creditInviter(tx, inviterId);
return { status: "qualified" as const, newRanks };
});
if (outcome.newRanks.length > 0) await notifyRankUp(prisma, inviterId);
return outcome.status;
}
/** Zähler des Einladenden erhöhen und neue Ränge vergeben. Gelöschte Einladende werden still übersprungen. */
export async function creditInviter(tx: Prisma.TransactionClient, inviterId: string) {
const updated = await tx.owner.updateMany({
where: { id: inviterId },
data: { qualifiedReferralCount: { increment: 1 } },
});
if (updated.count === 0) return [];
const inviter = await tx.owner.findUnique({
where: { id: inviterId },
select: { qualifiedReferralCount: true },
});
return awardRanks(tx, inviterId, inviter?.qualifiedReferralCount ?? 0);
}
// ─── Ränge ───────────────────────────────────────────────────────────────────
/**
* Vergibt fehlende Ränge für einen Owner und aktualisiert den Cache
* (referralRankId, referralPerks). Entzieht nie etwas (Bestandsschutz).
* Gibt die neu vergebenen Ränge zurück.
*/
export async function awardRanks(tx: Prisma.TransactionClient, ownerId: string, count: number) {
const ranks = await tx.referralRank.findMany({ orderBy: { threshold: "asc" } });
const existing = await tx.referralRankAchievement.findMany({ where: { ownerId }, select: { rankId: true } });
const achieved = new Set(existing.map((a) => a.rankId));
const newRanks = ranksToAward(ranks, count, achieved);
if (newRanks.length > 0) {
await tx.referralRankAchievement.createMany({
data: newRanks.map((rank) => ({ ownerId, rankId: rank.id })),
skipDuplicates: true,
});
newRanks.forEach((rank) => achieved.add(rank.id));
}
await refreshRankCache(tx, ownerId, ranks, achieved);
return newRanks;
}
/** Setzt Owner.referralRankId und Owner.referralPerks aus den erreichten Rängen. */
export async function refreshRankCache(
tx: Prisma.TransactionClient,
ownerId: string,
ranks: { id: string; threshold: number; isActive: boolean; perks: ReferralPerk[] }[],
achieved: ReadonlySet<string>
) {
const display = pickDisplayRank(ranks, achieved);
const perks = [...collectPerks(ranks, achieved)] as ReferralPerk[];
await tx.owner.update({
where: { id: ownerId },
data: { referralRankId: display?.id ?? null, referralPerks: perks },
});
}
/** Cache eines Owners aus seinen gespeicherten Achievements neu aufbauen (z. B. nach einem Rang-Entzug). */
export async function rebuildOwnerRankCache(tx: Prisma.TransactionClient, ownerId: string) {
const [ranks, achievements] = await Promise.all([
tx.referralRank.findMany({ orderBy: { threshold: "asc" } }),
tx.referralRankAchievement.findMany({ where: { ownerId }, select: { rankId: true } }),
]);
await refreshRankCache(tx, ownerId, ranks, new Set(achievements.map((a) => a.rankId)));
}
async function notifyRankUp(prisma: PrismaClient, ownerId: string) {
const pet = await firstPetOf(prisma, ownerId);
if (pet) notify(prisma, { recipientPetId: pet.id, type: "REFERRAL_RANK_UP" });
}
export { recomputeReferralRanks } from "@/lib/referral-recompute";
+1
View File
@@ -28,6 +28,7 @@ const isPublicRoute = createRouteMatcher([
"/ueber-uns",
"/kinderschutz",
"/join(.*)",
"/r/(.*)", // öffentliche Einladungsseite des Empfehlungsprogramms
"/api/webhooks/(.*)",
"/api/auth/(.*)",
"/api/cron/(.*)",
+4 -3
View File
@@ -14,13 +14,14 @@ export const petIdentitySelect = {
/**
* Pet identity plus the fields a feed/explore/profile card renders alongside
* the name: the verified checkmark and the owner's sponsor window (consumed
* via isActiveSponsor() in src/lib/sponsor.ts).
* the name: the verified checkmark, the owner's sponsor window (consumed
* via isActiveSponsor() in src/lib/sponsor.ts) and the owner's cached
* referral rank + perks (resolved client-side against referrals.ranks).
*/
export const petCardSelect = {
...petIdentitySelect,
isVerified: true,
owner: { select: { sponsorSince: true, sponsorPeriodEnd: true } },
owner: { select: { sponsorSince: true, sponsorPeriodEnd: true, referralRankId: true, referralPerks: true } },
} satisfies Prisma.PetSelect;
export type PetIdentity = Prisma.PetGetPayload<{ select: typeof petIdentitySelect }>;
+4
View File
@@ -26,6 +26,8 @@ import { sponsorRouter } from "@/trpc/routers/sponsor";
import { statsRouter } from "@/trpc/routers/stats";
import { broadcastRouter } from "@/trpc/routers/broadcast";
import { insightsRouter } from "@/trpc/routers/insights";
import { referralsRouter } from "@/trpc/routers/referrals";
import { referralAdminRouter } from "@/trpc/routers/referral-admin";
export const appRouter = router({
pets: petsRouter,
@@ -56,6 +58,8 @@ export const appRouter = router({
stats: statsRouter,
broadcast: broadcastRouter,
insights: insightsRouter,
referrals: referralsRouter,
referralAdmin: referralAdminRouter,
});
// Export the type for use in the client
+6 -1
View File
@@ -878,6 +878,7 @@ export const adminRouter = router({
select: {
ban: { select: { reason: true, expiresAt: true } },
_count: { select: { warnings: true } },
referralPerks: true,
},
},
},
@@ -889,7 +890,11 @@ export const adminRouter = router({
});
const hasNext = requests.length > input.limit;
const items = hasNext ? requests.slice(0, input.limit) : requests;
return { requests: items, nextCursor: hasNext ? items[items.length - 1]!.id : null };
// Empfehlungsprogramm-Vorteil VERIFICATION_PRIORITY: innerhalb der geladenen Seite nach oben sortieren.
// Der Cursor bleibt die letzte ID der ursprünglichen Reihenfolge, damit die Paginierung stimmt.
const hasPriority = (r: (typeof items)[number]) => r.pet.owner.referralPerks.includes("VERIFICATION_PRIORITY");
const sorted = [...items.filter(hasPriority), ...items.filter((r) => !hasPriority(r))];
return { requests: sorted, nextCursor: hasNext ? items[items.length - 1]!.id : null };
}),
// The checklist IS the decision — there's no separate approve/reject
+14 -1
View File
@@ -125,12 +125,25 @@ export const petsRouter = router({
include: {
species: { include: { translations: { where: { locale: ctx.locale } } } },
breed: { include: { translations: { where: { locale: ctx.locale } } } },
owner: petCardSelect.owner,
owner: { select: { ...petCardSelect.owner.select, invitedById: true } },
},
});
if (!pet) throw new TRPCError({ code: "NOT_FOUND" });
// Empfehlungsprogramm: "Eingeladen von <Tier>" — nur das Tier verlassen den Server, nie die Clerk-ID.
const { invitedById, ...owner } = pet.owner;
const invitedByPet = invitedById
? await ctx.prisma.pet.findFirst({
where: { ownerId: invitedById },
orderBy: { createdAt: "asc" },
select: { id: true, name: true },
})
: null;
return {
...pet,
owner,
invitedByPet,
species: { ...pet.species, name: pickLocalizedName(pet.species.name, pet.species.translations, ctx.locale) },
breed: pet.breed
? { ...pet.breed, name: pickLocalizedName(pet.breed.name, pet.breed.translations, ctx.locale) }
+6
View File
@@ -16,6 +16,7 @@ import { checkAgainstBlacklist } from "@/lib/blacklist";
import { createMentionNotifications } from "@/lib/mention-helpers";
import { assertPetOwnership } from "@/lib/assert-pet-ownership";
import { petIdentitySelect } from "@/repositories/pet-repository";
import { maybeQualifyReferral } from "@/lib/referrals";
/**
* Posts tRPC router — photo post creation, caption edit, deletion, feed hydration.
@@ -141,6 +142,11 @@ export const postsRouter = router({
console.error("[posts] feed fan-out failed", err)
);
// Empfehlungsprogramm: der erste eigene Beitrag macht eine Einladung gültig — never fails post creation
await maybeQualifyReferral(ctx.prisma, ctx.userId).catch((err) =>
console.error("[posts] referral qualification failed", err)
);
// Index hashtags — never fails post creation
const tags = extractHashtags(input.caption ?? null);
await saveHashtags(ctx.prisma, post.id, tags).catch((err) =>
+261
View File
@@ -0,0 +1,261 @@
import "server-only";
import { z } from "zod";
import { TRPCError } from "@trpc/server";
import { AdminRoleType, Prisma } from "@prisma/client";
import { router, protectedProcedure } from "@/trpc/init";
import { assertAdmin } from "@/trpc/routers/admin";
import { creditInviter, rebuildOwnerRankCache, recomputeReferralRanks } from "@/lib/referrals";
import { MAX_REFERRAL_RANKS, REFERRAL_COLOR_PATTERN, REFERRAL_ICON_KEYS, REFERRAL_PERKS } from "@/lib/referral-ranks";
/**
* Admin-Seite „Empfehlungen“ (/p/[secret]/referrals). Lesen ab MODERATOR,
* Ändern nur SUPER_ADMIN. Jede Änderung landet im ModerationLog. Nach jeder
* Rang-Änderung werden alle Owner in derselben Transaktion neu berechnet
* (Bestandsschutz: erreichte Ränge werden nie entfernt).
*/
const DAY_MS = 24 * 60 * 60 * 1000;
const LIST_LIMIT = 30;
const rankFields = z.object({
nameDe: z.string().trim().min(1).max(40),
nameEn: z.string().trim().min(1).max(40),
descriptionDe: z.string().trim().max(200),
descriptionEn: z.string().trim().max(200),
threshold: z.number().int().min(1).max(100_000),
icon: z.enum(REFERRAL_ICON_KEYS),
color: z.string().regex(REFERRAL_COLOR_PATTERN),
perks: z.array(z.enum(REFERRAL_PERKS)).max(REFERRAL_PERKS.length),
});
const firstPetSelect = {
pets: { orderBy: { createdAt: "asc" as const }, take: 1, select: { id: true, name: true, avatarKey: true } },
};
function rethrowUniqueConflict(err: unknown): never {
if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === "P2002") {
throw new TRPCError({ code: "CONFLICT", message: "Schwelle oder Schlüssel ist schon vergeben." });
}
throw err;
}
export const referralAdminRouter = router({
listRanks: protectedProcedure.query(async ({ ctx }) => {
await assertAdmin(ctx.prisma, ctx.userId, AdminRoleType.MODERATOR);
const [ranks, holders] = await Promise.all([
ctx.prisma.referralRank.findMany({ orderBy: { threshold: "asc" } }),
ctx.prisma.referralRankAchievement.groupBy({ by: ["rankId"], _count: { _all: true } }),
]);
const holderCount = new Map(holders.map((h) => [h.rankId, h._count._all]));
return ranks.map((rank) => ({ ...rank, holderCount: holderCount.get(rank.id) ?? 0 }));
}),
createRank: protectedProcedure
.input(rankFields.extend({ key: z.string().regex(/^[a-z0-9-]{2,40}$/) }))
.mutation(async ({ ctx, input }) => {
const callerRole = await assertAdmin(ctx.prisma, ctx.userId, AdminRoleType.SUPER_ADMIN);
if ((await ctx.prisma.referralRank.count()) >= MAX_REFERRAL_RANKS) {
throw new TRPCError({ code: "BAD_REQUEST", message: `Höchstens ${MAX_REFERRAL_RANKS} Ränge.` });
}
return ctx.prisma
.$transaction(async (tx) => {
const rank = await tx.referralRank.create({ data: input });
await recomputeReferralRanks(tx);
await tx.moderationLog.create({
data: { action: "CREATE_REFERRAL_RANK", targetType: "SYSTEM", targetId: rank.id, reason: `${input.key} ab ${input.threshold}`, moderatorId: ctx.userId, moderatorRole: callerRole, ipAddress: ctx.ip },
});
return rank;
})
.catch(rethrowUniqueConflict);
}),
updateRank: protectedProcedure
.input(rankFields.partial().extend({ id: z.string().min(1), isActive: z.boolean().optional() }))
.mutation(async ({ ctx, input }) => {
const callerRole = await assertAdmin(ctx.prisma, ctx.userId, AdminRoleType.SUPER_ADMIN);
const { id, ...data } = input;
return ctx.prisma
.$transaction(async (tx) => {
const rank = await tx.referralRank.update({ where: { id }, data });
await recomputeReferralRanks(tx);
const action = data.isActive === false ? "DEACTIVATE_REFERRAL_RANK" : "UPDATE_REFERRAL_RANK";
await tx.moderationLog.create({
data: { action, targetType: "SYSTEM", targetId: id, reason: `${rank.key}: ${Object.keys(data).join(", ")}`, moderatorId: ctx.userId, moderatorRole: callerRole, ipAddress: ctx.ip },
});
return rank;
})
.catch(rethrowUniqueConflict);
}),
getSettings: protectedProcedure.query(async ({ ctx }) => {
await assertAdmin(ctx.prisma, ctx.userId, AdminRoleType.MODERATOR);
return ctx.prisma.referralSettings.upsert({ where: { id: 1 }, create: { id: 1 }, update: {} });
}),
updateSettings: protectedProcedure
.input(
z.object({
programActive: z.boolean(),
redeemWindowDays: z.number().int().min(1).max(90),
dailyQualifyCap: z.number().int().min(1).max(1000),
autoMutualFollow: z.boolean(),
})
)
.mutation(async ({ ctx, input }) => {
const callerRole = await assertAdmin(ctx.prisma, ctx.userId, AdminRoleType.SUPER_ADMIN);
const [settings] = await ctx.prisma.$transaction([
ctx.prisma.referralSettings.upsert({
where: { id: 1 },
create: { id: 1, ...input, updatedBy: ctx.userId },
update: { ...input, updatedBy: ctx.userId },
}),
ctx.prisma.moderationLog.create({
data: { action: "UPDATE_REFERRAL_SETTINGS", targetType: "SYSTEM", targetId: "referral-settings", reason: JSON.stringify(input), moderatorId: ctx.userId, moderatorRole: callerRole, ipAddress: ctx.ip },
}),
]);
return settings;
}),
overview: protectedProcedure.query(async ({ ctx }) => {
await assertAdmin(ctx.prisma, ctx.userId, AdminRoleType.MODERATOR);
const since7 = new Date(Date.now() - 7 * DAY_MS);
const since30 = new Date(Date.now() - 30 * DAY_MS);
const [redeemed7, redeemed30, qualified7, qualified30, ownersWithPet, sources, topInviters, recent, flagged] =
await Promise.all([
ctx.prisma.owner.count({ where: { invitedAt: { gte: since7 } } }),
ctx.prisma.owner.count({ where: { invitedAt: { gte: since30 } } }),
ctx.prisma.owner.count({ where: { referralQualifiedAt: { gte: since7 } } }),
ctx.prisma.owner.count({ where: { referralQualifiedAt: { gte: since30 } } }),
ctx.prisma.owner.count({ where: { pets: { some: {} } } }),
ctx.prisma.owner.groupBy({ by: ["acquisitionSource"], _count: { _all: true } }),
ctx.prisma.owner.findMany({
where: { qualifiedReferralCount: { gt: 0 } },
orderBy: { qualifiedReferralCount: "desc" },
take: LIST_LIMIT,
select: { id: true, qualifiedReferralCount: true, referralRankId: true, referralPerks: true, ...firstPetSelect },
}),
ctx.prisma.owner.findMany({
where: { invitedAt: { not: null } },
orderBy: { invitedAt: "desc" },
take: LIST_LIMIT,
select: {
id: true, invitedAt: true, invitedById: true, registrationIp: true,
referralQualifiedAt: true, referralFlaggedAt: true, referralRevokedAt: true, ...firstPetSelect,
},
}),
ctx.prisma.owner.findMany({
where: { referralFlaggedAt: { not: null }, referralQualifiedAt: null, referralRevokedAt: null },
orderBy: { referralFlaggedAt: "asc" },
take: LIST_LIMIT,
select: { id: true, invitedById: true, referralFlaggedAt: true, ...firstPetSelect },
}),
]);
// Einladende der letzten Einlösungen nachladen — für Namen und den IP-Vergleich.
const inviterIds = [...new Set([...recent, ...flagged].map((o) => o.invitedById).filter((id): id is string => !!id))];
const inviters = await ctx.prisma.owner.findMany({
where: { id: { in: inviterIds } },
select: { id: true, registrationIp: true, ...firstPetSelect },
});
const inviterById = new Map(inviters.map((i) => [i.id, i]));
return {
redeemed7, redeemed30, qualified7, qualified30, ownersWithPet,
inviteFactor30: ownersWithPet > 0 ? qualified30 / ownersWithPet : 0,
sources: sources.map((s) => ({ source: s.acquisitionSource, count: s._count._all })),
topInviters: topInviters.map(({ pets, ...o }) => ({ ...o, pet: pets[0] ?? null })),
recent: recent.map(({ pets, registrationIp, invitedById, ...o }) => {
const inviter = invitedById ? inviterById.get(invitedById) : undefined;
return {
...o,
pet: pets[0] ?? null,
inviterPet: inviter?.pets[0] ?? null,
// Nur ein Hinweis für Moderatoren, keine automatische Sperre — die IP selbst verlässt den Server nicht.
sameIpAsInviter: !!registrationIp && registrationIp === inviter?.registrationIp,
};
}),
flagged: flagged.map(({ pets, invitedById, ...o }) => ({
...o,
pet: pets[0] ?? null,
inviterPet: invitedById ? (inviterById.get(invitedById)?.pets[0] ?? null) : null,
})),
};
}),
/** Tiere von Ownern mit einem bestimmten Vorteil — Kandidaten für Tier der Woche bzw. Team-Vorstellung. */
perkHolders: protectedProcedure
.input(z.object({ perk: z.enum(["FEATURE_POOL", "CURATOR_SPOTLIGHT"]) }))
.query(async ({ ctx, input }) => {
await assertAdmin(ctx.prisma, ctx.userId, AdminRoleType.MODERATOR);
const owners = await ctx.prisma.owner.findMany({
where: { referralPerks: { has: input.perk }, ban: null },
orderBy: { qualifiedReferralCount: "desc" },
take: 100,
select: {
id: true,
qualifiedReferralCount: true,
referralRankId: true,
pets: { orderBy: { createdAt: "asc" }, select: { id: true, name: true, avatarKey: true, _count: { select: { followers: true, posts: true } } } },
},
});
return owners;
}),
approveFlagged: protectedProcedure
.input(z.object({ ownerId: z.string().min(1) }))
.mutation(async ({ ctx, input }) => {
const callerRole = await assertAdmin(ctx.prisma, ctx.userId, AdminRoleType.SUPER_ADMIN);
await ctx.prisma.$transaction(async (tx) => {
const claimed = await tx.owner.updateMany({
where: { id: input.ownerId, referralFlaggedAt: { not: null }, referralQualifiedAt: null, referralRevokedAt: null },
data: { referralQualifiedAt: new Date(), referralFlaggedAt: null },
});
if (claimed.count === 0) throw new TRPCError({ code: "NOT_FOUND" });
const invitee = await tx.owner.findUnique({ where: { id: input.ownerId }, select: { invitedById: true } });
if (invitee?.invitedById) await creditInviter(tx, invitee.invitedById);
await tx.moderationLog.create({
data: { action: "APPROVE_REFERRAL", targetType: "USER", targetId: input.ownerId, reason: null, moderatorId: ctx.userId, moderatorRole: callerRole, ipAddress: ctx.ip },
});
});
return { success: true };
}),
revokeReferral: protectedProcedure
.input(z.object({ ownerId: z.string().min(1), reason: z.string().trim().min(1).max(500) }))
.mutation(async ({ ctx, input }) => {
const callerRole = await assertAdmin(ctx.prisma, ctx.userId, AdminRoleType.SUPER_ADMIN);
await ctx.prisma.$transaction(async (tx) => {
const invitee = await tx.owner.findUnique({
where: { id: input.ownerId },
select: { invitedById: true, referralQualifiedAt: true, referralRevokedAt: true },
});
if (!invitee?.invitedById || invitee.referralRevokedAt) throw new TRPCError({ code: "NOT_FOUND" });
await tx.owner.update({ where: { id: input.ownerId }, data: { referralRevokedAt: new Date(), referralFlaggedAt: null } });
if (invitee.referralQualifiedAt) {
// Ränge bleiben (Bestandsschutz) — bei Betrug zusätzlich revokeRank nutzen.
await tx.owner.updateMany({
where: { id: invitee.invitedById, qualifiedReferralCount: { gt: 0 } },
data: { qualifiedReferralCount: { decrement: 1 } },
});
}
await tx.moderationLog.create({
data: { action: "REVOKE_REFERRAL", targetType: "USER", targetId: input.ownerId, reason: input.reason, moderatorId: ctx.userId, moderatorRole: callerRole, ipAddress: ctx.ip },
});
});
return { success: true };
}),
revokeRank: protectedProcedure
.input(z.object({ ownerId: z.string().min(1), rankId: z.string().min(1), reason: z.string().trim().min(1).max(500) }))
.mutation(async ({ ctx, input }) => {
const callerRole = await assertAdmin(ctx.prisma, ctx.userId, AdminRoleType.SUPER_ADMIN);
await ctx.prisma.$transaction(async (tx) => {
await tx.referralRankAchievement.deleteMany({ where: { ownerId: input.ownerId, rankId: input.rankId } });
await rebuildOwnerRankCache(tx, input.ownerId);
await tx.moderationLog.create({
data: { action: "REVOKE_REFERRAL_RANK", targetType: "USER", targetId: input.ownerId, reason: `${input.rankId}: ${input.reason}`, moderatorId: ctx.userId, moderatorRole: callerRole, ipAddress: ctx.ip },
});
});
return { success: true };
}),
});
+155
View File
@@ -0,0 +1,155 @@
import "server-only";
import { z } from "zod";
import { router, protectedProcedure, rateLimited } from "@/trpc/init";
import { ensureReferralCode, getReferralSettings, redeemReferralCode } from "@/lib/referrals";
import { pickNextRank } from "@/lib/referral-ranks";
/**
* Empfehlungsprogramm — nutzerseitige Prozeduren. Admin-Prozeduren liegen in
* referral-admin.ts. Fehler tragen stabile Schlüssel (REFERRAL_ERRORS) als
* message, die Web und App selbst übersetzen.
*/
const ACQUISITION_SOURCES = [
"TIKTOK",
"INSTAGRAM",
"YOUTUBE",
"FACEBOOK",
"FRIENDS",
"SHELTER_OR_PARTNER",
"STORE_SEARCH",
"PRESS",
"OTHER",
] as const;
const MAX_LISTED_INVITEES = 100;
const DAY_MS = 24 * 60 * 60 * 1000;
export const referralsRouter = router({
/**
* Alle Ränge (auch inaktive, damit alte Caches noch auflösbar sind) — klein
* und selten geändert, der Client hält sie lange im Cache und löst damit
* Owner.referralRankId in Abzeichen auf.
*/
ranks: protectedProcedure.query(async ({ ctx }) => {
return ctx.prisma.referralRank.findMany({
orderBy: { threshold: "asc" },
select: {
id: true,
key: true,
threshold: true,
nameDe: true,
nameEn: true,
descriptionDe: true,
descriptionEn: true,
icon: true,
color: true,
perks: true,
isActive: true,
},
});
}),
/** Alles für die Seite „Freunde einladen“. Legt den eigenen Code bei Bedarf an. */
me: protectedProcedure.query(async ({ ctx }) => {
const code = await ensureReferralCode(ctx.prisma, ctx.userId);
const [owner, settings, ranks, achievements, invitees] = await Promise.all([
ctx.prisma.owner.findUnique({
where: { id: ctx.userId },
select: {
createdAt: true,
invitedById: true,
qualifiedReferralCount: true,
referralRankId: true,
referralPerks: true,
},
}),
getReferralSettings(ctx.prisma),
ctx.prisma.referralRank.findMany({ orderBy: { threshold: "asc" } }),
ctx.prisma.referralRankAchievement.findMany({
where: { ownerId: ctx.userId },
select: { rankId: true, achievedAt: true },
}),
ctx.prisma.owner.findMany({
where: { invitedById: ctx.userId },
orderBy: { invitedAt: "desc" },
take: MAX_LISTED_INVITEES,
select: {
invitedAt: true,
referralQualifiedAt: true,
referralFlaggedAt: true,
referralRevokedAt: true,
pets: { orderBy: { createdAt: "asc" }, take: 1, select: { id: true, name: true, avatarKey: true } },
},
}),
]);
const achievedIds = new Set(achievements.map((a) => a.rankId));
const next = pickNextRank(ranks, achievedIds);
const redeemDeadline = owner
? new Date(owner.createdAt.getTime() + settings.redeemWindowDays * DAY_MS)
: null;
return {
code,
programActive: settings.programActive,
qualifiedCount: owner?.qualifiedReferralCount ?? 0,
rankId: owner?.referralRankId ?? null,
perks: owner?.referralPerks ?? [],
achievedRankIds: [...achievedIds],
nextRankId: next?.id ?? null,
canRedeem:
settings.programActive && !!owner && !owner.invitedById && !!redeemDeadline && redeemDeadline > new Date(),
wasInvited: !!owner?.invitedById,
invitees: invitees.map((invitee) => ({
pet: invitee.pets[0] ?? null,
invitedAt: invitee.invitedAt,
status: invitee.referralRevokedAt
? ("revoked" as const)
: invitee.referralQualifiedAt
? ("counted" as const)
: invitee.referralFlaggedAt
? ("in-review" as const)
: ("waiting" as const),
})),
};
}),
/**
* Soll das Onboarding den Schritt „Wie hast du von PawFeed erfahren?“ zeigen?
* Nur einmal pro Owner (noch nicht beantwortet) — bei weiteren Tieren nicht.
*/
onboardingState: protectedProcedure.query(async ({ ctx }) => {
const [owner, settings] = await Promise.all([
ctx.prisma.owner.findUnique({
where: { id: ctx.userId },
select: { createdAt: true, invitedById: true, acquisitionAnsweredAt: true },
}),
getReferralSettings(ctx.prisma),
]);
if (!owner) return { showStep: false, canRedeem: false };
const withinWindow = Date.now() - owner.createdAt.getTime() <= settings.redeemWindowDays * DAY_MS;
return {
showStep: !owner.acquisitionAnsweredAt && withinWindow,
canRedeem: settings.programActive && !owner.invitedById && withinWindow,
};
}),
redeem: protectedProcedure
.use(rateLimited("referrals.redeem", 5, 600))
.input(z.object({ code: z.string().min(1).max(32) }))
.mutation(async ({ ctx, input }) => {
return redeemReferralCode(ctx.prisma, ctx.userId, input.code);
}),
/** Freiwillige Onboarding-Angabe „Wie hast du von PawFeed erfahren?“ — nur einmal. */
setAcquisitionSource: protectedProcedure
.input(z.object({ source: z.enum(ACQUISITION_SOURCES) }))
.mutation(async ({ ctx, input }) => {
await ctx.prisma.owner.updateMany({
where: { id: ctx.userId, acquisitionSource: null },
data: { acquisitionSource: input.source, acquisitionAnsweredAt: new Date() },
});
return { ok: true };
}),
});