Record activity history and support unpaginated transaction fetch
Adds an ActivityLog model that captures create/update/delete actions on transactions, accounts, and valuations, with snapshots that survive a cascading account deletion so a deleted transaction's amount and description remain auditable. Each service writes its log entry inside the same $transaction as the underlying mutation, so a failed mutation rolls back the log too. Also extends GET /transactions with all=true to skip pagination (capped at 10,000 rows) so the upcoming CSV export can pull every matching row in one request. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
+36
@@ -0,0 +1,36 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "EntityType" AS ENUM ('TRANSACTION', 'ACCOUNT', 'ACCOUNT_VALUATION');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "ActivityAction" AS ENUM ('CREATE', 'UPDATE', 'DELETE');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "activity_logs" (
|
||||
"id" TEXT NOT NULL,
|
||||
"user_id" TEXT NOT NULL,
|
||||
"entity_type" "EntityType" NOT NULL,
|
||||
"entity_id" TEXT NOT NULL,
|
||||
"action" "ActivityAction" NOT NULL,
|
||||
"account_id" TEXT,
|
||||
"destination_account_id" TEXT,
|
||||
"summary" TEXT,
|
||||
"snapshot" JSONB,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "activity_logs_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "activity_logs_user_id_created_at_idx" ON "activity_logs"("user_id", "created_at");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "activity_logs_user_id_account_id_created_at_idx" ON "activity_logs"("user_id", "account_id", "created_at");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "activity_logs_user_id_destination_account_id_created_at_idx" ON "activity_logs"("user_id", "destination_account_id", "created_at");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "activity_logs_user_id_entity_type_created_at_idx" ON "activity_logs"("user_id", "entity_type", "created_at");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "activity_logs" ADD CONSTRAINT "activity_logs_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -18,6 +18,7 @@ model User {
|
||||
accounts Account[]
|
||||
transactions Transaction[]
|
||||
categories Category[]
|
||||
activityLogs ActivityLog[]
|
||||
@@map("users")
|
||||
}
|
||||
|
||||
@@ -109,3 +110,36 @@ model Category {
|
||||
@@unique([userId, name])
|
||||
@@map("categories")
|
||||
}
|
||||
|
||||
enum EntityType {
|
||||
TRANSACTION
|
||||
ACCOUNT
|
||||
ACCOUNT_VALUATION
|
||||
}
|
||||
|
||||
enum ActivityAction {
|
||||
CREATE
|
||||
UPDATE
|
||||
DELETE
|
||||
}
|
||||
|
||||
model ActivityLog {
|
||||
id String @id @default(uuid())
|
||||
userId String @map("user_id")
|
||||
entityType EntityType @map("entity_type")
|
||||
entityId String @map("entity_id")
|
||||
action ActivityAction
|
||||
accountId String? @map("account_id")
|
||||
destinationAccountId String? @map("destination_account_id")
|
||||
summary String?
|
||||
snapshot Json?
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([userId, createdAt])
|
||||
@@index([userId, accountId, createdAt])
|
||||
@@index([userId, destinationAccountId, createdAt])
|
||||
@@index([userId, entityType, createdAt])
|
||||
@@map("activity_logs")
|
||||
}
|
||||
|
||||
@@ -3,9 +3,10 @@ import { AccountsService } from './accounts.service';
|
||||
import { AccountsController } from './accounts.controller';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { ValuationsModule } from '../valuations/valuations.module';
|
||||
import { ActivityLogModule } from '../activity-log/activity-log.module';
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule, ValuationsModule],
|
||||
imports: [AuthModule, ValuationsModule, ActivityLogModule],
|
||||
controllers: [AccountsController],
|
||||
providers: [AccountsService],
|
||||
exports: [AccountsService],
|
||||
|
||||
@@ -3,6 +3,7 @@ import { NotFoundException } from '@nestjs/common';
|
||||
import { AccountsService } from './accounts.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { EncryptionService } from '../encryption/encryption.service';
|
||||
import { ActivityLogService } from '../activity-log/activity-log.service';
|
||||
import { AccountType, TransactionType } from '@prisma/client';
|
||||
|
||||
jest.mock('@prisma/client', () => ({
|
||||
@@ -18,6 +19,12 @@ jest.mock('@prisma/client', () => ({
|
||||
RETIREMENT: 'RETIREMENT',
|
||||
},
|
||||
TransactionType: { INCOME: 'INCOME', EXPENSE: 'EXPENSE', TRANSFER: 'TRANSFER' },
|
||||
EntityType: {
|
||||
TRANSACTION: 'TRANSACTION',
|
||||
ACCOUNT: 'ACCOUNT',
|
||||
ACCOUNT_VALUATION: 'ACCOUNT_VALUATION',
|
||||
},
|
||||
ActivityAction: { CREATE: 'CREATE', UPDATE: 'UPDATE', DELETE: 'DELETE' },
|
||||
}));
|
||||
|
||||
describe('AccountsService', () => {
|
||||
@@ -67,6 +74,10 @@ describe('AccountsService', () => {
|
||||
decryptField: jest.fn((v: string | null) => v),
|
||||
};
|
||||
|
||||
const mockActivityLog = {
|
||||
log: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks();
|
||||
txClient = makeTxClient();
|
||||
@@ -80,6 +91,7 @@ describe('AccountsService', () => {
|
||||
AccountsService,
|
||||
{ provide: PrismaService, useValue: mockPrisma },
|
||||
{ provide: EncryptionService, useValue: mockEncryption },
|
||||
{ provide: ActivityLogService, useValue: mockActivityLog },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
@@ -419,4 +431,136 @@ describe('AccountsService', () => {
|
||||
expect(mockPrisma.$transaction).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('activity logging', () => {
|
||||
it('logs CREATE for a new account', async () => {
|
||||
mockPrisma.account.create.mockResolvedValue({ ...mockAccount, id: 'acc-new' });
|
||||
|
||||
await service.create(userId, {
|
||||
name: 'Main Checking',
|
||||
type: AccountType.CHECKING,
|
||||
balance: 5000,
|
||||
});
|
||||
|
||||
expect(mockActivityLog.log).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
userId,
|
||||
entityType: 'ACCOUNT',
|
||||
entityId: 'acc-new',
|
||||
action: 'CREATE',
|
||||
accountId: 'acc-new',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('logs UPDATE for an account edit', async () => {
|
||||
mockPrisma.account.findFirst.mockResolvedValue(mockAccount);
|
||||
mockPrisma.account.update.mockResolvedValue({ ...mockAccount, name: 'Renamed' });
|
||||
|
||||
await service.update(userId, 'acc-1', { name: 'Renamed' });
|
||||
|
||||
expect(mockActivityLog.log).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
userId,
|
||||
entityType: 'ACCOUNT',
|
||||
entityId: 'acc-1',
|
||||
action: 'UPDATE',
|
||||
accountId: 'acc-1',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('logs DELETE for the account AND a DELETE per cascaded transaction', async () => {
|
||||
mockPrisma.account.findFirst.mockResolvedValue(mockAccount);
|
||||
txClient.transaction.findMany.mockResolvedValue([
|
||||
{
|
||||
id: 't1',
|
||||
userId,
|
||||
accountId: 'acc-1',
|
||||
destinationAccountId: null,
|
||||
categoryId: null,
|
||||
type: TransactionType.EXPENSE,
|
||||
amount: 50,
|
||||
description: 'Coffee',
|
||||
date: new Date('2026-04-01'),
|
||||
},
|
||||
{
|
||||
id: 't2',
|
||||
userId,
|
||||
accountId: 'acc-1',
|
||||
destinationAccountId: 'acc-2',
|
||||
categoryId: null,
|
||||
type: TransactionType.TRANSFER,
|
||||
amount: 100,
|
||||
description: 'Move money',
|
||||
date: new Date('2026-04-02'),
|
||||
},
|
||||
]);
|
||||
txClient.account.findMany.mockResolvedValue([
|
||||
{ id: 'acc-2', type: AccountType.SAVINGS },
|
||||
]);
|
||||
txClient.account.delete.mockResolvedValue(mockAccount);
|
||||
|
||||
await service.remove(userId, 'acc-1');
|
||||
|
||||
// Account-level DELETE log
|
||||
expect(mockActivityLog.log).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
entityType: 'ACCOUNT',
|
||||
entityId: 'acc-1',
|
||||
action: 'DELETE',
|
||||
}),
|
||||
);
|
||||
// One DELETE log per cascaded transaction
|
||||
expect(mockActivityLog.log).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
entityType: 'TRANSACTION',
|
||||
entityId: 't1',
|
||||
action: 'DELETE',
|
||||
}),
|
||||
);
|
||||
expect(mockActivityLog.log).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
entityType: 'TRANSACTION',
|
||||
entityId: 't2',
|
||||
action: 'DELETE',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('logs DELETE entries BEFORE account.delete (so the audit trail survives the cascade)', async () => {
|
||||
const callOrder: string[] = [];
|
||||
mockPrisma.account.findFirst.mockResolvedValue(mockAccount);
|
||||
txClient.transaction.findMany.mockResolvedValue([
|
||||
{
|
||||
id: 't1',
|
||||
userId,
|
||||
accountId: 'acc-1',
|
||||
destinationAccountId: null,
|
||||
categoryId: null,
|
||||
type: TransactionType.EXPENSE,
|
||||
amount: 50,
|
||||
description: 'Coffee',
|
||||
date: new Date('2026-04-01'),
|
||||
},
|
||||
]);
|
||||
txClient.account.findMany.mockResolvedValue([]);
|
||||
mockActivityLog.log.mockImplementation(async (input: any) => {
|
||||
callOrder.push(`log:${input.entityType}:${input.action}`);
|
||||
});
|
||||
txClient.account.delete.mockImplementation(async () => {
|
||||
callOrder.push('delete:account');
|
||||
return mockAccount;
|
||||
});
|
||||
|
||||
await service.remove(userId, 'acc-1');
|
||||
|
||||
// All log calls must precede the account.delete
|
||||
const deleteIdx = callOrder.indexOf('delete:account');
|
||||
expect(deleteIdx).toBeGreaterThan(0);
|
||||
callOrder.slice(0, deleteIdx).forEach((entry) => {
|
||||
expect(entry.startsWith('log:')).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,9 +3,10 @@ import {
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { TransactionType } from '@prisma/client';
|
||||
import { TransactionType, EntityType, ActivityAction } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { EncryptionService } from '../encryption/encryption.service';
|
||||
import { ActivityLogService } from '../activity-log/activity-log.service';
|
||||
import { CreateAccountDto } from './dto/create-account.dto';
|
||||
import { UpdateAccountDto } from './dto/update-account.dto';
|
||||
import {
|
||||
@@ -13,11 +14,58 @@ import {
|
||||
signedDelta,
|
||||
} from '../transactions/transactions.service';
|
||||
|
||||
function accountSnapshot(a: {
|
||||
id: string;
|
||||
name: string;
|
||||
type: any;
|
||||
balance: any;
|
||||
institution: string | null;
|
||||
}) {
|
||||
return {
|
||||
id: a.id,
|
||||
name: a.name,
|
||||
type: a.type,
|
||||
balance: Number(a.balance),
|
||||
institution: a.institution,
|
||||
};
|
||||
}
|
||||
|
||||
function txnSnapshot(t: {
|
||||
id: string;
|
||||
amount: any;
|
||||
type: any;
|
||||
accountId: string;
|
||||
destinationAccountId: string | null;
|
||||
categoryId: string | null;
|
||||
description: string;
|
||||
date: Date;
|
||||
}) {
|
||||
return {
|
||||
id: t.id,
|
||||
amount: Number(t.amount),
|
||||
type: t.type,
|
||||
accountId: t.accountId,
|
||||
destinationAccountId: t.destinationAccountId,
|
||||
categoryId: t.categoryId,
|
||||
description: t.description,
|
||||
date: t.date instanceof Date ? t.date.toISOString() : t.date,
|
||||
};
|
||||
}
|
||||
|
||||
function txnSummary(t: { amount: any; type: any; description: string }): string {
|
||||
const amount = Number(t.amount).toLocaleString('en-US', {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
});
|
||||
return `$${amount} ${t.type} — ${t.description}`;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AccountsService {
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private encryption: EncryptionService,
|
||||
private activityLog: ActivityLogService,
|
||||
) {}
|
||||
|
||||
async create(userId: string, dto: CreateAccountDto) {
|
||||
@@ -32,6 +80,15 @@ export class AccountsService {
|
||||
});
|
||||
data.sortOrder = (max._max.sortOrder ?? -1) + 1;
|
||||
const account = await this.prisma.account.create({ data });
|
||||
await this.activityLog.log({
|
||||
userId,
|
||||
entityType: EntityType.ACCOUNT,
|
||||
entityId: account.id,
|
||||
action: ActivityAction.CREATE,
|
||||
accountId: account.id,
|
||||
summary: account.name,
|
||||
snapshot: accountSnapshot(account),
|
||||
});
|
||||
return this.decryptAccount(account);
|
||||
}
|
||||
|
||||
@@ -82,11 +139,20 @@ export class AccountsService {
|
||||
data.accountNumber = this.encryption.encryptField(data.accountNumber);
|
||||
}
|
||||
const account = await this.prisma.account.update({ where: { id }, data });
|
||||
await this.activityLog.log({
|
||||
userId,
|
||||
entityType: EntityType.ACCOUNT,
|
||||
entityId: account.id,
|
||||
action: ActivityAction.UPDATE,
|
||||
accountId: account.id,
|
||||
summary: account.name,
|
||||
snapshot: accountSnapshot(account),
|
||||
});
|
||||
return this.decryptAccount(account);
|
||||
}
|
||||
|
||||
async remove(userId: string, id: string) {
|
||||
await this.findOne(userId, id);
|
||||
const existing = await this.findOne(userId, id);
|
||||
|
||||
// Prisma cascades transaction rows away when the account is deleted, which
|
||||
// bypasses TransactionsService.remove() entirely. For transfers, that
|
||||
@@ -152,6 +218,33 @@ export class AccountsService {
|
||||
}
|
||||
}
|
||||
|
||||
// Snapshot every cascaded transaction BEFORE delete wipes them, so the
|
||||
// audit trail survives the cascade.
|
||||
for (const t of related) {
|
||||
await this.activityLog.log({
|
||||
userId,
|
||||
entityType: EntityType.TRANSACTION,
|
||||
entityId: t.id,
|
||||
action: ActivityAction.DELETE,
|
||||
accountId: t.accountId,
|
||||
destinationAccountId: t.destinationAccountId,
|
||||
summary: txnSummary(t),
|
||||
snapshot: txnSnapshot(t),
|
||||
tx,
|
||||
});
|
||||
}
|
||||
|
||||
await this.activityLog.log({
|
||||
userId,
|
||||
entityType: EntityType.ACCOUNT,
|
||||
entityId: id,
|
||||
action: ActivityAction.DELETE,
|
||||
accountId: id,
|
||||
summary: existing.name,
|
||||
snapshot: accountSnapshot(existing),
|
||||
tx,
|
||||
});
|
||||
|
||||
return tx.account.delete({ where: { id } });
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
|
||||
import {
|
||||
ActivityLogService,
|
||||
type ActivityLogFilters,
|
||||
} from './activity-log.service';
|
||||
import { AuthGuard } from '../auth/auth.guard';
|
||||
import { CurrentUser } from '../auth/user.decorator';
|
||||
import type { User } from '@prisma/client';
|
||||
|
||||
@Controller('activity')
|
||||
@UseGuards(AuthGuard)
|
||||
export class ActivityLogController {
|
||||
constructor(private readonly activityLog: ActivityLogService) {}
|
||||
|
||||
@Get()
|
||||
findAll(@CurrentUser() user: User, @Query() filters: ActivityLogFilters) {
|
||||
return this.activityLog.findAll(user.id, filters);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ActivityLogService } from './activity-log.service';
|
||||
import { ActivityLogController } from './activity-log.controller';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule],
|
||||
controllers: [ActivityLogController],
|
||||
providers: [ActivityLogService],
|
||||
exports: [ActivityLogService],
|
||||
})
|
||||
export class ActivityLogModule {}
|
||||
@@ -0,0 +1,185 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { ActivityLogService } from './activity-log.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
jest.mock('@prisma/client', () => ({
|
||||
PrismaClient: class {},
|
||||
EntityType: {
|
||||
TRANSACTION: 'TRANSACTION',
|
||||
ACCOUNT: 'ACCOUNT',
|
||||
ACCOUNT_VALUATION: 'ACCOUNT_VALUATION',
|
||||
},
|
||||
ActivityAction: { CREATE: 'CREATE', UPDATE: 'UPDATE', DELETE: 'DELETE' },
|
||||
}));
|
||||
|
||||
import { EntityType, ActivityAction } from '@prisma/client';
|
||||
|
||||
describe('ActivityLogService', () => {
|
||||
let service: ActivityLogService;
|
||||
const userId = 'user-1';
|
||||
|
||||
const mockPrisma: any = {
|
||||
activityLog: {
|
||||
create: jest.fn(),
|
||||
findMany: jest.fn(),
|
||||
count: jest.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks();
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
ActivityLogService,
|
||||
{ provide: PrismaService, useValue: mockPrisma },
|
||||
],
|
||||
}).compile();
|
||||
service = module.get<ActivityLogService>(ActivityLogService);
|
||||
});
|
||||
|
||||
describe('log', () => {
|
||||
it('creates an activity row with all fields populated', async () => {
|
||||
mockPrisma.activityLog.create.mockResolvedValue({});
|
||||
|
||||
await service.log({
|
||||
userId,
|
||||
entityType: EntityType.TRANSACTION,
|
||||
entityId: 'txn-1',
|
||||
action: ActivityAction.CREATE,
|
||||
accountId: 'acc-1',
|
||||
destinationAccountId: 'acc-2',
|
||||
summary: '$50.00 EXPENSE — Coffee',
|
||||
snapshot: { id: 'txn-1', amount: 50, type: 'EXPENSE' },
|
||||
});
|
||||
|
||||
expect(mockPrisma.activityLog.create).toHaveBeenCalledWith({
|
||||
data: {
|
||||
userId,
|
||||
entityType: EntityType.TRANSACTION,
|
||||
entityId: 'txn-1',
|
||||
action: ActivityAction.CREATE,
|
||||
accountId: 'acc-1',
|
||||
destinationAccountId: 'acc-2',
|
||||
summary: '$50.00 EXPENSE — Coffee',
|
||||
snapshot: { id: 'txn-1', amount: 50, type: 'EXPENSE' },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('honors a passed tx client and skips the default prisma client', async () => {
|
||||
const tx: any = { activityLog: { create: jest.fn().mockResolvedValue({}) } };
|
||||
|
||||
await service.log({
|
||||
userId,
|
||||
entityType: EntityType.ACCOUNT,
|
||||
entityId: 'acc-1',
|
||||
action: ActivityAction.DELETE,
|
||||
tx,
|
||||
});
|
||||
|
||||
expect(tx.activityLog.create).toHaveBeenCalledTimes(1);
|
||||
expect(mockPrisma.activityLog.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('falls back to prisma when no tx is provided', async () => {
|
||||
mockPrisma.activityLog.create.mockResolvedValue({});
|
||||
|
||||
await service.log({
|
||||
userId,
|
||||
entityType: EntityType.ACCOUNT,
|
||||
entityId: 'acc-1',
|
||||
action: ActivityAction.UPDATE,
|
||||
});
|
||||
|
||||
expect(mockPrisma.activityLog.create).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findAll', () => {
|
||||
it('returns paginated rows newest first with default page/limit', async () => {
|
||||
const rows = [{ id: 'a' }, { id: 'b' }];
|
||||
mockPrisma.activityLog.findMany.mockResolvedValue(rows);
|
||||
mockPrisma.activityLog.count.mockResolvedValue(2);
|
||||
|
||||
const result = await service.findAll(userId, {});
|
||||
|
||||
expect(mockPrisma.activityLog.findMany).toHaveBeenCalledWith({
|
||||
where: { userId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip: 0,
|
||||
take: 20,
|
||||
});
|
||||
expect(result).toEqual({ data: rows, total: 2, page: 1, limit: 20 });
|
||||
});
|
||||
|
||||
it('filters by entityType', async () => {
|
||||
mockPrisma.activityLog.findMany.mockResolvedValue([]);
|
||||
mockPrisma.activityLog.count.mockResolvedValue(0);
|
||||
|
||||
await service.findAll(userId, { entityType: EntityType.TRANSACTION });
|
||||
|
||||
expect(mockPrisma.activityLog.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { userId, entityType: EntityType.TRANSACTION },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('filters by action', async () => {
|
||||
mockPrisma.activityLog.findMany.mockResolvedValue([]);
|
||||
mockPrisma.activityLog.count.mockResolvedValue(0);
|
||||
|
||||
await service.findAll(userId, { action: ActivityAction.DELETE });
|
||||
|
||||
expect(mockPrisma.activityLog.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { userId, action: ActivityAction.DELETE },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('filters by accountId across primary OR destination', async () => {
|
||||
mockPrisma.activityLog.findMany.mockResolvedValue([]);
|
||||
mockPrisma.activityLog.count.mockResolvedValue(0);
|
||||
|
||||
await service.findAll(userId, { accountId: 'acc-1' });
|
||||
|
||||
expect(mockPrisma.activityLog.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: {
|
||||
userId,
|
||||
OR: [
|
||||
{ accountId: 'acc-1' },
|
||||
{ destinationAccountId: 'acc-1' },
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('filters by date range', async () => {
|
||||
mockPrisma.activityLog.findMany.mockResolvedValue([]);
|
||||
mockPrisma.activityLog.count.mockResolvedValue(0);
|
||||
|
||||
await service.findAll(userId, {
|
||||
startDate: '2026-04-01',
|
||||
endDate: '2026-04-30',
|
||||
});
|
||||
|
||||
const call = mockPrisma.activityLog.findMany.mock.calls[0][0];
|
||||
expect(call.where.createdAt.gte).toEqual(new Date('2026-04-01'));
|
||||
expect(call.where.createdAt.lte).toEqual(new Date('2026-04-30'));
|
||||
});
|
||||
|
||||
it('paginates with custom page and limit', async () => {
|
||||
mockPrisma.activityLog.findMany.mockResolvedValue([]);
|
||||
mockPrisma.activityLog.count.mockResolvedValue(50);
|
||||
|
||||
await service.findAll(userId, { page: 3, limit: 10 });
|
||||
|
||||
expect(mockPrisma.activityLog.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ skip: 20, take: 10 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Prisma, EntityType, ActivityAction } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
export interface LogInput {
|
||||
userId: string;
|
||||
entityType: EntityType;
|
||||
entityId: string;
|
||||
action: ActivityAction;
|
||||
accountId?: string | null;
|
||||
destinationAccountId?: string | null;
|
||||
summary?: string;
|
||||
snapshot?: Prisma.InputJsonValue;
|
||||
tx?: Prisma.TransactionClient;
|
||||
}
|
||||
|
||||
export interface ActivityLogFilters {
|
||||
entityType?: EntityType;
|
||||
action?: ActivityAction;
|
||||
accountId?: string;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ActivityLogService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
async log(input: LogInput): Promise<void> {
|
||||
const {
|
||||
tx,
|
||||
userId,
|
||||
entityType,
|
||||
entityId,
|
||||
action,
|
||||
accountId,
|
||||
destinationAccountId,
|
||||
summary,
|
||||
snapshot,
|
||||
} = input;
|
||||
|
||||
const data: any = { userId, entityType, entityId, action };
|
||||
if (accountId !== undefined) data.accountId = accountId;
|
||||
if (destinationAccountId !== undefined) {
|
||||
data.destinationAccountId = destinationAccountId;
|
||||
}
|
||||
if (summary !== undefined) data.summary = summary;
|
||||
if (snapshot !== undefined) data.snapshot = snapshot;
|
||||
|
||||
const client = tx ?? this.prisma;
|
||||
await client.activityLog.create({ data });
|
||||
}
|
||||
|
||||
async findAll(userId: string, filters: ActivityLogFilters) {
|
||||
const page = Number(filters.page) || 1;
|
||||
const limit = Number(filters.limit) || 20;
|
||||
|
||||
const where: Prisma.ActivityLogWhereInput = { userId };
|
||||
if (filters.entityType) where.entityType = filters.entityType;
|
||||
if (filters.action) where.action = filters.action;
|
||||
if (filters.accountId) {
|
||||
where.OR = [
|
||||
{ accountId: filters.accountId },
|
||||
{ destinationAccountId: filters.accountId },
|
||||
];
|
||||
}
|
||||
if (filters.startDate || filters.endDate) {
|
||||
where.createdAt = {};
|
||||
if (filters.startDate) {
|
||||
(where.createdAt as any).gte = new Date(filters.startDate);
|
||||
}
|
||||
if (filters.endDate) {
|
||||
(where.createdAt as any).lte = new Date(filters.endDate);
|
||||
}
|
||||
}
|
||||
|
||||
const [data, total] = await Promise.all([
|
||||
this.prisma.activityLog.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip: (page - 1) * limit,
|
||||
take: limit,
|
||||
}),
|
||||
this.prisma.activityLog.count({ where }),
|
||||
]);
|
||||
|
||||
return { data, total, page, limit };
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import { FilesModule } from './files/files.module';
|
||||
import { AggregationsModule } from './aggregations/aggregations.module';
|
||||
import { AdvisorModule } from './advisor/advisor.module';
|
||||
import { ValuationsModule } from './valuations/valuations.module';
|
||||
import { ActivityLogModule } from './activity-log/activity-log.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -26,6 +27,7 @@ import { ValuationsModule } from './valuations/valuations.module';
|
||||
AggregationsModule,
|
||||
AdvisorModule,
|
||||
ValuationsModule,
|
||||
ActivityLogModule,
|
||||
],
|
||||
controllers: [AppController],
|
||||
providers: [AppService],
|
||||
|
||||
@@ -2,9 +2,10 @@ import { Module } from '@nestjs/common';
|
||||
import { TransactionsService } from './transactions.service';
|
||||
import { TransactionsController } from './transactions.controller';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { ActivityLogModule } from '../activity-log/activity-log.module';
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule],
|
||||
imports: [AuthModule, ActivityLogModule],
|
||||
controllers: [TransactionsController],
|
||||
providers: [TransactionsService],
|
||||
exports: [TransactionsService],
|
||||
|
||||
@@ -3,6 +3,7 @@ import { BadRequestException, NotFoundException } from '@nestjs/common';
|
||||
import { TransactionsService } from './transactions.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { EncryptionService } from '../encryption/encryption.service';
|
||||
import { ActivityLogService } from '../activity-log/activity-log.service';
|
||||
import { TransactionType, AccountType } from '@prisma/client';
|
||||
|
||||
jest.mock('@prisma/client', () => ({
|
||||
@@ -18,6 +19,12 @@ jest.mock('@prisma/client', () => ({
|
||||
INVESTMENT: 'INVESTMENT',
|
||||
RETIREMENT: 'RETIREMENT',
|
||||
},
|
||||
EntityType: {
|
||||
TRANSACTION: 'TRANSACTION',
|
||||
ACCOUNT: 'ACCOUNT',
|
||||
ACCOUNT_VALUATION: 'ACCOUNT_VALUATION',
|
||||
},
|
||||
ActivityAction: { CREATE: 'CREATE', UPDATE: 'UPDATE', DELETE: 'DELETE' },
|
||||
Prisma: {},
|
||||
}));
|
||||
|
||||
@@ -72,6 +79,10 @@ describe('TransactionsService', () => {
|
||||
decryptField: jest.fn((v: string | null) => v),
|
||||
};
|
||||
|
||||
const mockActivityLog = {
|
||||
log: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks();
|
||||
txClient = makeTxClient();
|
||||
@@ -82,6 +93,7 @@ describe('TransactionsService', () => {
|
||||
TransactionsService,
|
||||
{ provide: PrismaService, useValue: mockPrisma },
|
||||
{ provide: EncryptionService, useValue: mockEncryption },
|
||||
{ provide: ActivityLogService, useValue: mockActivityLog },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
@@ -390,4 +402,105 @@ describe('TransactionsService', () => {
|
||||
await expect(service.remove(userId, 'nope')).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('activity logging', () => {
|
||||
it('logs CREATE inside the same $transaction', async () => {
|
||||
mockPrisma.account.findMany.mockResolvedValue([
|
||||
{ id: 'acc-1', type: AccountType.CHECKING },
|
||||
]);
|
||||
txClient.transaction.create.mockResolvedValue({
|
||||
...baseTxn,
|
||||
id: 'txn-new',
|
||||
type: TransactionType.EXPENSE,
|
||||
amount: 42.5,
|
||||
});
|
||||
|
||||
await service.create(userId, {
|
||||
accountId: 'acc-1',
|
||||
amount: 42.5,
|
||||
type: TransactionType.EXPENSE,
|
||||
description: 'Grocery run',
|
||||
date: '2026-04-01',
|
||||
});
|
||||
|
||||
expect(mockActivityLog.log).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
userId,
|
||||
entityType: 'TRANSACTION',
|
||||
entityId: 'txn-new',
|
||||
action: 'CREATE',
|
||||
accountId: 'acc-1',
|
||||
tx: txClient,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('logs UPDATE on edit', async () => {
|
||||
const existing = {
|
||||
...baseTxn,
|
||||
type: TransactionType.EXPENSE,
|
||||
amount: 100,
|
||||
};
|
||||
mockPrisma.transaction.findFirst.mockResolvedValue(existing);
|
||||
mockPrisma.account.findMany.mockResolvedValue([
|
||||
{ id: 'acc-1', type: AccountType.CHECKING },
|
||||
]);
|
||||
txClient.transaction.update.mockResolvedValue({ ...existing, amount: 150 });
|
||||
|
||||
await service.update(userId, 'txn-1', { amount: 150 });
|
||||
|
||||
expect(mockActivityLog.log).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
userId,
|
||||
entityType: 'TRANSACTION',
|
||||
entityId: 'txn-1',
|
||||
action: 'UPDATE',
|
||||
tx: txClient,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('logs DELETE before tx.transaction.delete', async () => {
|
||||
const callOrder: string[] = [];
|
||||
const existing = { ...baseTxn, type: TransactionType.EXPENSE, amount: 100 };
|
||||
mockPrisma.transaction.findFirst.mockResolvedValue(existing);
|
||||
txClient.account.findMany.mockResolvedValue([
|
||||
{ id: 'acc-1', type: AccountType.CHECKING },
|
||||
]);
|
||||
mockActivityLog.log.mockImplementation(async () => {
|
||||
callOrder.push('log');
|
||||
});
|
||||
txClient.transaction.delete.mockImplementation(async () => {
|
||||
callOrder.push('delete');
|
||||
return existing;
|
||||
});
|
||||
|
||||
await service.remove(userId, 'txn-1');
|
||||
|
||||
expect(mockActivityLog.log).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
userId,
|
||||
entityType: 'TRANSACTION',
|
||||
entityId: 'txn-1',
|
||||
action: 'DELETE',
|
||||
tx: txClient,
|
||||
snapshot: expect.objectContaining({ id: 'txn-1', amount: 100 }),
|
||||
}),
|
||||
);
|
||||
expect(callOrder).toEqual(['log', 'delete']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findAll with all=true', () => {
|
||||
it('omits skip/take when all=true and caps the row count', async () => {
|
||||
mockPrisma.transaction.findMany.mockResolvedValue([]);
|
||||
mockPrisma.transaction.count.mockResolvedValue(0);
|
||||
|
||||
await service.findAll(userId, { all: true } as any);
|
||||
|
||||
const call = mockPrisma.transaction.findMany.mock.calls[0][0];
|
||||
expect(call.skip).toBeUndefined();
|
||||
expect(call.take).toBe(10000);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,9 +5,18 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { EncryptionService } from '../encryption/encryption.service';
|
||||
import { ActivityLogService } from '../activity-log/activity-log.service';
|
||||
import { CreateTransactionDto } from './dto/create-transaction.dto';
|
||||
import { UpdateTransactionDto } from './dto/update-transaction.dto';
|
||||
import { TransactionType, AccountType, Prisma } from '@prisma/client';
|
||||
import {
|
||||
TransactionType,
|
||||
AccountType,
|
||||
EntityType,
|
||||
ActivityAction,
|
||||
Prisma,
|
||||
} from '@prisma/client';
|
||||
|
||||
export const EXPORT_ROW_CAP = 10000;
|
||||
|
||||
export interface TransactionFilters {
|
||||
accountId?: string;
|
||||
@@ -17,6 +26,41 @@ export interface TransactionFilters {
|
||||
endDate?: string;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
all?: boolean | string;
|
||||
}
|
||||
|
||||
function transactionSnapshot(t: {
|
||||
id: string;
|
||||
amount: any;
|
||||
type: TransactionType;
|
||||
accountId: string;
|
||||
destinationAccountId: string | null;
|
||||
categoryId: string | null;
|
||||
description: string;
|
||||
date: Date;
|
||||
}) {
|
||||
return {
|
||||
id: t.id,
|
||||
amount: Number(t.amount),
|
||||
type: t.type,
|
||||
accountId: t.accountId,
|
||||
destinationAccountId: t.destinationAccountId,
|
||||
categoryId: t.categoryId,
|
||||
description: t.description,
|
||||
date: t.date instanceof Date ? t.date.toISOString() : t.date,
|
||||
};
|
||||
}
|
||||
|
||||
function transactionSummary(t: {
|
||||
amount: any;
|
||||
type: TransactionType;
|
||||
description: string;
|
||||
}): string {
|
||||
const amount = Number(t.amount).toLocaleString('en-US', {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
});
|
||||
return `$${amount} ${t.type} — ${t.description}`;
|
||||
}
|
||||
|
||||
const txnInclude = {
|
||||
@@ -84,6 +128,7 @@ export class TransactionsService {
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private encryption: EncryptionService,
|
||||
private activityLog: ActivityLogService,
|
||||
) {}
|
||||
|
||||
async create(userId: string, dto: CreateTransactionDto) {
|
||||
@@ -152,6 +197,18 @@ export class TransactionsService {
|
||||
});
|
||||
}
|
||||
|
||||
await this.activityLog.log({
|
||||
userId,
|
||||
entityType: EntityType.TRANSACTION,
|
||||
entityId: created.id,
|
||||
action: ActivityAction.CREATE,
|
||||
accountId: created.accountId,
|
||||
destinationAccountId: created.destinationAccountId,
|
||||
summary: transactionSummary(created),
|
||||
snapshot: transactionSnapshot(created),
|
||||
tx,
|
||||
});
|
||||
|
||||
return created;
|
||||
});
|
||||
return this.decryptTransaction(txn);
|
||||
@@ -159,6 +216,7 @@ export class TransactionsService {
|
||||
|
||||
async findAll(userId: string, filters: TransactionFilters) {
|
||||
const { accountId, categoryId, type, startDate, endDate } = filters;
|
||||
const all = filters.all === true || filters.all === 'true';
|
||||
const page = Number(filters.page) || 1;
|
||||
const limit = Number(filters.limit) || 20;
|
||||
|
||||
@@ -178,18 +236,29 @@ export class TransactionsService {
|
||||
if (endDate) (where.date as any).lte = new Date(endDate);
|
||||
}
|
||||
|
||||
const findManyArgs: Prisma.TransactionFindManyArgs = {
|
||||
where,
|
||||
include: txnInclude,
|
||||
orderBy: { date: 'desc' },
|
||||
};
|
||||
if (all) {
|
||||
findManyArgs.take = EXPORT_ROW_CAP;
|
||||
} else {
|
||||
findManyArgs.skip = (page - 1) * limit;
|
||||
findManyArgs.take = limit;
|
||||
}
|
||||
|
||||
const [data, total] = await Promise.all([
|
||||
this.prisma.transaction.findMany({
|
||||
where,
|
||||
include: txnInclude,
|
||||
orderBy: { date: 'desc' },
|
||||
skip: (page - 1) * limit,
|
||||
take: limit,
|
||||
}),
|
||||
this.prisma.transaction.findMany(findManyArgs),
|
||||
this.prisma.transaction.count({ where }),
|
||||
]);
|
||||
|
||||
return { data: data.map((t) => this.decryptTransaction(t)), total, page, limit };
|
||||
return {
|
||||
data: data.map((t) => this.decryptTransaction(t)),
|
||||
total,
|
||||
page: all ? 1 : page,
|
||||
limit: all ? data.length : limit,
|
||||
};
|
||||
}
|
||||
|
||||
async findOne(userId: string, id: string) {
|
||||
@@ -312,6 +381,18 @@ export class TransactionsService {
|
||||
include: txnInclude,
|
||||
});
|
||||
|
||||
await this.activityLog.log({
|
||||
userId,
|
||||
entityType: EntityType.TRANSACTION,
|
||||
entityId: updated.id,
|
||||
action: ActivityAction.UPDATE,
|
||||
accountId: updated.accountId,
|
||||
destinationAccountId: updated.destinationAccountId,
|
||||
summary: transactionSummary(updated),
|
||||
snapshot: transactionSnapshot(updated),
|
||||
tx,
|
||||
});
|
||||
|
||||
// Apply the new transaction's effect
|
||||
if (balanceAffected) {
|
||||
const newPrimaryType = typeById.get(updated.accountId);
|
||||
@@ -421,6 +502,18 @@ export class TransactionsService {
|
||||
}
|
||||
}
|
||||
|
||||
await this.activityLog.log({
|
||||
userId,
|
||||
entityType: EntityType.TRANSACTION,
|
||||
entityId: existing.id,
|
||||
action: ActivityAction.DELETE,
|
||||
accountId: existing.accountId,
|
||||
destinationAccountId: existing.destinationAccountId,
|
||||
summary: transactionSummary(existing),
|
||||
snapshot: transactionSnapshot(existing),
|
||||
tx,
|
||||
});
|
||||
|
||||
return tx.transaction.delete({ where: { id } });
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ValuationsService } from './valuations.service';
|
||||
import { ActivityLogModule } from '../activity-log/activity-log.module';
|
||||
|
||||
@Module({
|
||||
imports: [ActivityLogModule],
|
||||
providers: [ValuationsService],
|
||||
exports: [ValuationsService],
|
||||
})
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { NotFoundException } from '@nestjs/common';
|
||||
import { ValuationsService } from './valuations.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { ActivityLogService } from '../activity-log/activity-log.service';
|
||||
|
||||
jest.mock('@prisma/client', () => ({
|
||||
PrismaClient: class {},
|
||||
@@ -15,6 +16,12 @@ jest.mock('@prisma/client', () => ({
|
||||
INVESTMENT: 'INVESTMENT',
|
||||
RETIREMENT: 'RETIREMENT',
|
||||
},
|
||||
EntityType: {
|
||||
TRANSACTION: 'TRANSACTION',
|
||||
ACCOUNT: 'ACCOUNT',
|
||||
ACCOUNT_VALUATION: 'ACCOUNT_VALUATION',
|
||||
},
|
||||
ActivityAction: { CREATE: 'CREATE', UPDATE: 'UPDATE', DELETE: 'DELETE' },
|
||||
}));
|
||||
|
||||
describe('ValuationsService', () => {
|
||||
@@ -36,12 +43,17 @@ describe('ValuationsService', () => {
|
||||
},
|
||||
};
|
||||
|
||||
const mockActivityLog = {
|
||||
log: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks();
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
ValuationsService,
|
||||
{ provide: PrismaService, useValue: mockPrisma },
|
||||
{ provide: ActivityLogService, useValue: mockActivityLog },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
@@ -181,4 +193,61 @@ describe('ValuationsService', () => {
|
||||
await expect(service.remove(userId, 'stranger-v')).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('activity logging', () => {
|
||||
it('logs CREATE on a new valuation', async () => {
|
||||
mockPrisma.account.findFirst.mockResolvedValue({ id: accountId });
|
||||
mockPrisma.accountValuation.create.mockResolvedValue({
|
||||
id: 'v-1',
|
||||
accountId,
|
||||
date: new Date('2026-04-17'),
|
||||
value: 100,
|
||||
});
|
||||
mockPrisma.accountValuation.findFirst.mockResolvedValue(null);
|
||||
|
||||
await service.create(userId, accountId, { date: '2026-04-17', value: 100 });
|
||||
|
||||
expect(mockActivityLog.log).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
userId,
|
||||
entityType: 'ACCOUNT_VALUATION',
|
||||
entityId: 'v-1',
|
||||
action: 'CREATE',
|
||||
accountId,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('logs DELETE before removing the valuation', async () => {
|
||||
const callOrder: string[] = [];
|
||||
mockPrisma.accountValuation.findFirst.mockResolvedValueOnce({
|
||||
id: 'v-1',
|
||||
accountId,
|
||||
value: 300,
|
||||
date: new Date('2026-04-17'),
|
||||
account: { userId },
|
||||
});
|
||||
mockPrisma.accountValuation.findFirst.mockResolvedValueOnce(null);
|
||||
mockActivityLog.log.mockImplementation(async () => {
|
||||
callOrder.push('log');
|
||||
});
|
||||
mockPrisma.accountValuation.delete.mockImplementation(async () => {
|
||||
callOrder.push('delete');
|
||||
return {};
|
||||
});
|
||||
|
||||
await service.remove(userId, 'v-1');
|
||||
|
||||
expect(mockActivityLog.log).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
userId,
|
||||
entityType: 'ACCOUNT_VALUATION',
|
||||
entityId: 'v-1',
|
||||
action: 'DELETE',
|
||||
accountId,
|
||||
}),
|
||||
);
|
||||
expect(callOrder).toEqual(['log', 'delete']);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { EntityType, ActivityAction } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { ActivityLogService } from '../activity-log/activity-log.service';
|
||||
import { CreateValuationDto } from './dto/create-valuation.dto';
|
||||
|
||||
/**
|
||||
@@ -13,9 +15,36 @@ function parseDateInput(dateStr: string): Date {
|
||||
return new Date(Date.UTC(y, m - 1, d, 12, 0, 0));
|
||||
}
|
||||
|
||||
function valuationSnapshot(v: {
|
||||
id: string;
|
||||
accountId: string;
|
||||
date: Date;
|
||||
value: any;
|
||||
}) {
|
||||
return {
|
||||
id: v.id,
|
||||
accountId: v.accountId,
|
||||
date: v.date instanceof Date ? v.date.toISOString() : v.date,
|
||||
value: Number(v.value),
|
||||
};
|
||||
}
|
||||
|
||||
function valuationSummary(v: { value: any; date: Date }): string {
|
||||
const value = Number(v.value).toLocaleString('en-US', {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
});
|
||||
const date =
|
||||
v.date instanceof Date ? v.date.toISOString().slice(0, 10) : String(v.date).slice(0, 10);
|
||||
return `$${value} on ${date}`;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ValuationsService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private activityLog: ActivityLogService,
|
||||
) {}
|
||||
|
||||
async create(userId: string, accountId: string, dto: CreateValuationDto) {
|
||||
const account = await this.prisma.account.findFirst({
|
||||
@@ -34,6 +63,17 @@ export class ValuationsService {
|
||||
});
|
||||
|
||||
await this.recomputeBalance(accountId);
|
||||
|
||||
await this.activityLog.log({
|
||||
userId,
|
||||
entityType: EntityType.ACCOUNT_VALUATION,
|
||||
entityId: created.id,
|
||||
action: ActivityAction.CREATE,
|
||||
accountId,
|
||||
summary: valuationSummary(created),
|
||||
snapshot: valuationSnapshot(created),
|
||||
});
|
||||
|
||||
return created;
|
||||
}
|
||||
|
||||
@@ -63,6 +103,16 @@ export class ValuationsService {
|
||||
throw new NotFoundException('Valuation not found');
|
||||
}
|
||||
|
||||
await this.activityLog.log({
|
||||
userId,
|
||||
entityType: EntityType.ACCOUNT_VALUATION,
|
||||
entityId: valuation.id,
|
||||
action: ActivityAction.DELETE,
|
||||
accountId: valuation.accountId,
|
||||
summary: valuationSummary(valuation),
|
||||
snapshot: valuationSnapshot(valuation),
|
||||
});
|
||||
|
||||
await this.prisma.accountValuation.delete({ where: { id: valuationId } });
|
||||
await this.recomputeBalance(valuation.accountId);
|
||||
return { success: true };
|
||||
|
||||
Reference in New Issue
Block a user