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>
175 lines
6.7 KiB
TypeScript
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());
|