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:
1 parent
32ba01f7cf
commit
18f25ce316
8 files changed
+193
-18
No files matched your search
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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(),
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}),
|
||||
|
||||
|
||||
Reference in new issue
Block a user