feat(admin): add GDPR Art. 15 data-export feature (Gitea #6, Plan B)

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.
This commit is contained in:
admin committed 2026-08-11 11:53:27 +02:00
1 parent 86ac5f4fee
commit f12dec05bd
5 files changed
+392 -1

No files matched your search

+82
View File
@@ -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();
});
});
+27 -1
View File
@@ -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 (
<div className="space-y-6">
<h1 className="text-2xl font-bold text-zinc-100">Users</h1>
@@ -120,6 +132,20 @@ export default function AdminUsersPage() {
)}
</Button>
)}
<Button
size="sm"
variant="outline"
className="border-zinc-700 text-zinc-300 hover:text-zinc-100"
onClick={() => exportDataMutation.mutate({ ownerId: user.id })}
disabled={exportDataMutation.isPending}
title="Datenauskunft (Art. 15 DSGVO) per E-Mail versenden"
>
{exportDataMutation.isPending && exportDataMutation.variables?.ownerId === user.id ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Download className="h-4 w-4" />
)}
</Button>
{user.ban ? (
<Button
size="sm"
+64
View File
@@ -0,0 +1,64 @@
import "server-only";
import { createClient, type SupabaseClient } from "@supabase/supabase-js";
/**
* Storage for GDPR Art. 15 data-export bundles — deliberately a SEPARATE,
* private bucket from supabase-storage.ts's "pet-avatars" (which is public,
* fine for avatars/post photos, wrong for a personal-data dump). Downloads
* only ever happen via a time-limited signed URL, never a public URL.
*/
let _client: SupabaseClient | null = null;
function getClient(): SupabaseClient {
if (!_client) {
_client = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!,
{ auth: { persistSession: false } }
);
}
return _client;
}
export const EXPORTS_BUCKET = "data-exports";
const SIGNED_URL_TTL_SECONDS = 7 * 24 * 60 * 60; // 7 days
/** Idempotent — creates the private bucket if it doesn't exist yet. */
async function ensureBucketExists(): Promise<void> {
const { error } = await getClient().storage.createBucket(EXPORTS_BUCKET, {
public: false,
});
// "already exists" is expected on every call after the first — not an error.
if (error && !error.message.toLowerCase().includes("already exists")) {
throw new Error(`Storage error creating exports bucket: ${error.message}`);
}
}
/**
* Uploads a JSON export bundle and returns a signed download URL valid for
* SIGNED_URL_TTL_SECONDS. The object itself is left in Storage afterward —
* no automated cleanup yet (acceptable for now: content is inert JSON, not
* independently browsable once the signed URL expires).
*/
export async function uploadExportAndGetSignedUrl(
key: string,
jsonContent: string
): Promise<string> {
await ensureBucketExists();
const client = getClient();
const { error: uploadError } = await client.storage
.from(EXPORTS_BUCKET)
.upload(key, jsonContent, { contentType: "application/json", upsert: true });
if (uploadError) throw new Error(`Storage error uploading export: ${uploadError.message}`);
const { data, error: signError } = await client.storage
.from(EXPORTS_BUCKET)
.createSignedUrl(key, SIGNED_URL_TTL_SECONDS);
if (signError || !data) {
throw new Error(`Storage error signing export URL: ${signError?.message ?? "unknown"}`);
}
return data.signedUrl;
}
+166
View File
@@ -0,0 +1,166 @@
import "server-only";
import type { PrismaClient } from "@prisma/client";
import { clerkClient } from "@clerk/nextjs/server";
import { getMediaUrl } from "@/lib/media-url";
import { getAvatarUrl } from "@/lib/avatar-url";
/**
* Aggregates everything PawFeed holds about one Owner into a single
* structured JSON object for a GDPR Art. 15 (right of access) export.
*
* Deliberately NOT a zip with embedded media bytes — see the Gitea #6
* research note: bundling images/video would need new archiving
* dependencies, risks timing out for owners with many posts, and Mux
* video assets aren't trivially re-downloadable. Media is referenced by
* its existing CDN URL instead, which stays reachable independently of
* 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).
*/
export async function buildDataExport(prisma: PrismaClient, ownerId: string) {
const [owner, clerkUser] = await Promise.all([
prisma.owner.findUnique({
where: { id: ownerId },
include: {
pets: {
include: {
species: { select: { name: true } },
breed: { select: { name: true } },
posts: {
include: { images: true, milestone: true },
orderBy: { createdAt: "asc" },
},
stories: { orderBy: { createdAt: "asc" } }, // only currently-active ones remain (lazy-delete)
comments: { orderBy: { createdAt: "asc" } },
reactions: true,
reposts: true,
following: { include: { followee: { select: { id: true, name: true } } } },
followers: { include: { follower: { select: { id: true, name: true } } } },
blocking: { include: { blocked: { select: { id: true, name: true } } } },
blockedBy: { include: { blocker: { select: { id: true, name: true } } } },
reports: true,
receivedNotifications: { orderBy: { createdAt: "asc" } },
weightLogs: true,
vetVisits: true,
vaccines: true,
healthCard: true,
conversationsAsA: {
include: {
petB: { select: { name: true } },
messages: { orderBy: { createdAt: "asc" } },
},
},
conversationsAsB: {
include: {
petA: { select: { name: true } },
messages: { orderBy: { createdAt: "asc" } },
},
},
},
},
ban: true,
adminRole: true,
},
}),
(await clerkClient()).users.getUser(ownerId).catch(() => null),
]);
if (!owner) return null;
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.",
account: {
ownerId: owner.id,
createdAt: owner.createdAt,
invitedById: owner.invitedById,
legalAcknowledgedAt: owner.legalAcknowledgedAt,
contentRightsConsentedAt: owner.contentRightsConsentedAt,
dataProcessingConsentedAt: owner.dataProcessingConsentedAt,
clerkProfile: clerkUser
? {
email: clerkUser.emailAddresses.find((e) => e.id === clerkUser.primaryEmailAddressId)
?.emailAddress ?? null,
firstName: clerkUser.firstName,
lastName: clerkUser.lastName,
createdAt: new Date(clerkUser.createdAt),
}
: null,
ban: owner.ban,
adminRole: owner.adminRole,
},
pets: owner.pets.map((pet) => ({
id: pet.id,
name: pet.name,
species: pet.species.name,
breed: pet.breed?.name ?? null,
bio: pet.bio,
adoptionStory: pet.adoptionStory,
avatarUrl: getAvatarUrl(pet.avatarKey),
dmPolicy: pet.dmPolicy,
birthday: pet.birthday,
adoptedAt: pet.adoptedAt,
createdAt: pet.createdAt,
posts: pet.posts.map((post) => ({
id: post.id,
type: post.type,
caption: post.caption,
createdAt: post.createdAt,
images: post.images.map((img) => getMediaUrl(img.storageKey)),
milestone: post.milestone,
})),
activeStories: pet.stories.map((s) => ({
mediaUrl: getMediaUrl(s.storageKey),
createdAt: s.createdAt,
expiresAt: s.expiresAt,
})),
comments: pet.comments.map((c) => ({ postId: c.postId, body: c.body, createdAt: c.createdAt })),
reactionsGiven: pet.reactions.map((r) => ({ postId: r.postId, createdAt: r.createdAt })),
reposts: pet.reposts.map((r) => ({ originalPostId: r.originalPostId, createdAt: r.createdAt })),
following: pet.following.map((f) => f.followee.name),
followers: pet.followers.map((f) => f.follower.name),
blocking: pet.blocking.map((b) => b.blocked.name),
blockedBy: pet.blockedBy.map((b) => b.blocker.name),
reportsFiled: pet.reports.map((r) => ({
reason: r.reason,
notes: r.notes,
createdAt: r.createdAt,
})),
notificationsReceived: pet.receivedNotifications.map((n) => ({
type: n.type,
createdAt: n.createdAt,
})),
health: {
weightLogs: pet.weightLogs,
vetVisits: pet.vetVisits,
vaccines: pet.vaccines,
healthCardShareLink: pet.healthCard
? `${process.env.NEXT_PUBLIC_APP_URL}/health-card/${pet.healthCard.token}`
: null,
},
conversations: [
...pet.conversationsAsA.map((c) => ({
withPet: c.petB.name,
messages: c.messages.map((m) => ({
fromMe: m.senderPetId === pet.id,
body: m.body,
createdAt: m.createdAt,
})),
})),
...pet.conversationsAsB.map((c) => ({
withPet: c.petA.name,
messages: c.messages.map((m) => ({
fromMe: m.senderPetId === pet.id,
body: m.body,
createdAt: m.createdAt,
})),
})),
],
})),
};
}
+53
View File
@@ -10,6 +10,9 @@ import {
ALLOWED_CONTENT_TYPES,
MAX_FILE_SIZE,
} from "@/lib/supabase-storage";
import { buildDataExport } from "@/lib/gdpr-export";
import { uploadExportAndGetSignedUrl } from "@/lib/data-export-storage";
import { sendMail } from "@/lib/mail";
/** True if userId is the env-bootstrapped super-owner or holds any AdminRole row. */
async function isAdmin(prisma: PrismaClient, userId: string): Promise<boolean> {
@@ -447,6 +450,56 @@ export const adminRouter = router({
return { conversationId: conversation.id, teamPetId: teamPet.id };
}),
// ─── GDPR Art. 15 data export (Gitea #6, Plan B) ──────────────────────────────
// Aggregates everything held about an Owner (gdpr-export.ts), uploads it as
// JSON to a private Supabase Storage bucket, and emails a 7-day signed
// download link — the same "temporary tokenized URL" shape as the existing
// /health-card/[token] pattern, just via Supabase's native signed URLs
// instead of a bespoke DB-backed token. Synchronous: acceptable for now,
// but a heavy account (many posts/messages) could make this request slow —
// flagged in the Gitea #6 research note as a scaling risk for later.
exportUserData: protectedProcedure
.input(z.object({ ownerId: z.string().min(1) }))
.mutation(async ({ ctx, input }) => {
await assertAdmin(ctx.prisma, ctx.userId);
const exportData = await buildDataExport(ctx.prisma, input.ownerId);
if (!exportData) throw new TRPCError({ code: "NOT_FOUND" });
const email = exportData.account.clerkProfile?.email;
if (!email) {
throw new TRPCError({
code: "PRECONDITION_FAILED",
message: "No email on file for this owner (Clerk profile missing or has no email).",
});
}
const key = `${input.ownerId}/${Date.now()}.json`;
const downloadUrl = await uploadExportAndGetSignedUrl(
key,
JSON.stringify(exportData, null, 2)
);
await sendMail({
to: email,
subject: "Deine Daten bei PawFeed — Auskunft gemäß Art. 15 DSGVO",
text: `Hallo,\n\nanbei der Link zu deiner Datenauskunft gemäß Art. 15 DSGVO. Der Link ist 7 Tage gültig:\n\n${downloadUrl}\n\nBei Fragen antworte einfach auf diese E-Mail.\n\nDein PawFeed-Team`,
html: `<p>Hallo,</p><p>anbei der Link zu deiner Datenauskunft gemäß Art. 15 DSGVO. Der Link ist <strong>7 Tage</strong> gültig:</p><p><a href="${downloadUrl}">${downloadUrl}</a></p><p>Bei Fragen antworte einfach auf diese E-Mail.</p><p>Dein PawFeed-Team</p>`,
});
await ctx.prisma.moderationLog.create({
data: {
action: "DATA_EXPORT",
targetType: "USER",
targetId: input.ownerId,
reason: null,
moderatorId: ctx.userId,
},
});
return { success: true, sentTo: email };
}),
// ─── Reports ─────────────────────────────────────────────────────────────────
listReports: protectedProcedure.query(async ({ ctx }) => {
await assertAdmin(ctx.prisma, ctx.userId);