From fabe6e341be092569eff3c1e0523f4690b2cced0 Mon Sep 17 00:00:00 2001 From: MOHAN Date: Fri, 17 Jul 2026 22:00:01 +0530 Subject: [PATCH] Add GDPR privacy export and erasure coverage --- src/auth/auth.controller.ts | 10 ++ src/auth/auth.service.ts | 229 ++++++++++++++++++++++++++- src/compliance/compliance.service.ts | 11 +- test/auth.service.spec.ts | 158 ++++++++++++++++++ 4 files changed, 404 insertions(+), 4 deletions(-) diff --git a/src/auth/auth.controller.ts b/src/auth/auth.controller.ts index bcc28db..3bad417 100644 --- a/src/auth/auth.controller.ts +++ b/src/auth/auth.controller.ts @@ -85,6 +85,16 @@ export class AuthController { return ok(await this.authService.getProfile(userId)); } + @Get("me/privacy-summary") + async privacySummary(@CurrentUser() userId: string) { + return ok(await this.authService.getPrivacySummary(userId)); + } + + @Get("me/data-export") + async dataExport(@CurrentUser() userId: string) { + return ok(await this.authService.exportPersonalData(userId)); + } + @Patch("profile") async updateProfile(@CurrentUser() userId: string, @Body() payload: UpdateProfileDto) { return ok(await this.authService.updateProfile(userId, payload)); diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts index 2b3a45b..748ea60 100644 --- a/src/auth/auth.service.ts +++ b/src/auth/auth.service.ts @@ -213,22 +213,208 @@ export class AuthService { }; } + async getPrivacySummary(userId: string) { + const user = await this.prisma.user.findUnique({ + where: { id: userId }, + select: { id: true, email: true, emailVerified: true, twoFactorEnabled: true, createdAt: true }, + }); + if (!user) throw new NotFoundException("User not found."); + + const [ + accounts, + transactions, + transactionComments, + rules, + taxReturns, + exports, + exportDownloadTokens, + apiKeys, + csvImportMappings, + notifications, + pushSubscriptions, + bills, + billPayees, + billPayments, + creditScores, + householdMemberships, + householdInvites, + householdGoals, + ] = await Promise.all([ + this.prisma.account.count({ where: { userId } }), + this.prisma.transactionRaw.count({ where: { account: { userId } } }), + this.prisma.transactionComment.count({ where: { userId } }), + this.prisma.rule.count({ where: { userId } }), + this.prisma.taxReturn.count({ where: { userId } }), + this.prisma.exportLog.count({ where: { userId } }), + this.prisma.exportDownloadToken.count({ where: { userId } }), + this.prisma.apiKey.count({ where: { userId } }), + this.prisma.csvImportMapping.count({ where: { userId } }), + this.prisma.notification.count({ where: { userId } }), + this.prisma.pushSubscription.count({ where: { userId } }), + this.prisma.bill.count({ where: { userId } }), + this.prisma.billPayee.count({ where: { userId } }), + this.prisma.billPayment.count({ where: { userId } }), + this.prisma.creditScoreEntry.count({ where: { userId } }), + this.prisma.householdMember.count({ where: { userId } }), + this.prisma.householdInvite.count({ where: { OR: [{ invitedById: userId }, { acceptedById: userId }] } }), + this.prisma.householdGoal.count({ where: { createdByUserId: userId } }), + ]); + + return { + subject: user, + dataCategories: { + accounts, + transactions, + transactionComments, + rules, + taxReturns, + exports, + exportDownloadTokens, + apiKeys, + csvImportMappings, + notifications, + pushSubscriptions, + bills, + billPayees, + billPayments, + creditScores, + householdMemberships, + householdInvites, + householdGoals, + }, + controls: { + exportEndpoint: "/api/auth/me/data-export", + deletionEndpoint: "/api/auth/me", + excludedFromExport: [ + "passwordHash", + "twoFactorSecret", + "session and refresh tokens", + "OAuth tokens", + "API key hashes", + "bank access tokens", + "raw bank payloads", + "stable internal account and transaction IDs", + ], + }, + }; + } + + async exportPersonalData(userId: string) { + const user = await this.prisma.user.findUnique({ + where: { id: userId }, + select: { + id: true, + email: true, + fullName: true, + phone: true, + companyName: true, + addressLine1: true, + addressLine2: true, + city: true, + state: true, + postalCode: true, + country: true, + role: true, + emailVerified: true, + twoFactorEnabled: true, + createdAt: true, + updatedAt: true, + }, + }); + if (!user) throw new NotFoundException("User not found."); + + const [accounts, transactions, rules, exports, apiKeys, taxReturns] = await Promise.all([ + this.prisma.account.findMany({ + where: { userId }, + select: { + institutionName: true, + accountType: true, + mask: true, + ownershipType: true, + currentBalance: true, + availableBalance: true, + isoCurrencyCode: true, + syncStatus: true, + isActive: true, + createdAt: true, + updatedAt: true, + }, + }), + this.prisma.transactionRaw.findMany({ + where: { account: { userId } }, + select: { + date: true, + amount: true, + description: true, + source: true, + ingestedAt: true, + derived: { + select: { + userCategory: true, + userNotes: true, + attribution: true, + splitMode: true, + splitMinePercent: true, + splitYoursPercent: true, + isHidden: true, + modifiedAt: true, + }, + }, + }, + orderBy: { date: "desc" }, + }), + this.prisma.rule.findMany({ + where: { userId }, + select: { name: true, priority: true, conditions: true, actions: true, isActive: true, createdAt: true }, + orderBy: { priority: "asc" }, + }), + this.prisma.exportLog.findMany({ + where: { userId }, + select: { format: true, destination: true, filters: true, rowCount: true, fileName: true, mimeType: true, fileHash: true, metadata: true, createdAt: true }, + orderBy: { createdAt: "desc" }, + }), + this.prisma.apiKey.findMany({ + where: { userId }, + select: { name: true, prefix: true, scopes: true, lastUsedAt: true, revokedAt: true, expiresAt: true, createdAt: true }, + orderBy: { createdAt: "desc" }, + }), + this.prisma.taxReturn.findMany({ + where: { userId }, + select: { taxYear: true, filingType: true, jurisdictions: true, status: true, summary: true, createdAt: true, updatedAt: true }, + orderBy: { createdAt: "desc" }, + }), + ]); + + return { + generatedAt: new Date().toISOString(), + subject: user, + minimizationNotes: [ + "Secrets, raw bank payloads, access tokens, token hashes, and stable internal account/transaction IDs are excluded.", + ], + data: { accounts, transactions, rules, exports, apiKeys, taxReturns }, + }; + } + async deleteAccount(userId: string) { const user = await this.prisma.user.findUnique({ where: { id: userId }, select: { id: true } }); if (!user) throw new NotFoundException("User not found."); await this.prisma.$transaction(async (tx) => { - const [accounts, rawTransactions, rules, taxReturns] = await Promise.all([ + const [accounts, rawTransactions, rules, taxReturns, bills, createdHouseholds] = await Promise.all([ tx.account.findMany({ where: { userId }, select: { id: true } }), tx.transactionRaw.findMany({ where: { account: { userId } }, select: { id: true } }), tx.rule.findMany({ where: { userId }, select: { id: true } }), tx.taxReturn.findMany({ where: { userId }, select: { id: true } }), + tx.bill.findMany({ where: { userId }, select: { id: true } }), + tx.household.findMany({ where: { createdByUserId: userId }, select: { id: true } }), ]); const accountIds = accounts.map((account) => account.id); const transactionIds = rawTransactions.map((transaction) => transaction.id); const ruleIds = rules.map((rule) => rule.id); const taxReturnIds = taxReturns.map((taxReturn) => taxReturn.id); + const billIds = bills.map((bill) => bill.id); + const createdHouseholdIds = createdHouseholds.map((household) => household.id); await tx.taxDocument.deleteMany({ where: { taxReturnId: { in: taxReturnIds } } }); await tx.ruleExecution.deleteMany({ @@ -239,14 +425,55 @@ export class AuthService { ], }, }); + await tx.transactionComment.deleteMany({ + where: { OR: [{ userId }, { rawTransactionId: { in: transactionIds } }] }, + }); await tx.transactionDerived.deleteMany({ where: { rawTransactionId: { in: transactionIds } } }); await tx.transactionRaw.deleteMany({ where: { id: { in: transactionIds } } }); await tx.account.deleteMany({ where: { id: { in: accountIds } } }); await tx.rule.deleteMany({ where: { userId } }); + await tx.exportDownloadToken.deleteMany({ where: { userId } }); await tx.exportLog.deleteMany({ where: { userId } }); await tx.auditLog.deleteMany({ where: { userId } }); await tx.abuseEvent.deleteMany({ where: { userId } }); + await tx.apiKey.deleteMany({ where: { userId } }); + await tx.csvImportMapping.deleteMany({ where: { userId } }); await tx.googleConnection.deleteMany({ where: { userId } }); + await tx.notificationPreference.deleteMany({ where: { userId } }); + await tx.notification.deleteMany({ where: { userId } }); + await tx.pushSubscription.deleteMany({ where: { userId } }); + await tx.billPayment.deleteMany({ + where: { OR: [{ userId }, { billId: { in: billIds } }] }, + }); + await tx.bill.deleteMany({ where: { userId } }); + await tx.billPayee.deleteMany({ where: { userId } }); + await tx.creditScoreEntry.deleteMany({ where: { userId } }); + await tx.householdInvite.deleteMany({ + where: { + OR: [ + { invitedById: userId }, + { acceptedById: userId }, + { householdId: { in: createdHouseholdIds } }, + ], + }, + }); + await tx.householdGoal.deleteMany({ + where: { + OR: [ + { createdByUserId: userId }, + { householdId: { in: createdHouseholdIds } }, + ], + }, + }); + await tx.householdMember.deleteMany({ + where: { + OR: [ + { userId }, + { householdId: { in: createdHouseholdIds } }, + ], + }, + }); + await tx.household.deleteMany({ where: { id: { in: createdHouseholdIds } } }); await tx.emailVerificationToken.deleteMany({ where: { userId } }); await tx.passwordResetToken.deleteMany({ where: { userId } }); await tx.refreshToken.deleteMany({ where: { userId } }); diff --git a/src/compliance/compliance.service.ts b/src/compliance/compliance.service.ts index 06cd3f1..448ac52 100644 --- a/src/compliance/compliance.service.ts +++ b/src/compliance/compliance.service.ts @@ -88,9 +88,14 @@ export class ComplianceService { { id: "P1.1", trustServiceCriterion: "Privacy", - status: "partial", - control: "User erasure is available through the authenticated account deletion flow.", - evidence: ["GDPR-style account deletion endpoint is implemented; formal privacy notices and DSR operating procedures remain external controls."], + status: "implemented", + control: "User erasure, privacy inventory, and minimized personal-data export are available through authenticated account privacy flows.", + evidence: [ + "DELETE /api/auth/me erases the account and associated user-owned application data in one transaction.", + "GET /api/auth/me/privacy-summary reports user-owned data categories and available controls.", + "GET /api/auth/me/data-export returns a minimized JSON export that excludes secrets, token hashes, raw bank payloads, and stable internal account/transaction IDs.", + "Formal privacy notices and DSR operating procedures remain external controls.", + ], }, ]; diff --git a/test/auth.service.spec.ts b/test/auth.service.spec.ts index dea81e7..9d6176d 100644 --- a/test/auth.service.spec.ts +++ b/test/auth.service.spec.ts @@ -4,6 +4,7 @@ import { AuthService } from "../src/auth/auth.service"; const model = (order: string[]) => ({ findMany: jest.fn(), findUnique: jest.fn(), + count: jest.fn(), create: jest.fn(), update: jest.fn(), updateMany: jest.fn(), @@ -20,11 +21,26 @@ const createService = () => { account: model(order), transactionRaw: model(order), transactionDerived: model(order), + transactionComment: model(order), rule: model(order), ruleExecution: model(order), exportLog: model(order), + exportDownloadToken: model(order), auditLog: model(order), + apiKey: model(order), + csvImportMapping: model(order), googleConnection: model(order), + notificationPreference: model(order), + notification: model(order), + pushSubscription: model(order), + billPayee: model(order), + bill: model(order), + billPayment: model(order), + creditScoreEntry: model(order), + household: model(order), + householdInvite: model(order), + householdGoal: model(order), + householdMember: model(order), emailVerificationToken: model(order), passwordResetToken: model(order), refreshToken: model(order), @@ -57,6 +73,24 @@ const createService = () => { const prisma = { user: { findUnique: jest.fn() }, + account: model(order), + transactionRaw: model(order), + transactionComment: model(order), + rule: model(order), + taxReturn: model(order), + exportLog: model(order), + exportDownloadToken: model(order), + apiKey: model(order), + csvImportMapping: model(order), + notification: model(order), + pushSubscription: model(order), + bill: model(order), + billPayee: model(order), + billPayment: model(order), + creditScoreEntry: model(order), + householdMember: model(order), + householdInvite: model(order), + householdGoal: model(order), $transaction: jest.fn((callback: (client: typeof tx) => Promise) => callback(tx)), }; const service = new AuthService( @@ -76,6 +110,8 @@ describe("AuthService account deletion", () => { tx.transactionRaw.findMany.mockResolvedValue([{ id: "tx_1" }]); tx.rule.findMany.mockResolvedValue([{ id: "rule_1" }]); tx.taxReturn.findMany.mockResolvedValue([{ id: "return_1" }]); + tx.bill.findMany.mockResolvedValue([{ id: "bill_1" }]); + tx.household.findMany.mockResolvedValue([{ id: "household_1" }]); await expect(service.deleteAccount("user_1")).resolves.toEqual({ message: "Account and associated personal data deleted.", @@ -89,6 +125,24 @@ describe("AuthService account deletion", () => { ], }, }); + expect(tx.transactionComment.deleteMany).toHaveBeenCalledWith({ + where: { OR: [{ userId: "user_1" }, { rawTransactionId: { in: ["tx_1"] } }] }, + }); + expect(tx.exportDownloadToken.deleteMany).toHaveBeenCalledWith({ where: { userId: "user_1" } }); + expect(tx.apiKey.deleteMany).toHaveBeenCalledWith({ where: { userId: "user_1" } }); + expect(tx.csvImportMapping.deleteMany).toHaveBeenCalledWith({ where: { userId: "user_1" } }); + expect(tx.billPayment.deleteMany).toHaveBeenCalledWith({ + where: { OR: [{ userId: "user_1" }, { billId: { in: ["bill_1"] } }] }, + }); + expect(tx.householdInvite.deleteMany).toHaveBeenCalledWith({ + where: { + OR: [ + { invitedById: "user_1" }, + { acceptedById: "user_1" }, + { householdId: { in: ["household_1"] } }, + ], + }, + }); expect(tx.user.delete).toHaveBeenCalledWith({ where: { id: "user_1" } }); expect(order.slice(0, 6)).toEqual([ "taxDocument.deleteMany", @@ -108,6 +162,110 @@ describe("AuthService account deletion", () => { await expect(service.deleteAccount("missing_user")).rejects.toBeInstanceOf(NotFoundException); expect(prisma.$transaction).not.toHaveBeenCalled(); }); + + it("returns privacy summary counts for user-owned data", async () => { + const { service, prisma } = createService(); + prisma.user.findUnique.mockResolvedValue({ + id: "user_1", + email: "owner@example.com", + emailVerified: true, + twoFactorEnabled: false, + createdAt: new Date("2026-01-01T00:00:00.000Z"), + }); + [ + prisma.account, + prisma.transactionRaw, + prisma.transactionComment, + prisma.rule, + prisma.taxReturn, + prisma.exportLog, + prisma.exportDownloadToken, + prisma.apiKey, + prisma.csvImportMapping, + prisma.notification, + prisma.pushSubscription, + prisma.bill, + prisma.billPayee, + prisma.billPayment, + prisma.creditScoreEntry, + prisma.householdMember, + prisma.householdInvite, + prisma.householdGoal, + ].forEach((entry, index) => entry.count.mockResolvedValue(index + 1)); + + await expect(service.getPrivacySummary("user_1")).resolves.toMatchObject({ + subject: { id: "user_1", email: "owner@example.com" }, + dataCategories: { + accounts: 1, + transactions: 2, + transactionComments: 3, + rules: 4, + taxReturns: 5, + exports: 6, + exportDownloadTokens: 7, + apiKeys: 8, + csvImportMappings: 9, + notifications: 10, + pushSubscriptions: 11, + bills: 12, + billPayees: 13, + billPayments: 14, + creditScores: 15, + householdMemberships: 16, + householdInvites: 17, + householdGoals: 18, + }, + controls: { + exportEndpoint: "/api/auth/me/data-export", + deletionEndpoint: "/api/auth/me", + }, + }); + }); + + it("exports personal data without secrets or raw bank payloads", async () => { + const { service, prisma } = createService(); + prisma.user.findUnique.mockResolvedValue({ + id: "user_1", + email: "owner@example.com", + fullName: "Owner", + phone: null, + companyName: null, + addressLine1: null, + addressLine2: null, + city: null, + state: null, + postalCode: null, + country: null, + role: "user", + emailVerified: true, + twoFactorEnabled: true, + createdAt: new Date("2026-01-01T00:00:00.000Z"), + updatedAt: new Date("2026-01-02T00:00:00.000Z"), + }); + [ + prisma.account, + prisma.transactionRaw, + prisma.rule, + prisma.exportLog, + prisma.apiKey, + prisma.taxReturn, + ].forEach((entry) => entry.findMany.mockResolvedValue([])); + + const result = await service.exportPersonalData("user_1"); + + expect(result.subject).not.toHaveProperty("passwordHash"); + expect(result.subject).not.toHaveProperty("twoFactorSecret"); + expect(prisma.account.findMany).toHaveBeenCalledWith(expect.objectContaining({ + select: expect.not.objectContaining({ plaidAccessToken: true, tellerAccessToken: true }), + })); + expect(prisma.transactionRaw.findMany).toHaveBeenCalledWith(expect.objectContaining({ + select: expect.not.objectContaining({ id: true, rawPayload: true, bankTransactionId: true }), + })); + expect(prisma.apiKey.findMany).toHaveBeenCalledWith(expect.objectContaining({ + select: expect.not.objectContaining({ keyHash: true }), + })); + expect(result.minimizationNotes.join(" ")).toContain("raw bank payloads"); + }); }); describe("AuthService session-bound tokens", () => {