Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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';
4 changes: 4 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -785,6 +788,7 @@ enum AuditAction {
RECOVERY_SETTLED
RECOVERY_MANUAL_OVERRIDE
DONOR_CREDIT_ISSUED
DONATION_IDENTITY_REVEALED
}

model AuditLog {
Expand Down
79 changes: 49 additions & 30 deletions src/controllers/donation.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -41,7 +41,7 @@ export class DonationController {
}
}

static async getDonations(req: Request, res: Response, next: NextFunction): Promise<void> {
static async getDonations(req: AuthRequest, res: Response, next: NextFunction): Promise<void> {
try {
const filters = {
campaignId: req.query.campaignId as string,
Expand All @@ -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);
}
Expand All @@ -72,11 +74,27 @@ export class DonationController {
static async getDonationById(req: AuthRequest, res: Response, next: NextFunction): Promise<void> {
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<void> {
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);
Expand All @@ -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,
Expand Down Expand Up @@ -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<void> {
static async getCampaignDonations(req: AuthRequest, res: Response, next: NextFunction): Promise<void> {
try {
const { campaignId } = req.params;

Expand All @@ -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);
}
Expand Down
15 changes: 14 additions & 1 deletion src/routes/donation.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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
Expand Down
5 changes: 4 additions & 1 deletion src/services/analytics.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
};
}
Expand Down
Loading