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 ? (