Files
petfeed/src/lib/gdpr-export.ts
T
admin f12dec05bd 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.
2026-08-11 11:53:27 +02:00

167 lines
6.2 KiB
TypeScript

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,
})),
})),
],
})),
};
}