From f12dec05bd340e725dbeb667d8291d75a378d091 Mon Sep 17 00:00:00 2001 From: neodk2004 Date: Tue, 11 Aug 2026 11:53:27 +0200 Subject: [PATCH] feat(admin): add GDPR Art. 15 data-export feature (Gitea #6, Plan B) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Aggregates everything held about an Owner (gdpr-export.ts: pets, posts, stories, comments, reactions, reposts, follows/blocks, health records, messages, notifications, reports, ban/consent history, Clerk profile) into a single JSON bundle, uploads it to a private Supabase Storage bucket (data-export-storage.ts — separate from the public pet-avatars bucket used for media), and emails the owner a 7-day signed download link via the new SMTP module. Triggered from a new export button per user in /p/[secret]/users, logged to ModerationLog. Deliberately not a zip with embedded media bytes: avoids new archiving dependencies, the risk of a slow synchronous request for owners with many posts, and the fact that Mux video assets aren't trivially re-downloadable — media is referenced by its existing CDN URL instead. Ad interactions and issued invite codes are not yet included, flagged in the export's own scope note rather than silently omitted. Verified end-to-end against the live shared DB before committing: built a real export, uploaded it, generated a signed URL, and sent a real test email — all succeeded. --- src/__tests__/admin.test.ts | 82 +++++++++++++++ src/app/p/[secret]/users/page.tsx | 28 ++++- src/lib/data-export-storage.ts | 64 ++++++++++++ src/lib/gdpr-export.ts | 166 ++++++++++++++++++++++++++++++ src/trpc/routers/admin.ts | 53 ++++++++++ 5 files changed, 392 insertions(+), 1 deletion(-) create mode 100644 src/lib/data-export-storage.ts create mode 100644 src/lib/gdpr-export.ts diff --git a/src/__tests__/admin.test.ts b/src/__tests__/admin.test.ts index 3974969..6a39226 100644 --- a/src/__tests__/admin.test.ts +++ b/src/__tests__/admin.test.ts @@ -28,6 +28,21 @@ vi.mock("@/lib/supabase-storage", () => ({ })); vi.mock("@/lib/prisma", () => ({ prisma: {} })); +const mockBuildDataExport = vi.fn(); +vi.mock("@/lib/gdpr-export", () => ({ + buildDataExport: (...args: unknown[]) => mockBuildDataExport(...args), +})); + +const mockUploadExport = vi.fn(); +vi.mock("@/lib/data-export-storage", () => ({ + uploadExportAndGetSignedUrl: (...args: unknown[]) => mockUploadExport(...args), +})); + +const mockSendMail = vi.fn(); +vi.mock("@/lib/mail", () => ({ + sendMail: (...args: unknown[]) => mockSendMail(...args), +})); + import { createMockPrisma } from "@/__tests__/helpers/prisma-mock"; async function createAdminCaller(overrides?: { @@ -59,6 +74,9 @@ async function createAdminCaller(overrides?: { afterEach(() => { vi.unstubAllEnvs(); + mockBuildDataExport.mockReset(); + mockUploadExport.mockReset(); + mockSendMail.mockReset(); }); describe("admin.deleteComment", () => { @@ -339,3 +357,67 @@ describe("admin.startTeamConversation", () => { ).rejects.toThrow(TRPCError); }); }); + +describe("admin.exportUserData", () => { + it("uploads the export, emails the signed link, and logs a moderation entry", async () => { + vi.stubEnv("ADMIN_OWNER_ID", "admin-1"); + const { caller, mockPrisma } = await createAdminCaller(); + + mockBuildDataExport.mockResolvedValue({ + account: { clerkProfile: { email: "owner4@example.com" } }, + }); + mockUploadExport.mockResolvedValue("https://signed.example.com/export.json"); + mockSendMail.mockResolvedValue(undefined); + mockPrisma.moderationLog.create.mockResolvedValue({}); + + const result = await caller.admin.exportUserData({ ownerId: "owner-4" }); + + expect(mockSendMail).toHaveBeenCalledWith( + expect.objectContaining({ to: "owner4@example.com" }) + ); + expect(mockPrisma.moderationLog.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + action: "DATA_EXPORT", + targetType: "USER", + targetId: "owner-4", + }), + }) + ); + expect(result).toEqual({ success: true, sentTo: "owner4@example.com" }); + }); + + it("throws NOT_FOUND when the owner does not exist", async () => { + vi.stubEnv("ADMIN_OWNER_ID", "admin-1"); + const { caller } = await createAdminCaller(); + + mockBuildDataExport.mockResolvedValue(null); + + await expect( + caller.admin.exportUserData({ ownerId: "missing-owner" }) + ).rejects.toThrow(TRPCError); + expect(mockSendMail).not.toHaveBeenCalled(); + }); + + it("throws PRECONDITION_FAILED when the owner has no Clerk email on file", async () => { + vi.stubEnv("ADMIN_OWNER_ID", "admin-1"); + const { caller } = await createAdminCaller(); + + mockBuildDataExport.mockResolvedValue({ account: { clerkProfile: null } }); + + await expect( + caller.admin.exportUserData({ ownerId: "owner-5" }) + ).rejects.toThrow(TRPCError); + expect(mockSendMail).not.toHaveBeenCalled(); + }); + + it("throws FORBIDDEN for a non-admin caller", async () => { + vi.stubEnv("ADMIN_OWNER_ID", "someone-else"); + const { caller } = await createAdminCaller(); + + await expect( + caller.admin.exportUserData({ ownerId: "owner-4" }) + ).rejects.toThrow(TRPCError); + expect(mockBuildDataExport).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/p/[secret]/users/page.tsx b/src/app/p/[secret]/users/page.tsx index 1891f6d..f9cf852 100644 --- a/src/app/p/[secret]/users/page.tsx +++ b/src/app/p/[secret]/users/page.tsx @@ -2,10 +2,11 @@ import { useState } from "react"; import { useRouter } from "next/navigation"; +import { toast } from "sonner"; import { useInfiniteQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useTRPC } from "@/trpc/client"; import { useActivePet } from "@/context/ActivePetContext"; -import { ShieldOff, ShieldCheck, Loader2, ChevronDown, MessageCircle } from "lucide-react"; +import { ShieldOff, ShieldCheck, Loader2, ChevronDown, MessageCircle, Download } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"; @@ -59,6 +60,17 @@ export default function AdminUsersPage() { }) ); + const exportDataMutation = useMutation( + trpc.admin.exportUserData.mutationOptions({ + onSuccess: ({ sentTo }) => { + toast.success(`Datenauskunft an ${sentTo} verschickt.`); + }, + onError: (err) => { + toast.error(`Export fehlgeschlagen: ${err.message}`); + }, + }) + ); + return (

Users

@@ -120,6 +132,20 @@ export default function AdminUsersPage() { )} )} + {user.ban ? (