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
44 changes: 29 additions & 15 deletions src/controllers/groupContributionController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ────────────────────────────────────────────────────────────────

Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -558,6 +559,7 @@ export const contribute = async (
payerId: userId,
amount: contributionAmount,
transactionId: txRecord.id,
isAnonymous: !!isAnonymous,
},
{ transaction: dbTransaction }
);
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -982,7 +995,7 @@ export const listContributions = async (
const plain = c.toJSON() as unknown as Record<string, unknown>;
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;
Expand Down Expand Up @@ -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 });
Expand Down Expand Up @@ -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" });
Expand Down Expand Up @@ -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;
Expand Down
10 changes: 8 additions & 2 deletions src/controllers/groupController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1031,9 +1031,14 @@ const getGroupMembers = async (req: AuthenticatedRequest, res: Response, next: N
const updateGroup = async (req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> => {
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<typeof Models>;

// Check if user is the owner or admin
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
68 changes: 63 additions & 5 deletions src/controllers/publicContributionController.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import { Response } from "express";
import { Op } from "sequelize";
import * as bcrypt from "bcrypt";
import { v4 as uuidv4 } from "uuid";
import Models from "../database/models";
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 ────────────────────────────────────────────────────────────────

Expand Down Expand Up @@ -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" });
Expand Down Expand Up @@ -385,6 +387,7 @@ export const contribute = async (
payerId: userId,
amount: contributionAmount,
transactionId: txRecord.id,
isAnonymous: !!isAnonymous,
},
{ transaction: dbTransaction }
);
Expand Down Expand Up @@ -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 },
Expand All @@ -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"] },
{
Expand All @@ -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;
});

Expand Down Expand Up @@ -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" });
Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand Down
26 changes: 26 additions & 0 deletions src/controllers/transactionController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ const {
PaymentRequest,
Action,
SubAction,
Group,
PublicContribution,
} = database_models;

// Helper function to calculate fee
Expand Down Expand Up @@ -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,
},
],
},
{
Expand All @@ -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,
},
],
},
],
Expand Down
Original file line number Diff line number Diff line change
@@ -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");
},
};
Original file line number Diff line number Diff line change
@@ -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");
},
};
2 changes: 2 additions & 0 deletions src/database/models/groupContributionPayment.model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand All @@ -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" }
);
Expand Down
2 changes: 2 additions & 0 deletions src/database/models/publicContributionPayment.model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand All @@ -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" }
);
Expand Down
Loading
Loading