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