diff --git a/messages/de.json b/messages/de.json index c1c0e1b..1870f9b 100644 --- a/messages/de.json +++ b/messages/de.json @@ -124,7 +124,8 @@ "addPhoto": "Profilfoto hinzufügen", "addPhotoDesc": "Gib deinem Tier ein Gesicht auf PawFeed. Du kannst das jederzeit ändern.", "done": "Fertig", - "skipForNow": "Jetzt überspringen" + "skipForNow": "Jetzt überspringen", + "loading": "Wird geladen" }, "Language": { "switch": "Sprache", @@ -477,7 +478,9 @@ "typePawBack": "hat deinen Post geteilt (Paw-Back)", "typeBirthday": "🎂 Alles Gute zum Geburtstag! Erstelle einen Jubiläums-Post.", "typeAdoptionDay": "🏠 Heute ist der Adoptionstag! Feiere den Jahrestag.", - "typePetSuggestion": "könnte dir gefallen" + "typePetSuggestion": "könnte dir gefallen", + "typeReferralJoined": "ist über deine Einladung zu PawFeed gekommen", + "typeReferralRankUp": "Du hast einen neuen Empfehlungsrang erreicht. Schau ihn dir an!" }, "PhotoPostForm": { "captionPlaceholder": "Bildunterschrift schreiben…", @@ -632,7 +635,8 @@ "statsFollowers": "Follower", "statsFollowing": "Folgt", "adoptionStory": "Adoptionsgeschichte", - "verified": "Verifiziert" + "verified": "Verifiziert", + "invitedBy": "Eingeladen von {name}" }, "EditPet": { "title": "{name} bearbeiten", @@ -765,5 +769,72 @@ "statSponsors": "Unterstützer:innen", "ctaHeading": "Lust, dabei zu sein?", "ctaButton": "Jetzt starten" + }, + "Referrals": { + "title": "Freunde einladen", + "subtitle": "Teile deinen Link. Jedes Tier, das über deine Einladung kommt und seinen ersten Beitrag postet, bringt dich einen Rang weiter.", + "programPaused": "Das Empfehlungsprogramm ist gerade pausiert. Dein Link funktioniert, neue Einladungen werden aber im Moment nicht gezählt.", + "shareHeading": "Dein Einladungslink", + "shareSubtitle": "Unbegrenzt oft nutzbar. Wer sich ohne Link registriert, kann deinen Code auch im Onboarding eingeben.", + "yourCode": "Dein Code", + "share": "Teilen", + "copyLink": "Link kopieren", + "copied": "Kopiert", + "copiedToast": "Link kopiert", + "shareTitle": "Komm zu PawFeed", + "shareText": "Mein Tier ist auf PawFeed, dem sozialen Netzwerk für Haustiere. Mit meinem Code {code} sind wir direkt verbunden:", + "ranksHeading": "Deine Ränge", + "countedInvites": "{count, plural, =0 {Noch keine gezählte Einladung} one {# gezählte Einladung} other {# gezählte Einladungen}}", + "nextRank": "Noch {remaining, plural, one {# Einladung} other {# Einladungen}} bis {name}", + "allRanksReached": "Du hast alle Ränge erreicht. Danke!", + "achieved": "Erreicht", + "threshold": "{count, plural, one {# Einladung} other {# Einladungen}}", + "inviteesHeading": "Über dich gekommen", + "inviteesEmpty": "Noch niemand. Teile deinen Link mit Freunden, die selbst ein Tier haben.", + "inviteeNoPet": "Noch kein Tier angelegt", + "statusCounted": "Zählt", + "statusWaiting": "Wartet auf ersten Beitrag", + "statusInReview": "Wird geprüft", + "statusRevoked": "Aberkannt", + "redeemHeading": "Wurdest du eingeladen?", + "redeemSubtitle": "Gib den Code der Person ein, die dich eingeladen hat. Das geht nur in den ersten Tagen nach der Registrierung.", + "redeemLabel": "Einladungscode", + "redeemCta": "Einlösen", + "redeemedToast": "Eingelöst. Du und {name} folgt euch jetzt.", + "redeemedToastGeneric": "Code eingelöst.", + "errorPaused": "Das Empfehlungsprogramm ist gerade pausiert.", + "errorInvalid": "Diesen Code gibt es nicht. Prüf die Schreibweise, z. B. BUBI-7K3M.", + "errorOwnCode": "Das ist dein eigener Code. Gib den Code der Person ein, die dich eingeladen hat.", + "errorAlreadyRedeemed": "Du hast schon einen Einladungscode eingelöst.", + "errorWindowExpired": "Einladungscodes können nur in den ersten Tagen nach der Registrierung eingelöst werden.", + "errorNoPet": "Leg zuerst ein Tier an, dann kannst du den Code einlösen.", + "errorTooMany": "Zu viele Versuche. Bitte warte ein paar Minuten.", + "errorGeneric": "Das hat nicht geklappt. Bitte versuch es noch einmal.", + "rulesNote": "Eine Einladung zählt, sobald das eingeladene Tier angelegt ist und seinen ersten Beitrag veröffentlicht hat. Ränge sind digitale Auszeichnungen ohne Geldwert. Einmal erreichte Ränge bleiben erhalten, außer bei Missbrauch." + }, + "ReferralOnboarding": { + "title": "Wie hast du von PawFeed erfahren?", + "subtitle": "Freiwillig. Es hilft uns zu verstehen, wo wir neue Tierfreunde erreichen.", + "source_FRIENDS": "Freunde oder Familie", + "source_TIKTOK": "TikTok", + "source_INSTAGRAM": "Instagram", + "source_YOUTUBE": "YouTube", + "source_FACEBOOK": "Facebook", + "source_SHELTER_OR_PARTNER": "Tierheim, Verein oder Laden", + "source_STORE_SEARCH": "App Store oder Google Play", + "source_PRESS": "Zeitung, Radio oder Blog", + "source_OTHER": "Anders", + "redeemed": "Eingelöst. Du und {name} folgt euch jetzt.", + "redeemedGeneric": "Code eingelöst.", + "continue": "Weiter", + "skip": "Überspringen" + }, + "ReferralLanding": { + "titleWithPet": "{name} lädt dich zu PawFeed ein", + "titleGeneric": "Du bist zu PawFeed eingeladen", + "subtitle": "PawFeed ist das soziale Netzwerk, in dem dein Tier das Profil hat: Fotos, Kurzvideos, Storys und Impfpass an einem Ort.", + "codeLabel": "Einladungscode", + "codeHint": "Gib den Code nach der Registrierung im Onboarding ein, dann seid ihr direkt verbunden.", + "webSignUp": "Im Browser registrieren" } } diff --git a/messages/en.json b/messages/en.json index 92a8b4c..9dcb179 100644 --- a/messages/en.json +++ b/messages/en.json @@ -124,7 +124,8 @@ "addPhoto": "Add a profile photo", "addPhotoDesc": "Give your pet a face on PawFeed. You can always change this later.", "done": "Done", - "skipForNow": "Skip for now" + "skipForNow": "Skip for now", + "loading": "Loading" }, "Language": { "switch": "Language", @@ -477,7 +478,9 @@ "typePawBack": "shared your post (Paw-Back)", "typeBirthday": "🎂 Happy Birthday! Create an anniversary post.", "typeAdoptionDay": "🏠 Today is adoption day! Celebrate the anniversary.", - "typePetSuggestion": "might be a good match" + "typePetSuggestion": "might be a good match", + "typeReferralJoined": "joined PawFeed through your invite", + "typeReferralRankUp": "You reached a new referral rank. Take a look!" }, "PhotoPostForm": { "captionPlaceholder": "Write a caption…", @@ -632,7 +635,8 @@ "statsFollowers": "followers", "statsFollowing": "following", "adoptionStory": "Adoption story", - "verified": "Verified" + "verified": "Verified", + "invitedBy": "Invited by {name}" }, "EditPet": { "title": "Edit {name}", @@ -765,5 +769,72 @@ "statSponsors": "Supporters", "ctaHeading": "Want to be part of it?", "ctaButton": "Get started" + }, + "Referrals": { + "title": "Invite friends", + "subtitle": "Share your link. Every pet that joins through your invite and publishes its first post moves you up a rank.", + "programPaused": "The referral program is paused right now. Your link still works, but new invites are not counted at the moment.", + "shareHeading": "Your invite link", + "shareSubtitle": "Use it as often as you like. People who sign up without the link can still enter your code during onboarding.", + "yourCode": "Your code", + "share": "Share", + "copyLink": "Copy link", + "copied": "Copied", + "copiedToast": "Link copied", + "shareTitle": "Join PawFeed", + "shareText": "My pet is on PawFeed, the social network for pets. Use my code {code} and we are connected right away:", + "ranksHeading": "Your ranks", + "countedInvites": "{count, plural, =0 {No counted invites yet} one {# counted invite} other {# counted invites}}", + "nextRank": "{remaining, plural, one {# more invite} other {# more invites}} until {name}", + "allRanksReached": "You reached every rank. Thank you!", + "achieved": "Reached", + "threshold": "{count, plural, one {# invite} other {# invites}}", + "inviteesHeading": "Joined through you", + "inviteesEmpty": "Nobody yet. Share your link with friends who have a pet.", + "inviteeNoPet": "No pet created yet", + "statusCounted": "Counts", + "statusWaiting": "Waiting for first post", + "statusInReview": "Under review", + "statusRevoked": "Revoked", + "redeemHeading": "Were you invited?", + "redeemSubtitle": "Enter the code of the person who invited you. This only works during the first days after signing up.", + "redeemLabel": "Invite code", + "redeemCta": "Redeem", + "redeemedToast": "Redeemed. You and {name} now follow each other.", + "redeemedToastGeneric": "Code redeemed.", + "errorPaused": "The referral program is paused right now.", + "errorInvalid": "That code does not exist. Check the spelling, e.g. BUBI-7K3M.", + "errorOwnCode": "That is your own code. Enter the code of the person who invited you.", + "errorAlreadyRedeemed": "You already redeemed an invite code.", + "errorWindowExpired": "Invite codes can only be redeemed during the first days after signing up.", + "errorNoPet": "Create a pet first, then you can redeem the code.", + "errorTooMany": "Too many attempts. Please wait a few minutes.", + "errorGeneric": "That did not work. Please try again.", + "rulesNote": "An invite counts once the invited pet is created and has published its first post. Ranks are digital awards without monetary value. Ranks you reach stay with you unless the program is abused." + }, + "ReferralOnboarding": { + "title": "How did you hear about PawFeed?", + "subtitle": "Optional. It helps us understand where we reach new pet lovers.", + "source_FRIENDS": "Friends or family", + "source_TIKTOK": "TikTok", + "source_INSTAGRAM": "Instagram", + "source_YOUTUBE": "YouTube", + "source_FACEBOOK": "Facebook", + "source_SHELTER_OR_PARTNER": "Shelter, club or shop", + "source_STORE_SEARCH": "App Store or Google Play", + "source_PRESS": "Newspaper, radio or blog", + "source_OTHER": "Other", + "redeemed": "Redeemed. You and {name} now follow each other.", + "redeemedGeneric": "Code redeemed.", + "continue": "Continue", + "skip": "Skip" + }, + "ReferralLanding": { + "titleWithPet": "{name} invites you to PawFeed", + "titleGeneric": "You are invited to PawFeed", + "subtitle": "PawFeed is the social network where your pet has the profile: photos, short videos, stories and vaccination record in one place.", + "codeLabel": "Invite code", + "codeHint": "Enter the code during onboarding after signing up and you are connected right away.", + "webSignUp": "Sign up in the browser" } } diff --git a/prisma/enable_rls_referrals.sql b/prisma/enable_rls_referrals.sql new file mode 100644 index 0000000..9d25162 --- /dev/null +++ b/prisma/enable_rls_referrals.sql @@ -0,0 +1,6 @@ +-- Empfehlungsprogramm (feat/referral-program): neue Tabellen für den +-- PostgREST-Zugriff sperren, wie alle anderen Tabellen (siehe enable_rls.sql). +-- Nach dem prisma db push einmal ausführen. +ALTER TABLE public."ReferralRank" ENABLE ROW LEVEL SECURITY; +ALTER TABLE public."ReferralRankAchievement" ENABLE ROW LEVEL SECURITY; +ALTER TABLE public."ReferralSettings" ENABLE ROW LEVEL SECURITY; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 60cd217..0e7b0bc 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -7,35 +7,125 @@ datasource db { } model Owner { - id String @id // = Clerk userId (e.g. "user_xxx") - createdAt DateTime @default(now()) - invitedById String? // Clerk userId of the person who invited this owner - registrationIp String? @db.VarChar(45) // captured once at the earliest guaranteed owner touchpoint, see legal.acknowledgeUploadConsent - pets Pet[] - adminRole AdminRole? - ban UserBan? - shadowban Shadowban? - issuedInvites InviteCode[] @relation("IssuedBy") - legalAcknowledgedAt DateTime? // last time the owner dismissed the legal-update banner; null = never seen one + id String @id // = Clerk userId (e.g. "user_xxx") + createdAt DateTime @default(now()) + invitedById String? // Clerk userId of the person who invited this owner + registrationIp String? @db.VarChar(45) // captured once at the earliest guaranteed owner touchpoint, see legal.acknowledgeUploadConsent + pets Pet[] + adminRole AdminRole? + ban UserBan? + shadowban Shadowban? + issuedInvites InviteCode[] @relation("IssuedBy") + legalAcknowledgedAt DateTime? // last time the owner dismissed the legal-update banner; null = never seen one contentRightsConsentedAt DateTime? // first-pet onboarding: confirms the owner holds all rights to uploaded photos/videos dataProcessingConsentedAt DateTime? // first-pet onboarding: Willenserklärung consenting to data transfer to Clerk/Cloudflare/Mux/Supabase - dataExportTokens DataExportToken[] - warnings Warning[] - themePreference ThemePreference? // null = follows system preference, never explicitly toggled - hideSpiders Boolean @default(false) // Arachnophobie-Helfer: hides Species="Spider" pets/posts from feed/explore/search when true - petSuggestionsEnabled Boolean @default(true) // opt-out for the PET_SUGGESTION cron notification (src/app/api/cron/pet-suggestions) - expoPushToken String? // mobile app's Expo push token, registered via owner.registerPushToken; single device only, overwritten on re-register + dataExportTokens DataExportToken[] + warnings Warning[] + themePreference ThemePreference? // null = follows system preference, never explicitly toggled + hideSpiders Boolean @default(false) // Arachnophobie-Helfer: hides Species="Spider" pets/posts from feed/explore/search when true + petSuggestionsEnabled Boolean @default(true) // opt-out for the PET_SUGGESTION cron notification (src/app/api/cron/pet-suggestions) + expoPushToken String? // mobile app's Expo push token, registered via owner.registerPushToken; single device only, overwritten on re-register // Sponsoring (Gitea #22) — freiwilliger Beitrag/Abo gegen ein "Sponsor"-Badge. - stripeCustomerId String? @unique + stripeCustomerId String? @unique sponsorSince DateTime? // permanent for one-time givers; for subscribers, valid only through sponsorPeriodEnd sponsorPeriodEnd DateTime? // only set for an active subscription; controls how long the badge stays visible after cancellation - sponsorSubscriptionId String? @unique - sponsorSubscriptionStatus String? @db.VarChar(20) // mirrors Stripe subscription.status + sponsorSubscriptionId String? @unique + sponsorSubscriptionStatus String? @db.VarChar(20) // mirrors Stripe subscription.status contributions SponsorContribution[] - broadcastReceipts BroadcastReceipt[] + broadcastReceipts BroadcastReceipt[] + + // Empfehlungsprogramm — Clerk-unabhängig: der Code wird erst nach der + // Registrierung im Onboarding eingelöst (src/lib/referrals.ts). invitedById + // oben ist dasselbe Feld wie im alten InviteCode-Ablauf. + referralCode String? @unique @db.VarChar(16) // persönlich, dauerhaft, unbegrenzt nutzbar, z. B. BUBI-7K3M + invitedAt DateTime? // Zeitpunkt der Code-Einlösung + referralQualifiedAt DateTime? // dieser (eingeladene) Owner hat ein Tier + ersten Beitrag → zählt beim Einladenden + referralFlaggedAt DateTime? // Qualifizierung lag über dem Tageslimit des Einladenden → wartet auf Admin-Freigabe, zählt noch nicht + referralRevokedAt DateTime? // vom Admin aberkannt → zählt nicht (mehr) + qualifiedReferralCount Int @default(0) // denormalisiert, nur in src/lib/referrals.ts gepflegt + referralRankId String? // Cache: höchster aktiver erreichter Rang, gepflegt von src/lib/referrals.ts + referralRank ReferralRank? @relation(fields: [referralRankId], references: [id], onDelete: SetNull) + referralPerks ReferralPerk[] @default([]) // Cache: Vereinigung der Vorteile aller erreichten aktiven Ränge, gepflegt von src/lib/referrals.ts + rankAchievements ReferralRankAchievement[] + acquisitionSource AcquisitionSource? // freiwillige Onboarding-Angabe "Wie hast du von PawFeed erfahren?" + acquisitionAnsweredAt DateTime? @@index([createdAt(sort: Desc)]) + @@index([invitedById]) +} + +enum AcquisitionSource { + TIKTOK + INSTAGRAM + YOUTUBE + FACEBOOK + FRIENDS + SHELTER_OR_PARTNER + STORE_SEARCH + PRESS + OTHER +} + +// ─── Empfehlungsprogramm ───────────────────────────────────────────────────── +// Ränge sind Daten (im Admin-Panel unter /p/[secret]/referrals bearbeitbar); +// nur die Vorteile (ReferralPerk) brauchen Code. Vorteile sind kumulativ über +// alle erreichten Ränge. Erreichte Ränge bleiben erhalten, auch wenn eine +// Schwelle später erhöht wird (Bestandsschutz über ReferralRankAchievement). + +enum ReferralPerk { + PROFILE_RING + NAME_BADGE + FEATURE_POOL + VERIFICATION_PRIORITY + CURATOR_SPOTLIGHT + EXPLORE_HIGHLIGHT // vorgesehen, noch nicht umgesetzt + HALL_OF_FAME // vorgesehen, noch nicht umgesetzt + EARLY_ACCESS // vorgesehen, noch nicht umgesetzt + VOUCHER // vorgesehen für Partner-Gutscheine, noch nicht umgesetzt +} + +model ReferralRank { + id String @id @default(cuid()) + key String @unique @db.VarChar(40) // stabil, nach Anlage nicht änderbar + threshold Int @unique // qualifizierte Einladungen + nameDe String @db.VarChar(40) + nameEn String @db.VarChar(40) + descriptionDe String @db.VarChar(200) + descriptionEn String @db.VarChar(200) + icon String @db.VarChar(30) // Schlüssel aus REFERRAL_ICON_KEYS (src/lib/referral-ranks.ts) + color String @db.VarChar(7) // #RRGGBB + perks ReferralPerk[] + isActive Boolean @default(true) // deaktivieren statt löschen — Achievements verweisen darauf + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + owners Owner[] + achievements ReferralRankAchievement[] +} + +// Wer hat welchen Rang wann erreicht — Grundlage für Bestandsschutz, +// einmalige Rang-Benachrichtigungen und spätere Gutscheine. +model ReferralRankAchievement { + id String @id @default(cuid()) + owner Owner @relation(fields: [ownerId], references: [id], onDelete: Cascade) + ownerId String + rank ReferralRank @relation(fields: [rankId], references: [id], onDelete: Restrict) + rankId String + achievedAt DateTime @default(now()) + + @@unique([ownerId, rankId]) + @@index([rankId]) +} + +// Genau eine Zeile (id = 1), im Admin-Panel bearbeitbar. +model ReferralSettings { + id Int @id @default(1) + programActive Boolean @default(true) + redeemWindowDays Int @default(7) + dailyQualifyCap Int @default(20) + autoMutualFollow Boolean @default(true) + updatedAt DateTime @updatedAt + updatedBy String? // ownerId des Admins } enum ThemePreference { @@ -50,7 +140,7 @@ model Warning { owner Owner @relation(fields: [ownerId], references: [id], onDelete: Cascade) ownerId String reason String @db.VarChar(500) - issuedBy String // ownerId of the moderator who issued it + issuedBy String // ownerId of the moderator who issued it createdAt DateTime @default(now()) @@index([ownerId, createdAt(sort: Desc)]) @@ -80,7 +170,7 @@ model VerificationRequest { meetsEstablishedPresence Boolean? // account has existed a while and actually posted reviewNote String? @db.VarChar(1000) - reviewedBy String? // ownerId of the moderator who decided + reviewedBy String? // ownerId of the moderator who decided reviewedAt DateTime? createdAt DateTime @default(now()) @@ -92,8 +182,8 @@ model InviteCode { id String @id @default(cuid()) code String @unique issuedBy Owner? @relation("IssuedBy", fields: [issuedById], references: [id]) - issuedById String? // null = admin-generated root invite - usedById String? // Clerk userId who redeemed this code (not a FK — owner may not exist yet) + issuedById String? // null = admin-generated root invite + usedById String? // Clerk userId who redeemed this code (not a FK — owner may not exist yet) usedAt DateTime? revoked Boolean @default(false) createdAt DateTime @default(now()) @@ -141,25 +231,25 @@ model BreedTranslation { } model Pet { - id String @id @default(cuid()) - owner Owner @relation(fields: [ownerId], references: [id]) - ownerId String - species Species @relation(fields: [speciesId], references: [id]) - speciesId String - breed Breed? @relation(fields: [breedId], references: [id]) - breedId String? - name String - nickname String? @db.VarChar(30) // Rufname/Spitzname; shown after the real name, e.g. "Rex (Rexi)" — see src/lib/pet-display-name.ts - bio String? - adoptionStory String? - avatarKey String? // Supabase Storage object key, NOT a full URL - isVerified Boolean @default(false) // admin-granted verification checkmark (Gitea #10 permission matrix) - dmPolicy DmPolicy @default(EVERYONE) // who can send this pet DMs + id String @id @default(cuid()) + owner Owner @relation(fields: [ownerId], references: [id]) + ownerId String + species Species @relation(fields: [speciesId], references: [id]) + speciesId String + breed Breed? @relation(fields: [breedId], references: [id]) + breedId String? + name String + nickname String? @db.VarChar(30) // Rufname/Spitzname; shown after the real name, e.g. "Rex (Rexi)" — see src/lib/pet-display-name.ts + bio String? + adoptionStory String? + avatarKey String? // Supabase Storage object key, NOT a full URL + isVerified Boolean @default(false) // admin-granted verification checkmark (Gitea #10 permission matrix) + dmPolicy DmPolicy @default(EVERYONE) // who can send this pet DMs followerVisibility FollowerVisibility @default(EVERYONE) // who can view this pet's followers/following lists (Gitea #33) - birthday DateTime? @db.Date // actual birthday for anniversary notifications - adoptedAt DateTime? @db.Date // adoption date for anniversary notifications - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + birthday DateTime? @db.Date // actual birthday for anniversary notifications + adoptedAt DateTime? @db.Date // adoption date for anniversary notifications + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt // Explicit, un-pre-checked opt-in required before the Health-Tracking area // (weight, vet visits, vaccines, emergency vet, feeding, Health Card) can be @@ -171,16 +261,16 @@ model Pet { healthConsentRevokedAt DateTime? // Phase 2 back-relations - posts Post[] - stories Story[] - storyViews StoryView[] @relation("StoryViewer") + posts Post[] + stories Story[] + storyViews StoryView[] @relation("StoryViewer") storyReactions StoryReaction[] @relation("StoryReactions") storyComments StoryComment[] @relation("StoryComments") - following Follow[] @relation("Follower") - followers Follow[] @relation("Followee") - blocking Block[] @relation("Blocker") - blockedBy Block[] @relation("Blocked") - reports Report[] @relation("Reporter") + following Follow[] @relation("Follower") + followers Follow[] @relation("Followee") + blocking Block[] @relation("Blocker") + blockedBy Block[] @relation("Blocked") + reports Report[] @relation("Reporter") // Phase 3 back-relations reactions Reaction[] @relation("PetReactions") @@ -225,15 +315,15 @@ model Pet { // ─── Phase 6 Enums ─────────────────────────────────────────────────────────── enum DmPolicy { - EVERYONE // anyone can send a DM + EVERYONE // anyone can send a DM FOLLOWERS_ONLY // only pets that this pet follows can send a DM } // Gitea #33 — who may view a pet's followers/following lists. enum FollowerVisibility { - EVERYONE // anyone can view the lists + EVERYONE // anyone can view the lists FOLLOWERS_ONLY // only pets that follow this pet can view the lists - NOBODY // only the owner can view the lists + NOBODY // only the owner can view the lists } // Gitea #18 — legal AI-content disclosure, shared across Post/Story/Pet. @@ -303,11 +393,11 @@ model Post { // purposes; every reader-facing query (feed/explore/profile/search/ // hashtag/milestones) filters hiddenAt: null. Admin views intentionally // do NOT filter on this so moderators can still see + unhide. - hiddenAt DateTime? + hiddenAt DateTime? // Admin curation (Gitea #10 Phase 4) — set by PIN_POST/UNPIN_POST. Pinned // posts skip the 48h trending window in explore.getTrending and sort first. - pinnedAt DateTime? + pinnedAt DateTime? // Denormalized engagement counters — kept in sync transactionally at each // reaction/comment/repost create+delete site (reactions.ts, comments.ts, @@ -322,17 +412,17 @@ model Post { aiDisclosure AiDisclosure @default(NONE) // Phase 3 back-relations - reactions Reaction[] @relation("PostReactions") - comments Comment[] @relation("PostComments") - repost Repost? @relation("RepostEntry") - originalReposts Repost[] @relation("OriginalReposts") - hashtags PostHashtag[] @relation("PostHashtags") + reactions Reaction[] @relation("PostReactions") + comments Comment[] @relation("PostComments") + repost Repost? @relation("RepostEntry") + originalReposts Repost[] @relation("OriginalReposts") + hashtags PostHashtag[] @relation("PostHashtags") // Phase 4 back-relations - notifications Notification[] + notifications Notification[] // Phase 5 back-relation - videoPost VideoPost? + videoPost VideoPost? @@index([petId, createdAt(sort: Desc)]) } @@ -387,18 +477,18 @@ model Story { // Gitea #29 — Denormalized engagement counters, same rationale as Post // (see Post model comment). Maintained transactionally in stories.ts. - reactionCount Int @default(0) - commentCount Int @default(0) + reactionCount Int @default(0) + commentCount Int @default(0) reactions StoryReaction[] comments StoryComment[] // Story music overlay — optional, admin-curated catalog only (no user // uploads, licensing liability). onDelete: SetNull so retiring/deleting a // MusicTrack never orphans historical stories that already used it. - musicTrack MusicTrack? @relation(fields: [musicTrackId], references: [id], onDelete: SetNull) - musicTrackId String? - musicOffsetSecs Float? @default(0) // start point within the track, since story display duration is fixed - musicMuteOriginal Boolean @default(false) // VIDEO stories only — mute the baked-in Mux audio in favor of the track + musicTrack MusicTrack? @relation(fields: [musicTrackId], references: [id], onDelete: SetNull) + musicTrackId String? + musicOffsetSecs Float? @default(0) // start point within the track, since story display duration is fixed + musicMuteOriginal Boolean @default(false) // VIDEO stories only — mute the baked-in Mux audio in favor of the track @@index([petId, expiresAt]) // supports lazy-delete filter query @@index([muxUploadId]) @@ -410,15 +500,15 @@ model Story { // Deliberately no user-upload path — copyright liability stays with the // admin who uploads and records `licenseNote` for each track. model MusicTrack { - id String @id @default(cuid()) - title String @db.VarChar(100) - artist String @db.VarChar(100) - licenseNote String @db.VarChar(500) // license/source reference, required for compliance - storageKey String // Supabase Storage object key, MUSIC_STORAGE_BUCKET + id String @id @default(cuid()) + title String @db.VarChar(100) + artist String @db.VarChar(100) + licenseNote String @db.VarChar(500) // license/source reference, required for compliance + storageKey String // Supabase Storage object key, MUSIC_STORAGE_BUCKET durationSecs Float - active Boolean @default(true) // soft-disable instead of deleting so past stories keep working - createdBy String // ownerId of the admin who uploaded it - createdAt DateTime @default(now()) + active Boolean @default(true) // soft-disable instead of deleting so past stories keep working + createdBy String // ownerId of the admin who uploaded it + createdAt DateTime @default(now()) stories Story[] @@ -572,15 +662,15 @@ model PostHashtag { // ─── Phase 5 Models ────────────────────────────────────────────────────────── model VideoPost { - id String @id @default(cuid()) - post Post @relation(fields: [postId], references: [id], onDelete: Cascade) - postId String @unique - muxUploadId String? // Mux direct upload ID — set on creation - muxAssetId String? // Mux asset ID — set by video.upload.asset_created webhook - muxPlaybackId String? // Mux playback ID — set by video.asset.ready webhook - status VideoStatus @default(PROCESSING) - durationSecs Float? // populated by webhook - aspectRatio String? // e.g. "16:9" from Mux + id String @id @default(cuid()) + post Post @relation(fields: [postId], references: [id], onDelete: Cascade) + postId String @unique + muxUploadId String? // Mux direct upload ID — set on creation + muxAssetId String? // Mux asset ID — set by video.upload.asset_created webhook + muxPlaybackId String? // Mux playback ID — set by video.asset.ready webhook + status VideoStatus @default(PROCESSING) + durationSecs Float? // populated by webhook + aspectRatio String? // e.g. "16:9" from Mux @@index([muxUploadId]) @@index([muxAssetId]) @@ -610,7 +700,7 @@ model Message { senderPet Pet @relation("MessageSender", fields: [senderPetId], references: [id]) senderPetId String body String @db.VarChar(1000) - readAt DateTime? // null = unread by recipient + readAt DateTime? // null = unread by recipient createdAt DateTime @default(now()) @@ -625,7 +715,7 @@ model WeightLog { pet Pet @relation(fields: [petId], references: [id], onDelete: Cascade) petId String date DateTime @db.Date - weightGrams Int // stored in grams; display as kg + weightGrams Int // stored in grams; display as kg notes String? @db.VarChar(200) createdAt DateTime @default(now()) @@ -700,14 +790,14 @@ model DataExportToken { } model PetFeedingInfo { - id String @id @default(cuid()) - pet Pet @relation(fields: [petId], references: [id], onDelete: Cascade) - petId String @unique - foodType String? @db.VarChar(100) + id String @id @default(cuid()) + pet Pet @relation(fields: [petId], references: [id], onDelete: Cascade) + petId String @unique + foodType String? @db.VarChar(100) feedingTimes Int? dailyAmountG Int? - foodBrand String? @db.VarChar(100) - specialDiet String? @db.VarChar(500) + foodBrand String? @db.VarChar(100) + specialDiet String? @db.VarChar(500) updatedAt DateTime @updatedAt } @@ -723,6 +813,8 @@ enum NotificationType { MENTION PAW_BACK PET_SUGGESTION + REFERRAL_JOINED // ein Tier ist über die eigene Einladung zu PawFeed gekommen + REFERRAL_RANK_UP // eigener Empfehlungsrang aufgestiegen (actorPetId = null) } // ─── Phase 4 Models ────────────────────────────────────────────────────────── @@ -755,12 +847,12 @@ enum AdminRoleType { } model AdminRole { - id String @id @default(cuid()) - owner Owner @relation(fields: [ownerId], references: [id], onDelete: Cascade) - ownerId String @unique - role AdminRoleType @default(MODERATOR) - grantedAt DateTime @default(now()) - grantedBy String? // ownerId of the granting super admin + id String @id @default(cuid()) + owner Owner @relation(fields: [ownerId], references: [id], onDelete: Cascade) + ownerId String @unique + role AdminRoleType @default(MODERATOR) + grantedAt DateTime @default(now()) + grantedBy String? // ownerId of the granting super admin } enum BlacklistEntryType { @@ -782,7 +874,7 @@ model BlacklistEntry { type BlacklistEntryType value String @db.VarChar(200) // normalized lowercase: word, link domain, or hashtag (no #) createdAt DateTime @default(now()) - createdBy String // ownerId of the moderator who added it + createdBy String // ownerId of the moderator who added it @@unique([type, value]) @@index([type]) @@ -794,7 +886,7 @@ model UserBan { ownerId String @unique reason String? @db.VarChar(500) bannedAt DateTime @default(now()) - bannedBy String // ownerId of moderator who issued the ban + bannedBy String // ownerId of moderator who issued the ban expiresAt DateTime? // null = permanent ban } @@ -809,7 +901,7 @@ model Shadowban { ownerId String @unique reason String @db.VarChar(500) shadowbannedAt DateTime @default(now()) - shadowbannedBy String // ownerId of moderator who issued it + shadowbannedBy String // ownerId of moderator who issued it expiresAt DateTime? // null = permanent } @@ -882,19 +974,19 @@ model AdRepost { } model ModerationLog { - id String @id @default(cuid()) - action String @db.VarChar(50) // DELETE_POST | DELETE_COMMENT | BAN_USER | UNBAN_USER | DELETE_IMAGE | DISMISS_REPORT | CREATE_AD | UPDATE_AD | DELETE_AD | GRANT_ROLE | REVOKE_ROLE | WARN_USER | VERIFY_PET | UNVERIFY_PET | DELETE_ACCOUNT | APPROVE_VERIFICATION_REQUEST | REJECT_VERIFICATION_REQUEST | HIDE_POST | UNHIDE_POST | SHADOWBAN_USER | UNSHADOWBAN_USER | SET_AI_DISCLOSURE | ENABLE_MAINTENANCE_MODE | DISABLE_MAINTENANCE_MODE | PIN_POST | UNPIN_POST | SEND_BROADCAST | DELETE_BROADCAST | CREATE_MUSIC_TRACK | UPDATE_MUSIC_TRACK | DELETE_MUSIC_TRACK - targetType String @db.VarChar(20) // POST | COMMENT | USER | IMAGE | AD | REPORT | PET | SYSTEM | MUSIC_TRACK - targetId String - reason String? @db.VarChar(500) - moderatorId String // ownerId + id String @id @default(cuid()) + action String @db.VarChar(50) // DELETE_POST | DELETE_COMMENT | BAN_USER | UNBAN_USER | DELETE_IMAGE | DISMISS_REPORT | CREATE_AD | UPDATE_AD | DELETE_AD | GRANT_ROLE | REVOKE_ROLE | WARN_USER | VERIFY_PET | UNVERIFY_PET | DELETE_ACCOUNT | APPROVE_VERIFICATION_REQUEST | REJECT_VERIFICATION_REQUEST | HIDE_POST | UNHIDE_POST | SHADOWBAN_USER | UNSHADOWBAN_USER | SET_AI_DISCLOSURE | ENABLE_MAINTENANCE_MODE | DISABLE_MAINTENANCE_MODE | PIN_POST | UNPIN_POST | SEND_BROADCAST | DELETE_BROADCAST | CREATE_MUSIC_TRACK | UPDATE_MUSIC_TRACK | DELETE_MUSIC_TRACK | CREATE_REFERRAL_RANK | UPDATE_REFERRAL_RANK | DEACTIVATE_REFERRAL_RANK | UPDATE_REFERRAL_SETTINGS | APPROVE_REFERRAL | REVOKE_REFERRAL | REVOKE_REFERRAL_RANK + targetType String @db.VarChar(20) // POST | COMMENT | USER | IMAGE | AD | REPORT | PET | SYSTEM | MUSIC_TRACK + targetId String + reason String? @db.VarChar(500) + moderatorId String // ownerId // Both nullable: added after the table already had rows, so historical // entries predate capture. Every new write always populates both — role is // "BOOTSTRAP" for the env-configured ADMIN_OWNER_ID (no AdminRole row of // its own), otherwise the AdminRoleType held at the time of the action. - moderatorRole String? @db.VarChar(20) - ipAddress String? @db.VarChar(45) // max IPv6 length; login-abuse / audit trail - createdAt DateTime @default(now()) + moderatorRole String? @db.VarChar(20) + ipAddress String? @db.VarChar(45) // max IPv6 length; login-abuse / audit trail + createdAt DateTime @default(now()) @@index([moderatorId, createdAt(sort: Desc)]) @@index([targetType, targetId]) @@ -916,7 +1008,7 @@ model LegalDocumentVersion { docType LegalDocType changeSummary String @db.VarChar(1000) publishedAt DateTime @default(now()) - publishedBy String // ownerId of the publishing admin + publishedBy String // ownerId of the publishing admin @@index([publishedAt(sort: Desc)]) } @@ -931,7 +1023,7 @@ model SponsorContribution { ownerId String type String @db.VarChar(20) // ONE_TIME | SUBSCRIPTION amountCents Int - currency String @db.VarChar(3) @default("eur") + currency String @default("eur") @db.VarChar(3) stripeCheckoutSessionId String? @unique stripeInvoiceId String? @unique createdAt DateTime @default(now()) @@ -951,7 +1043,7 @@ model Broadcast { title String @db.VarChar(200) body String @db.VarChar(2000) createdAt DateTime @default(now()) - createdBy String // ownerId of the publishing admin + createdBy String // ownerId of the publishing admin receipts BroadcastReceipt[] @@index([createdAt(sort: Desc)]) diff --git a/prisma/scripts/referral-setup.ts b/prisma/scripts/referral-setup.ts new file mode 100644 index 0000000..a295e0c --- /dev/null +++ b/prisma/scripts/referral-setup.ts @@ -0,0 +1,174 @@ +import { PrismaClient, Prisma, type ReferralPerk } from "@prisma/client"; +import { PrismaPg } from "@prisma/adapter-pg"; +import { Pool } from "pg"; +import { generateReferralCode } from "../../src/lib/referral-code"; +import { recomputeReferralRanks } from "../../src/lib/referral-recompute"; + +/** + * Einmaliges Setup für das Empfehlungsprogramm — idempotent, mehrfach ausführbar. + * + * 1. Legt die 7 Start-Ränge an, aber nur, wenn es den key noch nicht gibt — + * im Admin bearbeitete Ränge werden nie überschrieben. + * 2. Legt die Einstellungen (ReferralSettings, id = 1) an, falls sie fehlen. + * 3. Erzeugt einen persönlichen Code für jeden Owner ohne Code. + * 4. Nur mit --credit-existing: rechnet Einladungen aus dem alten + * InviteCode-System an (Owner mit invitedById und eigenem Beitrag) und + * vergibt die passenden Ränge. Ohne Benachrichtigungen. + * + * Ausführen (auf dem NAS im Wegwerf-Container, nach dem db push): + * npx tsx prisma/scripts/referral-setup.ts + * npx tsx prisma/scripts/referral-setup.ts --credit-existing + */ + +const databaseUrl = process.env.DATABASE_URL; +if (!databaseUrl) throw new Error("DATABASE_URL is not set."); + +const prisma = new PrismaClient({ adapter: new PrismaPg(new Pool({ connectionString: databaseUrl })) }); + +type DefaultRank = { + key: string; + threshold: number; + nameDe: string; + nameEn: string; + descriptionDe: string; + descriptionEn: string; + icon: string; + color: string; + perks: ReferralPerk[]; +}; + +// Vorteile sind kumulativ (src/lib/referral-ranks.ts) — jeder Rang listet nur, was neu dazukommt. +const DEFAULT_RANKS: DefaultRank[] = [ + { + key: "pfotenfreund", threshold: 1, nameDe: "Pfotenfreund", nameEn: "Paw Pal", + descriptionDe: "Der erste Freund ist über deine Einladung da.", + descriptionEn: "Your first friend joined through your invite.", + icon: "paw-print", color: "#b8734a", perks: ["PROFILE_RING"], + }, + { + key: "botschafter", threshold: 3, nameDe: "Botschafter", nameEn: "Ambassador", + descriptionDe: "Drei Freunde eingeladen. Abzeichen neben dem Namen.", + descriptionEn: "Three friends invited. Badge next to the name.", + icon: "heart", color: "#e8620f", perks: ["NAME_BADGE"], + }, + { + key: "rudelfuehrer", threshold: 5, nameDe: "Rudelführer", nameEn: "Pack Leader", + descriptionDe: "In der Auswahl für das Tier der Woche.", + descriptionEn: "In the running for Pet of the Week.", + icon: "shield", color: "#2f8a45", perks: ["FEATURE_POOL"], + }, + { + key: "leitpfote", threshold: 10, nameDe: "Leitpfote", nameEn: "Lead Paw", + descriptionDe: "Bevorzugte Verifizierung und eine Vorstellung durch das PawFeed-Team.", + descriptionEn: "Priority verification and a shout-out from the PawFeed team.", + icon: "star", color: "#2f5f9e", perks: ["VERIFICATION_PRIORITY", "CURATOR_SPOTLIGHT"], + }, + { + key: "goldene-pfote", threshold: 25, nameDe: "Goldene Pfote", nameEn: "Golden Paw", + descriptionDe: "Goldenes Abzeichen und goldener Ring.", + descriptionEn: "Golden badge and golden ring.", + icon: "award", color: "#c7951f", perks: ["EXPLORE_HIGHLIGHT"], + }, + { + key: "pfotenlegende", threshold: 50, nameDe: "Pfoten-Legende", nameEn: "Paw Legend", + descriptionDe: "Ein dauerhafter Platz in der Ruhmeshalle.", + descriptionEn: "A permanent place in the Hall of Fame.", + icon: "crown", color: "#7b4fc9", perks: ["HALL_OF_FAME"], + }, + { + key: "pawfeed-ikone", threshold: 100, nameDe: "PawFeed-Ikone", nameEn: "PawFeed Icon", + descriptionDe: "Neue Funktionen vor allen anderen testen.", + descriptionEn: "Try new features before everyone else.", + icon: "gem", color: "#0f7f86", perks: ["EARLY_ACCESS"], + }, +]; + +async function seedRanks() { + let created = 0; + for (const rank of DEFAULT_RANKS) { + const exists = await prisma.referralRank.findUnique({ where: { key: rank.key } }); + if (exists) continue; + const thresholdTaken = await prisma.referralRank.findUnique({ where: { threshold: rank.threshold } }); + if (thresholdTaken) { + console.warn(` ↷ ${rank.key}: Schwelle ${rank.threshold} ist schon vergeben, übersprungen`); + continue; + } + await prisma.referralRank.create({ data: rank }); + created++; + } + console.log(`Ränge: ${created} neu angelegt, ${DEFAULT_RANKS.length - created} vorhanden oder übersprungen`); +} + +async function seedSettings() { + await prisma.referralSettings.upsert({ where: { id: 1 }, create: { id: 1 }, update: {} }); + console.log("Einstellungen: vorhanden"); +} + +async function backfillCodes() { + const owners = await prisma.owner.findMany({ + where: { referralCode: null }, + select: { id: true, pets: { orderBy: { createdAt: "asc" }, take: 1, select: { name: true } } }, + }); + let created = 0; + for (const owner of owners) { + for (let attempt = 0; attempt < 5; attempt++) { + try { + await prisma.owner.update({ + where: { id: owner.id }, + data: { referralCode: generateReferralCode(owner.pets[0]?.name) }, + }); + created++; + break; + } catch (err) { + const isCollision = err instanceof Prisma.PrismaClientKnownRequestError && err.code === "P2002"; + if (!isCollision) throw err; + } + } + } + console.log(`Codes: ${created} von ${owners.length} Ownern ohne Code erzeugt`); +} + +async function creditExistingInvites() { + const now = new Date(); + const qualified = await prisma.owner.updateMany({ + where: { + invitedById: { not: null }, + referralQualifiedAt: null, + referralRevokedAt: null, + pets: { + some: { + posts: { some: { OR: [{ type: "PHOTO" }, { type: "VIDEO", videoPost: { status: "READY" } }] } }, + }, + }, + }, + data: { referralQualifiedAt: now }, + }); + // invitedAt für Alt-Einladungen nachtragen, damit Auswertungen nicht leer bleiben. + await prisma.$executeRaw`UPDATE "Owner" SET "invitedAt" = "createdAt" WHERE "invitedById" IS NOT NULL AND "invitedAt" IS NULL`; + + // Zähler komplett aus den Einladungen neu berechnen (idempotent). + await prisma.$executeRaw` + UPDATE "Owner" o SET "qualifiedReferralCount" = COALESCE(c.n, 0) + FROM (SELECT o2."id", ( + SELECT count(*)::int FROM "Owner" i + WHERE i."invitedById" = o2."id" AND i."referralQualifiedAt" IS NOT NULL AND i."referralRevokedAt" IS NULL + ) AS n FROM "Owner" o2) c + WHERE o."id" = c."id" AND o."qualifiedReferralCount" IS DISTINCT FROM COALESCE(c.n, 0)`; + + await recomputeReferralRanks(prisma); + console.log(`Alt-Einladungen: ${qualified.count} neu angerechnet, Zähler und Ränge neu berechnet`); +} + +async function main() { + await seedRanks(); + await seedSettings(); + await backfillCodes(); + if (process.argv.includes("--credit-existing")) await creditExistingInvites(); +} + +main() + .catch((err) => { + console.error(err); + process.exitCode = 1; + }) + .finally(() => prisma.$disconnect()); diff --git a/src/__tests__/helpers/prisma-mock.ts b/src/__tests__/helpers/prisma-mock.ts index 3a517ce..35e2468 100644 --- a/src/__tests__/helpers/prisma-mock.ts +++ b/src/__tests__/helpers/prisma-mock.ts @@ -325,6 +325,28 @@ interface MockMusicTrackClient { delete: MockFn; } +// ─── Empfehlungsprogramm ───────────────────────────────────────────────────── + +interface MockReferralRankClient { + findMany: MockFn; + findUnique: MockFn; + count: MockFn; + create: MockFn; + update: MockFn; +} + +interface MockReferralRankAchievementClient { + findMany: MockFn; + createMany: MockFn; + deleteMany: MockFn; + groupBy: MockFn; +} + +interface MockReferralSettingsClient { + findUnique: MockFn; + upsert: MockFn; +} + interface MockPrismaClient { pet: MockPetClient; owner: MockOwnerClient; @@ -371,7 +393,11 @@ interface MockPrismaClient { broadcast: MockBroadcastClient; broadcastReceipt: MockBroadcastReceiptClient; musicTrack: MockMusicTrackClient; + referralRank: MockReferralRankClient; + referralRankAchievement: MockReferralRankAchievementClient; + referralSettings: MockReferralSettingsClient; $transaction: MockFn; + $executeRaw: MockFn; $disconnect: MockFn; } @@ -648,7 +674,26 @@ export function createMockPrisma(): MockPrismaClient { update: vi.fn(), delete: vi.fn(), }, + referralRank: { + findMany: vi.fn().mockResolvedValue([]), + findUnique: vi.fn(), + count: vi.fn(), + create: vi.fn(), + update: vi.fn(), + }, + referralRankAchievement: { + findMany: vi.fn().mockResolvedValue([]), + createMany: vi.fn().mockResolvedValue({ count: 0 }), + deleteMany: vi.fn(), + groupBy: vi.fn().mockResolvedValue([]), + }, + // Ohne Settings-Zeile greifen die Standardwerte (DEFAULT_REFERRAL_SETTINGS). + referralSettings: { + findUnique: vi.fn().mockResolvedValue(null), + upsert: vi.fn(), + }, $transaction: vi.fn(), + $executeRaw: vi.fn().mockResolvedValue(0), $disconnect: vi.fn(), }; } diff --git a/src/__tests__/referrals-router.test.ts b/src/__tests__/referrals-router.test.ts new file mode 100644 index 0000000..4f6089e --- /dev/null +++ b/src/__tests__/referrals-router.test.ts @@ -0,0 +1,211 @@ +/** + * Empfehlungsprogramm — Router (referrals.ts für Nutzer, referral-admin.ts + * fürs Admin-Panel). Kernlogik ist in referrals.test.ts abgedeckt. + */ + +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { Prisma } from "@prisma/client"; + +vi.mock("server-only", () => ({})); +vi.mock("@clerk/nextjs/server", () => ({ auth: vi.fn().mockResolvedValue({ userId: "owner-1" }) })); +vi.mock("@/lib/prisma", () => ({ prisma: {} })); +vi.mock("@/lib/redis", () => ({ redis: {} })); +vi.mock("@/lib/rate-limit", () => ({ checkRateLimit: vi.fn().mockResolvedValue({ success: true }) })); +vi.mock("@/lib/feed-helpers", () => ({ backfillOnFollow: vi.fn().mockResolvedValue(undefined) })); +vi.mock("@/lib/notify", () => ({ notify: vi.fn() })); + +const mockAssertAdmin = vi.fn(); +vi.mock("@/trpc/routers/admin", () => ({ + assertAdmin: (...args: unknown[]) => mockAssertAdmin(...args), +})); + +import { TRPCError } from "@trpc/server"; +import { createMockPrisma } from "@/__tests__/helpers/prisma-mock"; + +type MockPrisma = ReturnType; + +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 | 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" }); + }); +}); diff --git a/src/__tests__/referrals.test.ts b/src/__tests__/referrals.test.ts new file mode 100644 index 0000000..c042eee --- /dev/null +++ b/src/__tests__/referrals.test.ts @@ -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; +// 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 | null; + inviter?: Record | 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 }) => { + 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 | 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); + }); +}); diff --git a/src/app/(app)/einladen/page.tsx b/src/app/(app)/einladen/page.tsx new file mode 100644 index 0000000..adbbdbc --- /dev/null +++ b/src/app/(app)/einladen/page.tsx @@ -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 ( +
+ +
+ ); + } + + const achieved = new Set(data.achievedRankIds); + + return ( +
+
+

+

+

{t("subtitle")}

+
+ + {!data.programActive && ( +

+ {t("programPaused")} +

+ )} + + + + + + + + {data.canRedeem && ( +
+

+ {t("redeemHeading")} +

+

{t("redeemSubtitle")}

+ toast.success(name ? t("redeemedToast", { name }) : t("redeemedToastGeneric"))} + /> +
+ )} + +

{t("rulesNote")}

+
+ ); +} diff --git a/src/app/(app)/notifications/page.tsx b/src/app/(app)/notifications/page.tsx index ddaa511..e768ef2 100644 --- a/src/app/(app)/notifications/page.tsx +++ b/src/app/(app)/notifications/page.tsx @@ -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 ; case "PAW_BACK": return ; case "PET_SUGGESTION": return ; + case "REFERRAL_JOINED": return ; + case "REFERRAL_RANK_UP": return ; } } @@ -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")}

diff --git a/src/app/(app)/pets/[petId]/page.tsx b/src/app/(app)/pets/[petId]/page.tsx index 9601b9d..dbbdb77 100644 --- a/src/app/(app)/pets/[petId]/page.tsx +++ b/src/app/(app)/pets/[petId]/page.tsx @@ -12,6 +12,7 @@ import { getAvatarUrl } from "@/lib/avatar-url"; import { ProfileActions } from "@/app/(app)/pets/[petId]/ProfileActions"; import { ProfileTabs } from "@/components/profile/ProfileTabs"; import { PetAvatarZoom } from "@/components/pet/PetAvatarZoom"; +import { ReferralAvatarRing } from "@/components/referrals/ReferralAvatarRing"; /** * Pet profile view — /pets/[petId] @@ -63,24 +64,40 @@ export default async function PetProfilePage({ params }: PageProps) {

{/* Pet identity */}
- + + +

{formatPetName(pet)}

{pet.species.name} + {pet.invitedByPet && ( +

+ {t.rich("invitedBy", { + name: pet.invitedByPet.name, + link: (chunks) => ( + + {chunks} + + ), + })} +

+ )} + {/* Owner attribution — only shown on own pet */} {isOwnPet && ownerUsername && (

diff --git a/src/app/api/webhooks/mux/route.ts b/src/app/api/webhooks/mux/route.ts index 8f38b63..be03074 100644 --- a/src/app/api/webhooks/mux/route.ts +++ b/src/app/api/webhooks/mux/route.ts @@ -4,6 +4,7 @@ import { mux, MUX_WEBHOOK_SECRET } from "@/lib/mux"; import { redis } from "@/lib/redis"; import { fanOutPost } from "@/lib/feed-helpers"; import { MAX_STORY_VIDEO_DURATION_SECS } from "@/lib/story-video-limits"; +import { maybeQualifyReferral } from "@/lib/referrals"; /** * POST /api/webhooks/mux @@ -73,7 +74,7 @@ export async function POST(req: NextRequest) { const videoPost = await prisma.videoPost.findFirst({ where: { muxAssetId: assetId }, - include: { post: { select: { id: true, petId: true, createdAt: true } } }, + include: { post: { select: { id: true, petId: true, createdAt: true, pet: { select: { ownerId: true } } } } }, }); if (videoPost) { @@ -95,6 +96,11 @@ export async function POST(req: NextRequest) { videoPost.post.createdAt, prisma ).catch((err) => console.error("[mux-webhook] fanOutPost failed", err)); + + // Empfehlungsprogramm: ein fertig verarbeitetes Video zählt als erster Beitrag + await maybeQualifyReferral(prisma, videoPost.post.pet.ownerId).catch((err) => + console.error("[mux-webhook] referral qualification failed", err) + ); break; } diff --git a/src/app/onboarding/pet/page.tsx b/src/app/onboarding/pet/page.tsx index 643f5c2..fb4d347 100644 --- a/src/app/onboarding/pet/page.tsx +++ b/src/app/onboarding/pet/page.tsx @@ -11,6 +11,10 @@ import { OnboardingGuidelines } from "@/components/legal/OnboardingGuidelines"; import { UploadConsent } from "@/components/legal/UploadConsent"; import { ArachnophobiaOptIn } from "@/components/legal/ArachnophobiaOptIn"; import { LanguageSwitcher } from "@/components/i18n/LanguageSwitcher"; +import { AcquisitionStep } from "@/components/referrals/AcquisitionStep"; +import { useQuery } from "@tanstack/react-query"; +import { useTRPC } from "@/trpc/client"; +import { Loader2 } from "lucide-react"; /** * Onboarding pet page — /onboarding/pet @@ -24,6 +28,8 @@ import { LanguageSwitcher } from "@/components/i18n/LanguageSwitcher"; * Step 2: Arachnophobie-Helfer opt-in (non-blocking, shown once — see * ArachnophobiaOptIn; always re-toggleable later on /pets) * Step 3: Create pet profile (name, species, etc.) + * Step 3b: "Wie hast du von PawFeed erfahren?" + optional referral code + * (Empfehlungsprogramm — once per owner, see referrals.onboardingState) * Step 4: Upload avatar photo (now that petId exists) * * Lives OUTSIDE the (app) layout group to avoid redirect loop. @@ -36,6 +42,15 @@ export default function OnboardingPetPage() { const [guidelinesAcknowledged, setGuidelinesAcknowledged] = useState(false); const [uploadConsentGiven, setUploadConsentGiven] = useState(false); const [arachnophobiaStepDone, setArachnophobiaStepDone] = useState(false); + const [acquisitionStepDone, setAcquisitionStepDone] = useState(false); + const trpc = useTRPC(); + // Erst nach dem Anlegen des Tiers abfragen — vorher existiert die Owner-Zeile ggf. noch nicht. + const { data: referralState } = useQuery({ + ...trpc.referrals.onboardingState.queryOptions(), + enabled: !!createdPetId, + }); + const isReferralStateLoading = !!createdPetId && !acquisitionStepDone && referralState === undefined; + const showAcquisitionStep = !!createdPetId && !acquisitionStepDone && !!referralState?.showStep; let content: React.ReactNode; @@ -45,6 +60,12 @@ export default function OnboardingPetPage() { content = setUploadConsentGiven(true)} />; } else if (!arachnophobiaStepDone) { content = setArachnophobiaStepDone(true)} />; + } else if (isReferralStateLoading) { + content = ; + } else if (showAcquisitionStep) { + content = ( + setAcquisitionStepDone(true)} /> + ); } else if (createdPetId) { content = (

diff --git a/src/app/p/[secret]/layout.tsx b/src/app/p/[secret]/layout.tsx index fc99407..1ae9806 100644 --- a/src/app/p/[secret]/layout.tsx +++ b/src/app/p/[secret]/layout.tsx @@ -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 }, ] : []), diff --git a/src/app/p/[secret]/referrals/page.tsx b/src/app/p/[secret]/referrals/page.tsx new file mode 100644 index 0000000..f0ded24 --- /dev/null +++ b/src/app/p/[secret]/referrals/page.tsx @@ -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 ( +
+
+

Empfehlungen

+

Ränge, Einstellungen und Auswertung des Empfehlungsprogramms.

+
+ + + Übersicht + Ränge + Einstellungen + + + + + +
+ ); +} diff --git a/src/app/p/[secret]/verification/page.tsx b/src/app/p/[secret]/verification/page.tsx index d1a41da..6950c22 100644 --- a/src/app/p/[secret]/verification/page.tsx +++ b/src/app/p/[secret]/verification/page.tsx @@ -212,6 +212,9 @@ export default function AdminVerificationPage() { Already verified )} + {req.pet.owner.referralPerks.includes("VERIFICATION_PRIORITY") && ( + Bevorzugt (Empfehlungsrang) + )} {req.pet.ownerId}
diff --git a/src/app/r/[code]/page.tsx b/src/app/r/[code]/page.tsx new file mode 100644 index 0000000..9d90613 --- /dev/null +++ b/src/app/r/[code]/page.tsx @@ -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 ( +
+ +
+
+ {inviterPet ? ( +
+ +

+ {t("titleWithPet", { name: inviterPet.name })} +

+
+ ) : ( +

{t("titleGeneric")}

+ )} + +

{t("subtitle")}

+ + {code && inviterPet && ( +
+

{t("codeLabel")}

+

{code}

+

{t("codeHint")}

+ +
+ )} + +
+ + + {t("webSignUp")} + +
+
+
+ +
+ ); +} diff --git a/src/components/admin/referrals/OverviewTab.tsx b/src/components/admin/referrals/OverviewTab.tsx new file mode 100644 index 0000000..edee8bc --- /dev/null +++ b/src/components/admin/referrals/OverviewTab.tsx @@ -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 = { + 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 ohne Tier; + return {pet.name}; +} + +type MaybeDate = Date | string | null; + +function statusOf(o: { referralRevokedAt: MaybeDate; referralQualifiedAt: MaybeDate; referralFlaggedAt: MaybeDate }) { + if (o.referralRevokedAt) return aberkannt; + if (o.referralQualifiedAt) return zählt; + if (o.referralFlaggedAt) return Prüfung; + return wartet; +} + +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 ; + + 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 ( +
+
+ {tiles.map((tile) => ( +
+
{tile.label}
+
{tile.value}
+
+ ))} +
+ + {data.flagged.length > 0 && ( +
+
    + {data.flagged.map((o) => ( +
  • +
  • + ))} +
+
+ )} + +
+
    + {data.sources.filter((s) => s.source).sort((a, b) => b.count - a.count).map((s) => ( +
  • + {SOURCE_LABELS[s.source ?? ""] ?? s.source} + + {s.count} +
  • + ))} +
+
+ +
+
    + {data.recent.map((o) => ( +
  • + ← + {o.sameIpAsInviter && gleiche IP} + {statusOf(o)} + {!o.referralRevokedAt && ( + + )} +
  • + ))} + {data.recent.length === 0 &&
  • Noch keine Einlösungen.
  • } +
+
+ +
+
    + {data.topInviters.map((o, i) => ( +
  1. + {i + 1} + + {o.qualifiedReferralCount} +
  2. + ))} +
+
+ +
+ + +
+
+ ); +} + +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 ( +
+
    + {owners.flatMap((o) => o.pets.map((pet) => ( +
  • + + {pet._count.followers} Follower · {pet._count.posts} Beiträge +
  • + )))} + {owners.length === 0 &&
  • Noch niemand.
  • } +
+
+ ); +} + +function Section({ title, hint, children }: { title: string; hint?: string; children: React.ReactNode }) { + return ( +
+
+

{title}

+ {hint &&

{hint}

} +
+ {children} +
+ ); +} diff --git a/src/components/admin/referrals/RankEditorDialog.tsx b/src/components/admin/referrals/RankEditorDialog.tsx new file mode 100644 index 0000000..48760a5 --- /dev/null +++ b/src/components/admin/referrals/RankEditorDialog.tsx @@ -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 = { + 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(initial); + const set = (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 ( + !next && onClose()}> + + + {mode === "create" ? "Rang hinzufügen" : `Rang bearbeiten: ${initial.nameDe}`} + + +
+ {/* Vorschau in Hell und Dunkel */} +
+ {[LIGHT_BG, DARK_BG].map((bg) => ( +
+ + + + + Bubi + +
+ ))} +
+ {lowContrast && ( +

+ Die Farbe hat auf hellem oder dunklem Hintergrund wenig Kontrast. Abzeichen und Ring sind dort schlecht zu sehen. +

+ )} + + {mode === "create" && ( + + set("key", e.target.value.toLowerCase())} className="font-mono" /> + + )} + +
+ set("nameDe", e.target.value)} /> + set("nameEn", e.target.value)} /> + set("descriptionDe", e.target.value)} /> + set("descriptionEn", e.target.value)} /> + + set("threshold", Number(e.target.value))} /> + + +
+ set("color", e.target.value)} className="h-9 w-12 cursor-pointer rounded border bg-transparent" /> + set("color", e.target.value)} className="font-mono" aria-invalid={!isColorValid} /> +
+
+
+ + +
+ {REFERRAL_ICON_KEYS.map((key) => { + return ( + + ); + })} +
+
+ + +
+ {REFERRAL_PERKS.map((perk) => ( + + ))} +
+
+ + {mode === "edit" && ( + + )} +
+ + + + + +
+
+ ); +} + +function Field({ label, hint, children }: { label: string; hint?: string; children: React.ReactNode }) { + return ( +
+

{label}

+ {children} + {hint &&

{hint}

} +
+ ); +} diff --git a/src/components/admin/referrals/RanksTab.tsx b/src/components/admin/referrals/RanksTab.tsx new file mode 100644 index 0000000..389ff9f --- /dev/null +++ b/src/components/admin/referrals/RanksTab.tsx @@ -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(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 ; + + return ( +
+
+

+ 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. +

+ +
+ +
+ + + + + + + + + + + {ranks.map((rank) => { + return ( + + + + + + + + ); + })} + +
SchwelleRangVorteileTräger +
{rank.threshold} + + + {rank.nameEn} · {rank.key} + {rank.perks.join(", ") || "–"}{rank.holderCount} + +
+
+ + {editor && ( + setEditor(null)} + onSave={handleSave} + /> + )} +
+ ); +} diff --git a/src/components/admin/referrals/SettingsTab.tsx b/src/components/admin/referrals/SettingsTab.tsx new file mode 100644 index 0000000..eec55cc --- /dev/null +++ b/src/components/admin/referrals/SettingsTab.tsx @@ -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 ; + return ; +} + +function SettingsForm({ initial }: { initial: SettingsValues }) { + const trpc = useTRPC(); + const qc = useQueryClient(); + const [form, setForm] = useState({ + programActive: initial.programActive, + redeemWindowDays: initial.redeemWindowDays, + dailyQualifyCap: initial.dailyQualifyCap, + autoMutualFollow: initial.autoMutualFollow, + }); + const set = (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 ( +
+ set("programActive", v)} + /> + set("autoMutualFollow", v)} + /> + set("redeemWindowDays", v)} + /> + set("dailyQualifyCap", v)} + /> + +
+ ); +} + +function ToggleRow({ title, hint, checked, onChange }: { title: string; hint: string; checked: boolean; onChange: (v: boolean) => void }) { + return ( + + ); +} + +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 ( +
+ + onChange(Number(e.target.value))} className="w-24 text-right font-mono" /> +
+ ); +} diff --git a/src/components/feed/PostCard.tsx b/src/components/feed/PostCard.tsx index 2d2ef2e..1db9adb 100644 --- a/src/components/feed/PostCard.tsx +++ b/src/components/feed/PostCard.tsx @@ -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) {

{formatPetName(pet)} - +

{formatRelativeTime(createdAt)} diff --git a/src/components/layout/MobileNav.tsx b/src/components/layout/MobileNav.tsx index 03a3b64..0637fe3 100644 --- a/src/components/layout/MobileNav.tsx +++ b/src/components/layout/MobileNav.tsx @@ -14,6 +14,7 @@ import { Moon, Sun, Sparkles, + Gift, } from "lucide-react"; import { useTranslations } from "next-intl"; import { NotificationBell } from "@/components/notifications/NotificationBell"; @@ -79,6 +80,7 @@ export function MobileNav({ pets }: MobileNavProps) { const isNotificationsPage = pathname.startsWith("/notifications"); const isMessagesPage = pathname.startsWith("/messages"); const isSponsorPage = pathname.startsWith("/unterstuetzen"); + const isInvitePage = pathname.startsWith("/einladen"); return (

@@ -147,6 +149,13 @@ export function MobileNav({ pets }: MobileNavProps) { + {t("addPet")} + router.push("/einladen")} + className={`flex items-center gap-2 cursor-pointer ${isInvitePage ? "text-orange-500" : ""}`} + > + router.push("/unterstuetzen")} className={`flex items-center gap-2 cursor-pointer ${isSponsorPage ? "text-orange-500" : ""}`} diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index 2c2ff18..faf866a 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -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; diff --git a/src/components/referrals/AcquisitionStep.tsx b/src/components/referrals/AcquisitionStep.tsx new file mode 100644 index 0000000..c62c923 --- /dev/null +++ b/src/components/referrals/AcquisitionStep.tsx @@ -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/ 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(rememberedCode ? "FRIENDS" : null); + const [inviterName, setInviterName] = useState(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 ( +
+
+

{t("title")}

+

{t("subtitle")}

+
+ +
+ {SOURCES.map((option) => ( + + ))} +
+ + {source === "FRIENDS" && canRedeem && ( +
+ {isRedeemed ? ( +

+

+ ) : ( + + )} +
+ )} + +
+ + +
+
+ ); +} diff --git a/src/components/referrals/InviteeList.tsx b/src/components/referrals/InviteeList.tsx new file mode 100644 index 0000000..71f23b8 --- /dev/null +++ b/src/components/referrals/InviteeList.tsx @@ -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 = { + 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 = { + 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 ( +
+

+ {t("inviteesHeading")} +

+ {invitees.length === 0 ? ( +

{t("inviteesEmpty")}

+ ) : ( +
    + {invitees.map((invitee, index) => ( +
  • + {invitee.pet ? ( + + + {invitee.pet.name} + + ) : ( + {t("inviteeNoPet")} + )} + + {t(STATUS_KEY[invitee.status])} + {invitee.invitedAt && ( + + {new Date(invitee.invitedAt).toLocaleDateString(locale)} + + )} + +
  • + ))} +
+ )} +
+ ); +} diff --git a/src/components/referrals/RankLadder.tsx b/src/components/referrals/RankLadder.tsx new file mode 100644 index 0000000..3a95945 --- /dev/null +++ b/src/components/referrals/RankLadder.tsx @@ -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; + 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 ( +
+
+
+

+ {t("ranksHeading")} +

+ + {t("countedInvites", { count: qualifiedCount })} + +
+ {next ? ( + <> +
+
+
+

+ {t("nextRank", { remaining: Math.max(0, next.threshold - qualifiedCount), name: next.name })} +

+ + ) : ( +

{t("allRanksReached")}

+ )} +
+ +
    + {activeRanks.map((rank) => { + const isAchieved = achievedRankIds.has(rank.id); + return ( +
  1. + + +
    +

    {rank.name}

    +

    {rank.description}

    +
    + + {isAchieved ? ( + + ) : ( + t("threshold", { count: rank.threshold }) + )} + +
  2. + ); + })} +
+
+ ); +} diff --git a/src/components/referrals/RedeemCodeForm.tsx b/src/components/referrals/RedeemCodeForm.tsx new file mode 100644 index 0000000..9b629bd --- /dev/null +++ b/src/components/referrals/RedeemCodeForm.tsx @@ -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 = { + 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(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 ( +
+ +
+ 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} + /> + +
+ {errorKey && ( +

+ {t(errorKey)} +

+ )} +
+ ); +} diff --git a/src/components/referrals/ReferralAvatarRing.tsx b/src/components/referrals/ReferralAvatarRing.tsx new file mode 100644 index 0000000..fa35cd4 --- /dev/null +++ b/src/components/referrals/ReferralAvatarRing.tsx @@ -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 ( +
+
{children}
+
+ ); +} diff --git a/src/components/referrals/ReferralRankBadge.tsx b/src/components/referrals/ReferralRankBadge.tsx new file mode 100644 index 0000000..cdcb4f5 --- /dev/null +++ b/src/components/referrals/ReferralRankBadge.tsx @@ -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 = { + "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) { + 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 ( + + + + ); +} + +/** 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; +} diff --git a/src/components/referrals/RememberReferralCode.tsx b/src/components/referrals/RememberReferralCode.tsx new file mode 100644 index 0000000..e54d406 --- /dev/null +++ b/src/components/referrals/RememberReferralCode.tsx @@ -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`; +} diff --git a/src/components/referrals/ShareInviteCard.tsx b/src/components/referrals/ShareInviteCard.tsx new file mode 100644 index 0000000..83998e2 --- /dev/null +++ b/src/components/referrals/ShareInviteCard.tsx @@ -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 ( +
+
+

+ {t("shareHeading")} +

+

{t("shareSubtitle")}

+
+ +
+

{t("yourCode")}

+

{code}

+
+ +
+ {canShare && ( + + )} + +
+
+ ); +} diff --git a/src/components/ui/pet-identity-badges.tsx b/src/components/ui/pet-identity-badges.tsx index 13830be..f7e4d59 100644 --- a/src/components/ui/pet-identity-badges.tsx +++ b/src/components/ui/pet-identity-badges.tsx @@ -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} /> )} + ); } diff --git a/src/hooks/useReferralRanks.ts b/src/hooks/useReferralRanks.ts new file mode 100644 index 0000000..64ee7a4 --- /dev/null +++ b/src/hooks/useReferralRanks.ts @@ -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 } { + 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])) }; +} diff --git a/src/lib/notify.ts b/src/lib/notify.ts index 9da48e9..e4cf60c 100644 --- a/src/lib/notify.ts +++ b/src/lib/notify.ts @@ -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." }, }; /** diff --git a/src/lib/referral-code.ts b/src/lib/referral-code.ts new file mode 100644 index 0000000..f316aa3 --- /dev/null +++ b/src/lib/referral-code.ts @@ -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 = { Ä: "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; +} diff --git a/src/lib/referral-ranks.ts b/src/lib/referral-ranks.ts new file mode 100644 index 0000000..2d09d36 --- /dev/null +++ b/src/lib/referral-ranks.ts @@ -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 = 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( + ranks: readonly T[], + achievedRankIds: ReadonlySet +): 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( + ranks: readonly T[], + achievedRankIds: ReadonlySet +): T | null { + return activeAchieved(ranks, achievedRankIds).reduce( + (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 +): Set { + 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( + ranks: readonly T[], + count: number, + achievedRankIds: ReadonlySet +): 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( + ranks: readonly T[], + achievedRankIds: ReadonlySet +): T | null { + return ( + ranks + .filter((rank) => rank.isActive && !achievedRankIds.has(rank.id)) + .sort((a, b) => a.threshold - b.threshold)[0] ?? null + ); +} diff --git a/src/lib/referral-recompute.ts b/src/lib/referral-recompute.ts new file mode 100644 index 0000000..242195e --- /dev/null +++ b/src/lib/referral-recompute.ts @@ -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): Promise { + 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" + )`; +} diff --git a/src/lib/referrals.ts b/src/lib/referrals.ts new file mode 100644 index 0000000..92bb6e3 --- /dev/null +++ b/src/lib/referrals.ts @@ -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 { + 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 { + 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 { + 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 { + 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 +) { + 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"; diff --git a/src/proxy.ts b/src/proxy.ts index c25f30a..226403e 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -28,6 +28,7 @@ const isPublicRoute = createRouteMatcher([ "/ueber-uns", "/kinderschutz", "/join(.*)", + "/r/(.*)", // öffentliche Einladungsseite des Empfehlungsprogramms "/api/webhooks/(.*)", "/api/auth/(.*)", "/api/cron/(.*)", diff --git a/src/repositories/pet-repository.ts b/src/repositories/pet-repository.ts index 3d730a9..a047fea 100644 --- a/src/repositories/pet-repository.ts +++ b/src/repositories/pet-repository.ts @@ -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 }>; diff --git a/src/trpc/routers/_app.ts b/src/trpc/routers/_app.ts index 80f2e6b..965092b 100644 --- a/src/trpc/routers/_app.ts +++ b/src/trpc/routers/_app.ts @@ -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 diff --git a/src/trpc/routers/admin.ts b/src/trpc/routers/admin.ts index 8bd8b34..061f77b 100644 --- a/src/trpc/routers/admin.ts +++ b/src/trpc/routers/admin.ts @@ -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 diff --git a/src/trpc/routers/pets.ts b/src/trpc/routers/pets.ts index 5c6af87..871cccc 100644 --- a/src/trpc/routers/pets.ts +++ b/src/trpc/routers/pets.ts @@ -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 " — 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) } diff --git a/src/trpc/routers/posts.ts b/src/trpc/routers/posts.ts index 1ec15ff..2b35afa 100644 --- a/src/trpc/routers/posts.ts +++ b/src/trpc/routers/posts.ts @@ -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) => diff --git a/src/trpc/routers/referral-admin.ts b/src/trpc/routers/referral-admin.ts new file mode 100644 index 0000000..33d7868 --- /dev/null +++ b/src/trpc/routers/referral-admin.ts @@ -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 }; + }), +}); diff --git a/src/trpc/routers/referrals.ts b/src/trpc/routers/referrals.ts new file mode 100644 index 0000000..f515566 --- /dev/null +++ b/src/trpc/routers/referrals.ts @@ -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 }; + }), +});