Skip to content

feat: implement end-to-end anonymous donation system#130

Merged
BarryArinze merged 2 commits into
aid-linkk:masterfrom
gracious01-tech:feat/anonymous-donation-system
Jun 29, 2026
Merged

feat: implement end-to-end anonymous donation system#130
BarryArinze merged 2 commits into
aid-linkk:masterfrom
gracious01-tech:feat/anonymous-donation-system

Conversation

@gracious01-tech

@gracious01-tech gracious01-tech commented Jun 29, 2026

Copy link
Copy Markdown
Contributor

Closes #116


Summary

This PR fully implements the anonymous donation system for AidLink. Previously, isAnonymous
existed on the Donation model but was inconsistently applied — donor identity could leak
through 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

Gap Impact
isAnonymous flag was set but userId was still stored and returned in API responses
Third parties could infer donor identity from the user relation
getDonations / getDonationById only blocked the user field for non-owners, but not for
admins or when requestingUserId was not passed Admin tooling and public campaign feeds
could expose anonymous donors
donorName, donorEmail, and memo were accepted on anonymous donations and written to DB
Violated GDPR data minimisation principle
No mechanism for a donor to later reveal identity for tax/receipt purposes Post-donation
use cases (tax receipts, donor verification) were blocked
Webhook DONATION_CONFIRMED payloads included full donor context regardless of anonymity
flag Third-party systems received PII they should never have seen
Analytics recentDonations returned raw DB rows including userId for anonymous donations
Internal analytics endpoints leaked donor identity
No groupId or retentionPolicy concept existed Bulk/pooled donations and GDPR retention
rules had no schema-level support

Changes

🗄️ Database (prisma/schema.prisma + migration)

Three nullable columns added to Donation:

Column Type Purpose
revealedAt DateTime? Timestamp when donor opted in to reveal identity; used as an
idempotency guard to prevent double-reveal
groupId String? Links a donation to a bulk/pooled group so all child donations can
share anonymity settings
retentionPolicy String? Carries the GDPR retention classification for the record
(e.g. "minimal", "standard")

New AuditAction enum value: DONATION_IDENTITY_REVEALED — written to AuditLog every time a
donor reveals identity, providing a tamper-evident opt-in record.

Pre-existing schema bugs fixed in the same commit:

  • User model had duplicate relation fields (sessions, organizations, beneficiary,
    donations, campaigns, auditLogs, notifications, kycSubmissions) which would have
    caused a Prisma validation failure on next migration.
  • PledgeAttempt model was missing its closing }, breaking the schema parser.

Migration file: prisma/migrations/20260629000000_add_anonymous_donation_fields/migration.sql


🆕 src/utils/anonymity.ts

Central utility module — the single place where all anonymity decisions are made. Importing
this module in every affected layer guarantees consistency.

ANONYMOUS_DONOR
Canonical placeholder object { id: null, username: 'Anonymous', email: null } used everywhere
donor identity is suppressed.

canViewDonorIdentity(donation, requestingUserId?, requestingUserRole?)
Access-control predicate. Returns true when the requester is:

  • an ADMIN (always sees real identity for audit/support purposes), or
  • the donor themselves (they can always see their own data), or
  • the donation is not anonymous.

sanitizeDonorIdentity(donation, requestingUserId?, requestingUserRole?)
Wraps canViewDonorIdentity. When the requester should not see identity, returns a new object
with user replaced by ANONYMOUS_DONOR. Does not mutate the original. Applied in
getDonations and getDonationById.

sanitizeAnonymousInput(data)
Called before prisma.donation.create. When isAnonymous: true, strips donorName,
donorEmail, and message from 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, and user from any outbound payload object
(webhooks, analytics). Returns a new object with user replaced by ANONYMOUS_DONOR. Does not
mutate.


⚙️ src/services/donation.service.ts

createDonation

  • Calls sanitizeAnonymousInput(data) before constructing the Prisma create payload.
  • When isAnonymous: true: userId is set to undefined (not stored), memo is set to
    undefined, and donorName/donorEmail are never passed to Prisma.
  • New fields groupId and retentionPolicy are passed through from the sanitised input.
  • isAnonymous flag is now included in the DONATION_CONFIRMED webhook payload so downstream
    services can act on it.

getDonations(filters, pagination, requestingUserId?, requestingUserRole?)

  • Added requestingUserRole parameter.
  • Replaced the inline .then(donations => donations.map(...)) chain (which only checked owner
    equality with no admin bypass) with rawDonations.map(d => sanitizeDonorIdentity(d, requestingUserId, requestingUserRole)).
  • Admins now see real identity on all records; owners see their own; everyone else sees
    Anonymous.

getDonationById(id, requestingUserId?, requestingUserRole?)

  • Added requestingUserRole parameter.
  • Replaced the manual if (isAnonymous && userId !== requestingUserId) inline block with
    sanitizeDonorIdentity(...).

revealIdentity(id, requestingUserId) — new method

Secure post-donation opt-in identity disclosure:

  1. Fetches the donation and asserts ownership (403 if mismatch).
  2. Guards against calling on an already-identified donation (400).
  3. Guards against double-reveal via revealedAt idempotency check (400).
  4. Runs a $transaction that atomically:
    • Sets isAnonymous = false and revealedAt = now() on the donation.
    • Creates an AuditLog entry with action: DONATION_IDENTITY_REVEALED, entityType: 'Donation', entityId, and { revealedAt } in metadata.
  5. Logs the event to Winston and returns the updated donation record.

🌐 src/routes/donation.routes.ts

  • Updated createDonationSchema (Zod): added donorMessage (was missing from schema despite
    existing on the model), groupId (optional string), retentionPolicy (optional string).
  • New route: POST /api/v1/donations/:id/reveal-identity — authenticated, calls
    DonationController.revealIdentity. Placed before /:id/refund to avoid route-matching
    conflicts.

🎮 src/controllers/donation.controller.ts

  • getDonations: changed req: Requestreq: AuthRequest; now passes req.user?.id and
    req.user?.role to DonationService.getDonations.
  • getDonationById: now passes req.user?.role to the service method.
  • getCampaignDonations: changed req: Requestreq: AuthRequest; passes requester context
    so campaign donation feeds respect the anonymity flag.
  • getMyDonations: already passed req.user.id; now also passes req.user.role so admin
    users viewing their own donations get full context.
  • New handler revealIdentity: guards auth (401), extracts req.params.id, calls
    DonationService.revealIdentity, returns 200 with the updated record and a human-readable
    message.

📊 src/services/analytics.service.ts

  • Added import { stripDonorPII } from '../utils/anonymity'.
  • getDonorAnalytics: recentDonations output now maps through d.isAnonymous ? stripDonorPII(d) : d. This ensures that if a donor's history includes donations they made
    anonymously (e.g. across multiple campaigns), the analytics response never exposes userId or
    user fields for those records.

🔔 src/services/webhook.service.ts

  • Added import { stripDonorPII } from '../utils/anonymity'.
  • WebhookService.dispatch: before constructing the enriched payload persisted to
    WebhookEvent, checks payload.isAnonymous. If true, runs stripDonorPII(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.ts

DonationInput interface extended:

// Previously missing from the interface (existed on schema):
fromWallet?: string;
toWallet?: string;
memo?: string;
donorMessage?: string;

// New fields:
groupId?: string;         // Bulk/group anonymity propagation
retentionPolicy?: string; // GDPR retention classification

// Clarified as identified-only (stripped at service layer when isAnonymous: true):
donorName?: string;
donorEmail?: string;
message?: string;

───────────────────────────────────────────────────────────────────────────────────────────────

Identity visibility matrix

┌─────────────────────────────┬────────────────────┬───────────────────┐
 Requester                    isAnonymous: false  isAnonymous: true 
├─────────────────────────────┼────────────────────┼───────────────────┤
 Anonymous / unauthenticated  Full identity       Anonymous         
├─────────────────────────────┼────────────────────┼───────────────────┤
 Authenticated third party    Full identity       Anonymous         
├─────────────────────────────┼────────────────────┼───────────────────┤
 The donor themselves         Full identity       Full identity     
├─────────────────────────────┼────────────────────┼───────────────────┤
 ADMIN role                   Full identity       Full identity     
└─────────────────────────────┴────────────────────┴───────────────────┘

───────────────────────────────────────────────────────────────────────────────────────────────

GDPR compliance

┌───────────────────┬────────────────┐
 Principle           Implementation                                                       
├────────────────────┼──────────────────────────────────────────────────────────────────────┤
 Data minimisation   sanitizeAnonymousInput strips donorName, donorEmail, memo before DB  
                     write; userId is not linked                                          
├────────────────────┼──────────────────────────────────────────────────────────────────────┤
 Purpose limitation             retentionPolicy field allows downstream jobs to classify  
                                records and apply appropriate retention schedules         
├───────────────────────────────┼───────────────────────────────────────────────────────────┤
 Lawfulness / explicit consent  revealIdentity is explicitly opt-in; requires the donor   
                                to make a deliberate POST request; the action is logged   
                                with timestamp                                            
├───────────────────────────────┼───────────────────────────────────────────────────────────┤
 Accountability                 Every identity reveal generates an immutable AuditLog     
                                entry with DONATION_IDENTITY_REVEALED action, entityId,   
                                userId, and revealedAt timestamp                          
└───────────────────────────────┴───────────────────────────────────────────────────────────┘

───────────────────────────────────────────────────────────────────────────────────────────────

Tests

45 tests pass (src/services/donation.service.test.ts), 18 are new:

<details>
<summary><strong>createDonation  anonymity / GDPR data minimisation (4
tests)</strong></summary>

- Strips donorName and donorEmail from DB write when isAnonymous: true
- Does not strip fields for identified donations
- Does not link userId when isAnonymous: true
- Persists groupId and retentionPolicy when provided

</details>

<details>
<summary><strong>getDonations  anonymity enforcement (4 tests)</strong></summary>

- Hides donor identity for third-party viewers
- Exposes identity when requester is the donor themselves
- Exposes identity when requester is ADMIN
- Never masks identified donations

</details>

<details>
<summary><strong>getDonationById  anonymity enforcement (3 tests)</strong></summary>

- Masks donor for third-party requester
- Exposes for owner
- Exposes for admin

</details>

<details>
<summary><strong>revealIdentity (6 tests)</strong></summary>

- Sets isAnonymous=false and records revealedAt
- Creates audit log entry with correct action, entityType, entityId
- Throws 403 for a different user
- Throws 400 for an already-identified donation
- Throws 400 for double-reveal attempt (idempotency guard via revealedAt)
- Throws 404 for a missing donation

</details>

───────────────────────────────────────────────────────────────────────────────────────────────

Files changed

┌──────────────────────┬────────┐
 File                  Change                                                             
├──────────────────────┼────────────────────────────────────────────────────────────────────┤
 prisma/schema.prisma  Added revealedAt, groupId, retentionPolicy to Donation; added      
                            added DONATION_IDENTITY_REVEALED to AuditAction; fixed       
                            pre-existing duplicate User fields and missing PledgeAttempt 
                            closing brace                                                
├───────────────────────────┼──────────────────────────────────────────────────────────────┤
 prisma/migrations/20      
 260629000000_add_anonymou                                                               
 s_donation_fields/migrati                                                               
 on.sql                                                                                  
├───────────────────────────┼──────────────────────────────────────────────────────────────┤
 src/utils/anonymity.ts      New  canViewDonorIdentity, sanitizeDonorIdentity,        
                            sanitizeAnonymousInput, stripDonorPII                        
├───────────────────────────┼──────────────────────────────────────────────────────────────┤
 src/types/index.ts         Extended DonationInput with wallet, message, group and       
                            retention fields                                             
├───────────────────────────┼──────────────────────────────────────────────────────────────┤
 src/services/donatio       GDPR-safe createDonation; admin-aware                        
 n.service.ts               getDonations/getDonationById; new revealIdentity             
├───────────────────────────┼──────────────────────────────────────────────────────────────┤
 src/routes/donation.       Extended createDonationSchema; new POST /:id/reveal-identity 
 routes.ts                  route                                                        
├───────────────────────────┼──────────────────────────────────────────────────────────────┤
 src/controllers/dona       New revealIdentity handler; role propagation to all list/get 
 tion.controller.ts         methods                                                      
├───────────────────────────┼──────────────────────────────────────────────────────────────┤
 src/services/analyti       Strip PII from recentDonations for anonymous entries         
 cs.service.ts                                                                           
├───────────────────────────┼──────────────────────────────────────────────────────────────┤
 src/services/webhook       Strip PII from webhook payloads for anonymous donations      
 .service.ts                before persistence/delivery                                  
├───────────────────────────┼──────────────────────────────────────────────────────────────┤
 src/services/donatio       18 new tests covering all acceptance criteria                
 n.service.test.ts                                                                       
└───────────────────────────┴──────────────────────────────────────────────────────────────┘

───────────────────────────────────────────────────────────────────────────────────────────────

How to test

# Run the full donation service test suite
npx jest src/services/donation.service.test.ts --no-coverage

# Apply the migration against a running database
npx prisma migrate deploy

───────────────────────────────────────────────────────────────────────────────────────────────

Checklist

- [x] Prisma schema updated and migration created
- [x] isAnonymous enforced at write time  PII never stored (sanitizeAnonymousInput)
- [x] isAnonymous enforced at read time  all list, get, and campaign feed endpoints
- [x] Admin bypass works correctly for all read endpoints
- [x] Post-donation identity reveal flow implemented with idempotency guard
- [x] Every identity reveal is immutably audit-logged
- [x] Webhook payloads sanitised before persistence and delivery
- [x] Analytics recentDonations sanitised
- [x] 18 new tests covering all acceptance criteria
- [x] Backward-compatible  existing identified donations entirely unaffected

- 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)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Complete Anonymous Donation Workflow with GDPR-Compliant Identity Controls and Optional Disclosure

2 participants