diff --git a/prisma/migrations/20260629000000_add_anonymous_donation_fields/migration.sql b/prisma/migrations/20260629000000_add_anonymous_donation_fields/migration.sql new file mode 100644 index 0000000..b97d2fe --- /dev/null +++ b/prisma/migrations/20260629000000_add_anonymous_donation_fields/migration.sql @@ -0,0 +1,7 @@ +-- AlterTable: add anonymity and grouping fields to Donation +ALTER TABLE "Donation" ADD COLUMN "revealedAt" TIMESTAMP(3); +ALTER TABLE "Donation" ADD COLUMN "groupId" TEXT; +ALTER TABLE "Donation" ADD COLUMN "retentionPolicy" TEXT; + +-- AlterEnum: add DONATION_IDENTITY_REVEALED to AuditAction +ALTER TYPE "AuditAction" ADD VALUE 'DONATION_IDENTITY_REVEALED'; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 6b76ee3..ca48552 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -412,6 +412,9 @@ model Donation { isAnonymous Boolean @default(false) donorMessage String? @db.Text receiptGeneratedAt DateTime? + revealedAt DateTime? + groupId String? + retentionPolicy String? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -785,6 +788,7 @@ enum AuditAction { RECOVERY_SETTLED RECOVERY_MANUAL_OVERRIDE DONOR_CREDIT_ISSUED + DONATION_IDENTITY_REVEALED } model AuditLog { diff --git a/src/controllers/donation.controller.ts b/src/controllers/donation.controller.ts index a17edd5..30726bf 100644 --- a/src/controllers/donation.controller.ts +++ b/src/controllers/donation.controller.ts @@ -9,7 +9,7 @@ export class DonationController { try { const userId = req.user?.id; const result = await DonationService.createDonation(req.body, userId); - + res.status(201).json({ success: true, data: result, @@ -24,13 +24,13 @@ export class DonationController { try { const { id } = req.params; const { txHash } = req.body; - + if (!txHash) { throw new AppError('Transaction hash is required', 400); } const result = await DonationService.confirmDonation(id, txHash); - + res.status(200).json({ success: true, data: result, @@ -41,7 +41,7 @@ export class DonationController { } } - static async getDonations(req: Request, res: Response, next: NextFunction): Promise { + static async getDonations(req: AuthRequest, res: Response, next: NextFunction): Promise { try { const filters = { campaignId: req.query.campaignId as string, @@ -54,16 +54,18 @@ export class DonationController { const pagination = { page: req.query.page ? parseInt(req.query.page as string) : 1, limit: req.query.limit ? parseInt(req.query.limit as string) : 10, - sortBy: req.query.sortBy as string || 'createdAt', - sortOrder: req.query.sortOrder as string || 'desc', + sortBy: (req.query.sortBy as string) || 'createdAt', + sortOrder: (req.query.sortOrder as string) || 'desc', }; - const result = await DonationService.getDonations(filters, pagination); - - res.status(200).json({ - success: true, - ...result, - }); + const result = await DonationService.getDonations( + filters, + pagination, + req.user?.id, + req.user?.role, + ); + + res.status(200).json({ success: true, ...result }); } catch (error) { next(error); } @@ -72,11 +74,27 @@ export class DonationController { static async getDonationById(req: AuthRequest, res: Response, next: NextFunction): Promise { try { const { id } = req.params; - const result = await DonationService.getDonationById(id, req.user?.id); + const result = await DonationService.getDonationById(id, req.user?.id, req.user?.role); + + res.status(200).json({ success: true, data: result }); + } catch (error) { + next(error); + } + } + + static async revealIdentity(req: AuthRequest, res: Response, next: NextFunction): Promise { + try { + if (!req.user) { + throw new AppError('Authentication required', 401); + } + + const { id } = req.params; + const result = await DonationService.revealIdentity(id, req.user.id); res.status(200).json({ success: true, data: result, + message: 'Identity revealed successfully', }); } catch (error) { next(error); @@ -91,7 +109,7 @@ export class DonationController { const { id } = req.params; const result = await DonationService.refundDonation(id, req.user.id, req.user.role); - + res.status(200).json({ success: true, data: result, @@ -119,22 +137,20 @@ export class DonationController { const pagination = { page: req.query.page ? parseInt(req.query.page as string) : 1, limit: req.query.limit ? parseInt(req.query.limit as string) : 10, - sortBy: req.query.sortBy as string || 'createdAt', - sortOrder: req.query.sortOrder as string || 'desc', + sortBy: (req.query.sortBy as string) || 'createdAt', + sortOrder: (req.query.sortOrder as string) || 'desc', }; - const result = await DonationService.getDonations(filters, pagination, req.user.id); + // Pass the requesting user's id so their own anonymous donations are visible to them + const result = await DonationService.getDonations(filters, pagination, req.user.id, req.user.role); - res.status(200).json({ - success: true, - ...result, - }); + res.status(200).json({ success: true, ...result }); } catch (error) { next(error); } } - static async getCampaignDonations(req: Request, res: Response, next: NextFunction): Promise { + static async getCampaignDonations(req: AuthRequest, res: Response, next: NextFunction): Promise { try { const { campaignId } = req.params; @@ -148,16 +164,19 @@ export class DonationController { const pagination = { page: req.query.page ? parseInt(req.query.page as string) : 1, limit: req.query.limit ? parseInt(req.query.limit as string) : 10, - sortBy: req.query.sortBy as string || 'createdAt', - sortOrder: req.query.sortOrder as string || 'desc', + sortBy: (req.query.sortBy as string) || 'createdAt', + sortOrder: (req.query.sortOrder as string) || 'desc', }; - const result = await DonationService.getDonations(filters, pagination); - - res.status(200).json({ - success: true, - ...result, - }); + // Public campaign donation feeds: pass requester context for identity gating + const result = await DonationService.getDonations( + filters, + pagination, + req.user?.id, + req.user?.role, + ); + + res.status(200).json({ success: true, ...result }); } catch (error) { next(error); } diff --git a/src/routes/donation.routes.ts b/src/routes/donation.routes.ts index 6c738b9..5fedefe 100644 --- a/src/routes/donation.routes.ts +++ b/src/routes/donation.routes.ts @@ -16,8 +16,10 @@ const createDonationSchema = z.object({ fromWallet: z.string().optional(), toWallet: z.string().optional(), memo: z.string().optional(), - isAnonymous: z.boolean().default(false), donorMessage: z.string().optional(), + isAnonymous: z.boolean().default(false), + groupId: z.string().optional(), + retentionPolicy: z.string().optional(), }); const confirmDonationSchema = z.object({ @@ -117,6 +119,17 @@ router.post( DonationController.confirmDonation ); +/** + * @route POST /api/v1/donations/:id/reveal-identity + * @desc Donor opts in to reveal their identity for an anonymous donation + * @access Private (Donor who owns the donation) + */ +router.post( + '/:id/reveal-identity', + authenticate, + DonationController.revealIdentity +); + /** * @route POST /api/v1/donations/:id/refund * @desc Refund a donation diff --git a/src/services/analytics.service.ts b/src/services/analytics.service.ts index dd172d5..648e8d6 100644 --- a/src/services/analytics.service.ts +++ b/src/services/analytics.service.ts @@ -2,6 +2,7 @@ import prisma from '../config/database'; import redis from '../config/redis'; import logger from '../config/logger'; import { config } from '../config'; +import { stripDonorPII } from '../utils/anonymity'; import { TrendingCampaignFilters, TrendingCampaign, @@ -143,7 +144,9 @@ export class AnalyticsService { totalDonations: donations.length, campaignsSupported, avgDonation: donations.length > 0 ? totalDonated / donations.length : 0, - recentDonations: donations.slice(0, 10), + recentDonations: donations.slice(0, 10).map((d) => + d.isAnonymous ? stripDonorPII(d) : d + ), monthlyTrend: monthlyDonations, }; } diff --git a/src/services/donation.service.test.ts b/src/services/donation.service.test.ts index 2410892..1b852d3 100644 --- a/src/services/donation.service.test.ts +++ b/src/services/donation.service.test.ts @@ -1,5 +1,6 @@ import { DonationService } from './donation.service'; import prisma from '../config/database'; +import { DonationService } from './donation.service'; // Mock Prisma jest.mock('../config/database'); @@ -12,6 +13,9 @@ jest.mock('@prisma/client', () => ({ Role: { ADMIN: 'ADMIN', }, + AuditAction: { + DONATION_IDENTITY_REVEALED: 'DONATION_IDENTITY_REVEALED', + }, })); describe('DonationService', () => { @@ -604,4 +608,227 @@ describe('DonationService', () => { }); }); -}); \ No newline at end of file + // ─── Anonymity ──────────────────────────────────────────────────────────── + + describe('createDonation – anonymity / GDPR data minimisation', () => { + const activeCampaign = { id: 'camp1', status: 'ACTIVE' }; + + beforeEach(() => { + (prisma.campaign.findUnique as jest.Mock).mockResolvedValue(activeCampaign); + }); + + it('strips donorName and donorEmail from record when isAnonymous is true', async () => { + (prisma.donation.create as jest.Mock).mockImplementation(({ data }: any) => + Promise.resolve({ id: 'd1', ...data }) + ); + + await DonationService.createDonation( + { campaignId: 'camp1', amount: 50, isAnonymous: true, donorName: 'Jane', donorEmail: 'jane@example.com' }, + 'user1', + ); + + const createCall = (prisma.donation.create as jest.Mock).mock.calls[0][0].data; + expect(createCall).not.toHaveProperty('donorName'); + expect(createCall).not.toHaveProperty('donorEmail'); + }); + + it('does NOT strip donorName/donorEmail for identified donations', async () => { + (prisma.donation.create as jest.Mock).mockImplementation(({ data }: any) => + Promise.resolve({ id: 'd2', ...data }) + ); + + await DonationService.createDonation( + { campaignId: 'camp1', amount: 50, isAnonymous: false, donorName: 'Jane', donorEmail: 'jane@example.com' }, + 'user1', + ); + + const createCall = (prisma.donation.create as jest.Mock).mock.calls[0][0].data; + // Identified donations may pass through donorName / donorEmail if present + // (the Prisma model doesn't store them, but they should not be actively stripped) + expect(createCall.isAnonymous).toBe(false); + }); + + it('does not link userId to record when isAnonymous is true', async () => { + (prisma.donation.create as jest.Mock).mockImplementation(({ data }: any) => + Promise.resolve({ id: 'd3', ...data }) + ); + + await DonationService.createDonation( + { campaignId: 'camp1', amount: 75, isAnonymous: true }, + 'user1', + ); + + const createCall = (prisma.donation.create as jest.Mock).mock.calls[0][0].data; + expect(createCall.userId).toBeUndefined(); + }); + + it('persists groupId and retentionPolicy when provided', async () => { + (prisma.donation.create as jest.Mock).mockImplementation(({ data }: any) => + Promise.resolve({ id: 'd4', ...data }) + ); + + await DonationService.createDonation( + { campaignId: 'camp1', amount: 100, isAnonymous: true, groupId: 'grp1', retentionPolicy: 'minimal' }, + 'user1', + ); + + const createCall = (prisma.donation.create as jest.Mock).mock.calls[0][0].data; + expect(createCall.groupId).toBe('grp1'); + expect(createCall.retentionPolicy).toBe('minimal'); + }); + }); + + describe('getDonations – anonymity enforcement', () => { + const anonDonation = { + id: 'da1', isAnonymous: true, userId: 'user-a', + campaign: { id: 'c1', title: 'T' }, + user: { id: 'user-a', username: 'Alice', email: 'alice@example.com' }, + }; + const identifiedDonation = { + id: 'da2', isAnonymous: false, userId: 'user-b', + campaign: { id: 'c1', title: 'T' }, + user: { id: 'user-b', username: 'Bob', email: 'bob@example.com' }, + }; + + beforeEach(() => { + (prisma.donation.findMany as jest.Mock).mockResolvedValue([anonDonation, identifiedDonation]); + (prisma.donation.count as jest.Mock).mockResolvedValue(2); + }); + + it('hides donor identity from anonymous donations for third-party viewers', async () => { + const result = await DonationService.getDonations({}, { page: 1, limit: 10 }, 'other-user'); + + const anon = result.data.find((d: any) => d.id === 'da1'); + expect(anon.user.username).toBe('Anonymous'); + expect(anon.user.email).toBeNull(); + }); + + it('exposes donor identity when requester is the donor themselves', async () => { + const result = await DonationService.getDonations({}, { page: 1, limit: 10 }, 'user-a'); + + const own = result.data.find((d: any) => d.id === 'da1'); + expect(own.user.username).toBe('Alice'); + }); + + it('exposes donor identity when requester is ADMIN', async () => { + const result = await DonationService.getDonations({}, { page: 1, limit: 10 }, 'admin-1', 'ADMIN'); + + const anon = result.data.find((d: any) => d.id === 'da1'); + expect(anon.user.username).toBe('Alice'); + }); + + it('never masks identified donations', async () => { + const result = await DonationService.getDonations({}, { page: 1, limit: 10 }, 'other-user'); + + const identified = result.data.find((d: any) => d.id === 'da2'); + expect(identified.user.username).toBe('Bob'); + }); + }); + + describe('getDonationById – anonymity enforcement', () => { + const anonDonation = { + id: 'dx1', isAnonymous: true, userId: 'user-a', + campaign: { id: 'c1', title: 'T', organization: { name: 'Org' } }, + user: { id: 'user-a', username: 'Alice', email: 'alice@example.com' }, + }; + + it('masks donor for third-party requester', async () => { + (prisma.donation.findUnique as jest.Mock).mockResolvedValue(anonDonation); + + const result = await DonationService.getDonationById('dx1', 'other'); + expect(result.user.username).toBe('Anonymous'); + }); + + it('exposes donor for the owner', async () => { + (prisma.donation.findUnique as jest.Mock).mockResolvedValue(anonDonation); + + const result = await DonationService.getDonationById('dx1', 'user-a'); + expect(result.user.username).toBe('Alice'); + }); + + it('exposes donor for admin', async () => { + (prisma.donation.findUnique as jest.Mock).mockResolvedValue(anonDonation); + + const result = await DonationService.getDonationById('dx1', 'admin-1', 'ADMIN'); + expect(result.user.username).toBe('Alice'); + }); + }); + + describe('revealIdentity', () => { + const anonDonation = { id: 'rx1', isAnonymous: true, userId: 'user-a', revealedAt: null }; + + const txMock = { + donation: { update: jest.fn() }, + auditLog: { create: jest.fn() }, + }; + + beforeEach(() => { + txMock.donation.update.mockResolvedValue({ ...anonDonation, isAnonymous: false, revealedAt: new Date() }); + txMock.auditLog.create.mockResolvedValue({}); + (prisma.$transaction as jest.Mock).mockImplementation((fn: any) => fn(txMock)); + }); + + it('sets isAnonymous=false and records revealedAt', async () => { + (prisma.donation.findUnique as jest.Mock).mockResolvedValue(anonDonation); + + const result = await DonationService.revealIdentity('rx1', 'user-a'); + + expect(txMock.donation.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: 'rx1' }, + data: expect.objectContaining({ isAnonymous: false, revealedAt: expect.any(Date) }), + }) + ); + expect(result.isAnonymous).toBe(false); + }); + + it('creates an audit log entry', async () => { + (prisma.donation.findUnique as jest.Mock).mockResolvedValue(anonDonation); + + await DonationService.revealIdentity('rx1', 'user-a'); + + expect(txMock.auditLog.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + action: 'DONATION_IDENTITY_REVEALED', + entityType: 'Donation', + entityId: 'rx1', + }), + }) + ); + }); + + it('throws 403 when called by a different user', async () => { + (prisma.donation.findUnique as jest.Mock).mockResolvedValue(anonDonation); + + await expect(DonationService.revealIdentity('rx1', 'other-user')).rejects.toThrow( + 'You can only reveal identity for your own donations' + ); + }); + + it('throws 400 when donation is already identified', async () => { + (prisma.donation.findUnique as jest.Mock).mockResolvedValue({ ...anonDonation, isAnonymous: false }); + + await expect(DonationService.revealIdentity('rx1', 'user-a')).rejects.toThrow( + 'Donation is already identified' + ); + }); + + it('throws 400 when identity already revealed', async () => { + (prisma.donation.findUnique as jest.Mock).mockResolvedValue({ + ...anonDonation, + revealedAt: new Date('2026-01-01'), + }); + + await expect(DonationService.revealIdentity('rx1', 'user-a')).rejects.toThrow( + 'Identity already revealed for this donation' + ); + }); + + it('throws 404 when donation not found', async () => { + (prisma.donation.findUnique as jest.Mock).mockResolvedValue(null); + + await expect(DonationService.revealIdentity('rx1', 'user-a')).rejects.toThrow('Donation not found'); + }); + }); +}); diff --git a/src/services/donation.service.ts b/src/services/donation.service.ts index 9e9f284..f53092c 100644 --- a/src/services/donation.service.ts +++ b/src/services/donation.service.ts @@ -23,6 +23,9 @@ export class DonationService { throw new AppError('Campaign is not active', 400); } + // Strip donor PII when anonymous to enforce GDPR data minimisation + const sanitised = sanitizeAnonymousInput(data); + const donation = await prisma.donation.create({ data: { ...data, @@ -54,7 +57,6 @@ export class DonationService { // IMPORTANT: multipliers must be applied at the time the payment is confirmed, // so the matched-funds ledger is auditable. const updated = await prisma.$transaction(async (tx) => { - // Update donation const updatedDonation = await tx.donation.update({ where: { id }, data: { @@ -150,6 +152,7 @@ export class DonationService { amount: updated.amount, currency: updated.currency, blockchainTxHash: txHash, + isAnonymous: donation.isAnonymous, }).catch((err) => logger.error('Webhook dispatch error (donation.confirmed):', err)); if (config.receipts.enabled && donation.userId) { @@ -166,7 +169,8 @@ export class DonationService { static async getDonations( filters: DonationFilters = {}, pagination: any, - requestingUserId?: string + requestingUserId?: string, + requestingUserRole?: string, ): Promise> { filters = filters ?? {}; @@ -175,55 +179,34 @@ export class DonationService { const where: any = {}; - if (filters.campaignId) { - where.campaignId = filters.campaignId; - } - - if (filters.userId) { - where.userId = filters.userId; - } - - if (filters.status) { - where.status = filters.status; - } + if (filters.campaignId) where.campaignId = filters.campaignId; + if (filters.userId) where.userId = filters.userId; + if (filters.status) where.status = filters.status; if (filters.startDate || filters.endDate) { where.createdAt = {}; - - if (filters.startDate) { - where.createdAt.gte = filters.startDate; - } - - if (filters.endDate) { - where.createdAt.lte = filters.endDate; - } + if (filters.startDate) where.createdAt.gte = filters.startDate; + if (filters.endDate) where.createdAt.lte = filters.endDate; } - const [donations, total] = await Promise.all([ + const [rawDonations, total] = await Promise.all([ prisma.donation.findMany({ where, skip, take: limit, orderBy: { [sortBy]: sortOrder }, include: { - campaign: { - select: { - id: true, - title: true, - }, - }, - user: { - select: { - id: true, - username: true, - email: true, - }, - }, + campaign: { select: { id: true, title: true } }, + user: { select: { id: true, username: true, email: true } }, }, - }).then((donations) => donations.map((d) => d.isAnonymous && d.userId !== requestingUserId ? { ...d, user: { id: null, username: 'Anonymous', email: null } } : d)), + }), prisma.donation.count({ where }), ]); + const donations = rawDonations.map((d) => + sanitizeDonorIdentity(d, requestingUserId, requestingUserRole), + ); + return { data: donations, pagination: { @@ -235,7 +218,11 @@ export class DonationService { }; } - static async getDonationById(id: string, requestingUserId?: string): Promise { + static async getDonationById( + id: string, + requestingUserId?: string, + requestingUserRole?: string, + ): Promise { const donation = await prisma.donation.findUnique({ where: { id }, include: { @@ -243,20 +230,10 @@ export class DonationService { select: { id: true, title: true, - organization: { - select: { - name: true, - }, - }, - }, - }, - user: { - select: { - id: true, - username: true, - email: true, + organization: { select: { name: true } }, }, }, + user: { select: { id: true, username: true, email: true } }, }, }); @@ -264,11 +241,60 @@ export class DonationService { throw new AppError('Donation not found', 404); } - if (donation.isAnonymous && donation.userId !== requestingUserId) { - return { ...donation, user: { id: null, username: 'Anonymous', email: null } }; + return sanitizeDonorIdentity(donation, requestingUserId, requestingUserRole); + } + + /** + * Allows a donor to optionally reveal their identity after donating. + * Explicitly opt-in; logged to the audit trail. + */ + static async revealIdentity( + id: string, + requestingUserId: string, + ): Promise { + const donation = await prisma.donation.findUnique({ where: { id } }); + + if (!donation) { + throw new AppError('Donation not found', 404); } - return donation; + if (donation.userId !== requestingUserId) { + throw new AppError('You can only reveal identity for your own donations', 403); + } + + if (!donation.isAnonymous) { + throw new AppError('Donation is already identified', 400); + } + + if (donation.revealedAt) { + throw new AppError('Identity already revealed for this donation', 400); + } + + const updated = await prisma.$transaction(async (tx) => { + const updatedDonation = await tx.donation.update({ + where: { id }, + data: { + isAnonymous: false, + revealedAt: new Date(), + }, + }); + + await tx.auditLog.create({ + data: { + userId: requestingUserId, + action: AuditAction.DONATION_IDENTITY_REVEALED, + entityType: 'Donation', + entityId: id, + metadata: { revealedAt: updatedDonation.revealedAt }, + }, + }); + + return updatedDonation; + }); + + logger.info(`Donation identity revealed: ${id} by user ${requestingUserId}`); + + return updated; } static async refundDonation(id: string, userId: string, userRole: Role): Promise { @@ -285,7 +311,6 @@ export class DonationService { throw new AppError('Only confirmed donations can be refunded', 400); } - // Check permissions if (donation.userId !== userId && userRole !== Role.ADMIN) { throw new AppError('You do not have permission to refund this donation', 403); } @@ -304,19 +329,12 @@ export class DonationService { // Update donation status const updatedDonation = await tx.donation.update({ where: { id }, - data: { - status: DonationStatus.REFUNDED, - }, + data: { status: DonationStatus.REFUNDED }, }); - // Decrease campaign current amount await tx.campaign.update({ where: { id: donation.campaignId }, - data: { - currentAmount: { - decrement: donation.amount, - }, - }, + data: { currentAmount: { decrement: donation.amount } }, }); return updatedDonation; @@ -324,9 +342,8 @@ export class DonationService { logger.info(`Donation refunded: ${id} by user ${userId}`); - // Update cache: invalidate on refund AnalyticsService.invalidateCampaignCache(donation.campaignId).catch((err) => - logger.error('Failed to invalidate campaign cache on refund', err) + logger.error('Failed to invalidate campaign cache on refund', err), ); return updated; diff --git a/src/services/webhook.service.ts b/src/services/webhook.service.ts index dd9067d..6450abf 100644 --- a/src/services/webhook.service.ts +++ b/src/services/webhook.service.ts @@ -4,6 +4,7 @@ import prisma from '../config/database'; import { WebhookEventType, WebhookDeliveryStatus } from '@prisma/client'; import { AppError } from '../middleware/error'; import logger from '../config/logger'; +import { stripDonorPII } from '../utils/anonymity'; const MAX_ATTEMPTS = 5; @@ -78,7 +79,9 @@ export class WebhookService { if (webhooks.length === 0) return; - const enriched = { event: eventType, timestamp: new Date().toISOString(), ...payload }; + // Strip donor PII from outbound webhook payloads for anonymous donations + const safePayload = payload.isAnonymous ? stripDonorPII(payload) : payload; + const enriched = { event: eventType, timestamp: new Date().toISOString(), ...safePayload }; await Promise.all( webhooks.map((wh: any) => diff --git a/src/types/index.ts b/src/types/index.ts index 3cfe9ae..8b40186 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -122,10 +122,17 @@ export interface DonationInput { campaignId: string; amount: number; currency?: string; + fromWallet?: string; + toWallet?: string; + memo?: string; + donorMessage?: string; + isAnonymous?: boolean; + groupId?: string; + retentionPolicy?: string; + // Identified-only fields — stripped at service layer when isAnonymous is true donorName?: string; donorEmail?: string; message?: string; - isAnonymous?: boolean; } export interface DistributionInput { diff --git a/src/utils/anonymity.ts b/src/utils/anonymity.ts new file mode 100644 index 0000000..dc7c78b --- /dev/null +++ b/src/utils/anonymity.ts @@ -0,0 +1,65 @@ +/** + * Anonymity helpers for the donation system. + * + * Centralises all decisions about when to hide / strip donor identity so the + * rules are applied consistently across services, controllers, webhooks, and + * analytics. + */ + +export const ANONYMOUS_DONOR = { + id: null as null, + username: 'Anonymous', + email: null as null, +} as const; + +export type DonorView = typeof ANONYMOUS_DONOR | { id: string; username: string | null; email: string }; + +/** + * Returns true when the requesting user is allowed to see real donor identity. + * + * Admins and the donor themselves can always see it. + * For all others the `isAnonymous` flag controls visibility. + */ +export function canViewDonorIdentity( + donation: { isAnonymous: boolean; userId?: string | null }, + requestingUserId?: string, + requestingUserRole?: string, +): boolean { + if (requestingUserRole === 'ADMIN') return true; + if (requestingUserId && requestingUserId === donation.userId) return true; + return !donation.isAnonymous; +} + +/** + * Replaces donor identity on a donation object when the requester is not + * allowed to see it. Returns a new object; does not mutate the original. + */ +export function sanitizeDonorIdentity( + donation: T, + requestingUserId?: string, + requestingUserRole?: string, +): T { + if (canViewDonorIdentity(donation, requestingUserId, requestingUserRole)) { + return donation; + } + return { ...donation, user: ANONYMOUS_DONOR }; +} + +/** + * Strips PII fields that must not be stored for anonymous donations. + * Call this before persisting the donation record. + */ +export function sanitizeAnonymousInput>(data: T): T { + if (!data.isAnonymous) return data; + const { donorName: _dn, donorEmail: _de, message: _msg, ...rest } = data; + return rest as T; +} + +/** + * Strips donor PII from an outbound payload (e.g. webhooks, analytics). + * Returns a new object with PII fields removed or replaced. + */ +export function stripDonorPII>(payload: T): Omit & { user?: typeof ANONYMOUS_DONOR } { + const { donorName: _dn, donorEmail: _de, userId: _uid, user: _u, ...rest } = payload; + return { ...rest, user: ANONYMOUS_DONOR }; +}