From 008016b947abd2874da1eef1a7ee8d684eae6db2 Mon Sep 17 00:00:00 2001 From: Femi <114834028+Toyosi5566@users.noreply.github.com> Date: Fri, 16 Oct 2026 15:32:36 +0100 Subject: [PATCH 1/2] security: ignore local environment files .env is read by dotenv.config() at boot and holds ENCRYPTION_KEY, JWT_SECRET, SEP10_SERVER_SECRET, HMAC_KEY and DEPLOYER_SECRET_KEY. None of the .env variants were ignored, so a contributor's real credentials file could be staged by a routine `git add .` with nothing to warn anyone. Ignore the .env variants and keep the committed example file trackable. No .env file is tracked at HEAD and no .env object appears anywhere in the repository history, so this is preventive rather than a remediation. --- .gitignore | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/.gitignore b/.gitignore index bea4619..ac0c2db 100644 --- a/.gitignore +++ b/.gitignore @@ -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 From 97d4a1e2e8b4deda38349ec5e7d4a072534111bf Mon Sep 17 00:00:00 2001 From: Greatefe Date: Sat, 17 Oct 2026 18:23:34 +0100 Subject: [PATCH 2/2] fix: fix(db): declare the on-delete behaviour for the group relat (#248) --- .../migration.sql | 35 ++++++++++++++++ prisma/schema.prisma | 8 ++-- src/__tests__/group.service.test.ts | 42 ++++++++++++++++++- src/services/group.service.ts | 16 +++++++ 4 files changed, 96 insertions(+), 5 deletions(-) create mode 100644 prisma/migrations/20260901000000_add_group_relation_on_delete/migration.sql diff --git a/prisma/migrations/20260901000000_add_group_relation_on_delete/migration.sql b/prisma/migrations/20260901000000_add_group_relation_on_delete/migration.sql new file mode 100644 index 0000000..fb1d197 --- /dev/null +++ b/prisma/migrations/20260901000000_add_group_relation_on_delete/migration.sql @@ -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; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index ca46c2b..9f02a4a 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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 @@ -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 @@ -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? @@ -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 diff --git a/src/__tests__/group.service.test.ts b/src/__tests__/group.service.test.ts index 9b40b76..69a2588 100644 --- a/src/__tests__/group.service.test.ts +++ b/src/__tests__/group.service.test.ts @@ -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) }; }); @@ -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 = [ diff --git a/src/services/group.service.ts b/src/services/group.service.ts index b0b85ad..043d902 100644 --- a/src/services/group.service.ts +++ b/src/services/group.service.ts @@ -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 } }); } }