feat: implement end-to-end anonymous donation system#130
Merged
BarryArinze merged 2 commits intoJun 29, 2026
Merged
Conversation
- Add revealedAt, groupId, retentionPolicy columns to Donation model - Add DONATION_IDENTITY_REVEALED to AuditAction enum - Add migration: 20260629000000_add_anonymous_donation_fields - Fix pre-existing schema bugs: duplicate User relations, missing PledgeAttempt closing brace New utility (src/utils/anonymity.ts): - sanitizeAnonymousInput: strips PII before DB write (GDPR data minimisation) - sanitizeDonorIdentity: masks donor in API responses (admin bypass included) - canViewDonorIdentity: single access-control predicate - stripDonorPII: removes PII from outbound payloads donation.service.ts: - createDonation: strip PII + unlink userId when isAnonymous - getDonations/getDonationById: consistent masking with admin bypass - revealIdentity: secure opt-in disclosure with atomic audit log entry donation.controller.ts: - Add revealIdentity handler - Pass requestingUserId + role to all list/get methods donation.routes.ts: - Add POST /:id/reveal-identity route - Extend createDonationSchema with groupId, retentionPolicy, donorMessage analytics.service.ts: - Strip PII from recentDonations for anonymous entries webhook.service.ts: - Strip PII from DONATION_CONFIRMED payload before persistence/delivery Tests: 45 pass (18 new covering GDPR minimisation, masking, admin bypass, revealIdentity happy/error paths)
9 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #116
Summary
This PR fully implements the anonymous donation system for AidLink. Previously,
isAnonymousexisted on the
Donationmodel but was inconsistently applied — donor identity could leakthrough list endpoints, analytics, and webhook payloads. This change closes all known
identity-leakage vectors, adds GDPR-compliant data minimisation at write time, introduces a
secure post-donation identity disclosure flow, and extends support for bulk/group anonymity and
retention policies. All changes are backward-compatible with existing identified donation
flows.
Problem
isAnonymousflag was set butuserIdwas still stored and returned in API responsesgetDonations/getDonationByIdonly blocked theuserfield for non-owners, but not forrequestingUserIdwas not passeddonorName,donorEmail, andmemowere accepted on anonymous donations and written to DBDONATION_CONFIRMEDpayloads included full donor context regardless of anonymityrecentDonationsreturned raw DB rows includinguserIdfor anonymous donationsgroupIdorretentionPolicyconcept existedChanges
🗄️ Database (
prisma/schema.prisma+ migration)Three nullable columns added to
Donation:revealedAtDateTime?groupIdString?retentionPolicyString?"minimal","standard")New
AuditActionenum value:DONATION_IDENTITY_REVEALED— written toAuditLogevery time adonor reveals identity, providing a tamper-evident opt-in record.
Pre-existing schema bugs fixed in the same commit:
Usermodel had duplicate relation fields (sessions,organizations,beneficiary,donations,campaigns,auditLogs,notifications,kycSubmissions) which would havecaused a Prisma validation failure on next migration.
PledgeAttemptmodel was missing its closing}, breaking the schema parser.Migration file:
prisma/migrations/20260629000000_add_anonymous_donation_fields/migration.sql🆕
src/utils/anonymity.tsCentral utility module — the single place where all anonymity decisions are made. Importing
this module in every affected layer guarantees consistency.
ANONYMOUS_DONORCanonical placeholder object
{ id: null, username: 'Anonymous', email: null }used everywheredonor identity is suppressed.
canViewDonorIdentity(donation, requestingUserId?, requestingUserRole?)Access-control predicate. Returns
truewhen the requester is:ADMIN(always sees real identity for audit/support purposes), orsanitizeDonorIdentity(donation, requestingUserId?, requestingUserRole?)Wraps
canViewDonorIdentity. When the requester should not see identity, returns a new objectwith
userreplaced byANONYMOUS_DONOR. Does not mutate the original. Applied ingetDonationsandgetDonationById.sanitizeAnonymousInput(data)Called before
prisma.donation.create. WhenisAnonymous: true, stripsdonorName,donorEmail, andmessagefrom the input object so they are never written to the database.This is the primary GDPR data minimisation control.
stripDonorPII(payload)Removes
donorName,donorEmail,userId, anduserfrom any outbound payload object(webhooks, analytics). Returns a new object with
userreplaced byANONYMOUS_DONOR. Does notmutate.
⚙️
src/services/donation.service.tscreateDonationsanitizeAnonymousInput(data)before constructing the Prismacreatepayload.isAnonymous: true:userIdis set toundefined(not stored),memois set toundefined, anddonorName/donorEmailare never passed to Prisma.groupIdandretentionPolicyare passed through from the sanitised input.isAnonymousflag is now included in theDONATION_CONFIRMEDwebhook payload so downstreamservices can act on it.
getDonations(filters, pagination, requestingUserId?, requestingUserRole?)requestingUserRoleparameter..then(donations => donations.map(...))chain (which only checked ownerequality with no admin bypass) with
rawDonations.map(d => sanitizeDonorIdentity(d, requestingUserId, requestingUserRole)).Anonymous.getDonationById(id, requestingUserId?, requestingUserRole?)requestingUserRoleparameter.if (isAnonymous && userId !== requestingUserId)inline block withsanitizeDonorIdentity(...).revealIdentity(id, requestingUserId)— new methodSecure post-donation opt-in identity disclosure:
403if mismatch).400).revealedAtidempotency check (400).$transactionthat atomically:isAnonymous = falseandrevealedAt = now()on the donation.AuditLogentry withaction: DONATION_IDENTITY_REVEALED,entityType: 'Donation',entityId, and{ revealedAt }in metadata.🌐
src/routes/donation.routes.tscreateDonationSchema(Zod): addeddonorMessage(was missing from schema despiteexisting on the model),
groupId(optional string),retentionPolicy(optional string).POST /api/v1/donations/:id/reveal-identity— authenticated, callsDonationController.revealIdentity. Placed before/:id/refundto avoid route-matchingconflicts.
🎮
src/controllers/donation.controller.tsgetDonations: changedreq: Request→req: AuthRequest; now passesreq.user?.idandreq.user?.roletoDonationService.getDonations.getDonationById: now passesreq.user?.roleto the service method.getCampaignDonations: changedreq: Request→req: AuthRequest; passes requester contextso campaign donation feeds respect the anonymity flag.
getMyDonations: already passedreq.user.id; now also passesreq.user.roleso adminusers viewing their own donations get full context.
revealIdentity: guards auth (401), extractsreq.params.id, callsDonationService.revealIdentity, returns200with the updated record and a human-readablemessage.
📊
src/services/analytics.service.tsimport { stripDonorPII } from '../utils/anonymity'.getDonorAnalytics:recentDonationsoutput now maps throughd.isAnonymous ? stripDonorPII(d) : d. This ensures that if a donor's history includes donations they madeanonymously (e.g. across multiple campaigns), the analytics response never exposes
userIdoruserfields for those records.🔔
src/services/webhook.service.tsimport { stripDonorPII } from '../utils/anonymity'.WebhookService.dispatch: before constructing the enriched payload persisted toWebhookEvent, checkspayload.isAnonymous. If true, runsstripDonorPII(payload)first.This ensures third-party webhook subscribers (payment processors, CRMs, audit systems) never
receive donor PII for anonymous donations, regardless of which event is being dispatched.
🏷️
src/types/index.tsDonationInputinterface extended: