fix: DSGVO-Lücken des Empfehlungsprogramms schließen (Auskunft + Löschung)

- Datenexport (Art. 15): neuer Block referralProgram mit eigenem Code,
  Herkunftsangabe, "eingeladen von", gemachten Einladungen, erreichten Rängen,
  aktuellen Vorteilen und den früheren Einmal-Codes. Andere Personen nur über
  den Tiernamen — invitedById (Clerk-ID eines Dritten) fliegt aus dem Export.
- Kontolöschung: invitedById bei Eingeladenen und usedById bei Beta-Codes
  werden geleert, damit keine Clerk-ID der gelöschten Person zurückbleibt.
  Gezählte Einladungen bleiben gezählt.
- Einlösen sperrt jetzt auch über invitedAt, damit nach dem Leeren von
  invitedById kein zweiter Code eingelöst werden kann.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-01 18:57:59 +02:00
1 parent 32ba01f7cf
commit 18f25ce316
8 files changed
+193 -18

No files matched your search

+91
View File
@@ -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<typeof createMockPrisma>) => 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");
});
});
+8
View File
@@ -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(),
+3 -3
View File
@@ -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 });
});
});
+9 -1
View File
@@ -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 });
+9 -1
View File
@@ -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 } });
+64 -6
View File
@@ -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,
+4 -3
View File
@@ -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);
+5 -4
View File
@@ -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,
};
}),