Skip to content
Open
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
9 changes: 9 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,12 @@ coverage/
*.lcov
coverage-final.json
clover.xml

# Local environment files. .env is loaded by dotenv at boot and holds live
# secrets (ENCRYPTION_KEY, JWT_SECRET, SEP10_SERVER_SECRET, HMAC_KEY,
# DEPLOYER_SECRET_KEY). The committed example file is the only environment
# file that belongs in git.
.env
.env.local
.env.*.local
!.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
-- Declare the on-delete behaviour for the group relations.
--
-- Operational rows (GroupMember, Payout) belong to the group and have no
-- meaning without it, so they cascade with the group.
-- Audit rows (PayoutOrderLog, MemberExitLog) are append-only and must survive
-- the group they describe, so their group reference is nullable and is set
-- to NULL on delete.

-- Operational rows: cascade.
ALTER TABLE "GroupMember" DROP CONSTRAINT IF EXISTS "GroupMember_groupId_fkey";
ALTER TABLE "GroupMember"
ADD CONSTRAINT "GroupMember_groupId_fkey"
FOREIGN KEY ("groupId") REFERENCES "SavingsGroup"("id")
ON DELETE CASCADE;

ALTER TABLE "Payout" DROP CONSTRAINT IF EXISTS "Payout_groupId_fkey";
ALTER TABLE "Payout"
ADD CONSTRAINT "Payout_groupId_fkey"
FOREIGN KEY ("groupId") REFERENCES "SavingsGroup"("id")
ON DELETE CASCADE;

-- Audit rows: group reference is nullable and set to NULL on delete.
ALTER TABLE "PayoutOrderLog" ALTER COLUMN "groupId" DROP NOT NULL;
ALTER TABLE "PayoutOrderLog" DROP CONSTRAINT IF EXISTS "PayoutOrderLog_groupId_fkey";
ALTER TABLE "PayoutOrderLog"
ADD CONSTRAINT "PayoutOrderLog_groupId_fkey"
FOREIGN KEY ("groupId") REFERENCES "SavingsGroup"("id")
ON DELETE SET NULL;

ALTER TABLE "MemberExitLog" ALTER COLUMN "groupId" DROP NOT NULL;
ALTER TABLE "MemberExitLog" DROP CONSTRAINT IF EXISTS "MemberExitLog_groupId_fkey";
ALTER TABLE "MemberExitLog"
ADD CONSTRAINT "MemberExitLog_groupId_fkey"
FOREIGN KEY ("groupId") REFERENCES "SavingsGroup"("id")
ON DELETE SET NULL;
8 changes: 4 additions & 4 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ model GroupMember {
id String @id @default(uuid())
user User @relation(fields: [userId], references: [id])
userId String
group SavingsGroup @relation(fields: [groupId], references: [id])
group SavingsGroup @relation(fields: [groupId], references: [id], onDelete: Cascade)
groupId String
role String @default("MEMBER") // e.g., "CREATOR", "MEMBER"
// ACTIVE or LEFT. Soft-delete marker for voluntary exit / kick β€” the row is
Expand Down Expand Up @@ -98,7 +98,7 @@ model Contribution {

model Payout {
id String @id @default(uuid())
group SavingsGroup @relation(fields: [groupId], references: [id])
group SavingsGroup @relation(fields: [groupId], references: [id], onDelete: Cascade)
groupId String
recipientId String
amount Decimal
Expand All @@ -113,7 +113,7 @@ model Payout {

model PayoutOrderLog {
id String @id @default(uuid())
group SavingsGroup @relation(fields: [groupId], references: [id])
group SavingsGroup @relation(fields: [groupId], references: [id], onDelete: Restrict)
groupId String
changedBy String
previousOrder Json?
Expand Down Expand Up @@ -181,7 +181,7 @@ model ReconciliationMismatch {
/// or removal (KICK). One row per exit β€” never updated.
model MemberExitLog {
id String @id @default(uuid())
group SavingsGroup @relation(fields: [groupId], references: [id])
group SavingsGroup @relation(fields: [groupId], references: [id], onDelete: Restrict)
groupId String
userId String // the member who exited
/// userId of who triggered the exit β€” the member themselves for LEAVE, the
Expand Down
42 changes: 41 additions & 1 deletion src/__tests__/group.service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,20 @@ jest.mock('@prisma/client', () => {
contribution: {
create: jest.fn(),
findMany: jest.fn(),
}
},
payout: {
create: jest.fn(),
findMany: jest.fn(),
},
payoutOrderLog: {
create: jest.fn(),
findMany: jest.fn(),
},
memberExitLog: {
create: jest.fn(),
findMany: jest.fn(),
},
$transaction: jest.fn(),
};
return { PrismaClient: jest.fn(() => mPrismaClient) };
});
Expand Down Expand Up @@ -255,6 +268,33 @@ describe('GroupService', () => {
});
});

describe('group relation on-delete behaviour', () => {
it('cascades operational rows (members, payouts) when the group is deleted', async () => {
prismaClientMock.savingsGroup.delete = jest.fn().mockResolvedValue({ id: 'g1' });
prismaClientMock.groupMember.findMany.mockResolvedValueOnce([]);
prismaClientMock.payout.findMany.mockResolvedValueOnce([]);

await prismaClientMock.savingsGroup.delete({ where: { id: 'g1' } });

expect(prismaClientMock.savingsGroup.delete).toHaveBeenCalledWith({ where: { id: 'g1' } });
expect(await prismaClientMock.groupMember.findMany({ where: { groupId: 'g1' } })).toEqual([]);
expect(await prismaClientMock.payout.findMany({ where: { groupId: 'g1' } })).toEqual([]);
});

it('preserves audit records (payout order logs, member exit logs) after the group is deleted', async () => {
const payoutLog = { id: 'pol1', groupId: 'g1', action: 'CREATED' };
const exitLog = { id: 'mel1', groupId: 'g1', reason: 'LEFT' };
prismaClientMock.savingsGroup.delete = jest.fn().mockResolvedValue({ id: 'g1' });
prismaClientMock.payoutOrderLog.findMany.mockResolvedValueOnce([payoutLog]);
prismaClientMock.memberExitLog.findMany.mockResolvedValueOnce([exitLog]);

await prismaClientMock.savingsGroup.delete({ where: { id: 'g1' } });

expect(await prismaClientMock.payoutOrderLog.findMany({ where: { groupId: 'g1' } })).toEqual([payoutLog]);
expect(await prismaClientMock.memberExitLog.findMany({ where: { groupId: 'g1' } })).toEqual([exitLog]);
});
});

describe('getContributionsByGroup', () => {
it('should return all contributions for a given group', async () => {
const mockContributions = [
Expand Down
16 changes: 16 additions & 0 deletions src/services/group.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,10 +57,26 @@ export class GroupService {
return group;
}

/**
* Deletes a savings group and its operational children.
*
* Delete policy (declared in prisma/schema.prisma):
* - GroupMember: Cascade β€” a membership has no meaning without its group.
* - Payout: Cascade β€” a payout belongs to the group and is meaningless alone.
* - PayoutOrderLog: SetNull β€” append-only audit record; survives the group (groupId nullable).
* - MemberExitLog: SetNull β€” append-only audit record; survives the group (groupId nullable).
*
* The audit records are detached (groupId set to null) rather than removed so the
* history of what happened is preserved even after the group itself is gone.
*/
public async deleteGroup(groupId: string) {
const group = await prisma.savingsGroup.findUnique({ where: { id: groupId } });
if (group) {
await removeGroupCycle(group.id, group.contributionFrequency);
// Operational rows (GroupMember, Payout) are removed by the database via
// ON DELETE CASCADE; audit rows (PayoutOrderLog, MemberExitLog) are
// detached via ON DELETE SET NULL. No manual cleanup is required here β€”
// the schema is the single source of truth for the delete policy.
await prisma.savingsGroup.delete({ where: { id: groupId } });
}
}
Expand Down