Files
petfeed/prisma/scripts/referral-setup.ts
T
adminandClaude Opus 5.5 4d0aa7ab9e feat: Clerk-unabhängiges Empfehlungsprogramm mit einstellbaren Rängen (Web + Admin)
Persönliche, unbegrenzt nutzbare Empfehlungscodes (z. B. BUBI-7K3M), die erst
nach der Registrierung im Onboarding eingelöst werden — keine Kopplung an den
Clerk-Sign-up-Ablauf mehr. Registrierung ohne Code bleibt möglich.

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 06:25:13 +02:00

175 lines
6.7 KiB
TypeScript

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());