From bc828ab9ba3e68f1a728f2add3f7353ea1ceb2cf Mon Sep 17 00:00:00 2001 From: christianahtemitope2005 Date: Mon, 31 Aug 2026 17:31:49 +0100 Subject: [PATCH] feat: add GET /api/v1/users/me/export endpoint (GDPR data export) Aggregates the authenticated user's profile, enrollments, quiz submissions, credentials, and reward claims into a single downloadable JSON document (Content-Disposition: attachment). Export is recorded via a new user.data_exported audit event. Closes #350 Closes #348 Closes #356 Closes #352 --- src/audit/index.ts | 1 + src/modules/users/user.controller.ts | 21 +++++++ src/modules/users/user.routes.ts | 13 ++++ src/modules/users/user.service.ts | 93 ++++++++++++++++++++++++++++ src/modules/users/user.types.ts | 37 +++++++++++ 5 files changed, 165 insertions(+) diff --git a/src/audit/index.ts b/src/audit/index.ts index 5124085..68a30dd 100644 --- a/src/audit/index.ts +++ b/src/audit/index.ts @@ -22,6 +22,7 @@ type AuditEvent = | "course.duplicated" | "course.reviewed" | "user.account_deleted" + | "user.data_exported" | "course.module.created" | "course.module.updated" | "course.module.deleted"; diff --git a/src/modules/users/user.controller.ts b/src/modules/users/user.controller.ts index f7fdd13..01b5bac 100644 --- a/src/modules/users/user.controller.ts +++ b/src/modules/users/user.controller.ts @@ -86,6 +86,27 @@ export class UserController { }); } + /** + * GET /api/users/me/export + * GDPR data export — returns all of the user's data as a downloadable + * JSON file (closes #350). + */ + async exportData( + request: FastifyRequest, + reply: FastifyReply + ): Promise { + const { authUser } = request as AuthenticatedRequest; + const data = await userService.exportUserData(authUser.id); + + reply + .header( + "Content-Disposition", + `attachment; filename="chainlearn-export-${authUser.id}.json"` + ) + .type("application/json") + .send(data); + } + /** * DELETE /api/users/me * Soft-delete the authenticated user's account. diff --git a/src/modules/users/user.routes.ts b/src/modules/users/user.routes.ts index 0974537..6009b29 100644 --- a/src/modules/users/user.routes.ts +++ b/src/modules/users/user.routes.ts @@ -140,6 +140,19 @@ export async function userRoutes(app: FastifyInstance): Promise { (request, reply) => notificationController.markRead(request, reply) ); + app.get( + "/me/export", + { + schema: { + description: + "Export all of the authenticated user's data (profile, enrollments, quiz submissions, credentials, reward claims) as a downloadable JSON file — GDPR data portability.", + tags: ["users"], + security: [{ bearerAuth: [] }], + } as FastifySchema, + }, + (request, reply) => userController.exportData(request, reply) + ); + app.delete( "/me", { diff --git a/src/modules/users/user.service.ts b/src/modules/users/user.service.ts index 1f4e08a..85148e9 100644 --- a/src/modules/users/user.service.ts +++ b/src/modules/users/user.service.ts @@ -32,6 +32,7 @@ import type { UserActivityPage, UserProfile, UserProgress, + UserDataExport, } from "./user.types.js"; export class UserService { @@ -522,6 +523,98 @@ export class UserService { logger.info({ userId }, "Account deleted"); } + /** + * GDPR data export — closes #350. Aggregates every category of data the + * platform holds on the user into a single downloadable JSON document. + */ + async exportUserData(userId: string): Promise { + const user = await db.query.users.findFirst({ + where: eq(users.id, userId), + }); + + if (!user) { + throw new NotFoundError("User"); + } + + const [enrollmentRows, submissionRows, credentialRows] = await Promise.all([ + db + .select({ + courseId: enrollments.courseId, + courseTitle: courses.title, + enrolledAt: enrollments.enrolledAt, + completedAt: enrollments.completedAt, + }) + .from(enrollments) + .innerJoin(courses, eq(enrollments.courseId, courses.id)) + .where(eq(enrollments.userId, userId)), + db + .select({ + id: quizSubmissions.id, + quizId: quizSubmissions.quizId, + score: quizSubmissions.score, + rewardClaimed: quizSubmissions.rewardClaimed, + rewardAmount: quizSubmissions.rewardAmount, + txHash: quizSubmissions.txHash, + submittedAt: quizSubmissions.submittedAt, + }) + .from(quizSubmissions) + .where(eq(quizSubmissions.userId, userId)), + db + .select({ + id: credentials.id, + courseId: credentials.courseId, + courseTitle: courses.title, + score: credentials.score, + nftAssetCode: credentials.nftAssetCode, + nftIssuer: credentials.nftIssuer, + mintTxHash: credentials.mintTxHash, + revoked: credentials.revoked, + mintedAt: credentials.mintedAt, + }) + .from(credentials) + .innerJoin(courses, eq(credentials.courseId, courses.id)) + .where(eq(credentials.userId, userId)), + ]); + + const rewardClaims = submissionRows + .filter((s) => s.rewardClaimed) + .map((s) => ({ + submissionId: s.id, + amount: s.rewardAmount, + txHash: s.txHash, + claimedAt: s.submittedAt, + })); + + const exportData: UserDataExport = { + exportVersion: 1, + exportedAt: new Date().toISOString(), + profile: { + id: user.id, + stellarAddress: user.stellarAddress, + displayName: user.displayName, + background: user.background, + learningGoal: user.learningGoal, + pace: user.pace ?? "medium", + language: user.language ?? "en", + credits: user.credits, + createdAt: user.createdAt, + }, + enrollments: enrollmentRows, + quizSubmissions: submissionRows.map((s) => ({ + id: s.id, + quizId: s.quizId, + score: s.score, + submittedAt: s.submittedAt, + })), + credentials: credentialRows, + rewardClaims, + }; + + await auditLog("user.data_exported", { userId }); + + return exportData; + } + private async deleteLocalAvatar(avatarUrl: string | null): Promise { if (!avatarUrl) return; diff --git a/src/modules/users/user.types.ts b/src/modules/users/user.types.ts index 3241a91..276503b 100644 --- a/src/modules/users/user.types.ts +++ b/src/modules/users/user.types.ts @@ -82,3 +82,40 @@ export interface AvatarUpload { mimetype: string; size: number; } + +// ─── GDPR Data Export (#350) ──────────────────────────────────────────────── + +export interface UserDataExport { + exportVersion: 1; + exportedAt: string; + profile: UserProfile; + enrollments: { + courseId: string; + courseTitle: string; + enrolledAt: Date; + completedAt: Date | null; + }[]; + quizSubmissions: { + id: string; + quizId: string; + score: number | null; + submittedAt: Date; + }[]; + credentials: { + id: string; + courseId: string; + courseTitle: string; + score: number; + nftAssetCode: string | null; + nftIssuer: string | null; + mintTxHash: string | null; + revoked: boolean; + mintedAt: Date; + }[]; + rewardClaims: { + submissionId: string; + amount: number | null; + txHash: string | null; + claimedAt: Date; + }[]; +}