From 18ee89d97313016eaed5198b625f80b1579271f6 Mon Sep 17 00:00:00 2001 From: Olivier BYIRINGIRO Date: Mon, 20 Jul 2026 16:28:58 +0200 Subject: [PATCH] feat: add isAnonymous attribute to contribution payments and implement privacy redaction for anonymous contributions --- .../groupContributionController.ts | 44 ++++++++---- src/controllers/groupController.ts | 10 ++- .../publicContributionController.ts | 68 +++++++++++++++++-- src/controllers/transactionController.ts | 26 +++++++ ...onymous-to-public-contribution-payments.js | 19 ++++++ ...nonymous-to-group-contribution-payments.js | 19 ++++++ .../models/groupContributionPayment.model.ts | 2 + .../models/publicContributionPayment.model.ts | 2 + src/types/group.ts | 1 + src/types/model.ts | 6 +- src/utils/paymentPrivacy.ts | 23 +++++++ 11 files changed, 196 insertions(+), 24 deletions(-) create mode 100644 src/database/migrations/20260717000000-add-is-anonymous-to-public-contribution-payments.js create mode 100644 src/database/migrations/20260717000001-add-is-anonymous-to-group-contribution-payments.js create mode 100644 src/utils/paymentPrivacy.ts diff --git a/src/controllers/groupContributionController.ts b/src/controllers/groupContributionController.ts index f4257f6..a2e71b9 100644 --- a/src/controllers/groupContributionController.ts +++ b/src/controllers/groupContributionController.ts @@ -7,6 +7,7 @@ import { AuthenticatedRequest } from "../types/requests"; import { GroupMemberRole, GroupMemberStatus } from "../types/group"; import { NotificationType } from "../utils/notificationConfig"; import { createAndSendNotification } from "../utils/notificationService"; +import { redactAnonymousPayments } from "../utils/paymentPrivacy"; // ─── helpers ──────────────────────────────────────────────────────────────── @@ -301,7 +302,7 @@ export const contribute = async ( const groupId = req.params.groupId as string; const contributionId = req.params.contributionId as string; const userId = req.user.id; - const { amount, pin } = req.body; + const { amount, pin, isAnonymous } = req.body; if (!pin || !/^\d{4}$/.test(String(pin))) { res.status(400).json({ @@ -558,6 +559,7 @@ export const contribute = async ( payerId: userId, amount: contributionAmount, transactionId: txRecord.id, + isAnonymous: !!isAnonymous, }, { transaction: dbTransaction } ); @@ -604,21 +606,32 @@ export const contribute = async ( console.error("disburseFunds error on goal reached:", err) ); } - const progressPayload = { - groupId, - contributionId, - collectedAmount: contribution.collectedAmount, - goalAmount: contribution.goalAmount, - contributorCount: contribution.contributorCount, - status: contribution.status, - payerId: userId, - payerName: `${payer.firstName} ${payer.lastName}`, - amount: contributionAmount, + const buildProgressPayload = (recipientId: string) => { + const recipientIsPrivileged = + recipientId === userId || + allMembers.some( + (m) => + m.userId === recipientId && + [GroupMemberRole.OWNER, GroupMemberRole.ADMIN].includes(m.role as GroupMemberRole) + ); + const revealIdentity = !isAnonymous || recipientIsPrivileged; + + return { + groupId, + contributionId, + collectedAmount: contribution.collectedAmount, + goalAmount: contribution.goalAmount, + contributorCount: contribution.contributorCount, + status: contribution.status, + payerId: revealIdentity ? userId : null, + payerName: revealIdentity ? `${payer.firstName} ${payer.lastName}` : "Anonymous", + amount: contributionAmount, + }; }; if (io) { allMembers.forEach((m) => { - io.to(`user_${m.userId}`).emit("group_contribution_updated", progressPayload); + io.to(`user_${m.userId}`).emit("group_contribution_updated", buildProgressPayload(m.userId)); }); if (isGoalReached) { @@ -982,7 +995,7 @@ export const listContributions = async ( const plain = c.toJSON() as unknown as Record; const payments = paymentsByContribution.get(c.id) ?? []; if (memberIsAdmin || c.visibilityMode === "all") { - plain.payments = payments; + plain.payments = redactAnonymousPayments(payments, userId, memberIsAdmin); } plain.myPayment = payments.find((p: any) => p.payerId === userId) ?? null; return plain; @@ -1061,6 +1074,7 @@ export const getContribution = async ( plain.payments = myPayment ? [myPayment] : []; } + plain.payments = redactAnonymousPayments(plain.payments as any[] ?? [], userId, memberIsAdmin); plain.myPayment = myPayment; res.status(200).json({ success: true, data: plain }); @@ -1245,7 +1259,7 @@ export const listContributors = async ( order: [["createdAt", "DESC"]], }); - res.status(200).json({ success: true, data: payments }); + res.status(200).json({ success: true, data: redactAnonymousPayments(payments, userId, memberIsAdmin) }); } catch (error) { console.error("listContributors error:", error); res.status(500).json({ success: false, message: "Internal server error" }); @@ -1435,7 +1449,7 @@ export const listMyContributions = async ( plain.groupName = groupMap.get(c.groupId) ?? "Unknown Group"; plain.isAdmin = isAdmin; if (isAdmin || c.visibilityMode === "all") { - plain.payments = payments; + plain.payments = redactAnonymousPayments(payments, userId, isAdmin); } plain.myPayment = payments.find((p: any) => p.payerId === userId) ?? null; return plain; diff --git a/src/controllers/groupController.ts b/src/controllers/groupController.ts index 10f463b..224cb46 100644 --- a/src/controllers/groupController.ts +++ b/src/controllers/groupController.ts @@ -1031,9 +1031,14 @@ const getGroupMembers = async (req: AuthenticatedRequest, res: Response, next: N const updateGroup = async (req: AuthenticatedRequest, res: Response, next: NextFunction): Promise => { try { const { groupId } = req.params; - const { name, description, picture, isPrivate, maxMembers }: UpdateGroupRequest = req.body; + const { name, description, picture, isPrivate, privacyType, maxMembers }: UpdateGroupRequest = req.body; const userId = req.user.id; + if (privacyType !== undefined && !Object.values(GroupPrivacyType).includes(privacyType)) { + res.status(400).json({ message: "Invalid privacyType" }); + return; + } + const models = req.app.get('models') as ReturnType; // Check if user is the owner or admin @@ -1063,6 +1068,7 @@ const updateGroup = async (req: AuthenticatedRequest, res: Response, next: NextF if (description !== undefined) updateData.description = description?.trim(); if (picture !== undefined) updateData.picture = picture; if (isPrivate !== undefined) updateData.isPrivate = isPrivate; + if (privacyType !== undefined) updateData.privacyType = privacyType; if (maxMembers !== undefined && maxMembers >= (group.memberCount || 0)) updateData.maxMembers = maxMembers; // Update the group @@ -1077,7 +1083,7 @@ const updateGroup = async (req: AuthenticatedRequest, res: Response, next: NextF if (updateData.name) changes.push('name'); if (updateData.description !== undefined) changes.push('description'); if (updateData.picture) changes.push('picture'); - if (updateData.isPrivate !== undefined) changes.push('privacy settings'); + if (updateData.isPrivate !== undefined || updateData.privacyType !== undefined) changes.push('privacy settings'); if (updateData.maxMembers) changes.push('member limit'); const updateDescription = changes.length > 0 diff --git a/src/controllers/publicContributionController.ts b/src/controllers/publicContributionController.ts index 279ef77..e9cf6d3 100644 --- a/src/controllers/publicContributionController.ts +++ b/src/controllers/publicContributionController.ts @@ -1,4 +1,5 @@ import { Response } from "express"; +import { Op } from "sequelize"; import * as bcrypt from "bcrypt"; import { v4 as uuidv4 } from "uuid"; import Models from "../database/models"; @@ -6,6 +7,7 @@ import { AuthenticatedRequest } from "../types/requests"; import { NotificationType } from "../utils/notificationConfig"; import { createAndSendNotification } from "../utils/notificationService"; import { GroupMemberRole, GroupMemberStatus, GroupPrivacyType, GroupExpirationType } from "../types/group"; +import { redactAnonymousPayments } from "../utils/paymentPrivacy"; // ─── helpers ──────────────────────────────────────────────────────────────── @@ -184,7 +186,7 @@ export const contribute = async ( const models = getModels(req); const contributionId = req.params.contributionId as string; const userId = req.user.id; - const { amount, pin } = req.body; + const { amount, pin, isAnonymous } = req.body; if (!pin || !/^\d{4}$/.test(String(pin))) { res.status(400).json({ success: false, message: "A valid 4-digit PIN is required" }); @@ -385,6 +387,7 @@ export const contribute = async ( payerId: userId, amount: contributionAmount, transactionId: txRecord.id, + isAnonymous: !!isAnonymous, }, { transaction: dbTransaction } ); @@ -697,6 +700,8 @@ export const getContribution = async ( plain.payments = myPayment ? [myPayment] : []; } + plain.payments = redactAnonymousPayments(plain.payments as any[] ?? [], userId, isCreator); + if (userId) { const myPayment = await models.PublicContributionPayment.findOne({ where: { contributionId, payerId: userId }, @@ -723,8 +728,19 @@ export const listMyContributions = async ( const models = getModels(req); const userId = req.user.id; + const myPayments = await models.PublicContributionPayment.findAll({ + where: { payerId: userId }, + attributes: ["contributionId"], + }); + const contributedIds = [...new Set(myPayments.map((p) => p.contributionId))]; + const contributions = await models.PublicContribution.findAll({ - where: { createdBy: userId }, + where: { + [Op.or]: [ + { createdBy: userId }, + ...(contributedIds.length ? [{ id: { [Op.in]: contributedIds } }] : []), + ], + }, include: [ { model: models.User, as: "creator", attributes: ["id", "firstName", "lastName"] }, { @@ -745,7 +761,18 @@ export const listMyContributions = async ( const data = contributions.map((c) => { const plain = c.get({ plain: true }) as any; - plain.isCreator = true; + const isCreator = c.createdBy === userId; + const isContributor = contributedIds.includes(c.id); + + let payments = (plain.payments as any[]) ?? []; + if (!isCreator && c.visibilityMode === "creator_only") { + payments = payments.filter((p) => p.payerId === userId); + } + + plain.isCreator = isCreator; + plain.isContributor = isContributor; + plain.myPayment = payments.find((p) => p.payerId === userId) ?? null; + plain.payments = redactAnonymousPayments(payments, userId, isCreator); return plain; }); @@ -881,7 +908,7 @@ export const listContributors = async ( order: [["createdAt", "DESC"]], }); - res.status(200).json({ success: true, data: payments }); + res.status(200).json({ success: true, data: redactAnonymousPayments(payments, userId, isCreator) }); } catch (error) { console.error("listContributors error:", error); res.status(500).json({ success: false, message: "Internal server error" }); @@ -1200,7 +1227,7 @@ export const joinLinkedGroup = async ( const isAutoApproved = group.privacyType === GroupPrivacyType.PUBLIC; - await models.GroupMember.create({ + const membership = await models.GroupMember.create({ groupId: group.id, userId, role: GroupMemberRole.MEMBER, @@ -1223,6 +1250,37 @@ export const joinLinkedGroup = async ( }); } + if (!isAutoApproved) { + const user = await models.User.findByPk(userId); + if (user) { + await createAndSendNotification(req.app, { + type: NotificationType.GROUP_JOIN_REQUEST, + recipientId: group.ownerId, + data: { + groupId: group.id, + groupName: group.name, + requestId: membership.id, + userId: user.id, + userName: `${user.firstName} ${user.lastName}`, + message: `${user.firstName} ${user.lastName} wants to join your group "${group.name}"`, + title: "Group Join Request", + actions: [ + { + type: "approve", + label: "Approve", + url: `${process.env.FRONTEND_URL}/groups/${group.id}/requests/${membership.id}/respond?action=approve`, + }, + { + type: "decline", + label: "Decline", + url: `${process.env.FRONTEND_URL}/groups/${group.id}/requests/${membership.id}/respond?action=decline`, + }, + ], + }, + }); + } + } + res.status(200).json({ success: true, message: isAutoApproved diff --git a/src/controllers/transactionController.ts b/src/controllers/transactionController.ts index ef85240..6456a5f 100644 --- a/src/controllers/transactionController.ts +++ b/src/controllers/transactionController.ts @@ -26,6 +26,8 @@ const { PaymentRequest, Action, SubAction, + Group, + PublicContribution, } = database_models; // Helper function to calculate fee @@ -960,6 +962,18 @@ const getTransactionHistory = async ( attributes: ["id", "name", "email"], required: false, }, + { + model: Group, + as: "group", + attributes: ["id", "name", "profilePictureUrl"], + required: false, + }, + { + model: PublicContribution, + as: "publicContribution", + attributes: ["id", "title"], + required: false, + }, ], }, { @@ -986,6 +1000,18 @@ const getTransactionHistory = async ( attributes: ["id", "name", "email"], required: false, }, + { + model: Group, + as: "group", + attributes: ["id", "name", "profilePictureUrl"], + required: false, + }, + { + model: PublicContribution, + as: "publicContribution", + attributes: ["id", "title"], + required: false, + }, ], }, ], diff --git a/src/database/migrations/20260717000000-add-is-anonymous-to-public-contribution-payments.js b/src/database/migrations/20260717000000-add-is-anonymous-to-public-contribution-payments.js new file mode 100644 index 0000000..a6fd4e2 --- /dev/null +++ b/src/database/migrations/20260717000000-add-is-anonymous-to-public-contribution-payments.js @@ -0,0 +1,19 @@ +"use strict"; + +module.exports = { + up: async (queryInterface, Sequelize) => { + const tableDesc = await queryInterface.describeTable("PublicContributionPayments"); + + if (!tableDesc.isAnonymous) { + await queryInterface.addColumn("PublicContributionPayments", "isAnonymous", { + type: Sequelize.BOOLEAN, + allowNull: false, + defaultValue: false, + }); + } + }, + + down: async (queryInterface) => { + await queryInterface.removeColumn("PublicContributionPayments", "isAnonymous"); + }, +}; diff --git a/src/database/migrations/20260717000001-add-is-anonymous-to-group-contribution-payments.js b/src/database/migrations/20260717000001-add-is-anonymous-to-group-contribution-payments.js new file mode 100644 index 0000000..7a09a9d --- /dev/null +++ b/src/database/migrations/20260717000001-add-is-anonymous-to-group-contribution-payments.js @@ -0,0 +1,19 @@ +"use strict"; + +module.exports = { + up: async (queryInterface, Sequelize) => { + const tableDesc = await queryInterface.describeTable("GroupContributionPayments"); + + if (!tableDesc.isAnonymous) { + await queryInterface.addColumn("GroupContributionPayments", "isAnonymous", { + type: Sequelize.BOOLEAN, + allowNull: false, + defaultValue: false, + }); + } + }, + + down: async (queryInterface) => { + await queryInterface.removeColumn("GroupContributionPayments", "isAnonymous"); + }, +}; diff --git a/src/database/models/groupContributionPayment.model.ts b/src/database/models/groupContributionPayment.model.ts index c661e41..17070eb 100644 --- a/src/database/models/groupContributionPayment.model.ts +++ b/src/database/models/groupContributionPayment.model.ts @@ -14,6 +14,7 @@ class GroupContributionPayment extends Model< public amount!: number; public transactionId!: string | null; public currency!: string; + public isAnonymous!: boolean; public createdAt?: Date; public updatedAt?: Date; } @@ -27,6 +28,7 @@ const groupContributionPayment_model = (sequelize: Sequelize) => { amount: { type: DataTypes.DECIMAL(15, 2), allowNull: false }, transactionId: { type: DataTypes.UUID, allowNull: true }, currency: { type: DataTypes.STRING, defaultValue: "RWF" }, + isAnonymous: { type: DataTypes.BOOLEAN, defaultValue: false }, }, { sequelize, tableName: "GroupContributionPayments" } ); diff --git a/src/database/models/publicContributionPayment.model.ts b/src/database/models/publicContributionPayment.model.ts index cbe07a7..d8dc678 100644 --- a/src/database/models/publicContributionPayment.model.ts +++ b/src/database/models/publicContributionPayment.model.ts @@ -14,6 +14,7 @@ class PublicContributionPayment extends Model< public amount!: number; public transactionId!: string | null; public currency!: string; + public isAnonymous!: boolean; public createdAt?: Date; public updatedAt?: Date; } @@ -27,6 +28,7 @@ const publicContributionPayment_model = (sequelize: Sequelize) => { amount: { type: DataTypes.DECIMAL(15, 2), allowNull: false }, transactionId: { type: DataTypes.UUID, allowNull: true }, currency: { type: DataTypes.STRING, defaultValue: "RWF" }, + isAnonymous: { type: DataTypes.BOOLEAN, defaultValue: false }, }, { sequelize, tableName: "PublicContributionPayments" } ); diff --git a/src/types/group.ts b/src/types/group.ts index f03312c..e5b0e50 100644 --- a/src/types/group.ts +++ b/src/types/group.ts @@ -166,6 +166,7 @@ export interface UpdateGroupRequest { description?: string; picture?: string; isPrivate?: boolean; + privacyType?: GroupPrivacyType; maxMembers?: number; } diff --git a/src/types/model.ts b/src/types/model.ts index c105253..b1a9e10 100644 --- a/src/types/model.ts +++ b/src/types/model.ts @@ -884,13 +884,14 @@ export interface PublicContributionPaymentAttributes { amount: number; transactionId?: string | null; currency: string; + isAnonymous: boolean; createdAt?: Date; updatedAt?: Date; } export type PublicContributionPaymentCreationAttributes = Optional< PublicContributionPaymentAttributes, - "id" | "transactionId" | "currency" | "createdAt" | "updatedAt" + "id" | "transactionId" | "currency" | "isAnonymous" | "createdAt" | "updatedAt" >; export interface GroupContributionAttributes { @@ -941,11 +942,12 @@ export interface GroupContributionPaymentAttributes { amount: number; transactionId?: string | null; currency: string; + isAnonymous: boolean; createdAt?: Date; updatedAt?: Date; } export type GroupContributionPaymentCreationAttributes = Optional< GroupContributionPaymentAttributes, - "id" | "transactionId" | "currency" | "createdAt" | "updatedAt" + "id" | "transactionId" | "currency" | "isAnonymous" | "createdAt" | "updatedAt" >; diff --git a/src/utils/paymentPrivacy.ts b/src/utils/paymentPrivacy.ts new file mode 100644 index 0000000..134e829 --- /dev/null +++ b/src/utils/paymentPrivacy.ts @@ -0,0 +1,23 @@ +// Redacts payer identity on anonymous contributions before a payment list +// leaves the server. Money and totals are never touched — only the `payer`/ +// `payerId` fields are hidden from viewers who aren't the payer themselves +// or a privileged viewer (campaign creator / group owner-admin). +export const redactAnonymousPayments = >( + payments: T[], + viewerId: string | undefined, + viewerIsPrivileged: boolean +): T[] => { + return payments.map((payment) => { + const plain: any = typeof payment.toJSON === "function" ? payment.toJSON() : payment; + + if (!plain.isAnonymous || plain.payerId === viewerId || viewerIsPrivileged) { + return plain; + } + + return { + ...plain, + payerId: null, + payer: { id: null, firstName: "Anonymous", lastName: "" }, + }; + }); +};