diff --git a/prisma/migrations/20260429000000_add_transaction_audit_log/migration.sql b/prisma/migrations/20260429000000_add_transaction_audit_log/migration.sql new file mode 100644 index 0000000..db614fd --- /dev/null +++ b/prisma/migrations/20260429000000_add_transaction_audit_log/migration.sql @@ -0,0 +1,29 @@ +-- CreateTable +CREATE TABLE "transaction_audit_logs" ( + "id" TEXT NOT NULL, + "transaction_id" TEXT NOT NULL, + "actor_id" TEXT, + "action" TEXT NOT NULL, + "previous_data" JSONB, + "new_data" JSONB, + "ip_address" TEXT, + "user_agent" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "transaction_audit_logs_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "transaction_audit_logs_transaction_id_created_at_idx" ON "transaction_audit_logs"("transaction_id", "created_at"); + +-- CreateIndex +CREATE INDEX "transaction_audit_logs_actor_id_idx" ON "transaction_audit_logs"("actor_id"); + +-- CreateIndex +CREATE INDEX "transaction_audit_logs_action_idx" ON "transaction_audit_logs"("action"); + +-- AddForeignKey +ALTER TABLE "transaction_audit_logs" ADD CONSTRAINT "transaction_audit_logs_transaction_id_fkey" FOREIGN KEY ("transaction_id") REFERENCES "transactions"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "transaction_audit_logs" ADD CONSTRAINT "transaction_audit_logs_actor_id_fkey" FOREIGN KEY ("actor_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/src/transactions/transaction-audit.service.spec.ts b/src/transactions/transaction-audit.service.spec.ts new file mode 100644 index 0000000..6de09bd --- /dev/null +++ b/src/transactions/transaction-audit.service.spec.ts @@ -0,0 +1,59 @@ +import { TransactionAuditService } from './transaction-audit.service'; + +const mockPrisma = { + transactionAuditLog: { + create: jest.fn(), + findMany: jest.fn(), + }, +}; + +describe('TransactionAuditService', () => { + let service: TransactionAuditService; + + beforeEach(() => { + jest.clearAllMocks(); + service = new TransactionAuditService(mockPrisma as any); + }); + + it('creates an audit log entry with correct fields', async () => { + mockPrisma.transactionAuditLog.create.mockResolvedValue({ id: 'log-1' }); + + await service.log('tx-1', 'CREATED', null, { amount: 100 }, { actorId: 'user-1', ipAddress: '127.0.0.1' }); + + expect(mockPrisma.transactionAuditLog.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + transactionId: 'tx-1', + action: 'CREATED', + previousData: undefined, + newData: { amount: 100 }, + actorId: 'user-1', + ipAddress: '127.0.0.1', + }), + }); + }); + + it('creates a log with null actor for system actions', async () => { + mockPrisma.transactionAuditLog.create.mockResolvedValue({ id: 'log-2' }); + + await service.log('tx-1', 'UPDATED', { status: 'PENDING' }, { status: 'COMPLETED' }); + + expect(mockPrisma.transactionAuditLog.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ actorId: undefined }), + }); + }); + + it('returns audit logs ordered by createdAt asc', async () => { + const logs = [{ id: 'log-1', action: 'CREATED' }]; + mockPrisma.transactionAuditLog.findMany.mockResolvedValue(logs); + + const result = await service.findByTransaction('tx-1'); + + expect(mockPrisma.transactionAuditLog.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { transactionId: 'tx-1' }, + orderBy: { createdAt: 'asc' }, + }), + ); + expect(result).toBe(logs); + }); +}); diff --git a/src/transactions/transaction-audit.service.ts b/src/transactions/transaction-audit.service.ts new file mode 100644 index 0000000..c0097ee --- /dev/null +++ b/src/transactions/transaction-audit.service.ts @@ -0,0 +1,51 @@ +import { Injectable } from '@nestjs/common'; +import { PrismaService } from '../database/prisma.service'; + +export interface AuditContext { + actorId?: string; + ipAddress?: string; + userAgent?: string; +} + +@Injectable() +export class TransactionAuditService { + constructor(private readonly prisma: PrismaService) {} + + async log( + transactionId: string, + action: string, + previousData: object | null, + newData: object | null, + ctx: AuditContext = {}, + ) { + return this.prisma.transactionAuditLog.create({ + data: { + transactionId, + action, + previousData: previousData ?? undefined, + newData: newData ?? undefined, + actorId: ctx.actorId, + ipAddress: ctx.ipAddress, + userAgent: ctx.userAgent, + }, + }); + } + + async findByTransaction(transactionId: string) { + return this.prisma.transactionAuditLog.findMany({ + where: { transactionId }, + orderBy: { createdAt: 'asc' }, + select: { + id: true, + action: true, + previousData: true, + newData: true, + ipAddress: true, + createdAt: true, + actor: { + select: { id: true, firstName: true, lastName: true, email: true }, + }, + }, + }); + } +}