diff --git a/src/__tests__/account-data.test.ts b/src/__tests__/account-data.test.ts new file mode 100644 index 0000000..a2e7261 --- /dev/null +++ b/src/__tests__/account-data.test.ts @@ -0,0 +1,91 @@ +/** + * DSGVO-Pfade rund um das Empfehlungsprogramm: Datenauskunft (gdpr-export.ts) + * und Kontolöschung (delete-account.ts). + */ + +import { describe, it, expect, vi } from "vitest"; + +vi.mock("server-only", () => ({})); +const mockDeleteUser = vi.fn().mockResolvedValue(undefined); +vi.mock("@clerk/nextjs/server", () => ({ + clerkClient: vi.fn().mockResolvedValue({ + users: { getUser: vi.fn().mockResolvedValue(null), deleteUser: (...a: unknown[]) => mockDeleteUser(...a) }, + }), +})); +vi.mock("@/lib/supabase-storage", () => ({ deleteFiles: vi.fn().mockResolvedValue(undefined) })); +vi.mock("@/lib/media-url", () => ({ getMediaUrl: (key: string) => `media/${key}` })); +vi.mock("@/lib/avatar-url", () => ({ getAvatarUrl: (key: string | null) => (key ? `avatar/${key}` : null) })); + +import { createMockPrisma } from "@/__tests__/helpers/prisma-mock"; +import { deleteOwnerAccount } from "@/lib/delete-account"; +import { buildDataExport } from "@/lib/gdpr-export"; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const asDb = (m: ReturnType) => m as any; + +describe("deleteOwnerAccount", () => { + it("removes the deleted owner's id from other owners' referral and legacy invite links", async () => { + const prisma = createMockPrisma(); + prisma.pet.findMany.mockResolvedValue([]); + prisma.pet.deleteMany.mockResolvedValue({ count: 0 }); + prisma.owner.updateMany.mockResolvedValue({ count: 2 }); + prisma.owner.delete.mockResolvedValue({}); + + await deleteOwnerAccount(asDb(prisma), "owner-gone"); + + expect(prisma.owner.updateMany).toHaveBeenCalledWith({ + where: { invitedById: "owner-gone" }, + data: { invitedById: null }, + }); + expect(prisma.inviteCode.updateMany).toHaveBeenCalledWith({ + where: { usedById: "owner-gone" }, + data: { usedById: null }, + }); + expect(mockDeleteUser).toHaveBeenCalledWith("owner-gone"); + }); +}); + +describe("buildDataExport — referral program", () => { + it("includes code, ranks and invites but names other people only by pet name", async () => { + const prisma = createMockPrisma(); + prisma.owner.findUnique.mockResolvedValue({ + id: "owner-1", + createdAt: new Date("2026-09-01"), + invitedById: "user_inviter_secret_id", + invitedAt: new Date("2026-09-02"), + referralQualifiedAt: new Date("2026-09-03"), + referralFlaggedAt: null, + referralRevokedAt: null, + referralCode: "LUNA-7K3M", + qualifiedReferralCount: 1, + referralPerks: ["PROFILE_RING"], + acquisitionSource: "FRIENDS", + acquisitionAnsweredAt: new Date("2026-09-02"), + legalAcknowledgedAt: null, + contentRightsConsentedAt: null, + dataProcessingConsentedAt: null, + ban: null, + adminRole: null, + pets: [], + rankAchievements: [{ achievedAt: new Date("2026-09-10"), rank: { nameDe: "Pfotenfreund", threshold: 1 } }], + issuedInvites: [], + }); + prisma.pet.findFirst.mockResolvedValue({ name: "Bubi" }); + prisma.owner.findMany.mockResolvedValue([ + { invitedAt: new Date("2026-09-09"), referralQualifiedAt: new Date("2026-09-10"), referralRevokedAt: null, pets: [{ name: "Kiki" }] }, + ]); + + const data = await buildDataExport(asDb(prisma), "owner-1"); + + expect(data?.referralProgram).toMatchObject({ + ownCode: "LUNA-7K3M", + acquisitionSource: "FRIENDS", + invitedBy: { petName: "Bubi" }, + countedInvites: 1, + invitesMade: [{ petName: "Kiki" }], + ranksReached: [{ rank: "Pfotenfreund", threshold: 1 }], + currentPerks: ["PROFILE_RING"], + }); + expect(JSON.stringify(data)).not.toContain("user_inviter_secret_id"); + }); +}); diff --git a/src/__tests__/helpers/prisma-mock.ts b/src/__tests__/helpers/prisma-mock.ts index bf3a139..27e7de9 100644 --- a/src/__tests__/helpers/prisma-mock.ts +++ b/src/__tests__/helpers/prisma-mock.ts @@ -21,6 +21,7 @@ interface MockPetClient { findFirst: MockFn; update: MockFn; delete: MockFn; + deleteMany: MockFn; count: MockFn; } @@ -31,12 +32,15 @@ interface MockOwnerClient { create: MockFn; update: MockFn; updateMany: MockFn; + delete: MockFn; count: MockFn; } interface MockInviteCodeClient { count: MockFn; createMany: MockFn; + deleteMany: MockFn; + updateMany: MockFn; } interface MockLegalDocumentVersionClient { @@ -410,6 +414,7 @@ export function createMockPrisma(): MockPrismaClient { findFirst: vi.fn(), update: vi.fn(), delete: vi.fn(), + deleteMany: vi.fn(), count: vi.fn(), }, owner: { @@ -419,6 +424,7 @@ export function createMockPrisma(): MockPrismaClient { create: vi.fn(), update: vi.fn(), updateMany: vi.fn(), + delete: vi.fn(), count: vi.fn(), }, // Legacy InviteCode table (pre-referral-program one-time codes) — only @@ -426,6 +432,8 @@ export function createMockPrisma(): MockPrismaClient { inviteCode: { count: vi.fn().mockResolvedValue(0), createMany: vi.fn().mockResolvedValue({ count: 0 }), + deleteMany: vi.fn().mockResolvedValue({ count: 0 }), + updateMany: vi.fn().mockResolvedValue({ count: 0 }), }, legalDocumentVersion: { create: vi.fn(), diff --git a/src/__tests__/referrals-router.test.ts b/src/__tests__/referrals-router.test.ts index 4f6089e..aea2b4f 100644 --- a/src/__tests__/referrals-router.test.ts +++ b/src/__tests__/referrals-router.test.ts @@ -77,17 +77,17 @@ describe("referrals.setAcquisitionSource", () => { 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 }); + prisma.owner.findUnique.mockResolvedValue({ createdAt: new Date(), invitedAt: 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() }); + prisma.owner.findUnique.mockResolvedValue({ createdAt: new Date(), invitedAt: 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 }); + prisma.owner.findUnique.mockResolvedValue({ createdAt: old, invitedAt: null, acquisitionAnsweredAt: null }); expect(await caller.referrals.onboardingState()).toEqual({ showStep: false, canRedeem: false }); }); }); diff --git a/src/__tests__/referrals.test.ts b/src/__tests__/referrals.test.ts index c042eee..b253537 100644 --- a/src/__tests__/referrals.test.ts +++ b/src/__tests__/referrals.test.ts @@ -151,7 +151,7 @@ describe("redeemReferralCode", () => { expect(result).toEqual({ inviterPetName: "Bubi" }); expect(mockPrisma.owner.updateMany).toHaveBeenCalledWith({ - where: { id: "owner-invitee", invitedById: null }, + where: { id: "owner-invitee", invitedById: null, invitedAt: null }, data: { invitedById: "owner-inviter", invitedAt: NOW }, }); expect(mockPrisma.follow.upsert).toHaveBeenCalledTimes(2); @@ -196,6 +196,14 @@ describe("redeemReferralCode", () => { ); }); + it("rejects a second redemption after the inviter's account was deleted", async () => { + // delete-account.ts leert invitedById, invitedAt bleibt als Marker stehen. + const mockPrisma = setupRedeem({ invitee: { createdAt: NOW, invitedById: null, invitedAt: new Date(NOW.getTime() - DAY) } }); + 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 }); diff --git a/src/lib/delete-account.ts b/src/lib/delete-account.ts index 4074935..f03d84f 100644 --- a/src/lib/delete-account.ts +++ b/src/lib/delete-account.ts @@ -6,7 +6,8 @@ import { deleteFiles } from "@/lib/supabase-storage"; /** * Permanently deletes an Owner and everything cascading from their pets * (posts, comments, reactions, follows, notifications, etc.), storage files, - * issued invite codes, and the Clerk account itself. Irreversible — shared + * issued invite codes, referral rank achievements (cascade), references to + * this owner in other owners' invitedById, and the Clerk account itself. Irreversible — shared * by the self-service delete route and the admin-triggered GDPR erasure * action, so both paths stay in sync. */ @@ -44,6 +45,13 @@ export async function deleteOwnerAccount(prisma: PrismaClient, ownerId: string): await prisma.inviteCode.deleteMany({ where: { issuedById: ownerId } }); + // Empfehlungsprogramm: die Clerk-ID der gelöschten Person darf nicht bei + // anderen Ownern stehen bleiben. Gezählte Einladungen bleiben gezählt + // (referralQualifiedAt), nur die Zuordnung zur gelöschten Person entfällt. + // Gleiches gilt für Einmal-Codes aus der Beta, die diese Person eingelöst hat. + await prisma.owner.updateMany({ where: { invitedById: ownerId }, data: { invitedById: null } }); + await prisma.inviteCode.updateMany({ where: { usedById: ownerId }, data: { usedById: null } }); + // Cascades AdminRole + UserBan + Warning await prisma.owner.delete({ where: { id: ownerId } }); diff --git a/src/lib/gdpr-export.ts b/src/lib/gdpr-export.ts index 056a3eb..b53e82e 100644 --- a/src/lib/gdpr-export.ts +++ b/src/lib/gdpr-export.ts @@ -16,9 +16,14 @@ import { getAvatarUrl } from "@/lib/avatar-url"; * this export. * * Scope: covers the social graph, health records, messages, moderation - * history, and consent timestamps. Advertisement interactions and issued - * invite codes are NOT yet included — flagged as a known gap rather than - * silently omitted (see the emailed cover note). + * history, consent timestamps, the referral program (own code, who invited + * this owner, invites made, ranks, acquisition answer) and legacy one-time + * invite codes. Advertisement interactions are NOT yet included — flagged + * as a known gap rather than silently omitted (see the emailed cover note). + * + * Other owners are only ever referenced by their pet's name, never by + * their Clerk userId (invitedById, invitee ids) — that id is the other + * person's data, not this owner's. */ export async function buildDataExport(prisma: PrismaClient, ownerId: string) { const [owner, clerkUser] = await Promise.all([ @@ -63,6 +68,11 @@ export async function buildDataExport(prisma: PrismaClient, ownerId: string) { }, ban: true, adminRole: true, + rankAchievements: { + orderBy: { achievedAt: "asc" }, + include: { rank: { select: { nameDe: true, threshold: true } } }, + }, + issuedInvites: { select: { code: true, createdAt: true, usedAt: true, revoked: true } }, }, }), (await clerkClient()).users.getUser(ownerId).catch(() => null), @@ -70,15 +80,35 @@ export async function buildDataExport(prisma: PrismaClient, ownerId: string) { if (!owner) return null; + const [invitedByPet, invitees] = await Promise.all([ + owner.invitedById + ? prisma.pet.findFirst({ + where: { ownerId: owner.invitedById }, + orderBy: { createdAt: "asc" }, + select: { name: true }, + }) + : null, + prisma.owner.findMany({ + where: { invitedById: ownerId }, + orderBy: { invitedAt: "asc" }, + select: { + invitedAt: true, + referralQualifiedAt: true, + referralRevokedAt: true, + pets: { orderBy: { createdAt: "asc" }, take: 1, select: { name: true } }, + }, + }), + ]); + return { exportedAt: new Date().toISOString(), scopeNote: - "Enthält Social-Graph-, Health- und Nachrichtendaten sowie Consent-Zeitstempel. " + - "Werbeanzeigen-Interaktionen und ausgegebene Einladungscodes sind aktuell noch nicht enthalten.", + "Enthält Social-Graph-, Health- und Nachrichtendaten, Consent-Zeitstempel, das Empfehlungsprogramm " + + "und frühere Einladungscodes. Werbeanzeigen-Interaktionen sind aktuell noch nicht enthalten. " + + "Andere Personen werden nur über den Namen ihres Tiers genannt.", account: { ownerId: owner.id, createdAt: owner.createdAt, - invitedById: owner.invitedById, legalAcknowledgedAt: owner.legalAcknowledgedAt, contentRightsConsentedAt: owner.contentRightsConsentedAt, dataProcessingConsentedAt: owner.dataProcessingConsentedAt, @@ -94,6 +124,34 @@ export async function buildDataExport(prisma: PrismaClient, ownerId: string) { ban: owner.ban, adminRole: owner.adminRole, }, + referralProgram: { + ownCode: owner.referralCode, + acquisitionSource: owner.acquisitionSource, + acquisitionAnsweredAt: owner.acquisitionAnsweredAt, + invitedBy: owner.invitedById + ? { + petName: invitedByPet?.name ?? null, + invitedAt: owner.invitedAt, + countedAt: owner.referralQualifiedAt, + flaggedForReviewAt: owner.referralFlaggedAt, + revokedAt: owner.referralRevokedAt, + } + : null, + countedInvites: owner.qualifiedReferralCount, + invitesMade: invitees.map((i) => ({ + petName: i.pets[0]?.name ?? null, + invitedAt: i.invitedAt, + countedAt: i.referralQualifiedAt, + revokedAt: i.referralRevokedAt, + })), + ranksReached: owner.rankAchievements.map((a) => ({ + rank: a.rank.nameDe, + threshold: a.rank.threshold, + reachedAt: a.achievedAt, + })), + currentPerks: owner.referralPerks, + legacyInviteCodes: owner.issuedInvites, + }, pets: owner.pets.map((pet) => ({ id: pet.id, name: pet.name, diff --git a/src/lib/referrals.ts b/src/lib/referrals.ts index 92bb6e3..14fec26 100644 --- a/src/lib/referrals.ts +++ b/src/lib/referrals.ts @@ -140,10 +140,11 @@ export async function redeemReferralCode( const owner = await prisma.owner.findUnique({ where: { id: ownerId }, - select: { createdAt: true, invitedById: true }, + select: { createdAt: true, invitedById: true, invitedAt: true }, }); if (!owner) fail("PRECONDITION_FAILED", REFERRAL_ERRORS.NO_PET); - if (owner.invitedById) fail("CONFLICT", REFERRAL_ERRORS.ALREADY_REDEEMED); + // invitedAt zählt mit: wird ein Einladender gelöscht, leert delete-account.ts nur invitedById. + if (owner.invitedById || owner.invitedAt) fail("CONFLICT", REFERRAL_ERRORS.ALREADY_REDEEMED); if (now.getTime() - owner.createdAt.getTime() > settings.redeemWindowDays * DAY_MS) { fail("PRECONDITION_FAILED", REFERRAL_ERRORS.WINDOW_EXPIRED); } @@ -161,7 +162,7 @@ export async function redeemReferralCode( // Bewacht mit invitedById: null — zwei parallele Einlösungen können nicht beide gewinnen. const claimed = await prisma.owner.updateMany({ - where: { id: ownerId, invitedById: null }, + where: { id: ownerId, invitedById: null, invitedAt: null }, data: { invitedById: inviter.id, invitedAt: now }, }); if (claimed.count === 0) fail("CONFLICT", REFERRAL_ERRORS.ALREADY_REDEEMED); diff --git a/src/trpc/routers/referrals.ts b/src/trpc/routers/referrals.ts index f515566..1211b3f 100644 --- a/src/trpc/routers/referrals.ts +++ b/src/trpc/routers/referrals.ts @@ -59,6 +59,7 @@ export const referralsRouter = router({ select: { createdAt: true, invitedById: true, + invitedAt: true, qualifiedReferralCount: true, referralRankId: true, referralPerks: true, @@ -99,8 +100,8 @@ export const referralsRouter = router({ achievedRankIds: [...achievedIds], nextRankId: next?.id ?? null, canRedeem: - settings.programActive && !!owner && !owner.invitedById && !!redeemDeadline && redeemDeadline > new Date(), - wasInvited: !!owner?.invitedById, + settings.programActive && !!owner && !owner.invitedAt && !!redeemDeadline && redeemDeadline > new Date(), + wasInvited: !!owner?.invitedAt, invitees: invitees.map((invitee) => ({ pet: invitee.pets[0] ?? null, invitedAt: invitee.invitedAt, @@ -123,7 +124,7 @@ export const referralsRouter = router({ const [owner, settings] = await Promise.all([ ctx.prisma.owner.findUnique({ where: { id: ctx.userId }, - select: { createdAt: true, invitedById: true, acquisitionAnsweredAt: true }, + select: { createdAt: true, invitedAt: true, acquisitionAnsweredAt: true }, }), getReferralSettings(ctx.prisma), ]); @@ -131,7 +132,7 @@ export const referralsRouter = router({ const withinWindow = Date.now() - owner.createdAt.getTime() <= settings.redeemWindowDays * DAY_MS; return { showStep: !owner.acquisitionAnsweredAt && withinWindow, - canRedeem: settings.programActive && !owner.invitedById && withinWindow, + canRedeem: settings.programActive && !owner.invitedAt && withinWindow, }; }),