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 diff --git a/prisma/migrations/20260901000000_convert_status_strings_to_enums/migration.sql b/prisma/migrations/20260901000000_convert_status_strings_to_enums/migration.sql new file mode 100644 index 0000000..ec43081 --- /dev/null +++ b/prisma/migrations/20260901000000_convert_status_strings_to_enums/migration.sql @@ -0,0 +1,96 @@ +-- Convert remaining free-form status strings to enums +-- This migration is designed to fail loudly if any existing row +-- contains a value outside the documented set, rather than coercing it. + +-- ------------------------------------------------------------------------------ +-- ContributionStatus: PENDING, COMPLETED, FAILED +-- ------------------------------------------------------------------------------ +CREATE TYPE "ContributionStatus" AS ENUM ('PENDING', 'COMPLETED', 'FAILED'); + +-- Fail loudly if any existing row has an undocumented value. +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 FROM "Contribution" + WHERE status NOT IN ('PENDING', 'COMPLETED', 'FAILED') + ) THEN + RAISE EXCEPTION 'Contribution.status contains values outside the ContributionStatus enum'; + END IF; +END $$; + +ALTER TABLE "Contribution" + ALTER COLUMN status TYPE "ContributionStatus" + USING status::"ContributionStatus", + ALTER COLUMN status SET DEFAULT 'PENDING'; + +-- ------------------------------------------------------------------------------ +-- PayoutStatus: COMPLETED +-- ------------------------------------------------------------------------------ +CREATE TYPE "PayoutStatus" AS ENUM ('COMPLETED'); + +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 FROM "Payout" + WHERE status NOT IN ('COMPLETED') + ) THEN + RAISE EXCEPTION 'Payout.status contains values outside the PayoutStatus enum'; + END IF; +END $$; + +ALTER TABLE "Payout" + ALTER COLUMN status TYPE "PayoutStatus" + USING status::"PayoutStatus"; + +-- ------------------------------------------------------------------------------ +-- PayoutOrderLogReason: REORDER, SKIP_DEFAULT +-- ------------------------------------------------------------------------------ +CREATE TYPE "PayoutOrderLogReason" AS ENUM ('REORDER', 'SKIP_DEFAULT'); + +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 FROM "PayoutOrderLog" + WHERE reason NOT IN ('REORDER', 'SKIP_DEFAULT') + ) THEN + RAISE EXCEPTION 'PayoutOrderLog.reason contains values outside the PayoutOrderLogReason enum'; + END IF; +END $$; + +ALTER TABLE "PayoutOrderLog" + ALTER COLUMN reason TYPE "PayoutOrderLogReason" + USING reason::"PayoutOrderLogReason"; + +-- ----------------------------------------------------------------------------- +-- MemberExitLogType: LEAVE, KICK +-- ------------------------------------------------------------------------------ +CREATE TYPE "MemberExitLogType" AS ENUM ('LEAVE', 'KICK'); + +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 FROM "MemberExitLog" + WHERE type NOT IN ('LEAVE', 'KICK') + ) THEN + RAISE EXCEPTION 'MemberExitLog.type contains values outside the MemberExitLogType enum'; + END IF; +END $$; + +ALTER TABLE "MemberExitLog" + ALTER COLUMN type TYPE "MemberExitLogType" + USING type::"MemberExitLogType"; + +-- ------------------------------------------------------------------------------ +-- SecretAccessLogOperation: SEND, CONTRIBUTE, TRUSTLINE, KEY_ROTATION +-- ------------------------------------------------------------------------------ +CREATE TYPE "SecretAccessLogOperation" AS ENUM ('SEND', 'CONTRIBUTE', 'TRUSTLINE', 'KEY_ROTATION'); + +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 FROM "SecretAccessLog" + WHERE operation NOT IN ('SEND', 'CONTRIBUTE', 'TRUSTLINE', 'KEY_ROTATION') + ) THEN + RAISE EXCEPTION 'SecretAccessLog.operation contains values outside the SecretAccessLogOperation enum'; + END IF; +END $$;} \ No newline at end of file diff --git a/prisma/schema.prisma b/prisma/schema.prisma index ca46c2b..96a75b7 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -35,6 +35,33 @@ enum DeploymentStatus { FAILED } +enum ContributionStatus { + PENDING + COMPLETED + FAILED +} + +enum PayoutStatus { + COMPLETED +} + +enum PayoutOrderReason { + REORDER + SKIP_DEFAULT +} + +enum SecretAccessOperation { + SEND + CONTRIBUTE + TRUSTLINE + KEY_ROTATION +} + +enum MemberExitType { + LEAVE + KICK +} + model SavingsGroup { id String @id @default(uuid()) name String @@ -89,7 +116,7 @@ model Contribution { groupId String amount Decimal transactionHash String? - status String @default("PENDING") // PENDING, COMPLETED, FAILED + status ContributionStatus @default(PENDING) createdAt DateTime @default(now()) @@index([userId]) @@ -104,7 +131,7 @@ model Payout { amount Decimal transactionHash String cycleNumber Int - status String @default("COMPLETED") + status PayoutStatus @default(COMPLETED) createdAt DateTime @default(now()) @@index([groupId]) @@ -118,7 +145,7 @@ model PayoutOrderLog { changedBy String previousOrder Json? newOrder Json - reason String @default("REORDER") // e.g., "REORDER", "SKIP_DEFAULT" + reason PayoutOrderReason @default(REORDER) createdAt DateTime @default(now()) @@index([groupId]) @@ -146,7 +173,7 @@ model PaymentRequest { model SecretAccessLog { id String @id @default(uuid()) userId String - operation String // "SEND" | "CONTRIBUTE" | "TRUSTLINE" | "KEY_ROTATION" + operation SecretAccessOperation success Boolean errorMessage String? createdAt DateTime @default(now()) @@ -187,7 +214,7 @@ model MemberExitLog { /// userId of who triggered the exit — the member themselves for LEAVE, the /// creator for KICK. initiatedBy String - type String // "LEAVE" | "KICK" + type MemberExitType totalContributed Decimal totalReceived Decimal /// totalContributed - totalReceived. Positive is refunded to the member; diff --git a/src/__tests__/payout.service.test.ts b/src/__tests__/payout.service.test.ts index 14467f0..8397e25 100644 --- a/src/__tests__/payout.service.test.ts +++ b/src/__tests__/payout.service.test.ts @@ -1,4 +1,4 @@ -import { PrismaClient } from '@prisma/client'; +import { PrismaClient, PayoutOrderLogReason } from '@prisma/client'; import { PayoutService, PayoutOrderLockedError, @@ -28,7 +28,7 @@ jest.mock('@prisma/client', () => { }, $transaction: jest.fn((ops: any[]) => Promise.resolve(ops)), }; - return { PrismaClient: jest.fn(() => mPrismaClient), Prisma: { Decimal } }; + return { PrismaClient: jest.fn(() => mPrismaClient), Prisma: { Decimal }, PayoutOrderLogReason: { REORDER: 'REORDER', SKIP_DEFAULT: 'SKIP_DEFAULT' } }; }); jest.mock('../queue/contribution-scheduler.queue', () => ({ @@ -153,7 +153,7 @@ describe('PayoutService', () => { changedBy: 'u1', previousOrder: ['u1', 'u2', 'u3'], // default join order since payoutOrder was null newOrder: ['u3', 'u1', 'u2'], - reason: 'REORDER', + reason: PayoutOrderLogReason.REORDER, }, }); }); @@ -387,7 +387,7 @@ describe('PayoutService', () => { data: { payoutOrder: ['u1', 'u3', 'u2'] }, }); expect(prisma.payoutOrderLog.create).toHaveBeenCalledWith({ - data: expect.objectContaining({ reason: 'SKIP_DEFAULT', changedBy: 'u1' }), + data: expect.objectContaining({ reason: PayoutOrderLogReason.SKIP_DEFAULT, changedBy: 'u1' }), }); }); diff --git a/src/services/group-exit.service.ts b/src/services/group-exit.service.ts index e756805..70d8950 100644 --- a/src/services/group-exit.service.ts +++ b/src/services/group-exit.service.ts @@ -1,6 +1,7 @@ import * as StellarSdk from '@stellar/stellar-sdk'; import { prisma } from '../lib/prisma'; import { PayoutService } from './payout.service'; +import { MemberExitType } from '@prisma/client'; import { SorobanService } from './soroban.service'; import { WhatsAppService } from './whatsapp.service'; import { removeGroupCycle } from '../queue/contribution-scheduler.queue'; @@ -166,7 +167,7 @@ export class GroupExitService { groupId: group.id, userId: member.userId, initiatedBy, - type, + type: type as MemberExitType, totalContributed: String(totalContributed), totalReceived: String(totalReceived), netOwed: String(netOwed), diff --git a/src/services/payout.service.ts b/src/services/payout.service.ts index 7392b32..7d6c15a 100644 --- a/src/services/payout.service.ts +++ b/src/services/payout.service.ts @@ -1,4 +1,4 @@ -import { PrismaClient, Prisma } from '@prisma/client'; +import { PrismaClient, Prisma, PayoutOrderLogReason, PayoutStatus, ContributionStatus } from '@prisma/client'; import { WhatsAppService } from './whatsapp.service'; import { SorobanService } from './soroban.service'; import { config } from '../config/env'; @@ -100,7 +100,7 @@ export class PayoutService { changedBy: requesterUserId, previousOrder, newOrder: order, - reason: 'REORDER', + reason: PayoutOrderLogReason.REORDER, }, }), ]); @@ -230,7 +230,7 @@ export class PayoutService { changedBy: requesterUserId, previousOrder: currentOrder, newOrder, - reason: 'SKIP_DEFAULT', + reason: PayoutOrderLogReason.SKIP_DEFAULT, }, }), ]); @@ -345,7 +345,7 @@ export class PayoutService { amount, transactionHash: hash, cycleNumber, - status: 'COMPLETED', + status: PayoutStatus.COMPLETED, }, }); diff --git a/src/utils/audit-logger.ts b/src/utils/audit-logger.ts index 6097537..68199eb 100644 --- a/src/utils/audit-logger.ts +++ b/src/utils/audit-logger.ts @@ -1,6 +1,7 @@ import { prisma } from '../lib/prisma'; +import type { SecretAccessOperation } from '@prisma/client'; -export type SecretOperation = 'SEND' | 'CONTRIBUTE' | 'TRUSTLINE' | 'KEY_ROTATION'; +export type SecretOperation = SecretAccessOperation; export async function logSecretAccess( userId: string, @@ -20,4 +21,4 @@ export async function logSecretAccess( } catch (e) { console.error('Audit log failed:', e); } -} \ No newline at end of file +}