diff --git a/packages/server/package.json b/packages/server/package.json index 14116b211..5d0ce3cd4 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -33,16 +33,16 @@ "dependencies": { "@aws-sdk/client-s3": "^3.576.0", "@aws-sdk/s3-request-presigner": "^3.583.0", - "@bigcapital/email-components": "*", - "@bigcapital/pdf-templates": "*", - "@bigcapital/utils": "*", + "@bigcapital/email-components": "workspace:*", + "@bigcapital/pdf-templates": "workspace:*", + "@bigcapital/utils": "workspace:*", + "@bull-board/api": "^5.22.0", + "@bull-board/express": "^5.22.0", + "@bull-board/nestjs": "^5.22.0", "@casl/ability": "^5.4.3", "@lemonsqueezy/lemonsqueezy.js": "^2.2.0", "@liaoliaots/nestjs-redis": "^10.0.0", "@nest-lab/throttler-storage-redis": "^1.1.0", - "@bull-board/api": "^5.22.0", - "@bull-board/express": "^5.22.0", - "@bull-board/nestjs": "^5.22.0", "@nestjs/bull": "^10.2.1", "@nestjs/bullmq": "^10.2.2", "@nestjs/cache-manager": "^2.2.2", @@ -69,7 +69,7 @@ "async": "^3.2.0", "async-mutex": "^0.5.0", "axios": "^1.6.0", - "bcrypt": "^5.1.1", + "bcrypt": "5.1.1", "bcryptjs": "^2.4.3", "bluebird": "^3.7.2", "bull": "^4.16.3", diff --git a/packages/server/src/common/events/events.ts b/packages/server/src/common/events/events.ts index 22c770b71..654bc6362 100644 --- a/packages/server/src/common/events/events.ts +++ b/packages/server/src/common/events/events.ts @@ -95,6 +95,14 @@ export const events = { onActivated: 'onAccountActivated', }, + /** + * Contacts service. + */ + contacts: { + onActivated: 'onContactActivated', + onInactivated: 'onContactInactivated', + }, + /** * Manual journals service. */ diff --git a/packages/server/src/database/tenant/migrations/20260408120000_create_audit_logs_table.ts b/packages/server/src/database/tenant/migrations/20260408120000_create_audit_logs_table.ts new file mode 100644 index 000000000..7d96fa2fe --- /dev/null +++ b/packages/server/src/database/tenant/migrations/20260408120000_create_audit_logs_table.ts @@ -0,0 +1,17 @@ +exports.up = (knex) => { + return knex.schema.createTable('audit_logs', (table) => { + table.increments('id').primary(); + table.integer('user_id').unsigned().nullable().index(); + table.string('action', 64).notNullable(); + table.string('subject', 64).notNullable(); + table.integer('subject_id').unsigned().nullable(); + table.json('metadata').nullable(); + table.string('ip', 64).nullable(); + table.timestamp('created_at').notNullable().defaultTo(knex.fn.now()); + + table.index(['subject', 'subject_id']); + table.index(['created_at']); + }); +}; + +exports.down = (knex) => knex.schema.dropTableIfExists('audit_logs'); diff --git a/packages/server/src/i18n/en/audit_log.json b/packages/server/src/i18n/en/audit_log.json new file mode 100644 index 000000000..314eebfd1 --- /dev/null +++ b/packages/server/src/i18n/en/audit_log.json @@ -0,0 +1,116 @@ +{ + "subject.SaleInvoice": "Sale Invoice", + "subject.SaleEstimate": "Sale Estimate", + "subject.SaleReceipt": "Sale Receipt", + "subject.PaymentReceive": "Payment Received", + "subject.PaymentMade": "Payment Made", + "subject.CreditNote": "Credit Note", + "subject.VendorCredit": "Vendor Credit", + "subject.ManualJournal": "Manual Journal", + "subject.InventoryAdjustment": "Inventory Adjustment", + "subject.WarehouseTransfer": "Warehouse Transfer", + "subject.ItemCategory": "Item Category", + "subject.BankRule": "Bank Rule", + "subject.TransactionsLocking": "Transactions Locking", + "subject.CreditNoteRefund": "Credit Note Refund", + "subject.VendorCreditRefund": "Vendor Credit Refund", + "subject.Cashflow": "Cashflow", + "subject.TaxRate": "Tax Rate", + "subject.UncategorizedTransaction": "Uncategorized Transaction", + "subject.PlaidTransactions": "Plaid Transactions", + "subject.BankTransaction": "Bank Transaction", + "subject.Bill": "Bill", + "subject.Expense": "Expense", + "subject.Account": "Account", + "subject.Item": "Item", + "subject.Customer": "Customer", + "subject.Vendor": "Vendor", + "subject.Role": "Role", + "subject.Warehouse": "Warehouse", + "subject.Branch": "Branch", + + "action.created": "Created", + "action.edited": "Edited", + "action.deleted": "Deleted", + "action.opened": "Opened", + "action.delivered": "Delivered", + "action.writtenoff": "Written Off", + "action.writtenoff_canceled": "Write-off Canceled", + "action.published": "Published", + "action.refund_created": "Refund Created", + "action.categorized": "Categorized", + "action.activated": "Activated", + "action.initiated": "Initiated", + "action.transferred": "Transferred", + "action.locking_changed": "Locking Changed", + + "metadata.bill_with_amount": "Bill {{billNumber}} - {{amount}} {{currencyCode}}", + "metadata.bill": "Bill {{billNumber}}", + "metadata.invoice_with_balance": "Invoice {{invoiceNumber}} - Balance: {{balance}} {{currencyCode}}", + "metadata.invoice": "Invoice {{invoiceNumber}}", + "metadata.receipt_with_amount": "Receipt {{receiptNumber}} - {{amount}} {{currencyCode}}", + "metadata.receipt": "Receipt {{receiptNumber}}", + "metadata.estimate_with_total": "Estimate {{estimateNumber}} - {{total}} {{currencyCode}}", + "metadata.estimate": "Estimate {{estimateNumber}}", + "metadata.payment_receive_with_amount": "Payment {{paymentReceiveNo}} - {{amount}} {{currencyCode}}", + "metadata.payment_receive": "Payment {{paymentReceiveNo}}", + "metadata.payment_made_with_amount": "Payment {{paymentNumber}} - {{amount}} {{currencyCode}}", + "metadata.payment_made": "Payment {{paymentNumber}}", + "metadata.expense_with_currency": "Expense - {{amount}} {{currencyCode}}", + "metadata.expense": "Expense - {{amount}}", + "metadata.expense_plain": "Expense", + "metadata.credit_note_with_amount": "Credit Note {{creditNoteNumber}} - {{amount}} {{currencyCode}}", + "metadata.credit_note": "Credit Note {{creditNoteNumber}}", + "metadata.vendor_credit_with_total": "Vendor Credit {{vendorCreditNumber}} - {{total}} {{currencyCode}}", + "metadata.vendor_credit": "Vendor Credit {{vendorCreditNumber}}", + "metadata.journal_with_amount": "Journal {{journalNumber}} - {{amount}} {{currencyCode}}", + "metadata.journal": "Journal {{journalNumber}}", + "metadata.cashflow_with_currency": "Cashflow - {{amount}} {{currencyCode}}", + "metadata.cashflow": "Cashflow - {{amount}}", + "metadata.cashflow_plain": "Cashflow", + "metadata.account_with_code": "Account: {{name}} ({{code}})", + "metadata.account": "Account: {{name}}", + "metadata.account_plain": "Account", + "metadata.adjustment": "Adjustment: {{reason}}", + "metadata.adjustment_plain": "Inventory Adjustment", + "metadata.transfer": "Transfer: {{transactionNumber}}", + "metadata.transfer_plain": "Warehouse Transfer", + "metadata.item_with_code": "{{name}} ({{code}})", + "metadata.item": "{{name}}", + "metadata.item_plain": "Item", + "metadata.customer_with_email": "{{displayName}} ({{email}})", + "metadata.customer": "{{displayName}}", + "metadata.customer_plain": "Customer", + "metadata.vendor_with_email": "{{displayName}} ({{email}})", + "metadata.vendor": "{{displayName}}", + "metadata.vendor_plain": "Vendor", + "metadata.role_with_old": "Role: {{roleName}} (was: {{oldRoleName}})", + "metadata.role": "Role: {{roleName}}", + "metadata.role_plain": "Role", + "metadata.tax_rate": "{{name}} - {{rate}}%", + "metadata.tax_rate_name": "{{name}}", + "metadata.tax_rate_plain": "Tax Rate", + "metadata.warehouse": "Warehouse: {{code}}", + "metadata.warehouse_plain": "Warehouse", + "metadata.branch_with_code": "{{name}} ({{code}})", + "metadata.branch": "{{name}}", + "metadata.branch_plain": "Branch", + "metadata.item_category": "Category: {{name}}", + "metadata.item_category_plain": "Item Category", + "metadata.bank_rule": "Rule: {{name}}", + "metadata.bank_rule_plain": "Bank Rule", + "metadata.locking_with_date": "Module: {{module}} locked to {{lockToDate}}", + "metadata.locking_module": "Module: {{module}}", + "metadata.locking_plain": "Transactions Locking", + "metadata.refund_amount": "Refund - {{amount}}", + "metadata.refund_plain": "Refund", + "metadata.imported_with_payee": "Imported - {{payee}}: {{amount}} {{currencyCode}}", + "metadata.imported": "Imported - {{amount}} {{currencyCode}}", + "metadata.imported_plain": "Imported Transaction", + "metadata.plaid_with_batch": "Plaid Sync - Account {{plaidAccountId}} (Batch: {{batch}})", + "metadata.plaid": "Plaid Sync - Account {{plaidAccountId}}", + "metadata.plaid_plain": "Plaid Sync", + "metadata.bank_with_payee": "{{payee}}: {{amount}} {{currencyCode}}", + "metadata.bank": "{{amount}} {{currencyCode}}", + "metadata.bank_plain": "Bank Transaction" +} diff --git a/packages/server/src/interfaces/Account.ts b/packages/server/src/interfaces/Account.ts index 9fb905e3f..2575c8819 100644 --- a/packages/server/src/interfaces/Account.ts +++ b/packages/server/src/interfaces/Account.ts @@ -147,6 +147,8 @@ export interface IAccountEventDeletePayload { export interface IAccountEventActivatedPayload { tenantId: number; accountId: number; + activate: boolean; + account: IAccount; trx: Knex.Transaction; } diff --git a/packages/server/src/interfaces/Item.ts b/packages/server/src/interfaces/Item.ts index 63816bdff..cb8dee4c3 100644 --- a/packages/server/src/interfaces/Item.ts +++ b/packages/server/src/interfaces/Item.ts @@ -155,6 +155,18 @@ export interface IItemEventDeletedPayload { trx: Knex.Transaction; } +export interface IItemEventActivatedPayload { + item: Item; + itemId: number; + trx: Knex.Transaction; +} + +export interface IItemEventInactivatedPayload { + item: Item; + itemId: number; + trx: Knex.Transaction; +} + export enum ItemAction { CREATE = 'Create', EDIT = 'Edit', diff --git a/packages/server/src/modules/Accounts/Accounts.types.ts b/packages/server/src/modules/Accounts/Accounts.types.ts index e5b8af0ca..1ebc9f043 100644 --- a/packages/server/src/modules/Accounts/Accounts.types.ts +++ b/packages/server/src/modules/Accounts/Accounts.types.ts @@ -67,6 +67,8 @@ export interface IAccountEventDeletePayload { export interface IAccountEventActivatedPayload { accountId: number; + activate: boolean; + account: Account; trx: Knex.Transaction; } diff --git a/packages/server/src/modules/Accounts/ActivateAccount.service.ts b/packages/server/src/modules/Accounts/ActivateAccount.service.ts index 4b8ed6217..d166cd8d3 100644 --- a/packages/server/src/modules/Accounts/ActivateAccount.service.ts +++ b/packages/server/src/modules/Accounts/ActivateAccount.service.ts @@ -53,6 +53,8 @@ export class ActivateAccount { // Triggers `onAccountActivated` event. this.eventEmitter.emitAsync(events.accounts.onActivated, { accountId, + activate, + account: oldAccount, trx, } as IAccountEventActivatedPayload); }); diff --git a/packages/server/src/modules/App/App.module.ts b/packages/server/src/modules/App/App.module.ts index ef50f0efa..906b1e7ec 100644 --- a/packages/server/src/modules/App/App.module.ts +++ b/packages/server/src/modules/App/App.module.ts @@ -102,6 +102,7 @@ import { BankingCategorizeModule } from '../BankingCategorize/BankingCategorize. import { TenantModelsInitializeModule } from '../Tenancy/TenantModelsInitialize.module'; import { BillLandedCostsModule } from '../BillLandedCosts/BillLandedCosts.module'; import { SocketModule } from '../Socket/Socket.module'; +import { EEModule } from '../EE/EE.module'; import { ThrottlerGuard } from '@nestjs/throttler'; import { AppThrottleModule } from './AppThrottle.module'; @@ -256,6 +257,7 @@ import { AppThrottleModule } from './AppThrottle.module'; UsersModule, ContactsModule, SocketModule, + EEModule, ], controllers: [AppController], providers: [ diff --git a/packages/server/src/modules/AuditLogs/AuditLog.service.ts b/packages/server/src/modules/AuditLogs/AuditLog.service.ts new file mode 100644 index 000000000..33066e001 --- /dev/null +++ b/packages/server/src/modules/AuditLogs/AuditLog.service.ts @@ -0,0 +1,71 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { ClsService } from 'nestjs-cls'; +import { Knex } from 'knex'; +import * as moment from 'moment'; +import '@/utils/moment-mysql'; +import { AuditLog } from './models/AuditLog.model'; +import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel'; +import { TENANCY_DB_CONNECTION } from '@/modules/Tenancy/TenancyDB/TenancyDB.constants'; + +const METADATA_JSON_MAX = 8000; + +export interface RecordAuditLogParams { + /** When set, the row is written in the same DB transaction as the business change. */ + trx?: Knex.Transaction; + action: string; + subject: string; + subjectId?: number | null; + metadata?: Record | null; +} + +@Injectable() +export class AuditLogService { + constructor( + private readonly cls: ClsService, + @Inject(AuditLog.name) + private readonly auditLogModel: TenantModelProxy, + @Inject(TENANCY_DB_CONNECTION) + private readonly tenantKnex: () => Knex, + ) {} + + /** + * Persists one audit row. Prefer always passing `trx` from domain event payloads so the + * audit row rolls back with failed business transactions. If `trx` is omitted, the insert + * runs on a separate connection/transaction (only use after the business change committed). + */ + async record(params: RecordAuditLogParams): Promise { + const userId = this.cls.get('userId') ?? null; + const ip = (this.cls.get('ip') as string) ?? null; + const executor = params.trx ?? this.tenantKnex(); + const metadata = this.normalizeMetadata(params.metadata); + + await this.auditLogModel() + .query(executor) + .insert({ + userId, + action: params.action, + subject: params.subject, + subjectId: params.subjectId ?? null, + metadata, + ip, + // MySQL DATETIME expects `YYYY-MM-DD HH:mm:ss`, not ISO-8601 with `T`/`Z`. + createdAt: moment().toMySqlDateTime(), + }); + } + + private normalizeMetadata( + metadata: Record | null | undefined, + ): Record | null { + if (metadata == null) return null; + try { + const s = JSON.stringify(metadata); + if (s.length <= METADATA_JSON_MAX) return metadata; + return { + _truncated: true, + summary: s.slice(0, METADATA_JSON_MAX), + }; + } catch { + return { _error: 'metadata_not_serializable' }; + } + } +} diff --git a/packages/server/src/modules/AuditLogs/AuditLogs.controller.ts b/packages/server/src/modules/AuditLogs/AuditLogs.controller.ts new file mode 100644 index 000000000..7fcbb5e5b --- /dev/null +++ b/packages/server/src/modules/AuditLogs/AuditLogs.controller.ts @@ -0,0 +1,25 @@ +import { Controller, Get, Query, UseGuards } from '@nestjs/common'; +import { ApiOperation, ApiTags } from '@nestjs/swagger'; +import { ApiCommonHeaders } from '@/common/decorators/ApiCommonHeaders'; +import { AuthorizationGuard } from '@/modules/Roles/Authorization.guard'; +import { PermissionGuard } from '@/modules/Roles/Permission.guard'; +import { RequirePermission } from '@/modules/Roles/RequirePermission.decorator'; +import { AbilitySubject } from '@/modules/Roles/Roles.types'; +import { GetAuditLogsQueryDto } from './dtos/GetAuditLogsQuery.dto'; +import { GetAuditLogsService } from './queries/GetAuditLogs.service'; +import { AuditLogAction } from './types/AuditLogs.types'; + +@Controller('audit-logs') +@ApiTags('Audit logs') +@ApiCommonHeaders() +@UseGuards(AuthorizationGuard, PermissionGuard) +export class AuditLogsController { + constructor(private readonly getAuditLogsService: GetAuditLogsService) {} + + @Get() + @RequirePermission(AuditLogAction.View, AbilitySubject.AuditLog) + @ApiOperation({ summary: 'List financial audit log entries for the tenant.' }) + getAuditLogs(@Query() query: GetAuditLogsQueryDto) { + return this.getAuditLogsService.getAuditLogs(query); + } +} diff --git a/packages/server/src/modules/AuditLogs/AuditLogs.module.ts b/packages/server/src/modules/AuditLogs/AuditLogs.module.ts new file mode 100644 index 000000000..a38a2c898 --- /dev/null +++ b/packages/server/src/modules/AuditLogs/AuditLogs.module.ts @@ -0,0 +1,20 @@ +import { Module } from '@nestjs/common'; +import { AuditLogsController } from './AuditLogs.controller'; +import { AuditLogService } from './AuditLog.service'; +import { GetAuditLogsService } from './queries/GetAuditLogs.service'; +import { FinancialAuditLogSubscriber } from './subscribers/FinancialAuditLog.subscriber'; +import { AuthorizationGuard } from '@/modules/Roles/Authorization.guard'; +import { PermissionGuard } from '@/modules/Roles/Permission.guard'; + +@Module({ + controllers: [AuditLogsController], + providers: [ + AuditLogService, + GetAuditLogsService, + FinancialAuditLogSubscriber, + AuthorizationGuard, + PermissionGuard, + ], + exports: [AuditLogService], +}) +export class AuditLogsModule {} diff --git a/packages/server/src/modules/AuditLogs/dtos/GetAuditLogsQuery.dto.ts b/packages/server/src/modules/AuditLogs/dtos/GetAuditLogsQuery.dto.ts new file mode 100644 index 000000000..5f4e08683 --- /dev/null +++ b/packages/server/src/modules/AuditLogs/dtos/GetAuditLogsQuery.dto.ts @@ -0,0 +1,75 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { Transform } from 'class-transformer'; +import { + ArrayMaxSize, + IsArray, + IsInt, + IsOptional, + IsString, + Max, + Min, +} from 'class-validator'; +import { ToNumber } from '@/common/decorators/Validators'; + +function toOptionalStringArray(value: unknown): string[] | undefined { + if (value === undefined || value === null || value === '') { + return undefined; + } + const raw = Array.isArray(value) ? value : [value]; + const filtered = raw + .map((v) => (v == null ? '' : String(v).trim())) + .filter((v) => v.length > 0); + if (!filtered.length) { + return undefined; + } + return [...new Set(filtered)]; +} + +export class GetAuditLogsQueryDto { + @ApiPropertyOptional({ minimum: 1, default: 1 }) + @IsOptional() + @ToNumber() + @IsInt() + @Min(1) + page?: number; + + @ApiPropertyOptional({ minimum: 1, maximum: 100, default: 20 }) + @IsOptional() + @ToNumber() + @IsInt() + @Min(1) + @Max(100) + pageSize?: number; + + @ApiPropertyOptional({ type: [String], isArray: true }) + @Transform(({ value }) => toOptionalStringArray(value)) + @IsOptional() + @IsArray() + @ArrayMaxSize(50) + @IsString({ each: true }) + subject?: string[]; + + @ApiPropertyOptional({ type: [String], isArray: true }) + @Transform(({ value }) => toOptionalStringArray(value)) + @IsOptional() + @IsArray() + @ArrayMaxSize(50) + @IsString({ each: true }) + action?: string[]; + + @ApiPropertyOptional({ description: 'System user id' }) + @IsOptional() + @ToNumber() + @IsInt() + userId?: number; + + @ApiPropertyOptional({ description: 'ISO date (inclusive), start of day' }) + @IsOptional() + @IsString() + from?: string; + + @ApiPropertyOptional({ description: 'ISO date (inclusive), end of day' }) + @IsOptional() + @IsString() + to?: string; +} diff --git a/packages/server/src/modules/AuditLogs/models/AuditLog.model.ts b/packages/server/src/modules/AuditLogs/models/AuditLog.model.ts new file mode 100644 index 000000000..a811ab90f --- /dev/null +++ b/packages/server/src/modules/AuditLogs/models/AuditLog.model.ts @@ -0,0 +1,63 @@ +import { Model } from 'objection'; +import { TenantBaseModel } from '@/modules/System/models/TenantBaseModel'; +import { TenantUser } from '@/modules/Tenancy/TenancyModels/models/TenantUser.model'; + +export class AuditLog extends TenantBaseModel { + public tenantUser?: TenantUser; + + public id!: number; + /** System user id (matches CLS `userId` / `users.system_user_id` in tenant DB). */ + public userId!: number | null; + public action!: string; + public subject!: string; + public subjectId!: number | null; + public metadata!: Record | null; + public ip!: string | null; + public createdAt!: Date | string; + + static get tableName() { + return 'audit_logs'; + } + + static get jsonAttributes() { + return ['metadata']; + } + + /** + * No `updated_at`; `created_at` is set in AuditLogService. + */ + get timestamps() { + return []; + } + + static get relationMappings() { + return { + tenantUser: { + relation: Model.BelongsToOneRelation, + modelClass: TenantUser, + join: { + from: 'audit_logs.userId', + to: 'users.systemUserId', + }, + }, + }; + } + + static get jsonSchema() { + return { + type: 'object', + required: ['action', 'subject'], + properties: { + id: { type: 'integer' }, + userId: { type: ['integer', 'null'] }, + action: { type: 'string', maxLength: 64 }, + subject: { type: 'string', maxLength: 64 }, + subjectId: { type: ['integer', 'null'] }, + metadata: { type: ['object', 'null'] }, + ip: { type: ['string', 'null'], maxLength: 64 }, + // Stored as MySQL `YYYY-MM-DD HH:mm:ss` (see AuditLogService), not strict ISO-8601. + createdAt: { type: 'string' }, + }, + }; + } +} diff --git a/packages/server/src/modules/AuditLogs/queries/GetAuditLogList.transformer.ts b/packages/server/src/modules/AuditLogs/queries/GetAuditLogList.transformer.ts new file mode 100644 index 000000000..d43dc6711 --- /dev/null +++ b/packages/server/src/modules/AuditLogs/queries/GetAuditLogList.transformer.ts @@ -0,0 +1,75 @@ +import * as moment from 'moment'; +import { Transformer } from '@/modules/Transformer/Transformer'; +import { + formatAction, + formatMetadataSummary, + formatSubject, +} from './GetAuditLogList.transformer.utils'; + +export class GetAuditLogListTransformer extends Transformer { + public excludeAttributes = (): string[] => { + return ['*']; + }; + + public includeAttributes = (): string[] => { + return [ + 'id', + 'userId', + 'userName', + 'userEmail', + 'action', + 'subject', + 'subjectId', + 'metadata', + 'summary', + 'ip', + 'createdAt', + 'createdAtFormatted', + ]; + }; + + protected userName = (item: Record): string | null => { + if (!item.tenantUser) return null; + const u = item.tenantUser as Record; + const firstName = u.firstName || u.first_name || ''; + const lastName = u.lastName || u.last_name || ''; + const fullName = u.fullName || u.full_name || ''; + const name = fullName || `${firstName} ${lastName}`.trim(); + return name || null; + }; + + protected userEmail = (item: Record): string | null => { + if (!item.tenantUser) return null; + const u = item.tenantUser as Record; + const email = + u.email || u.emailAddress || u.email_address || ''; + return email || null; + }; + + protected action = (item: Record): string => { + return formatAction(item.action, this.context.i18n.t.bind(this.context.i18n)); + }; + + protected subject = (item: Record): string => { + return formatSubject(item.subject, this.context.i18n.t.bind(this.context.i18n)); + }; + + protected summary = (item: Record): string => { + return formatMetadataSummary( + item.metadata, + item.subject, + this.context.i18n.t.bind(this.context.i18n), + ); + }; + + protected createdAt = (item: Record): string => { + const raw = item.createdAt; + if (typeof raw === 'string') return raw; + return (raw as Date)?.toISOString?.() ?? String(raw); + }; + + protected createdAtFormatted = (item: Record): string => { + const createdAtStr = this.createdAt(item); + return moment(createdAtStr).format('YYYY-MM-DD HH:mm:ss'); + }; +} diff --git a/packages/server/src/modules/AuditLogs/queries/GetAuditLogList.transformer.utils.ts b/packages/server/src/modules/AuditLogs/queries/GetAuditLogList.transformer.utils.ts new file mode 100644 index 000000000..da47f62ed --- /dev/null +++ b/packages/server/src/modules/AuditLogs/queries/GetAuditLogList.transformer.utils.ts @@ -0,0 +1,374 @@ +export type TranslateFn = (key: string, options?: { args?: Record }) => string; + +const defaultT: TranslateFn = (key) => key; + +/** + * Format camelCase subject to readable text using i18n. + */ +export function formatSubject(subject: string, t: TranslateFn = defaultT): string { + return t(`audit_log.subject.${subject}`); +} + +/** + * Format action to capitalized text using i18n. + */ +export function formatAction(action: string, t: TranslateFn = defaultT): string { + if (!action) return ''; + return t(`audit_log.action.${action}`); +} + +/** + * Format metadata into a human-readable summary based on subject type. + */ +export function formatMetadataSummary( + metadata: Record | null, + subject: string, + t: TranslateFn = defaultT, +): string { + if (metadata == null) return ''; + + const formatters: Record) => string> = { + Bill: (m) => { + if (m.billNumber) { + return m.amount + ? t('audit_log.metadata.bill_with_amount', { + args: { billNumber: String(m.billNumber), amount: String(m.amount), currencyCode: String(m.currencyCode || '') }, + }) + : t('audit_log.metadata.bill', { + args: { billNumber: String(m.billNumber) }, + }); + } + return String(m.billNumber || ''); + }, + SaleInvoice: (m) => { + if (m.invoiceNumber) { + return m.balance + ? t('audit_log.metadata.invoice_with_balance', { + args: { invoiceNumber: String(m.invoiceNumber), balance: String(m.balance), currencyCode: String(m.currencyCode || '') }, + }) + : t('audit_log.metadata.invoice', { + args: { invoiceNumber: String(m.invoiceNumber) }, + }); + } + return String(m.invoiceNumber || ''); + }, + SaleReceipt: (m) => { + if (m.receiptNumber) { + return m.amount + ? t('audit_log.metadata.receipt_with_amount', { + args: { receiptNumber: String(m.receiptNumber), amount: String(m.amount), currencyCode: String(m.currencyCode || '') }, + }) + : t('audit_log.metadata.receipt', { + args: { receiptNumber: String(m.receiptNumber) }, + }); + } + return String(m.receiptNumber || ''); + }, + SaleEstimate: (m) => { + if (m.estimateNumber) { + return m.total + ? t('audit_log.metadata.estimate_with_total', { + args: { estimateNumber: String(m.estimateNumber), total: String(m.total), currencyCode: String(m.currencyCode || '') }, + }) + : t('audit_log.metadata.estimate', { + args: { estimateNumber: String(m.estimateNumber) }, + }); + } + return String(m.estimateNumber || ''); + }, + PaymentReceive: (m) => { + if (m.paymentReceiveNo) { + return m.amount + ? t('audit_log.metadata.payment_receive_with_amount', { + args: { paymentReceiveNo: String(m.paymentReceiveNo), amount: String(m.amount), currencyCode: String(m.currencyCode || '') }, + }) + : t('audit_log.metadata.payment_receive', { + args: { paymentReceiveNo: String(m.paymentReceiveNo) }, + }); + } + return String(m.paymentReceiveNo || ''); + }, + PaymentMade: (m) => { + if (m.paymentNumber) { + return m.amount + ? t('audit_log.metadata.payment_made_with_amount', { + args: { paymentNumber: String(m.paymentNumber), amount: String(m.amount), currencyCode: String(m.currencyCode || '') }, + }) + : t('audit_log.metadata.payment_made', { + args: { paymentNumber: String(m.paymentNumber) }, + }); + } + return String(m.paymentNumber || ''); + }, + Expense: (m) => { + if (m.amount) { + return m.currencyCode + ? t('audit_log.metadata.expense_with_currency', { + args: { amount: String(m.amount), currencyCode: String(m.currencyCode) }, + }) + : t('audit_log.metadata.expense', { + args: { amount: String(m.amount) }, + }); + } + return t('audit_log.metadata.expense_plain'); + }, + CreditNote: (m) => { + if (m.creditNoteNumber) { + return m.amount + ? t('audit_log.metadata.credit_note_with_amount', { + args: { creditNoteNumber: String(m.creditNoteNumber), amount: String(m.amount), currencyCode: String(m.currencyCode || '') }, + }) + : t('audit_log.metadata.credit_note', { + args: { creditNoteNumber: String(m.creditNoteNumber) }, + }); + } + return String(m.creditNoteNumber || ''); + }, + VendorCredit: (m) => { + if (m.vendorCreditNumber) { + return m.total + ? t('audit_log.metadata.vendor_credit_with_total', { + args: { vendorCreditNumber: String(m.vendorCreditNumber), total: String(m.total), currencyCode: String(m.currencyCode || '') }, + }) + : t('audit_log.metadata.vendor_credit', { + args: { vendorCreditNumber: String(m.vendorCreditNumber) }, + }); + } + return String(m.vendorCreditNumber || ''); + }, + ManualJournal: (m) => { + if (m.journalNumber) { + return m.amount + ? t('audit_log.metadata.journal_with_amount', { + args: { journalNumber: String(m.journalNumber), amount: String(m.amount), currencyCode: String(m.currencyCode || '') }, + }) + : t('audit_log.metadata.journal', { + args: { journalNumber: String(m.journalNumber) }, + }); + } + return String(m.journalNumber || ''); + }, + Cashflow: (m) => { + if (m.amount) { + return m.currencyCode + ? t('audit_log.metadata.cashflow_with_currency', { + args: { amount: String(m.amount), currencyCode: String(m.currencyCode) }, + }) + : t('audit_log.metadata.cashflow', { + args: { amount: String(m.amount) }, + }); + } + return t('audit_log.metadata.cashflow_plain'); + }, + Account: (m) => { + if (m.name) { + return m.code + ? t('audit_log.metadata.account_with_code', { + args: { name: String(m.name), code: String(m.code) }, + }) + : t('audit_log.metadata.account', { + args: { name: String(m.name) }, + }); + } + return t('audit_log.metadata.account_plain'); + }, + InventoryAdjustment: (m) => { + return m.reason + ? t('audit_log.metadata.adjustment', { + args: { reason: String(m.reason) }, + }) + : t('audit_log.metadata.adjustment_plain'); + }, + WarehouseTransfer: (m) => { + if (m.transactionNumber) { + return t('audit_log.metadata.transfer', { + args: { transactionNumber: String(m.transactionNumber) }, + }); + } + return t('audit_log.metadata.transfer_plain'); + }, + Item: (m) => { + if (m.name) { + return m.code + ? t('audit_log.metadata.item_with_code', { + args: { name: String(m.name), code: String(m.code) }, + }) + : t('audit_log.metadata.item', { + args: { name: String(m.name) }, + }); + } + return t('audit_log.metadata.item_plain'); + }, + Customer: (m) => { + if (m.displayName) { + return m.email + ? t('audit_log.metadata.customer_with_email', { + args: { displayName: String(m.displayName), email: String(m.email) }, + }) + : t('audit_log.metadata.customer', { + args: { displayName: String(m.displayName) }, + }); + } + return t('audit_log.metadata.customer_plain'); + }, + Vendor: (m) => { + if (m.displayName) { + return m.email + ? t('audit_log.metadata.vendor_with_email', { + args: { displayName: String(m.displayName), email: String(m.email) }, + }) + : t('audit_log.metadata.vendor', { + args: { displayName: String(m.displayName) }, + }); + } + return t('audit_log.metadata.vendor_plain'); + }, + Role: (m) => { + if (m.roleName) { + return m.oldRoleName + ? t('audit_log.metadata.role_with_old', { + args: { roleName: String(m.roleName), oldRoleName: String(m.oldRoleName) }, + }) + : t('audit_log.metadata.role', { + args: { roleName: String(m.roleName) }, + }); + } + return t('audit_log.metadata.role_plain'); + }, + TaxRate: (m) => { + if (m.name) { + return m.rate !== undefined + ? t('audit_log.metadata.tax_rate', { + args: { name: String(m.name), rate: String(m.rate) }, + }) + : t('audit_log.metadata.tax_rate_name', { + args: { name: String(m.name) }, + }); + } + return t('audit_log.metadata.tax_rate_plain'); + }, + Warehouse: (m) => { + return m.code + ? t('audit_log.metadata.warehouse', { + args: { code: String(m.code) }, + }) + : t('audit_log.metadata.warehouse_plain'); + }, + Branch: (m) => { + if (m.name) { + return m.code + ? t('audit_log.metadata.branch_with_code', { + args: { name: String(m.name), code: String(m.code) }, + }) + : t('audit_log.metadata.branch', { + args: { name: String(m.name) }, + }); + } + return t('audit_log.metadata.branch_plain'); + }, + ItemCategory: (m) => { + return m.name + ? t('audit_log.metadata.item_category', { + args: { name: String(m.name) }, + }) + : t('audit_log.metadata.item_category_plain'); + }, + BankRule: (m) => { + return m.name + ? t('audit_log.metadata.bank_rule', { + args: { name: String(m.name) }, + }) + : t('audit_log.metadata.bank_rule_plain'); + }, + TransactionsLocking: (m) => { + if (m.module) { + return m.lockToDate + ? t('audit_log.metadata.locking_with_date', { + args: { module: String(m.module), lockToDate: String(m.lockToDate) }, + }) + : t('audit_log.metadata.locking_module', { + args: { module: String(m.module) }, + }); + } + return t('audit_log.metadata.locking_plain'); + }, + CreditNoteRefund: (m) => { + if (m.amount) { + return t('audit_log.metadata.refund_amount', { + args: { amount: String(m.amount) }, + }); + } + return t('audit_log.metadata.refund_plain'); + }, + VendorCreditRefund: (m) => { + if (m.amount) { + return t('audit_log.metadata.refund_amount', { + args: { amount: String(m.amount) }, + }); + } + return t('audit_log.metadata.refund_plain'); + }, + UncategorizedTransaction: (m) => { + if (m.amount) { + return m.payee + ? t('audit_log.metadata.imported_with_payee', { + args: { payee: String(m.payee), amount: String(m.amount), currencyCode: String(m.currencyCode || '') }, + }) + : t('audit_log.metadata.imported', { + args: { amount: String(m.amount), currencyCode: String(m.currencyCode || '') }, + }); + } + return t('audit_log.metadata.imported_plain'); + }, + PlaidTransactions: (m) => { + if (m.plaidAccountId) { + return m.batch + ? t('audit_log.metadata.plaid_with_batch', { + args: { plaidAccountId: String(m.plaidAccountId), batch: String(m.batch) }, + }) + : t('audit_log.metadata.plaid', { + args: { plaidAccountId: String(m.plaidAccountId) }, + }); + } + return t('audit_log.metadata.plaid_plain'); + }, + BankTransaction: (m) => { + if (m.amount) { + return m.payee + ? t('audit_log.metadata.bank_with_payee', { + args: { payee: String(m.payee), amount: String(m.amount), currencyCode: String(m.currencyCode || '') }, + }) + : t('audit_log.metadata.bank', { + args: { amount: String(m.amount), currencyCode: String(m.currencyCode || '') }, + }); + } + return t('audit_log.metadata.bank_plain'); + }, + }; + + const formatter = formatters[subject]; + if (formatter) { + try { + return formatter(metadata); + } catch (e) { + // Fallback to default below + } + } + + const entries = Object.entries(metadata).filter( + ([, value]) => value !== null && value !== undefined && value !== '', + ); + + if (entries.length === 0) return ''; + + return entries + .slice(0, 3) + .map(([key, value]) => { + const displayKey = key + .replace(/([A-Z])/g, ' $1') + .replace(/^./, (str) => str.toUpperCase()) + .trim(); + return `${displayKey}: ${value}`; + }) + .join(', '); +} diff --git a/packages/server/src/modules/AuditLogs/queries/GetAuditLogs.service.ts b/packages/server/src/modules/AuditLogs/queries/GetAuditLogs.service.ts new file mode 100644 index 000000000..cfe0a6eed --- /dev/null +++ b/packages/server/src/modules/AuditLogs/queries/GetAuditLogs.service.ts @@ -0,0 +1,79 @@ +import { Inject, Injectable } from '@nestjs/common'; +import * as moment from 'moment'; +import { AuditLog } from '../models/AuditLog.model'; +import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel'; +import { GetAuditLogsQueryDto } from '../dtos/GetAuditLogsQuery.dto'; +import { TransformerInjectable } from '@/modules/Transformer/TransformerInjectable.service'; +import { GetAuditLogListTransformer } from './GetAuditLogList.transformer'; + +export interface AuditLogListItem { + id: number; + userId: number | null; + userName: string | null; + userEmail: string | null; + action: string; + subject: string; + subjectId: number | null; + metadata: Record | null; + summary: string; + ip: string | null; + createdAt: string; + createdAtFormatted: string; +} + +@Injectable() +export class GetAuditLogsService { + constructor( + @Inject(AuditLog.name) + private readonly auditLogModel: TenantModelProxy, + private readonly transformer: TransformerInjectable, + ) {} + + async getAuditLogs(query: GetAuditLogsQueryDto): Promise<{ + data: AuditLogListItem[]; + pagination: { total: number; page: number; pageSize: number }; + }> { + const page = query.page ?? 1; + const pageSize = query.pageSize ?? 20; + const pageIndex = Math.max(0, page - 1); + + let q = this.auditLogModel() + .query() + .withGraphFetched('tenantUser') + .orderBy('createdAt', 'desc'); + + if (query.subject?.length) { + q = q.whereIn('subject', query.subject); + } + if (query.action?.length) { + q = q.whereIn('action', query.action); + } + if (query.userId != null) { + q = q.where('userId', query.userId); + } + if (query.from) { + const from = moment(query.from).startOf('day').format('YYYY-MM-DD HH:mm:ss'); + q = q.where('createdAt', '>=', from); + } + if (query.to) { + const to = moment(query.to).endOf('day').format('YYYY-MM-DD HH:mm:ss'); + q = q.where('createdAt', '<=', to); + } + + const result = await q.page(pageIndex, pageSize); + + const data = (await this.transformer.transform( + result.results, + new GetAuditLogListTransformer(), + )) as AuditLogListItem[]; + + return { + data, + pagination: { + total: result.total, + page, + pageSize, + }, + }; + } +} diff --git a/packages/server/src/modules/AuditLogs/subscribers/FinancialAuditLog.subscriber.ts b/packages/server/src/modules/AuditLogs/subscribers/FinancialAuditLog.subscriber.ts new file mode 100644 index 000000000..8b7bbfb8a --- /dev/null +++ b/packages/server/src/modules/AuditLogs/subscribers/FinancialAuditLog.subscriber.ts @@ -0,0 +1,807 @@ +import { Injectable } from '@nestjs/common'; +import { OnEvent } from '@nestjs/event-emitter'; +import { Knex } from 'knex'; +import { events } from '@/common/events/events'; +import { AbilitySubject } from '@/modules/Roles/Roles.types'; +import { AuditLogService } from '../AuditLog.service'; +import { + IBillCreatedPayload, + IBillEditedPayload, + IBIllEventDeletedPayload, + IBillOpenedPayload, +} from '@/modules/Bills/Bills.types'; +import { + ISaleInvoiceCreatedPayload, + ISaleInvoiceEditedPayload, + ISaleInvoiceDeletedPayload, + ISaleInvoiceEventDeliveredPayload, + ISaleInvoiceWrittenOffCanceledPayload, + ISaleInvoiceWriteoffCreatePayload, +} from '@/modules/SaleInvoices/SaleInvoice.types'; +import { + ISaleReceiptCreatedPayload, + ISaleReceiptEditedPayload, + ISaleReceiptEventDeletedPayload, +} from '@/modules/SaleReceipts/types/SaleReceipts.types'; +import { + IPaymentReceivedCreatedPayload, + IPaymentReceivedEditedPayload, + IPaymentReceivedDeletedPayload, +} from '@/modules/PaymentReceived/types/PaymentReceived.types'; +import { + IBillPaymentEventCreatedPayload, + IBillPaymentEventEditedPayload, + IBillPaymentEventDeletedPayload, +} from '@/modules/BillPayments/types/BillPayments.types'; +import { + IExpenseCreatedPayload, + IExpenseEventEditPayload, + IExpenseEventDeletePayload, + IExpenseEventPublishedPayload, +} from '@/modules/Expenses/Expenses.types'; +import { + ICreditNoteCreatedPayload, + ICreditNoteEditedPayload, + ICreditNoteDeletedPayload, + ICreditNoteOpenedPayload, +} from '@/modules/CreditNotes/types/CreditNotes.types'; +import { + IVendorCreditCreatedPayload, + IVendorCreditEditedPayload, + IVendorCreditDeletedPayload, + IVendorCreditOpenedPayload, +} from '@/modules/VendorCredit/types/VendorCredit.types'; +import { + IManualJournalEventCreatedPayload, + IManualJournalEventEditedPayload, + IManualJournalEventDeletedPayload, + IManualJournalEventPublishedPayload, +} from '@/modules/ManualJournals/types/ManualJournals.types'; +import { + ICommandCashflowCreatedPayload, + ICommandCashflowDeletedPayload, + ICashflowTransactionCategorizedPayload, +} from '@/modules/BankingTransactions/types/BankingTransactions.types'; +import { + IAccountEventCreatedPayload, + IAccountEventDeletedPayload, + IAccountEventActivatedPayload, +} from '@/interfaces/Account'; +import { + IInventoryAdjustmentEventCreatedPayload, + IInventoryAdjustmentEventPublishedPayload, + IInventoryAdjustmentEventDeletedPayload, +} from '@/modules/InventoryAdjutments/types/InventoryAdjustments.types'; +import { + IWarehouseTransferCreated, + IWarehouseTransferEditedPayload, + IWarehouseTransferDeletedPayload, + IWarehouseTransferInitiatedPayload, + IWarehouseTransferTransferredPayload, +} from '@/modules/Warehouses/Warehouse.types'; +import { + ITransactionsLockingPartialUnlocked, + ITransactionsLockingCanceled, +} from '@/modules/TransactionsLocking/types/TransactionsLocking.types'; +import { + ISaleEstimateCreatedPayload, + ISaleEstimateEditedPayload, + ISaleEstimateDeletedPayload, +} from '@/modules/SaleEstimates/types/SaleEstimates.types'; +import { IRefundCreditNoteCreatedPayload } from '@/modules/CreditNoteRefunds/types/CreditNoteRefunds.types'; +import { IRefundVendorCreditCreatedPayload } from '@/modules/VendorCreditsRefund/types/VendorCreditRefund.types'; + +@Injectable() +export class FinancialAuditLogSubscriber { + constructor(private readonly auditLog: AuditLogService) {} + + private async write( + trx: Knex.Transaction | undefined, + action: string, + subject: string, + subjectId: number | null, + metadata: Record, + ) { + await this.auditLog.record({ trx, action, subject, subjectId, metadata }); + } + + // --- Bills --- + @OnEvent(events.bill.onCreated) + async onBillCreated({ bill, trx }: IBillCreatedPayload) { + await this.write(trx, 'created', AbilitySubject.Bill, bill.id, { + billNumber: bill.billNumber, + amount: bill.amount, + currencyCode: bill.currencyCode, + }); + } + + @OnEvent(events.bill.onEdited) + async onBillEdited({ bill, trx }: IBillEditedPayload) { + await this.write(trx, 'edited', AbilitySubject.Bill, bill.id, { + billNumber: bill.billNumber, + amount: bill.amount, + currencyCode: bill.currencyCode, + }); + } + + @OnEvent(events.bill.onDeleted) + async onBillDeleted({ billId, oldBill, trx }: IBIllEventDeletedPayload) { + await this.write(trx, 'deleted', AbilitySubject.Bill, billId, { + billNumber: oldBill.billNumber, + }); + } + + @OnEvent(events.bill.onOpened) + async onBillOpened({ bill, trx }: IBillOpenedPayload) { + await this.write(trx, 'opened', AbilitySubject.Bill, bill.id, { + billNumber: bill.billNumber, + }); + } + + // --- Sale invoices --- + @OnEvent(events.saleInvoice.onCreated) + async onSaleInvoiceCreated({ + saleInvoice, + trx, + }: ISaleInvoiceCreatedPayload) { + await this.write(trx, 'created', AbilitySubject.SaleInvoice, saleInvoice.id, { + invoiceNumber: saleInvoice.invoiceNo, + balance: saleInvoice.balance, + currencyCode: saleInvoice.currencyCode, + }); + } + + @OnEvent(events.saleInvoice.onEdited) + async onSaleInvoiceEdited({ saleInvoice, trx }: ISaleInvoiceEditedPayload) { + await this.write(trx, 'edited', AbilitySubject.SaleInvoice, saleInvoice.id, { + invoiceNumber: saleInvoice.invoiceNo, + balance: saleInvoice.balance, + currencyCode: saleInvoice.currencyCode, + }); + } + + @OnEvent(events.saleInvoice.onDeleted) + async onSaleInvoiceDeleted({ + saleInvoiceId, + oldSaleInvoice, + trx, + }: ISaleInvoiceDeletedPayload) { + await this.write( + trx, + 'deleted', + AbilitySubject.SaleInvoice, + saleInvoiceId, + { invoiceNumber: oldSaleInvoice.invoiceNo }, + ); + } + + @OnEvent(events.saleInvoice.onDelivered) + async onSaleInvoiceDelivered({ + saleInvoice, + trx, + }: ISaleInvoiceEventDeliveredPayload) { + await this.write(trx, 'delivered', AbilitySubject.SaleInvoice, saleInvoice.id, { + invoiceNumber: saleInvoice.invoiceNo, + }); + } + + @OnEvent(events.saleInvoice.onWrittenoff) + async onSaleInvoiceWrittenoff({ + saleInvoice, + trx, + }: ISaleInvoiceWriteoffCreatePayload) { + await this.write(trx, 'writtenoff', AbilitySubject.SaleInvoice, saleInvoice.id, { + invoiceNumber: saleInvoice.invoiceNo, + }); + } + + @OnEvent(events.saleInvoice.onWrittenoffCanceled) + async onSaleInvoiceWrittenoffCanceled({ + saleInvoice, + trx, + }: ISaleInvoiceWrittenOffCanceledPayload) { + await this.write( + trx, + 'writtenoff_canceled', + AbilitySubject.SaleInvoice, + saleInvoice.id, + { invoiceNumber: saleInvoice.invoiceNo }, + ); + } + + // --- Sale receipts --- + @OnEvent(events.saleReceipt.onCreated) + async onSaleReceiptCreated({ saleReceipt, trx }: ISaleReceiptCreatedPayload) { + await this.write(trx, 'created', AbilitySubject.SaleReceipt, saleReceipt.id, { + receiptNumber: saleReceipt.receiptNumber, + amount: saleReceipt.total, + currencyCode: saleReceipt.currencyCode, + }); + } + + @OnEvent(events.saleReceipt.onEdited) + async onSaleReceiptEdited({ saleReceipt, trx }: ISaleReceiptEditedPayload) { + await this.write(trx, 'edited', AbilitySubject.SaleReceipt, saleReceipt.id, { + receiptNumber: saleReceipt.receiptNumber, + amount: saleReceipt.total, + currencyCode: saleReceipt.currencyCode, + }); + } + + @OnEvent(events.saleReceipt.onDeleted) + async onSaleReceiptDeleted({ + saleReceiptId, + oldSaleReceipt, + trx, + }: ISaleReceiptEventDeletedPayload) { + await this.write( + trx, + 'deleted', + AbilitySubject.SaleReceipt, + saleReceiptId, + { receiptNumber: oldSaleReceipt.receiptNumber }, + ); + } + + // --- Payments received --- + @OnEvent(events.paymentReceive.onCreated) + async onPaymentReceivedCreated({ + paymentReceive, + trx, + }: IPaymentReceivedCreatedPayload) { + await this.write( + trx, + 'created', + AbilitySubject.PaymentReceive, + paymentReceive.id, + { + paymentReceiveNo: paymentReceive.paymentReceiveNo, + amount: paymentReceive.amount, + currencyCode: paymentReceive.currencyCode, + }, + ); + } + + @OnEvent(events.paymentReceive.onEdited) + async onPaymentReceivedEdited({ + paymentReceive, + trx, + }: IPaymentReceivedEditedPayload) { + await this.write( + trx, + 'edited', + AbilitySubject.PaymentReceive, + paymentReceive.id, + { + paymentReceiveNo: paymentReceive.paymentReceiveNo, + amount: paymentReceive.amount, + }, + ); + } + + @OnEvent(events.paymentReceive.onDeleted) + async onPaymentReceivedDeleted({ + paymentReceiveId, + oldPaymentReceive, + trx, + }: IPaymentReceivedDeletedPayload) { + await this.write( + trx, + 'deleted', + AbilitySubject.PaymentReceive, + paymentReceiveId, + { paymentReceiveNo: oldPaymentReceive.paymentReceiveNo }, + ); + } + + // --- Bill payments (payments made) --- + @OnEvent(events.billPayment.onCreated) + async onBillPaymentCreated({ + billPayment, + trx, + }: IBillPaymentEventCreatedPayload) { + await this.write( + trx, + 'created', + AbilitySubject.PaymentMade, + billPayment.id, + { + paymentNumber: billPayment.paymentNumber, + amount: billPayment.amount, + currencyCode: billPayment.currencyCode, + }, + ); + } + + @OnEvent(events.billPayment.onEdited) + async onBillPaymentEdited({ + billPayment, + trx, + }: IBillPaymentEventEditedPayload) { + await this.write( + trx, + 'edited', + AbilitySubject.PaymentMade, + billPayment.id, + { + paymentNumber: billPayment.paymentNumber, + amount: billPayment.amount, + }, + ); + } + + @OnEvent(events.billPayment.onDeleted) + async onBillPaymentDeleted({ + billPaymentId, + oldBillPayment, + trx, + }: IBillPaymentEventDeletedPayload) { + await this.write( + trx, + 'deleted', + AbilitySubject.PaymentMade, + billPaymentId, + { paymentNumber: oldBillPayment.paymentNumber }, + ); + } + + // --- Expenses --- + @OnEvent(events.expenses.onCreated) + async onExpenseCreated({ expense, expenseId, trx }: IExpenseCreatedPayload) { + await this.write(trx, 'created', AbilitySubject.Expense, expenseId, { + amount: expense.totalAmount, + currencyCode: expense.currencyCode, + }); + } + + @OnEvent(events.expenses.onEdited) + async onExpenseEdited({ expense, expenseId, trx }: IExpenseEventEditPayload) { + await this.write(trx, 'edited', AbilitySubject.Expense, expenseId, { + amount: expense.totalAmount, + currencyCode: expense.currencyCode, + }); + } + + @OnEvent(events.expenses.onDeleted) + async onExpenseDeleted({ expenseId, oldExpense, trx }: IExpenseEventDeletePayload) { + await this.write(trx, 'deleted', AbilitySubject.Expense, expenseId, { + amount: oldExpense.totalAmount, + }); + } + + @OnEvent(events.expenses.onPublished) + async onExpensePublished({ expense, expenseId, trx }: IExpenseEventPublishedPayload) { + await this.write(trx, 'published', AbilitySubject.Expense, expenseId, { + amount: expense.totalAmount, + currencyCode: expense.currencyCode, + }); + } + + // --- Credit notes --- + @OnEvent(events.creditNote.onCreated) + async onCreditNoteCreated({ creditNote, trx }: ICreditNoteCreatedPayload) { + await this.write(trx, 'created', AbilitySubject.CreditNote, creditNote.id, { + creditNoteNumber: creditNote.creditNoteNumber, + amount: creditNote.total, + currencyCode: creditNote.currencyCode, + }); + } + + @OnEvent(events.creditNote.onEdited) + async onCreditNoteEdited({ creditNote, trx }: ICreditNoteEditedPayload) { + await this.write(trx, 'edited', AbilitySubject.CreditNote, creditNote.id, { + creditNoteNumber: creditNote.creditNoteNumber, + amount: creditNote.total, + }); + } + + @OnEvent(events.creditNote.onDeleted) + async onCreditNoteDeleted({ + creditNoteId, + oldCreditNote, + trx, + }: ICreditNoteDeletedPayload) { + await this.write( + trx, + 'deleted', + AbilitySubject.CreditNote, + creditNoteId, + { creditNoteNumber: oldCreditNote.creditNoteNumber }, + ); + } + + @OnEvent(events.creditNote.onOpened) + async onCreditNoteOpened({ creditNote, trx }: ICreditNoteOpenedPayload) { + await this.write(trx, 'opened', AbilitySubject.CreditNote, creditNote.id, { + creditNoteNumber: creditNote.creditNoteNumber, + }); + } + + @OnEvent(events.creditNote.onRefundCreated) + async onCreditNoteRefundCreated({ + trx, + refundCreditNote, + creditNote, + }: IRefundCreditNoteCreatedPayload) { + await this.write(trx, 'refund_created', 'CreditNoteRefund', refundCreditNote.id, { + creditNoteId: creditNote.id, + amount: refundCreditNote.amount, + }); + } + + // --- Vendor credits --- + @OnEvent(events.vendorCredit.onCreated) + async onVendorCreditCreated({ vendorCredit, trx }: IVendorCreditCreatedPayload) { + await this.write( + trx, + 'created', + AbilitySubject.VendorCredit, + vendorCredit.id, + { + vendorCreditNumber: vendorCredit.vendorCreditNumber, + total: vendorCredit.total, + currencyCode: vendorCredit.currencyCode, + }, + ); + } + + @OnEvent(events.vendorCredit.onEdited) + async onVendorCreditEdited({ vendorCredit, trx }: IVendorCreditEditedPayload) { + await this.write( + trx, + 'edited', + AbilitySubject.VendorCredit, + vendorCredit.id, + { + vendorCreditNumber: vendorCredit.vendorCreditNumber, + total: vendorCredit.total, + }, + ); + } + + @OnEvent(events.vendorCredit.onDeleted) + async onVendorCreditDeleted({ + vendorCreditId, + oldVendorCredit, + trx, + }: IVendorCreditDeletedPayload) { + await this.write( + trx, + 'deleted', + AbilitySubject.VendorCredit, + vendorCreditId, + { vendorCreditNumber: oldVendorCredit.vendorCreditNumber }, + ); + } + + @OnEvent(events.vendorCredit.onOpened) + async onVendorCreditOpened({ + vendorCredit, + vendorCreditId, + trx, + }: IVendorCreditOpenedPayload) { + await this.write(trx, 'opened', AbilitySubject.VendorCredit, vendorCreditId, { + vendorCreditNumber: vendorCredit.vendorCreditNumber, + }); + } + + @OnEvent(events.vendorCredit.onRefundCreated) + async onVendorCreditRefundCreated({ + trx, + refundVendorCredit, + vendorCredit, + }: IRefundVendorCreditCreatedPayload) { + await this.write( + trx, + 'refund_created', + 'VendorCreditRefund', + refundVendorCredit.id, + { vendorCreditId: vendorCredit.id, amount: refundVendorCredit.amount }, + ); + } + + // --- Manual journals --- + @OnEvent(events.manualJournals.onCreated) + async onManualJournalCreated({ + manualJournal, + trx, + }: IManualJournalEventCreatedPayload) { + await this.write( + trx, + 'created', + AbilitySubject.ManualJournal, + manualJournal.id, + { + journalNumber: manualJournal.journalNumber, + amount: manualJournal.amount, + currencyCode: manualJournal.currencyCode, + }, + ); + } + + @OnEvent(events.manualJournals.onEdited) + async onManualJournalEdited({ + manualJournal, + trx, + }: IManualJournalEventEditedPayload) { + await this.write( + trx, + 'edited', + AbilitySubject.ManualJournal, + manualJournal.id, + { + journalNumber: manualJournal.journalNumber, + amount: manualJournal.amount, + }, + ); + } + + @OnEvent(events.manualJournals.onDeleted) + async onManualJournalDeleted({ + manualJournalId, + trx, + }: IManualJournalEventDeletedPayload) { + await this.write(trx, 'deleted', AbilitySubject.ManualJournal, manualJournalId, {}); + } + + @OnEvent(events.manualJournals.onPublished) + async onManualJournalPublished({ + manualJournal, + trx, + }: IManualJournalEventPublishedPayload) { + await this.write( + trx, + 'published', + AbilitySubject.ManualJournal, + manualJournal.id, + { journalNumber: manualJournal.journalNumber }, + ); + } + + // --- Cashflow --- + @OnEvent(events.cashflow.onTransactionCreated) + async onCashflowCreated({ + cashflowTransaction, + trx, + }: ICommandCashflowCreatedPayload) { + await this.write(trx, 'created', AbilitySubject.Cashflow, cashflowTransaction.id, { + amount: cashflowTransaction.amount, + currencyCode: cashflowTransaction.currencyCode, + }); + } + + @OnEvent(events.cashflow.onTransactionDeleted) + async onCashflowDeleted({ + cashflowTransactionId, + trx, + }: ICommandCashflowDeletedPayload) { + await this.write(trx, 'deleted', AbilitySubject.Cashflow, cashflowTransactionId, {}); + } + + @OnEvent(events.cashflow.onTransactionCategorized) + async onCashflowCategorized({ + cashflowTransaction, + trx, + }: ICashflowTransactionCategorizedPayload) { + await this.write( + trx, + 'categorized', + AbilitySubject.Cashflow, + cashflowTransaction.id, + { amount: cashflowTransaction.amount }, + ); + } + + // --- GL accounts --- + @OnEvent(events.accounts.onCreated) + async onAccountCreated({ account, accountId, trx }: IAccountEventCreatedPayload) { + await this.write(trx, 'created', AbilitySubject.Account, accountId, { + name: account.name, + code: account.code, + }); + } + + @OnEvent(events.accounts.onDeleted) + async onAccountDeleted({ accountId, oldAccount, trx }: IAccountEventDeletedPayload) { + await this.write(trx, 'deleted', AbilitySubject.Account, accountId, { + name: oldAccount.name, + code: oldAccount.code, + }); + } + + @OnEvent(events.accounts.onActivated) + async onAccountActivated({ accountId, trx }: IAccountEventActivatedPayload) { + await this.write(trx, 'activated', AbilitySubject.Account, accountId, {}); + } + + // --- Inventory adjustments --- + @OnEvent(events.inventoryAdjustment.onQuickCreated) + async onInventoryAdjustmentCreated({ + inventoryAdjustment, + inventoryAdjustmentId, + trx, + }: IInventoryAdjustmentEventCreatedPayload) { + await this.write( + trx, + 'created', + AbilitySubject.InventoryAdjustment, + inventoryAdjustmentId, + { reason: inventoryAdjustment.reason }, + ); + } + + @OnEvent(events.inventoryAdjustment.onPublished) + async onInventoryAdjustmentPublished({ + inventoryAdjustment, + inventoryAdjustmentId, + trx, + }: IInventoryAdjustmentEventPublishedPayload) { + await this.write( + trx, + 'published', + AbilitySubject.InventoryAdjustment, + inventoryAdjustmentId, + { reason: inventoryAdjustment.reason }, + ); + } + + @OnEvent(events.inventoryAdjustment.onDeleted) + async onInventoryAdjustmentDeleted({ + inventoryAdjustmentId, + trx, + }: IInventoryAdjustmentEventDeletedPayload) { + await this.write( + trx, + 'deleted', + AbilitySubject.InventoryAdjustment, + inventoryAdjustmentId, + {}, + ); + } + + // --- Warehouse transfers --- + @OnEvent(events.warehouseTransfer.onCreated) + async onWarehouseTransferCreated({ + warehouseTransfer, + trx, + }: IWarehouseTransferCreated) { + await this.write( + trx, + 'created', + 'WarehouseTransfer', + warehouseTransfer.id, + { + transactionNumber: (warehouseTransfer as { transactionNumber?: string }) + .transactionNumber, + }, + ); + } + + @OnEvent(events.warehouseTransfer.onEdited) + async onWarehouseTransferEdited({ + warehouseTransfer, + trx, + }: IWarehouseTransferEditedPayload) { + await this.write( + trx, + 'edited', + 'WarehouseTransfer', + warehouseTransfer.id, + { + transactionNumber: (warehouseTransfer as { transactionNumber?: string }) + .transactionNumber, + }, + ); + } + + @OnEvent(events.warehouseTransfer.onDeleted) + async onWarehouseTransferDeleted({ + oldWarehouseTransfer, + trx, + }: IWarehouseTransferDeletedPayload) { + await this.write( + trx, + 'deleted', + 'WarehouseTransfer', + oldWarehouseTransfer.id, + {}, + ); + } + + @OnEvent(events.warehouseTransfer.onInitiated) + async onWarehouseTransferInitiated({ + warehouseTransfer, + trx, + }: IWarehouseTransferInitiatedPayload) { + await this.write( + trx, + 'initiated', + 'WarehouseTransfer', + warehouseTransfer.id, + { + transactionNumber: (warehouseTransfer as { transactionNumber?: string }) + .transactionNumber, + }, + ); + } + + @OnEvent(events.warehouseTransfer.onTransferred) + async onWarehouseTransferTransferred({ + warehouseTransfer, + trx, + }: IWarehouseTransferTransferredPayload) { + await this.write( + trx, + 'transferred', + 'WarehouseTransfer', + warehouseTransfer.id, + { + transactionNumber: (warehouseTransfer as { transactionNumber?: string }) + .transactionNumber, + }, + ); + } + + // --- Transactions locking (settings change; no trx on payload) --- + @OnEvent(events.transactionsLocking.partialUnlocked) + async onTransactionsLockingChanged( + payload: ITransactionsLockingPartialUnlocked | ITransactionsLockingCanceled, + ) { + const meta: Record = { module: payload.module }; + if ('transactionLockingDTO' in payload && payload.transactionLockingDTO) { + meta.lockToDate = (payload.transactionLockingDTO as { lockToDate?: Date }) + .lockToDate; + } + if ('cancelLockingDTO' in payload && payload.cancelLockingDTO) { + meta.cancelReason = (payload.cancelLockingDTO as { reason?: string }).reason; + } + await this.write(undefined, 'locking_changed', 'TransactionsLocking', null, meta); + } + + // --- Sale estimates --- + @OnEvent(events.saleEstimate.onCreated) + async onSaleEstimateCreated({ + saleEstimate, + saleEstimateId, + trx, + }: ISaleEstimateCreatedPayload) { + await this.write( + trx, + 'created', + AbilitySubject.SaleEstimate, + saleEstimate?.id ?? saleEstimateId, + { + estimateNumber: saleEstimate.estimateNumber, + total: saleEstimate.total, + currencyCode: saleEstimate.currencyCode, + }, + ); + } + + @OnEvent(events.saleEstimate.onEdited) + async onSaleEstimateEdited({ + saleEstimate, + estimateId, + trx, + }: ISaleEstimateEditedPayload) { + await this.write(trx, 'edited', AbilitySubject.SaleEstimate, estimateId, { + estimateNumber: saleEstimate.estimateNumber, + total: saleEstimate.total, + }); + } + + @OnEvent(events.saleEstimate.onDeleted) + async onSaleEstimateDeleted({ + saleEstimateId, + oldSaleEstimate, + trx, + }: ISaleEstimateDeletedPayload) { + await this.write( + trx, + 'deleted', + AbilitySubject.SaleEstimate, + saleEstimateId, + { estimateNumber: oldSaleEstimate.estimateNumber }, + ); + } +} diff --git a/packages/server/src/modules/AuditLogs/types/AuditLogs.types.ts b/packages/server/src/modules/AuditLogs/types/AuditLogs.types.ts new file mode 100644 index 000000000..8a9fc8794 --- /dev/null +++ b/packages/server/src/modules/AuditLogs/types/AuditLogs.types.ts @@ -0,0 +1,3 @@ +export enum AuditLogAction { + View = 'View', +} diff --git a/packages/server/src/modules/BankingTransactions/models/UncategorizedBankTransaction.ts b/packages/server/src/modules/BankingTransactions/models/UncategorizedBankTransaction.ts index daee38503..b36fb9d04 100644 --- a/packages/server/src/modules/BankingTransactions/models/UncategorizedBankTransaction.ts +++ b/packages/server/src/modules/BankingTransactions/models/UncategorizedBankTransaction.ts @@ -20,6 +20,7 @@ export class UncategorizedBankTransaction extends TenantBaseModel { readonly pending: boolean; readonly categorizeRefId!: number; readonly categorizeRefType!: string; + readonly currencyCode!: string; /** * Table name. diff --git a/packages/server/src/modules/BankingTransactionsExclude/commands/ExcludeBankTransaction.service.ts b/packages/server/src/modules/BankingTransactionsExclude/commands/ExcludeBankTransaction.service.ts index d5165b3ba..3dfb7929f 100644 --- a/packages/server/src/modules/BankingTransactionsExclude/commands/ExcludeBankTransaction.service.ts +++ b/packages/server/src/modules/BankingTransactionsExclude/commands/ExcludeBankTransaction.service.ts @@ -47,6 +47,7 @@ export class ExcludeBankTransactionService { return this.uow.withTransaction(async (trx: Knex.Transaction) => { await this.eventEmitter.emitAsync(events.bankTransactions.onExcluding, { uncategorizedTransactionId, + uncategorizedTransaction: oldUncategorizedTransaction, trx, } as IBankTransactionUnexcludingEventPayload); @@ -59,6 +60,7 @@ export class ExcludeBankTransactionService { await this.eventEmitter.emitAsync(events.bankTransactions.onExcluded, { uncategorizedTransactionId, + uncategorizedTransaction: oldUncategorizedTransaction, trx, } as IBankTransactionUnexcludedEventPayload); }); diff --git a/packages/server/src/modules/BankingTransactionsExclude/commands/UnexcludeBankTransaction.service.ts b/packages/server/src/modules/BankingTransactionsExclude/commands/UnexcludeBankTransaction.service.ts index 3a3ec6d75..597a5defe 100644 --- a/packages/server/src/modules/BankingTransactionsExclude/commands/UnexcludeBankTransaction.service.ts +++ b/packages/server/src/modules/BankingTransactionsExclude/commands/UnexcludeBankTransaction.service.ts @@ -50,6 +50,7 @@ export class UnexcludeBankTransactionService { return this.uow.withTransaction(async (trx: Knex.Transaction) => { await this.eventEmitter.emitAsync(events.bankTransactions.onUnexcluding, { uncategorizedTransactionId, + uncategorizedTransaction: oldUncategorizedTransaction, trx, } as IBankTransactionUnexcludingEventPayload); @@ -62,6 +63,7 @@ export class UnexcludeBankTransactionService { await this.eventEmitter.emitAsync(events.bankTransactions.onUnexcluded, { uncategorizedTransactionId, + uncategorizedTransaction: oldUncategorizedTransaction, trx, } as IBankTransactionUnexcludedEventPayload); }); diff --git a/packages/server/src/modules/BankingTransactionsExclude/types/BankTransactionsExclude.types.ts b/packages/server/src/modules/BankingTransactionsExclude/types/BankTransactionsExclude.types.ts index 4bd23b98a..2759c513a 100644 --- a/packages/server/src/modules/BankingTransactionsExclude/types/BankTransactionsExclude.types.ts +++ b/packages/server/src/modules/BankingTransactionsExclude/types/BankTransactionsExclude.types.ts @@ -1,4 +1,5 @@ import { Knex } from "knex"; +import { UncategorizedBankTransaction } from "@/modules/BankingTransactions/models/UncategorizedBankTransaction"; export interface ExcludedBankTransactionsQuery { page?: number; @@ -17,14 +18,17 @@ export interface IBankTransactionUnexcludingEventPayload { export interface IBankTransactionUnexcludedEventPayload { uncategorizedTransactionId: number; + uncategorizedTransaction?: UncategorizedBankTransaction; trx?: Knex.Transaction } export interface IBankTransactionExcludingEventPayload { uncategorizedTransactionId: number; + uncategorizedTransaction?: UncategorizedBankTransaction; trx?: Knex.Transaction } export interface IBankTransactionExcludedEventPayload { uncategorizedTransactionId: number; + uncategorizedTransaction?: UncategorizedBankTransaction; trx?: Knex.Transaction } diff --git a/packages/server/src/modules/Contacts/commands/ActivateContact.service.ts b/packages/server/src/modules/Contacts/commands/ActivateContact.service.ts index f777faa29..8ca10d9d7 100644 --- a/packages/server/src/modules/Contacts/commands/ActivateContact.service.ts +++ b/packages/server/src/modules/Contacts/commands/ActivateContact.service.ts @@ -2,13 +2,17 @@ import { ServiceError } from '@/modules/Items/ServiceError'; import { Contact } from '../models/Contact'; import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel'; import { Inject, Injectable } from '@nestjs/common'; +import { EventEmitter2 } from '@nestjs/event-emitter'; import { ERRORS } from '../Contacts.constants'; +import { events } from '@/common/events/events'; @Injectable() export class ActivateContactService { constructor( @Inject(Contact.name) private readonly contactModel: TenantModelProxy, + + private readonly eventEmitter: EventEmitter2, ) {} async activateContact(contactId: number) { @@ -24,5 +28,11 @@ export class ActivateContactService { .query() .findById(contactId) .update({ active: true }); + + // Triggers `onContactActivated` event. + await this.eventEmitter.emitAsync(events.contacts.onActivated, { + contactId, + contact, + }); } } diff --git a/packages/server/src/modules/Contacts/commands/InactivateContact.service.ts b/packages/server/src/modules/Contacts/commands/InactivateContact.service.ts index 775c8898f..77c5ad26e 100644 --- a/packages/server/src/modules/Contacts/commands/InactivateContact.service.ts +++ b/packages/server/src/modules/Contacts/commands/InactivateContact.service.ts @@ -3,12 +3,16 @@ import { ServiceError } from '@/modules/Items/ServiceError'; import { Contact } from '../models/Contact'; import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel'; import { ERRORS } from '../Contacts.constants'; +import { EventEmitter2 } from '@nestjs/event-emitter'; +import { events } from '@/common/events/events'; @Injectable() export class InactivateContactService { constructor( @Inject(Contact.name) private readonly contactModel: TenantModelProxy, + + private readonly eventEmitter: EventEmitter2, ) {} async inactivateContact(contactId: number) { @@ -24,5 +28,11 @@ export class InactivateContactService { .query() .findById(contactId) .update({ active: false }); + + // Triggers `onContactInactivated` event. + await this.eventEmitter.emitAsync(events.contacts.onInactivated, { + contactId, + contact, + }); } } diff --git a/packages/server/src/modules/EE/AuditLogs/AuditLog.service.ts b/packages/server/src/modules/EE/AuditLogs/AuditLog.service.ts new file mode 100644 index 000000000..33066e001 --- /dev/null +++ b/packages/server/src/modules/EE/AuditLogs/AuditLog.service.ts @@ -0,0 +1,71 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { ClsService } from 'nestjs-cls'; +import { Knex } from 'knex'; +import * as moment from 'moment'; +import '@/utils/moment-mysql'; +import { AuditLog } from './models/AuditLog.model'; +import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel'; +import { TENANCY_DB_CONNECTION } from '@/modules/Tenancy/TenancyDB/TenancyDB.constants'; + +const METADATA_JSON_MAX = 8000; + +export interface RecordAuditLogParams { + /** When set, the row is written in the same DB transaction as the business change. */ + trx?: Knex.Transaction; + action: string; + subject: string; + subjectId?: number | null; + metadata?: Record | null; +} + +@Injectable() +export class AuditLogService { + constructor( + private readonly cls: ClsService, + @Inject(AuditLog.name) + private readonly auditLogModel: TenantModelProxy, + @Inject(TENANCY_DB_CONNECTION) + private readonly tenantKnex: () => Knex, + ) {} + + /** + * Persists one audit row. Prefer always passing `trx` from domain event payloads so the + * audit row rolls back with failed business transactions. If `trx` is omitted, the insert + * runs on a separate connection/transaction (only use after the business change committed). + */ + async record(params: RecordAuditLogParams): Promise { + const userId = this.cls.get('userId') ?? null; + const ip = (this.cls.get('ip') as string) ?? null; + const executor = params.trx ?? this.tenantKnex(); + const metadata = this.normalizeMetadata(params.metadata); + + await this.auditLogModel() + .query(executor) + .insert({ + userId, + action: params.action, + subject: params.subject, + subjectId: params.subjectId ?? null, + metadata, + ip, + // MySQL DATETIME expects `YYYY-MM-DD HH:mm:ss`, not ISO-8601 with `T`/`Z`. + createdAt: moment().toMySqlDateTime(), + }); + } + + private normalizeMetadata( + metadata: Record | null | undefined, + ): Record | null { + if (metadata == null) return null; + try { + const s = JSON.stringify(metadata); + if (s.length <= METADATA_JSON_MAX) return metadata; + return { + _truncated: true, + summary: s.slice(0, METADATA_JSON_MAX), + }; + } catch { + return { _error: 'metadata_not_serializable' }; + } + } +} diff --git a/packages/server/src/modules/EE/AuditLogs/AuditLogs.controller.ts b/packages/server/src/modules/EE/AuditLogs/AuditLogs.controller.ts new file mode 100644 index 000000000..dadf24f3d --- /dev/null +++ b/packages/server/src/modules/EE/AuditLogs/AuditLogs.controller.ts @@ -0,0 +1,42 @@ +import { Controller, Get, Query, UseGuards } from '@nestjs/common'; +import { ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { ApiCommonHeaders } from '@/common/decorators/ApiCommonHeaders'; +import { AuthorizationGuard } from '@/modules/Roles/Authorization.guard'; +import { PermissionGuard } from '@/modules/Roles/Permission.guard'; +import { RequirePermission } from '@/modules/Roles/RequirePermission.decorator'; +import { AbilitySubject } from '@/modules/Roles/Roles.types'; +import { GetAuditLogsQueryDto } from './dtos/GetAuditLogsQuery.dto'; +import { GetAuditLogsResponseDto } from './dtos/GetAuditLogsResponse.dto'; +import { GetAuditLogFilterOptionsResponseDto } from './dtos/GetAuditLogFilterOptionsResponse.dto'; +import { GetAuditLogsService } from './queries/GetAuditLogs.service'; +import { GetAuditLogFilterOptionsService } from './queries/GetAuditLogFilterOptions.service'; +import { AuditLogAction } from './types/AuditLogs.types'; + +@Controller('audit-logs') +@ApiTags('Audit logs') +@ApiCommonHeaders() +@UseGuards(AuthorizationGuard, PermissionGuard) +export class AuditLogsController { + constructor( + private readonly getAuditLogsService: GetAuditLogsService, + private readonly getAuditLogFilterOptionsService: GetAuditLogFilterOptionsService, + ) {} + + @Get('filter-options') + @RequirePermission(AuditLogAction.View, AbilitySubject.AuditLog) + @ApiOperation({ + summary: 'Distinct subject and action values for audit log filters.', + }) + @ApiOkResponse({ type: GetAuditLogFilterOptionsResponseDto }) + getAuditLogFilterOptions() { + return this.getAuditLogFilterOptionsService.getFilterOptions(); + } + + @Get() + @RequirePermission(AuditLogAction.View, AbilitySubject.AuditLog) + @ApiOperation({ summary: 'List financial audit log entries for the tenant.' }) + @ApiOkResponse({ type: GetAuditLogsResponseDto }) + getAuditLogs(@Query() query: GetAuditLogsQueryDto) { + return this.getAuditLogsService.getAuditLogs(query); + } +} diff --git a/packages/server/src/modules/EE/AuditLogs/AuditLogs.module.ts b/packages/server/src/modules/EE/AuditLogs/AuditLogs.module.ts new file mode 100644 index 000000000..75444c7bf --- /dev/null +++ b/packages/server/src/modules/EE/AuditLogs/AuditLogs.module.ts @@ -0,0 +1,28 @@ +import { Module } from '@nestjs/common'; +import { AuditLogsController } from './AuditLogs.controller'; +import { AuditLogService } from './AuditLog.service'; +import { GetAuditLogsService } from './queries/GetAuditLogs.service'; +import { GetAuditLogFilterOptionsService } from './queries/GetAuditLogFilterOptions.service'; +import { FinancialAuditLogSubscriber } from './subscribers/FinancialAuditLog.subscriber'; +import { AuthorizationGuard } from '@/modules/Roles/Authorization.guard'; +import { PermissionGuard } from '@/modules/Roles/Permission.guard'; +import { RegisterTenancyModel } from '@/modules/Tenancy/TenancyModels/Tenancy.module'; +import { AuditLog } from './models/AuditLog.model'; + +const models = [ + RegisterTenancyModel(AuditLog) +]; +@Module({ + imports: [...models], + controllers: [AuditLogsController], + providers: [ + AuditLogService, + GetAuditLogsService, + GetAuditLogFilterOptionsService, + FinancialAuditLogSubscriber, + AuthorizationGuard, + PermissionGuard, + ], + exports: [AuditLogService, ...models], +}) +export class AuditLogsModule {} diff --git a/packages/server/src/modules/EE/AuditLogs/dtos/GetAuditLogFilterOptionsResponse.dto.ts b/packages/server/src/modules/EE/AuditLogs/dtos/GetAuditLogFilterOptionsResponse.dto.ts new file mode 100644 index 000000000..5aeba81f3 --- /dev/null +++ b/packages/server/src/modules/EE/AuditLogs/dtos/GetAuditLogFilterOptionsResponse.dto.ts @@ -0,0 +1,9 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class GetAuditLogFilterOptionsResponseDto { + @ApiProperty({ type: [String], example: ['sale_invoice', 'bill', 'payment'] }) + subjects: string[]; + + @ApiProperty({ type: [String], example: ['created', 'edited', 'deleted'] }) + actions: string[]; +} diff --git a/packages/server/src/modules/EE/AuditLogs/dtos/GetAuditLogsQuery.dto.ts b/packages/server/src/modules/EE/AuditLogs/dtos/GetAuditLogsQuery.dto.ts new file mode 100644 index 000000000..5f4e08683 --- /dev/null +++ b/packages/server/src/modules/EE/AuditLogs/dtos/GetAuditLogsQuery.dto.ts @@ -0,0 +1,75 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { Transform } from 'class-transformer'; +import { + ArrayMaxSize, + IsArray, + IsInt, + IsOptional, + IsString, + Max, + Min, +} from 'class-validator'; +import { ToNumber } from '@/common/decorators/Validators'; + +function toOptionalStringArray(value: unknown): string[] | undefined { + if (value === undefined || value === null || value === '') { + return undefined; + } + const raw = Array.isArray(value) ? value : [value]; + const filtered = raw + .map((v) => (v == null ? '' : String(v).trim())) + .filter((v) => v.length > 0); + if (!filtered.length) { + return undefined; + } + return [...new Set(filtered)]; +} + +export class GetAuditLogsQueryDto { + @ApiPropertyOptional({ minimum: 1, default: 1 }) + @IsOptional() + @ToNumber() + @IsInt() + @Min(1) + page?: number; + + @ApiPropertyOptional({ minimum: 1, maximum: 100, default: 20 }) + @IsOptional() + @ToNumber() + @IsInt() + @Min(1) + @Max(100) + pageSize?: number; + + @ApiPropertyOptional({ type: [String], isArray: true }) + @Transform(({ value }) => toOptionalStringArray(value)) + @IsOptional() + @IsArray() + @ArrayMaxSize(50) + @IsString({ each: true }) + subject?: string[]; + + @ApiPropertyOptional({ type: [String], isArray: true }) + @Transform(({ value }) => toOptionalStringArray(value)) + @IsOptional() + @IsArray() + @ArrayMaxSize(50) + @IsString({ each: true }) + action?: string[]; + + @ApiPropertyOptional({ description: 'System user id' }) + @IsOptional() + @ToNumber() + @IsInt() + userId?: number; + + @ApiPropertyOptional({ description: 'ISO date (inclusive), start of day' }) + @IsOptional() + @IsString() + from?: string; + + @ApiPropertyOptional({ description: 'ISO date (inclusive), end of day' }) + @IsOptional() + @IsString() + to?: string; +} diff --git a/packages/server/src/modules/EE/AuditLogs/dtos/GetAuditLogsResponse.dto.ts b/packages/server/src/modules/EE/AuditLogs/dtos/GetAuditLogsResponse.dto.ts new file mode 100644 index 000000000..b494b5795 --- /dev/null +++ b/packages/server/src/modules/EE/AuditLogs/dtos/GetAuditLogsResponse.dto.ts @@ -0,0 +1,62 @@ +import { ApiProperty } from '@nestjs/swagger'; + +class PaginationMetaDto { + @ApiProperty({ example: 100 }) + total: number; + + @ApiProperty({ example: 1 }) + page: number; + + @ApiProperty({ example: 20 }) + pageSize: number; +} + +export class AuditLogListItemDto { + @ApiProperty({ example: 1 }) + id: number; + + @ApiProperty({ example: 5, required: false, nullable: true }) + userId: number | null; + + @ApiProperty({ example: 'John Doe', required: false, nullable: true }) + userName: string | null; + + @ApiProperty({ example: 'john@example.com', required: false, nullable: true }) + userEmail: string | null; + + @ApiProperty({ example: 'created' }) + action: string; + + @ApiProperty({ example: 'sale_invoice' }) + subject: string; + + @ApiProperty({ example: 42, required: false, nullable: true }) + subjectId: number | null; + + @ApiProperty({ + required: false, + nullable: true, + example: { invoiceNumber: 'INV-001' }, + }) + metadata: Record | null; + + @ApiProperty({ example: 'Invoice INV-001 was created for $500.00' }) + summary: string; + + @ApiProperty({ example: '192.168.1.1', required: false, nullable: true }) + ip: string | null; + + @ApiProperty({ example: '2025-04-12T18:30:00.000Z' }) + createdAt: string; + + @ApiProperty({ example: 'Apr 12, 2025 at 06:30 PM' }) + createdAtFormatted: string; +} + +export class GetAuditLogsResponseDto { + @ApiProperty({ type: [AuditLogListItemDto] }) + data: AuditLogListItemDto[]; + + @ApiProperty({ type: PaginationMetaDto }) + pagination: PaginationMetaDto; +} diff --git a/packages/server/src/modules/EE/AuditLogs/models/AuditLog.model.ts b/packages/server/src/modules/EE/AuditLogs/models/AuditLog.model.ts new file mode 100644 index 000000000..a811ab90f --- /dev/null +++ b/packages/server/src/modules/EE/AuditLogs/models/AuditLog.model.ts @@ -0,0 +1,63 @@ +import { Model } from 'objection'; +import { TenantBaseModel } from '@/modules/System/models/TenantBaseModel'; +import { TenantUser } from '@/modules/Tenancy/TenancyModels/models/TenantUser.model'; + +export class AuditLog extends TenantBaseModel { + public tenantUser?: TenantUser; + + public id!: number; + /** System user id (matches CLS `userId` / `users.system_user_id` in tenant DB). */ + public userId!: number | null; + public action!: string; + public subject!: string; + public subjectId!: number | null; + public metadata!: Record | null; + public ip!: string | null; + public createdAt!: Date | string; + + static get tableName() { + return 'audit_logs'; + } + + static get jsonAttributes() { + return ['metadata']; + } + + /** + * No `updated_at`; `created_at` is set in AuditLogService. + */ + get timestamps() { + return []; + } + + static get relationMappings() { + return { + tenantUser: { + relation: Model.BelongsToOneRelation, + modelClass: TenantUser, + join: { + from: 'audit_logs.userId', + to: 'users.systemUserId', + }, + }, + }; + } + + static get jsonSchema() { + return { + type: 'object', + required: ['action', 'subject'], + properties: { + id: { type: 'integer' }, + userId: { type: ['integer', 'null'] }, + action: { type: 'string', maxLength: 64 }, + subject: { type: 'string', maxLength: 64 }, + subjectId: { type: ['integer', 'null'] }, + metadata: { type: ['object', 'null'] }, + ip: { type: ['string', 'null'], maxLength: 64 }, + // Stored as MySQL `YYYY-MM-DD HH:mm:ss` (see AuditLogService), not strict ISO-8601. + createdAt: { type: 'string' }, + }, + }; + } +} diff --git a/packages/server/src/modules/EE/AuditLogs/queries/GetAuditLogFilterOptions.service.ts b/packages/server/src/modules/EE/AuditLogs/queries/GetAuditLogFilterOptions.service.ts new file mode 100644 index 000000000..d6efe8cc9 --- /dev/null +++ b/packages/server/src/modules/EE/AuditLogs/queries/GetAuditLogFilterOptions.service.ts @@ -0,0 +1,47 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { snakeCase } from 'lodash'; +import { AuditLog } from '../models/AuditLog.model'; +import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel'; + +export interface AuditLogFilterOption { + key: string; + label: string; +} + +export interface AuditLogFilterOptions { + subjects: AuditLogFilterOption[]; + actions: AuditLogFilterOption[]; +} + +@Injectable() +export class GetAuditLogFilterOptionsService { + constructor( + @Inject(AuditLog.name) + private readonly auditLogModel: TenantModelProxy, + ) {} + + async getFilterOptions(): Promise { + const subjectRows = await this.auditLogModel() + .query() + .select('subject') + .groupBy('subject') + .orderBy('subject', 'asc'); + + const actionRows = await this.auditLogModel() + .query() + .select('action') + .groupBy('action') + .orderBy('action', 'asc'); + + return { + subjects: subjectRows + .map((r) => r.subject) + .filter(Boolean) + .map((key) => ({ key, label: snakeCase(key) })), + actions: actionRows + .map((r) => r.action) + .filter(Boolean) + .map((key) => ({ key, label: snakeCase(key) })), + }; + } +} diff --git a/packages/server/src/modules/EE/AuditLogs/queries/GetAuditLogs.service.ts b/packages/server/src/modules/EE/AuditLogs/queries/GetAuditLogs.service.ts new file mode 100644 index 000000000..bfd62bbed --- /dev/null +++ b/packages/server/src/modules/EE/AuditLogs/queries/GetAuditLogs.service.ts @@ -0,0 +1,79 @@ +import { Inject, Injectable } from '@nestjs/common'; +import * as moment from 'moment'; +import { AuditLog } from '../models/AuditLog.model'; +import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel'; +import { GetAuditLogsQueryDto } from '../dtos/GetAuditLogsQuery.dto'; +import { TransformerInjectable } from '@/modules/Transformer/TransformerInjectable.service'; +import { GetAuditLogListTransformer } from '@/modules/AuditLogs/queries/GetAuditLogList.transformer'; + +export interface AuditLogListItem { + id: number; + userId: number | null; + userName: string | null; + userEmail: string | null; + action: string; + subject: string; + subjectId: number | null; + metadata: Record | null; + summary: string; + ip: string | null; + createdAt: string; + createdAtFormatted: string; +} + +@Injectable() +export class GetAuditLogsService { + constructor( + @Inject(AuditLog.name) + private readonly auditLogModel: TenantModelProxy, + private readonly transformer: TransformerInjectable, + ) {} + + async getAuditLogs(query: GetAuditLogsQueryDto): Promise<{ + data: AuditLogListItem[]; + pagination: { total: number; page: number; pageSize: number }; + }> { + const page = query.page ?? 1; + const pageSize = query.pageSize ?? 20; + const pageIndex = Math.max(0, page - 1); + + let q = this.auditLogModel() + .query() + .withGraphFetched('tenantUser') + .orderBy('createdAt', 'desc'); + + if (query.subject?.length) { + q = q.whereIn('subject', query.subject); + } + if (query.action?.length) { + q = q.whereIn('action', query.action); + } + if (query.userId != null) { + q = q.where('userId', query.userId); + } + if (query.from) { + const from = moment(query.from).startOf('day').format('YYYY-MM-DD HH:mm:ss'); + q = q.where('createdAt', '>=', from); + } + if (query.to) { + const to = moment(query.to).endOf('day').format('YYYY-MM-DD HH:mm:ss'); + q = q.where('createdAt', '<=', to); + } + + const result = await q.page(pageIndex, pageSize); + + const data = (await this.transformer.transform( + result.results, + new GetAuditLogListTransformer(), + )) as AuditLogListItem[]; + + return { + data, + pagination: { + total: result.total, + page, + pageSize, + }, + }; + } +} diff --git a/packages/server/src/modules/EE/AuditLogs/subscribers/FinancialAuditLog.subscriber.ts b/packages/server/src/modules/EE/AuditLogs/subscribers/FinancialAuditLog.subscriber.ts new file mode 100644 index 000000000..8d90484b0 --- /dev/null +++ b/packages/server/src/modules/EE/AuditLogs/subscribers/FinancialAuditLog.subscriber.ts @@ -0,0 +1,1180 @@ +import { Injectable } from '@nestjs/common'; +import { OnEvent } from '@nestjs/event-emitter'; +import { Knex } from 'knex'; +import { events } from '@/common/events/events'; +import { AbilitySubject } from '@/modules/Roles/Roles.types'; +import { AuditLogService } from '../AuditLog.service'; +import { + IBillCreatedPayload, + IBillEditedPayload, + IBIllEventDeletedPayload, + IBillOpenedPayload, +} from '@/modules/Bills/Bills.types'; +import { + ISaleInvoiceCreatedPayload, + ISaleInvoiceEditedPayload, + ISaleInvoiceDeletedPayload, + ISaleInvoiceEventDeliveredPayload, + ISaleInvoiceWrittenOffCanceledPayload, + ISaleInvoiceWriteoffCreatePayload, +} from '@/modules/SaleInvoices/SaleInvoice.types'; +import { + ISaleReceiptCreatedPayload, + ISaleReceiptEditedPayload, + ISaleReceiptEventDeletedPayload, +} from '@/modules/SaleReceipts/types/SaleReceipts.types'; +import { + IPaymentReceivedCreatedPayload, + IPaymentReceivedEditedPayload, + IPaymentReceivedDeletedPayload, +} from '@/modules/PaymentReceived/types/PaymentReceived.types'; +import { + IBillPaymentEventCreatedPayload, + IBillPaymentEventEditedPayload, + IBillPaymentEventDeletedPayload, +} from '@/modules/BillPayments/types/BillPayments.types'; +import { + IExpenseCreatedPayload, + IExpenseEventEditPayload, + IExpenseEventDeletePayload, + IExpenseEventPublishedPayload, +} from '@/modules/Expenses/Expenses.types'; +import { + ICreditNoteCreatedPayload, + ICreditNoteEditedPayload, + ICreditNoteDeletedPayload, + ICreditNoteOpenedPayload, +} from '@/modules/CreditNotes/types/CreditNotes.types'; +import { + IVendorCreditCreatedPayload, + IVendorCreditEditedPayload, + IVendorCreditDeletedPayload, + IVendorCreditOpenedPayload, +} from '@/modules/VendorCredit/types/VendorCredit.types'; +import { + IManualJournalEventCreatedPayload, + IManualJournalEventEditedPayload, + IManualJournalEventDeletedPayload, + IManualJournalEventPublishedPayload, +} from '@/modules/ManualJournals/types/ManualJournals.types'; +import { + ICommandCashflowCreatedPayload, + ICommandCashflowDeletedPayload, + ICashflowTransactionCategorizedPayload, +} from '@/modules/BankingTransactions/types/BankingTransactions.types'; +import { + IAccountEventCreatedPayload, + IAccountEventDeletedPayload, + IAccountEventActivatedPayload, +} from '@/interfaces/Account'; +import { + IInventoryAdjustmentEventCreatedPayload, + IInventoryAdjustmentEventPublishedPayload, + IInventoryAdjustmentEventDeletedPayload, +} from '@/modules/InventoryAdjutments/types/InventoryAdjustments.types'; +import { + IWarehouseTransferCreated, + IWarehouseTransferEditedPayload, + IWarehouseTransferDeletedPayload, + IWarehouseTransferInitiatedPayload, + IWarehouseTransferTransferredPayload, +} from '@/modules/Warehouses/Warehouse.types'; +import { + ITransactionsLockingPartialUnlocked, + ITransactionsLockingCanceled, +} from '@/modules/TransactionsLocking/types/TransactionsLocking.types'; +import { + ISaleEstimateCreatedPayload, + ISaleEstimateEditedPayload, + ISaleEstimateDeletedPayload, +} from '@/modules/SaleEstimates/types/SaleEstimates.types'; +import { IRefundCreditNoteCreatedPayload } from '@/modules/CreditNoteRefunds/types/CreditNoteRefunds.types'; +import { IRefundVendorCreditCreatedPayload } from '@/modules/VendorCreditsRefund/types/VendorCreditRefund.types'; +import { + IItemEventCreatedPayload, + IItemEventEditedPayload, + IItemEventDeletedPayload, + IItemEventActivatedPayload, + IItemEventInactivatedPayload, +} from '@/interfaces/Item'; +import { + ICustomerEventCreatedPayload, + ICustomerEventEditedPayload, + ICustomerEventDeletedPayload, +} from '@/modules/Customers/types/Customers.types'; +import { + IVendorEventCreatedPayload, + IVendorEventEditedPayload, + IVendorEventDeletedPayload, +} from '@/modules/Vendors/types/Vendors.types'; +import { + IRoleCreatedPayload, + IRoleEditedPayload, + IRoleDeletedPayload, +} from '@/modules/Roles/Roles.types'; +import { + ITaxRateCreatedPayload, + ITaxRateEditedPayload, + ITaxRateDeletedPayload, + ITaxRateActivatedPayload, +} from '@/modules/TaxRates/TaxRates.types'; +import { + IWarehouseCreatedPayload, + IWarehouseEditedPayload, + IWarehouseDeletedPayload, +} from '@/modules/Warehouses/Warehouse.types'; +import { + IBranchesActivatedPayload, + IBranchMarkedAsPrimaryPayload, +} from '@/modules/Branches/Branches.types'; +import { + IItemCategoryCreatedPayload, + IItemCategoryEditedPayload, + IItemCategoryDeletedPayload, +} from '@/modules/ItemCategories/ItemCategory.interfaces'; +import { + IBankRuleEventCreatedPayload, + IBankRuleEventEditedPayload, + IBankRuleEventDeletedPayload, +} from '@/modules/BankRules/types'; +import { + IUncategorizedTransactionCreatedEventPayload, +} from '@/modules/BankingCategorize/types/BankingCategorize.types'; +import { + IPlaidTransactionsSyncedEventPayload, +} from '@/modules/BankingPlaid/types/BankingPlaid.types'; +import { + IBankTransactionExcludedEventPayload, + IBankTransactionUnexcludedEventPayload, +} from '@/modules/BankingTransactionsExclude/types/BankTransactionsExclude.types'; + +@Injectable() +export class FinancialAuditLogSubscriber { + constructor(private readonly auditLog: AuditLogService) {} + + private async write( + trx: Knex.Transaction | undefined, + action: string, + subject: string, + subjectId: number | null, + metadata: Record, + ) { + await this.auditLog.record({ trx, action, subject, subjectId, metadata }); + } + + // --- Bills --- + @OnEvent(events.bill.onCreated) + async onBillCreated({ bill, trx }: IBillCreatedPayload) { + await this.write(trx, 'created', AbilitySubject.Bill, bill.id, { + billNumber: bill.billNumber, + amount: bill.amount, + currencyCode: bill.currencyCode, + }); + } + + @OnEvent(events.bill.onEdited) + async onBillEdited({ bill, trx }: IBillEditedPayload) { + await this.write(trx, 'edited', AbilitySubject.Bill, bill.id, { + billNumber: bill.billNumber, + amount: bill.amount, + currencyCode: bill.currencyCode, + }); + } + + @OnEvent(events.bill.onDeleted) + async onBillDeleted({ billId, oldBill, trx }: IBIllEventDeletedPayload) { + await this.write(trx, 'deleted', AbilitySubject.Bill, billId, { + billNumber: oldBill.billNumber, + }); + } + + @OnEvent(events.bill.onOpened) + async onBillOpened({ bill, trx }: IBillOpenedPayload) { + await this.write(trx, 'opened', AbilitySubject.Bill, bill.id, { + billNumber: bill.billNumber, + }); + } + + // --- Sale invoices --- + @OnEvent(events.saleInvoice.onCreated) + async onSaleInvoiceCreated({ + saleInvoice, + trx, + }: ISaleInvoiceCreatedPayload) { + await this.write(trx, 'created', AbilitySubject.SaleInvoice, saleInvoice.id, { + invoiceNumber: saleInvoice.invoiceNo, + balance: saleInvoice.balance, + currencyCode: saleInvoice.currencyCode, + }); + } + + @OnEvent(events.saleInvoice.onEdited) + async onSaleInvoiceEdited({ saleInvoice, trx }: ISaleInvoiceEditedPayload) { + await this.write(trx, 'edited', AbilitySubject.SaleInvoice, saleInvoice.id, { + invoiceNumber: saleInvoice.invoiceNo, + balance: saleInvoice.balance, + currencyCode: saleInvoice.currencyCode, + }); + } + + @OnEvent(events.saleInvoice.onDeleted) + async onSaleInvoiceDeleted({ + saleInvoiceId, + oldSaleInvoice, + trx, + }: ISaleInvoiceDeletedPayload) { + await this.write( + trx, + 'deleted', + AbilitySubject.SaleInvoice, + saleInvoiceId, + { invoiceNumber: oldSaleInvoice.invoiceNo }, + ); + } + + @OnEvent(events.saleInvoice.onDelivered) + async onSaleInvoiceDelivered({ + saleInvoice, + trx, + }: ISaleInvoiceEventDeliveredPayload) { + await this.write(trx, 'delivered', AbilitySubject.SaleInvoice, saleInvoice.id, { + invoiceNumber: saleInvoice.invoiceNo, + }); + } + + @OnEvent(events.saleInvoice.onWrittenoff) + async onSaleInvoiceWrittenoff({ + saleInvoice, + trx, + }: ISaleInvoiceWriteoffCreatePayload) { + await this.write(trx, 'writtenoff', AbilitySubject.SaleInvoice, saleInvoice.id, { + invoiceNumber: saleInvoice.invoiceNo, + }); + } + + @OnEvent(events.saleInvoice.onWrittenoffCanceled) + async onSaleInvoiceWrittenoffCanceled({ + saleInvoice, + trx, + }: ISaleInvoiceWrittenOffCanceledPayload) { + await this.write( + trx, + 'writtenoff_canceled', + AbilitySubject.SaleInvoice, + saleInvoice.id, + { invoiceNumber: saleInvoice.invoiceNo }, + ); + } + + // --- Sale receipts --- + @OnEvent(events.saleReceipt.onCreated) + async onSaleReceiptCreated({ saleReceipt, trx }: ISaleReceiptCreatedPayload) { + await this.write(trx, 'created', AbilitySubject.SaleReceipt, saleReceipt.id, { + receiptNumber: saleReceipt.receiptNumber, + amount: saleReceipt.total, + currencyCode: saleReceipt.currencyCode, + }); + } + + @OnEvent(events.saleReceipt.onEdited) + async onSaleReceiptEdited({ saleReceipt, trx }: ISaleReceiptEditedPayload) { + await this.write(trx, 'edited', AbilitySubject.SaleReceipt, saleReceipt.id, { + receiptNumber: saleReceipt.receiptNumber, + amount: saleReceipt.total, + currencyCode: saleReceipt.currencyCode, + }); + } + + @OnEvent(events.saleReceipt.onDeleted) + async onSaleReceiptDeleted({ + saleReceiptId, + oldSaleReceipt, + trx, + }: ISaleReceiptEventDeletedPayload) { + await this.write( + trx, + 'deleted', + AbilitySubject.SaleReceipt, + saleReceiptId, + { receiptNumber: oldSaleReceipt.receiptNumber }, + ); + } + + // --- Payments received --- + @OnEvent(events.paymentReceive.onCreated) + async onPaymentReceivedCreated({ + paymentReceive, + trx, + }: IPaymentReceivedCreatedPayload) { + await this.write( + trx, + 'created', + AbilitySubject.PaymentReceive, + paymentReceive.id, + { + paymentReceiveNo: paymentReceive.paymentReceiveNo, + amount: paymentReceive.amount, + currencyCode: paymentReceive.currencyCode, + }, + ); + } + + @OnEvent(events.paymentReceive.onEdited) + async onPaymentReceivedEdited({ + paymentReceive, + trx, + }: IPaymentReceivedEditedPayload) { + await this.write( + trx, + 'edited', + AbilitySubject.PaymentReceive, + paymentReceive.id, + { + paymentReceiveNo: paymentReceive.paymentReceiveNo, + amount: paymentReceive.amount, + }, + ); + } + + @OnEvent(events.paymentReceive.onDeleted) + async onPaymentReceivedDeleted({ + paymentReceiveId, + oldPaymentReceive, + trx, + }: IPaymentReceivedDeletedPayload) { + await this.write( + trx, + 'deleted', + AbilitySubject.PaymentReceive, + paymentReceiveId, + { paymentReceiveNo: oldPaymentReceive.paymentReceiveNo }, + ); + } + + // --- Bill payments (payments made) --- + @OnEvent(events.billPayment.onCreated) + async onBillPaymentCreated({ + billPayment, + trx, + }: IBillPaymentEventCreatedPayload) { + await this.write( + trx, + 'created', + AbilitySubject.PaymentMade, + billPayment.id, + { + paymentNumber: billPayment.paymentNumber, + amount: billPayment.amount, + currencyCode: billPayment.currencyCode, + }, + ); + } + + @OnEvent(events.billPayment.onEdited) + async onBillPaymentEdited({ + billPayment, + trx, + }: IBillPaymentEventEditedPayload) { + await this.write( + trx, + 'edited', + AbilitySubject.PaymentMade, + billPayment.id, + { + paymentNumber: billPayment.paymentNumber, + amount: billPayment.amount, + }, + ); + } + + @OnEvent(events.billPayment.onDeleted) + async onBillPaymentDeleted({ + billPaymentId, + oldBillPayment, + trx, + }: IBillPaymentEventDeletedPayload) { + await this.write( + trx, + 'deleted', + AbilitySubject.PaymentMade, + billPaymentId, + { paymentNumber: oldBillPayment.paymentNumber }, + ); + } + + // --- Expenses --- + @OnEvent(events.expenses.onCreated) + async onExpenseCreated({ expense, expenseId, trx }: IExpenseCreatedPayload) { + await this.write(trx, 'created', AbilitySubject.Expense, expenseId, { + amount: expense.totalAmount, + currencyCode: expense.currencyCode, + }); + } + + @OnEvent(events.expenses.onEdited) + async onExpenseEdited({ expense, expenseId, trx }: IExpenseEventEditPayload) { + await this.write(trx, 'edited', AbilitySubject.Expense, expenseId, { + amount: expense.totalAmount, + currencyCode: expense.currencyCode, + }); + } + + @OnEvent(events.expenses.onDeleted) + async onExpenseDeleted({ expenseId, oldExpense, trx }: IExpenseEventDeletePayload) { + await this.write(trx, 'deleted', AbilitySubject.Expense, expenseId, { + amount: oldExpense.totalAmount, + }); + } + + @OnEvent(events.expenses.onPublished) + async onExpensePublished({ expense, expenseId, trx }: IExpenseEventPublishedPayload) { + await this.write(trx, 'published', AbilitySubject.Expense, expenseId, { + amount: expense.totalAmount, + currencyCode: expense.currencyCode, + }); + } + + // --- Credit notes --- + @OnEvent(events.creditNote.onCreated) + async onCreditNoteCreated({ creditNote, trx }: ICreditNoteCreatedPayload) { + await this.write(trx, 'created', AbilitySubject.CreditNote, creditNote.id, { + creditNoteNumber: creditNote.creditNoteNumber, + amount: creditNote.total, + currencyCode: creditNote.currencyCode, + }); + } + + @OnEvent(events.creditNote.onEdited) + async onCreditNoteEdited({ creditNote, trx }: ICreditNoteEditedPayload) { + await this.write(trx, 'edited', AbilitySubject.CreditNote, creditNote.id, { + creditNoteNumber: creditNote.creditNoteNumber, + amount: creditNote.total, + }); + } + + @OnEvent(events.creditNote.onDeleted) + async onCreditNoteDeleted({ + creditNoteId, + oldCreditNote, + trx, + }: ICreditNoteDeletedPayload) { + await this.write( + trx, + 'deleted', + AbilitySubject.CreditNote, + creditNoteId, + { creditNoteNumber: oldCreditNote.creditNoteNumber }, + ); + } + + @OnEvent(events.creditNote.onOpened) + async onCreditNoteOpened({ creditNote, trx }: ICreditNoteOpenedPayload) { + await this.write(trx, 'opened', AbilitySubject.CreditNote, creditNote.id, { + creditNoteNumber: creditNote.creditNoteNumber, + }); + } + + @OnEvent(events.creditNote.onRefundCreated) + async onCreditNoteRefundCreated({ + trx, + refundCreditNote, + creditNote, + }: IRefundCreditNoteCreatedPayload) { + await this.write(trx, 'refund_created', 'CreditNoteRefund', refundCreditNote.id, { + creditNoteId: creditNote.id, + amount: refundCreditNote.amount, + }); + } + + // --- Vendor credits --- + @OnEvent(events.vendorCredit.onCreated) + async onVendorCreditCreated({ vendorCredit, trx }: IVendorCreditCreatedPayload) { + await this.write( + trx, + 'created', + AbilitySubject.VendorCredit, + vendorCredit.id, + { + vendorCreditNumber: vendorCredit.vendorCreditNumber, + total: vendorCredit.total, + currencyCode: vendorCredit.currencyCode, + }, + ); + } + + @OnEvent(events.vendorCredit.onEdited) + async onVendorCreditEdited({ vendorCredit, trx }: IVendorCreditEditedPayload) { + await this.write( + trx, + 'edited', + AbilitySubject.VendorCredit, + vendorCredit.id, + { + vendorCreditNumber: vendorCredit.vendorCreditNumber, + total: vendorCredit.total, + }, + ); + } + + @OnEvent(events.vendorCredit.onDeleted) + async onVendorCreditDeleted({ + vendorCreditId, + oldVendorCredit, + trx, + }: IVendorCreditDeletedPayload) { + await this.write( + trx, + 'deleted', + AbilitySubject.VendorCredit, + vendorCreditId, + { vendorCreditNumber: oldVendorCredit.vendorCreditNumber }, + ); + } + + @OnEvent(events.vendorCredit.onOpened) + async onVendorCreditOpened({ + vendorCredit, + vendorCreditId, + trx, + }: IVendorCreditOpenedPayload) { + await this.write(trx, 'opened', AbilitySubject.VendorCredit, vendorCreditId, { + vendorCreditNumber: vendorCredit.vendorCreditNumber, + }); + } + + @OnEvent(events.vendorCredit.onRefundCreated) + async onVendorCreditRefundCreated({ + trx, + refundVendorCredit, + vendorCredit, + }: IRefundVendorCreditCreatedPayload) { + await this.write( + trx, + 'refund_created', + 'VendorCreditRefund', + refundVendorCredit.id, + { vendorCreditId: vendorCredit.id, amount: refundVendorCredit.amount }, + ); + } + + // --- Manual journals --- + @OnEvent(events.manualJournals.onCreated) + async onManualJournalCreated({ + manualJournal, + trx, + }: IManualJournalEventCreatedPayload) { + await this.write( + trx, + 'created', + AbilitySubject.ManualJournal, + manualJournal.id, + { + journalNumber: manualJournal.journalNumber, + amount: manualJournal.amount, + currencyCode: manualJournal.currencyCode, + }, + ); + } + + @OnEvent(events.manualJournals.onEdited) + async onManualJournalEdited({ + manualJournal, + trx, + }: IManualJournalEventEditedPayload) { + await this.write( + trx, + 'edited', + AbilitySubject.ManualJournal, + manualJournal.id, + { + journalNumber: manualJournal.journalNumber, + amount: manualJournal.amount, + }, + ); + } + + @OnEvent(events.manualJournals.onDeleted) + async onManualJournalDeleted({ + manualJournalId, + trx, + }: IManualJournalEventDeletedPayload) { + await this.write(trx, 'deleted', AbilitySubject.ManualJournal, manualJournalId, {}); + } + + @OnEvent(events.manualJournals.onPublished) + async onManualJournalPublished({ + manualJournal, + trx, + }: IManualJournalEventPublishedPayload) { + await this.write( + trx, + 'published', + AbilitySubject.ManualJournal, + manualJournal.id, + { journalNumber: manualJournal.journalNumber }, + ); + } + + // --- Cashflow --- + @OnEvent(events.cashflow.onTransactionCreated) + async onCashflowCreated({ + cashflowTransaction, + trx, + }: ICommandCashflowCreatedPayload) { + await this.write(trx, 'created', AbilitySubject.Cashflow, cashflowTransaction.id, { + amount: cashflowTransaction.amount, + currencyCode: cashflowTransaction.currencyCode, + }); + } + + @OnEvent(events.cashflow.onTransactionDeleted) + async onCashflowDeleted({ + cashflowTransactionId, + trx, + }: ICommandCashflowDeletedPayload) { + await this.write(trx, 'deleted', AbilitySubject.Cashflow, cashflowTransactionId, {}); + } + + @OnEvent(events.cashflow.onTransactionCategorized) + async onCashflowCategorized({ + cashflowTransaction, + trx, + }: ICashflowTransactionCategorizedPayload) { + await this.write( + trx, + 'categorized', + AbilitySubject.Cashflow, + cashflowTransaction.id, + { amount: cashflowTransaction.amount }, + ); + } + + // --- GL accounts --- + @OnEvent(events.accounts.onCreated) + async onAccountCreated({ account, accountId, trx }: IAccountEventCreatedPayload) { + await this.write(trx, 'created', AbilitySubject.Account, accountId, { + name: account.name, + code: account.code, + }); + } + + @OnEvent(events.accounts.onDeleted) + async onAccountDeleted({ accountId, oldAccount, trx }: IAccountEventDeletedPayload) { + await this.write(trx, 'deleted', AbilitySubject.Account, accountId, { + name: oldAccount.name, + code: oldAccount.code, + }); + } + + @OnEvent(events.accounts.onActivated) + async onAccountActivated({ accountId, activate, account, trx }: IAccountEventActivatedPayload) { + await this.write(trx, activate ? 'activated' : 'inactivated', AbilitySubject.Account, accountId, { + name: account.name, + code: account.code, + }); + } + + // --- Contacts (Customers/Vendors) --- + @OnEvent(events.contacts.onActivated) + async onContactActivated({ contactId, contact }: { contactId: number; contact: any }) { + const subject = contact.contactService === 'vendor' ? AbilitySubject.Vendor : AbilitySubject.Customer; + await this.write(null, 'activated', subject, contactId, { + displayName: contact.displayName, + email: contact.email, + }); + } + + @OnEvent(events.contacts.onInactivated) + async onContactInactivated({ contactId, contact }: { contactId: number; contact: any }) { + const subject = contact.contactService === 'vendor' ? AbilitySubject.Vendor : AbilitySubject.Customer; + await this.write(null, 'inactivated', subject, contactId, { + displayName: contact.displayName, + email: contact.email, + }); + } + + // --- Inventory adjustments --- + @OnEvent(events.inventoryAdjustment.onQuickCreated) + async onInventoryAdjustmentCreated({ + inventoryAdjustment, + inventoryAdjustmentId, + trx, + }: IInventoryAdjustmentEventCreatedPayload) { + await this.write( + trx, + 'created', + AbilitySubject.InventoryAdjustment, + inventoryAdjustmentId, + { reason: inventoryAdjustment.reason }, + ); + } + + @OnEvent(events.inventoryAdjustment.onPublished) + async onInventoryAdjustmentPublished({ + inventoryAdjustment, + inventoryAdjustmentId, + trx, + }: IInventoryAdjustmentEventPublishedPayload) { + await this.write( + trx, + 'published', + AbilitySubject.InventoryAdjustment, + inventoryAdjustmentId, + { reason: inventoryAdjustment.reason }, + ); + } + + @OnEvent(events.inventoryAdjustment.onDeleted) + async onInventoryAdjustmentDeleted({ + inventoryAdjustmentId, + trx, + }: IInventoryAdjustmentEventDeletedPayload) { + await this.write( + trx, + 'deleted', + AbilitySubject.InventoryAdjustment, + inventoryAdjustmentId, + {}, + ); + } + + // --- Warehouse transfers --- + @OnEvent(events.warehouseTransfer.onCreated) + async onWarehouseTransferCreated({ + warehouseTransfer, + trx, + }: IWarehouseTransferCreated) { + await this.write( + trx, + 'created', + 'WarehouseTransfer', + warehouseTransfer.id, + { + transactionNumber: (warehouseTransfer as { transactionNumber?: string }) + .transactionNumber, + }, + ); + } + + @OnEvent(events.warehouseTransfer.onEdited) + async onWarehouseTransferEdited({ + warehouseTransfer, + trx, + }: IWarehouseTransferEditedPayload) { + await this.write( + trx, + 'edited', + 'WarehouseTransfer', + warehouseTransfer.id, + { + transactionNumber: (warehouseTransfer as { transactionNumber?: string }) + .transactionNumber, + }, + ); + } + + @OnEvent(events.warehouseTransfer.onDeleted) + async onWarehouseTransferDeleted({ + oldWarehouseTransfer, + trx, + }: IWarehouseTransferDeletedPayload) { + await this.write( + trx, + 'deleted', + 'WarehouseTransfer', + oldWarehouseTransfer.id, + {}, + ); + } + + @OnEvent(events.warehouseTransfer.onInitiated) + async onWarehouseTransferInitiated({ + warehouseTransfer, + trx, + }: IWarehouseTransferInitiatedPayload) { + await this.write( + trx, + 'initiated', + 'WarehouseTransfer', + warehouseTransfer.id, + { + transactionNumber: (warehouseTransfer as { transactionNumber?: string }) + .transactionNumber, + }, + ); + } + + @OnEvent(events.warehouseTransfer.onTransferred) + async onWarehouseTransferTransferred({ + warehouseTransfer, + trx, + }: IWarehouseTransferTransferredPayload) { + await this.write( + trx, + 'transferred', + 'WarehouseTransfer', + warehouseTransfer.id, + { + transactionNumber: (warehouseTransfer as { transactionNumber?: string }) + .transactionNumber, + }, + ); + } + + // --- Transactions locking (settings change; no trx on payload) --- + @OnEvent(events.transactionsLocking.partialUnlocked) + async onTransactionsLockingChanged( + payload: ITransactionsLockingPartialUnlocked | ITransactionsLockingCanceled, + ) { + const meta: Record = { module: payload.module }; + if ('transactionLockingDTO' in payload && payload.transactionLockingDTO) { + meta.lockToDate = (payload.transactionLockingDTO as { lockToDate?: Date }) + .lockToDate; + } + if ('cancelLockingDTO' in payload && payload.cancelLockingDTO) { + meta.cancelReason = (payload.cancelLockingDTO as { reason?: string }).reason; + } + await this.write(undefined, 'locking_changed', 'TransactionsLocking', null, meta); + } + + // --- Sale estimates --- + @OnEvent(events.saleEstimate.onCreated) + async onSaleEstimateCreated({ + saleEstimate, + saleEstimateId, + trx, + }: ISaleEstimateCreatedPayload) { + await this.write( + trx, + 'created', + AbilitySubject.SaleEstimate, + saleEstimate?.id ?? saleEstimateId, + { + estimateNumber: saleEstimate.estimateNumber, + total: saleEstimate.total, + currencyCode: saleEstimate.currencyCode, + }, + ); + } + + @OnEvent(events.saleEstimate.onEdited) + async onSaleEstimateEdited({ + saleEstimate, + estimateId, + trx, + }: ISaleEstimateEditedPayload) { + await this.write(trx, 'edited', AbilitySubject.SaleEstimate, estimateId, { + estimateNumber: saleEstimate.estimateNumber, + total: saleEstimate.total, + }); + } + + @OnEvent(events.saleEstimate.onDeleted) + async onSaleEstimateDeleted({ + saleEstimateId, + oldSaleEstimate, + trx, + }: ISaleEstimateDeletedPayload) { + await this.write( + trx, + 'deleted', + AbilitySubject.SaleEstimate, + saleEstimateId, + { estimateNumber: oldSaleEstimate.estimateNumber }, + ); + } + + // --- Items --- + @OnEvent(events.item.onCreated) + async onItemCreated({ item, itemId, trx }: IItemEventCreatedPayload) { + await this.write(trx, 'created', AbilitySubject.Item, itemId, { + name: item.name, + code: item.code, + type: item.type, + }); + } + + @OnEvent(events.item.onEdited) + async onItemEdited({ item, itemId, trx }: IItemEventEditedPayload) { + await this.write(trx, 'edited', AbilitySubject.Item, itemId, { + name: item.name, + code: item.code, + type: item.type, + }); + } + + @OnEvent(events.item.onDeleted) + async onItemDeleted({ itemId, oldItem, trx }: IItemEventDeletedPayload) { + await this.write(trx, 'deleted', AbilitySubject.Item, itemId, { + name: oldItem.name, + code: oldItem.code, + type: oldItem.type, + }); + } + + @OnEvent(events.item.onActivated) + async onItemActivated({ item, itemId, trx }: IItemEventActivatedPayload) { + await this.write(trx, 'activated', AbilitySubject.Item, itemId, { + name: item.name, + code: item.code, + type: item.type, + }); + } + + @OnEvent(events.item.onInactivated) + async onItemInactivated({ item, itemId, trx }: IItemEventInactivatedPayload) { + await this.write(trx, 'inactivated', AbilitySubject.Item, itemId, { + name: item.name, + code: item.code, + type: item.type, + }); + } + + // --- Customers --- + @OnEvent(events.customers.onCreated) + async onCustomerCreated({ customer, customerId, trx }: ICustomerEventCreatedPayload) { + await this.write(trx, 'created', AbilitySubject.Customer, customerId, { + displayName: customer.displayName, + email: customer.email, + }); + } + + @OnEvent(events.customers.onEdited) + async onCustomerEdited({ customer, customerId, trx }: ICustomerEventEditedPayload) { + await this.write(trx, 'edited', AbilitySubject.Customer, customerId, { + displayName: customer.displayName, + email: customer.email, + }); + } + + @OnEvent(events.customers.onDeleted) + async onCustomerDeleted({ customerId, oldCustomer, trx }: ICustomerEventDeletedPayload) { + await this.write(trx, 'deleted', AbilitySubject.Customer, customerId, { + displayName: oldCustomer.displayName, + email: oldCustomer.email, + }); + } + + // --- Vendors --- + @OnEvent(events.vendors.onCreated) + async onVendorCreated({ vendor, vendorId, trx }: IVendorEventCreatedPayload) { + await this.write(trx, 'created', AbilitySubject.Vendor, vendorId, { + displayName: vendor.displayName, + email: vendor.email, + }); + } + + @OnEvent(events.vendors.onEdited) + async onVendorEdited({ vendor, vendorId, trx }: IVendorEventEditedPayload) { + await this.write(trx, 'edited', AbilitySubject.Vendor, vendorId, { + displayName: vendor.displayName, + email: vendor.email, + }); + } + + @OnEvent(events.vendors.onDeleted) + async onVendorDeleted({ vendorId, oldVendor, trx }: IVendorEventDeletedPayload) { + await this.write(trx, 'deleted', AbilitySubject.Vendor, vendorId, { + displayName: oldVendor.displayName, + email: oldVendor.email, + }); + } + + // --- Roles --- + @OnEvent(events.roles.onCreated) + async onRoleCreated({ role, trx }: IRoleCreatedPayload) { + await this.write(trx, 'created', AbilitySubject.Role, role.id, { + roleName: role.name, + }); + } + + @OnEvent(events.roles.onEdited) + async onRoleEdited({ role, oldRole, trx }: IRoleEditedPayload) { + await this.write(trx, 'edited', AbilitySubject.Role, role.id, { + roleName: role.name, + oldRoleName: oldRole.name, + }); + } + + @OnEvent(events.roles.onDeleted) + async onRoleDeleted({ roleId, oldRole, trx }: IRoleDeletedPayload) { + await this.write(trx, 'deleted', AbilitySubject.Role, roleId, { + roleName: oldRole.name, + }); + } + + // --- Tax Rates --- + @OnEvent(events.taxRates.onCreated) + async onTaxRateCreated({ taxRate, trx }: ITaxRateCreatedPayload) { + await this.write(trx, 'created', AbilitySubject.TaxRate, taxRate.id, { + name: taxRate.name, + rate: taxRate.rate, + code: taxRate.code, + }); + } + + @OnEvent(events.taxRates.onEdited) + async onTaxRateEdited({ taxRate, oldTaxRate, trx }: ITaxRateEditedPayload) { + await this.write(trx, 'edited', AbilitySubject.TaxRate, taxRate.id, { + name: taxRate.name, + rate: taxRate.rate, + code: taxRate.code, + }); + } + + @OnEvent(events.taxRates.onDeleted) + async onTaxRateDeleted({ oldTaxRate, trx }: ITaxRateDeletedPayload) { + await this.write(trx, 'deleted', AbilitySubject.TaxRate, oldTaxRate.id, { + name: oldTaxRate.name, + rate: oldTaxRate.rate, + code: oldTaxRate.code, + }); + } + + @OnEvent(events.taxRates.onActivated) + async onTaxRateActivated({ taxRateId, trx }: ITaxRateActivatedPayload) { + await this.write(trx, 'activated', AbilitySubject.TaxRate, taxRateId, {}); + } + + // --- Warehouse --- + @OnEvent(events.warehouse.onCreated) + async onWarehouseCreated({ warehouse, warehouseDTO, trx }: IWarehouseCreatedPayload) { + await this.write(trx, 'created', AbilitySubject.Warehouse, warehouse.id, { + code: warehouseDTO.code, + }); + } + + @OnEvent(events.warehouse.onEdited) + async onWarehouseEdited({ warehouse, warehouseDTO, trx }: IWarehouseEditedPayload) { + await this.write(trx, 'edited', AbilitySubject.Warehouse, warehouse.id, { + code: warehouseDTO.code, + }); + } + + @OnEvent(events.warehouse.onDeleted) + async onWarehouseDeleted({ warehouseId, trx }: IWarehouseDeletedPayload) { + await this.write(trx, 'deleted', AbilitySubject.Warehouse, warehouseId, {}); + } + + // --- Branches --- + @OnEvent(events.branch.onActivated) + async onBranchActivated({ primaryBranch, trx }: IBranchesActivatedPayload) { + await this.write(trx, 'activated', AbilitySubject.Branch, primaryBranch.id, { + name: primaryBranch.name, + code: primaryBranch.code, + }); + } + + @OnEvent(events.branch.onMarkedPrimary) + async onBranchMarkedPrimary({ markedBranch, trx }: IBranchMarkedAsPrimaryPayload) { + await this.write(trx, 'marked_primary', AbilitySubject.Branch, markedBranch.id, { + name: markedBranch.name, + code: markedBranch.code, + }); + } + + // --- Item Category --- + @OnEvent(events.itemCategory.onCreated) + async onItemCategoryCreated({ itemCategory, trx }: IItemCategoryCreatedPayload) { + await this.write(trx, 'created', 'ItemCategory', itemCategory.id, { + name: itemCategory.name, + description: itemCategory.description, + }); + } + + @OnEvent(events.itemCategory.onEdited) + async onItemCategoryEdited({ oldItemCategory, trx }: IItemCategoryEditedPayload) { + await this.write(trx, 'edited', 'ItemCategory', oldItemCategory.id, { + name: oldItemCategory.name, + description: oldItemCategory.description, + }); + } + + @OnEvent(events.itemCategory.onDeleted) + async onItemCategoryDeleted({ itemCategoryId, oldItemCategory }: IItemCategoryDeletedPayload) { + await this.write(undefined, 'deleted', 'ItemCategory', itemCategoryId, { + name: oldItemCategory.name, + }); + } + + // --- Bank Rules --- + @OnEvent(events.bankRules.onCreated) + async onBankRuleCreated({ bankRule, trx }: IBankRuleEventCreatedPayload) { + await this.write(trx, 'created', 'BankRule', bankRule.id, { + name: bankRule.name, + applyIfAccountId: bankRule.applyIfAccountId, + }); + } + + @OnEvent(events.bankRules.onEdited) + async onBankRuleEdited({ bankRule, oldBankRule, trx }: IBankRuleEventEditedPayload) { + await this.write(trx, 'edited', 'BankRule', bankRule.id, { + name: bankRule.name, + applyIfAccountId: bankRule.applyIfAccountId, + }); + } + + @OnEvent(events.bankRules.onDeleted) + async onBankRuleDeleted({ ruleId, trx }: IBankRuleEventDeletedPayload) { + await this.write(trx, 'deleted', 'BankRule', ruleId, {}); + } + + // --- Uncategorized Transactions (Imported) --- + @OnEvent(events.cashflow.onTransactionUncategorizedCreated) + async onUncategorizedTransactionCreated({ + uncategorizedTransaction, + trx, + }: IUncategorizedTransactionCreatedEventPayload) { + await this.write(trx, 'created', 'UncategorizedTransaction', uncategorizedTransaction.id, { + amount: uncategorizedTransaction.amount, + currencyCode: uncategorizedTransaction.currencyCode, + payee: uncategorizedTransaction.payee, + description: uncategorizedTransaction.description, + plaidTransactionId: uncategorizedTransaction.plaidTransactionId, + }); + } + + // --- Plaid Sync Events --- + @OnEvent(events.plaid.onTransactionsSynced) + async onPlaidTransactionsSynced({ + plaidAccountId, + batch, + trx, + }: IPlaidTransactionsSyncedEventPayload) { + await this.write(trx, 'synced', 'PlaidTransactions', null, { + plaidAccountId, + batch, + }); + } + + // --- Excluded Bank Transactions --- + @OnEvent(events.bankTransactions.onExcluded) + async onBankTransactionExcluded({ + uncategorizedTransactionId, + uncategorizedTransaction, + trx, + }: IBankTransactionExcludedEventPayload) { + await this.write(trx, 'excluded', 'BankTransaction', uncategorizedTransactionId, { + amount: uncategorizedTransaction?.amount, + currencyCode: uncategorizedTransaction?.currencyCode, + payee: uncategorizedTransaction?.payee, + description: uncategorizedTransaction?.description, + accountId: uncategorizedTransaction?.accountId, + }); + } + + @OnEvent(events.bankTransactions.onUnexcluded) + async onBankTransactionUnexcluded({ + uncategorizedTransactionId, + uncategorizedTransaction, + trx, + }: IBankTransactionUnexcludedEventPayload) { + await this.write(trx, 'unexcluded', 'BankTransaction', uncategorizedTransactionId, { + amount: uncategorizedTransaction?.amount, + currencyCode: uncategorizedTransaction?.currencyCode, + payee: uncategorizedTransaction?.payee, + description: uncategorizedTransaction?.description, + accountId: uncategorizedTransaction?.accountId, + }); + } +} diff --git a/packages/server/src/modules/EE/AuditLogs/types/AuditLogs.types.ts b/packages/server/src/modules/EE/AuditLogs/types/AuditLogs.types.ts new file mode 100644 index 000000000..8a9fc8794 --- /dev/null +++ b/packages/server/src/modules/EE/AuditLogs/types/AuditLogs.types.ts @@ -0,0 +1,3 @@ +export enum AuditLogAction { + View = 'View', +} diff --git a/packages/server/src/modules/EE/EE.module.ts b/packages/server/src/modules/EE/EE.module.ts new file mode 100644 index 000000000..b065a4722 --- /dev/null +++ b/packages/server/src/modules/EE/EE.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common'; +import { AuditLogsModule } from './AuditLogs/AuditLogs.module'; + +@Module({ + imports: [AuditLogsModule], + exports: [AuditLogsModule], +}) +export class EEModule {} diff --git a/packages/server/src/modules/Items/ActivateItem.service.ts b/packages/server/src/modules/Items/ActivateItem.service.ts index c51764c35..e3beec92c 100644 --- a/packages/server/src/modules/Items/ActivateItem.service.ts +++ b/packages/server/src/modules/Items/ActivateItem.service.ts @@ -4,6 +4,7 @@ import { Inject, Injectable } from '@nestjs/common'; import { Item } from './models/Item'; import { UnitOfWork } from '../Tenancy/TenancyDB/UnitOfWork.service'; import { events } from '@/common/events/events'; +import { IItemEventActivatedPayload } from '@/interfaces/Item'; import { TenantModelProxy } from '../System/models/TenantBaseModel'; @Injectable() @@ -39,7 +40,11 @@ export class ActivateItemService { .patch({ active: true }); // Triggers `onItemActivated` event. - await this.eventEmitter.emitAsync(events.item.onActivated, {}); + await this.eventEmitter.emitAsync(events.item.onActivated, { + itemId, + item: oldItem, + trx, + } as IItemEventActivatedPayload); }, trx); } } diff --git a/packages/server/src/modules/Items/InactivateItem.service.ts b/packages/server/src/modules/Items/InactivateItem.service.ts index ae2612363..ecb88b409 100644 --- a/packages/server/src/modules/Items/InactivateItem.service.ts +++ b/packages/server/src/modules/Items/InactivateItem.service.ts @@ -3,6 +3,7 @@ import { Knex } from 'knex'; import { EventEmitter2 } from '@nestjs/event-emitter'; import { Item } from './models/Item'; import { events } from '@/common/events/events'; +import { IItemEventInactivatedPayload } from '@/interfaces/Item'; import { UnitOfWork } from '../Tenancy/TenancyDB/UnitOfWork.service'; import { TenantModelProxy } from '../System/models/TenantBaseModel'; @@ -38,7 +39,11 @@ export class InactivateItem { .patch({ active: false }); // Triggers `onItemInactivated` event. - await this.eventEmitter.emitAsync(events.item.onInactivated, { trx }); + await this.eventEmitter.emitAsync(events.item.onInactivated, { + itemId, + item: oldItem, + trx, + } as IItemEventInactivatedPayload); }, trx); } } diff --git a/packages/server/src/modules/Roles/AbilitySchema.ts b/packages/server/src/modules/Roles/AbilitySchema.ts index 36c35cf71..c89c60259 100644 --- a/packages/server/src/modules/Roles/AbilitySchema.ts +++ b/packages/server/src/modules/Roles/AbilitySchema.ts @@ -16,6 +16,7 @@ import { BillAction } from "../Bills/Bills.types"; import { AbilitySubject, ISubjectAbilitiesSchema, ISubjectAbilitySchema } from "./Roles.types"; import { PaymentReceiveAction } from "../PaymentReceived/types/PaymentReceived.types"; import { PreferencesAction } from "../Settings/Settings.types"; +import { AuditLogAction } from "../EE/AuditLogs/types/AuditLogs.types"; export const AbilitySchema: ISubjectAbilitiesSchema[] = [ { @@ -305,6 +306,13 @@ export const AbilitySchema: ISubjectAbilitiesSchema[] = [ }, ], }, + { + subject: AbilitySubject.AuditLog, + subjectLabel: 'ability.audit_log', + abilities: [ + { key: AuditLogAction.View, label: 'ability.view' }, + ], + }, ]; /** diff --git a/packages/server/src/modules/Roles/Permission.guard.ts b/packages/server/src/modules/Roles/Permission.guard.ts new file mode 100644 index 000000000..53995d286 --- /dev/null +++ b/packages/server/src/modules/Roles/Permission.guard.ts @@ -0,0 +1,50 @@ +import { + Injectable, + CanActivate, + ExecutionContext, + ForbiddenException, +} from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { Request } from 'express'; +import { + REQUIRED_PERMISSION_KEY, + RequiredPermission, +} from './RequirePermission.decorator'; + +/** + * Guard that checks CASL `ability` on the request (attached by AuthorizationGuard). + */ +@Injectable() +export class PermissionGuard implements CanActivate { + constructor(private readonly reflector: Reflector) {} + + canActivate(context: ExecutionContext): boolean { + const requiredPermission = this.reflector.getAllAndOverride( + REQUIRED_PERMISSION_KEY, + [context.getHandler(), context.getClass()], + ); + + if (!requiredPermission) { + return true; + } + + const request = context.switchToHttp().getRequest(); + const ability = (request as any).ability; + + if (!ability) { + throw new ForbiddenException( + 'Ability instance not found. Ensure AuthorizationGuard is applied.', + ); + } + + const { ability: action, subject } = requiredPermission; + + if (!ability.can(action, subject)) { + throw new ForbiddenException( + `You do not have permission to ${action} ${subject}`, + ); + } + + return true; + } +} diff --git a/packages/server/src/modules/Roles/RequirePermission.decorator.ts b/packages/server/src/modules/Roles/RequirePermission.decorator.ts new file mode 100644 index 000000000..c5cbc28b5 --- /dev/null +++ b/packages/server/src/modules/Roles/RequirePermission.decorator.ts @@ -0,0 +1,14 @@ +import { SetMetadata } from '@nestjs/common'; +import { AbilitySubject } from './Roles.types'; + +export const REQUIRED_PERMISSION_KEY = 'requiredPermission'; + +export interface RequiredPermission { + ability: string; + subject: AbilitySubject | string; +} + +export const RequirePermission = ( + ability: string, + subject: AbilitySubject | string, +) => SetMetadata(REQUIRED_PERMISSION_KEY, { ability, subject }); diff --git a/packages/server/src/modules/Roles/Roles.types.ts b/packages/server/src/modules/Roles/Roles.types.ts index 258b76509..74fc63505 100644 --- a/packages/server/src/modules/Roles/Roles.types.ts +++ b/packages/server/src/modules/Roles/Roles.types.ts @@ -60,7 +60,11 @@ export enum AbilitySubject { CreditNote = 'CreditNode', VendorCredit = 'VendorCredit', Project = 'Project', - TaxRate = 'TaxRate' + TaxRate = 'TaxRate', + AuditLog = 'AuditLog', + Role = 'Role', + Warehouse = 'Warehouse', + Branch = 'Branch', } export interface IRoleCreatedPayload { diff --git a/packages/webapp/package.json b/packages/webapp/package.json index cd5c26f95..8172fc1a9 100644 --- a/packages/webapp/package.json +++ b/packages/webapp/package.json @@ -3,9 +3,9 @@ "version": "0.10.2", "private": true, "dependencies": { - "@bigcapital/email-components": "*", - "@bigcapital/pdf-templates": "*", - "@bigcapital/utils": "*", + "@bigcapital/email-components": "workspace:*", + "@bigcapital/pdf-templates": "workspace:*", + "@bigcapital/utils": "workspace:*", "@blueprintjs-formik/core": "^0.3.7", "@blueprintjs-formik/datetime": "^0.4.0", "@blueprintjs-formik/select": "^0.4.5", diff --git a/packages/webapp/src/components/FinancialSheet/FinancialSheet.tsx b/packages/webapp/src/components/FinancialSheet/FinancialSheet.tsx index b82b69fe1..71aa9df20 100644 --- a/packages/webapp/src/components/FinancialSheet/FinancialSheet.tsx +++ b/packages/webapp/src/components/FinancialSheet/FinancialSheet.tsx @@ -44,6 +44,7 @@ export function FinancialSheet({ () => getBasisLabel(basis), [getBasisLabel, basis], ); + const hasHead = companyName || sheetType || dateText; return ( - {companyName && {companyName}} - {sheetType && {sheetType}} - - {dateText && {dateText}} + {hasHead && ( +
+ {companyName && {companyName}} + {sheetType && {sheetType}} + {dateText && {dateText}} +
+ )} {children} diff --git a/packages/webapp/src/components/FinancialSheet/StyledFinancialSheet.tsx b/packages/webapp/src/components/FinancialSheet/StyledFinancialSheet.tsx index 44a80f37d..fda89a49e 100644 --- a/packages/webapp/src/components/FinancialSheet/StyledFinancialSheet.tsx +++ b/packages/webapp/src/components/FinancialSheet/StyledFinancialSheet.tsx @@ -12,6 +12,7 @@ export const FinancialSheetRoot = styled.div` min-height: 400px; display: flex; flex-direction: column; + gap: 24px; ${(props) => props.fullWidth && @@ -73,9 +74,7 @@ export const FinancialSheetFooter = styled.div` padding-left: 10px; } `; -export const FinancialSheetTable = styled.div` - margin-top: 24px; -`; +export const FinancialSheetTable = styled.div``; export const FinancialSheetFooterBasis = styled.span``; export const FinancialSheetFooterCurrentTime = styled.span``; diff --git a/packages/webapp/src/constants/abilityOption.tsx b/packages/webapp/src/constants/abilityOption.tsx index 70b73a99d..fbae0bf1f 100644 --- a/packages/webapp/src/constants/abilityOption.tsx +++ b/packages/webapp/src/constants/abilityOption.tsx @@ -23,6 +23,7 @@ export const AbilitySubject = { Project: 'Project', TaxRate: 'TaxRate', BankRule: 'BankRule', + AuditLog: 'AuditLog', }; export const ItemAction = { @@ -202,3 +203,7 @@ export const BankRuleAction = { Edit: 'Edit', Delete: 'Delete', }; + +export const AuditLogAction = { + View: 'View', +}; diff --git a/packages/webapp/src/constants/financialReportsMenu.tsx b/packages/webapp/src/constants/financialReportsMenu.tsx index 507e63c76..4523d521d 100644 --- a/packages/webapp/src/constants/financialReportsMenu.tsx +++ b/packages/webapp/src/constants/financialReportsMenu.tsx @@ -1,7 +1,7 @@ // @ts-nocheck import React from 'react'; import { FormattedMessage as T } from '@/components'; -import { ReportsAction, AbilitySubject } from '@/constants/abilityOption'; +import { ReportsAction, AbilitySubject, AuditLogAction } from '@/constants/abilityOption'; export const financialReportMenus = [ { @@ -194,4 +194,16 @@ export const financialReportMenus = [ }, ], }, + { + sectionTitle: , + reports: [ + { + title: , + desc: , + link: '/financial-reports/audit-log', + subject: AbilitySubject.AuditLog, + ability: AuditLogAction.View, + }, + ], + }, ]; diff --git a/packages/webapp/src/containers/FinancialStatements/AuditLog/AuditLogActionsBar.tsx b/packages/webapp/src/containers/FinancialStatements/AuditLog/AuditLogActionsBar.tsx new file mode 100644 index 000000000..044412827 --- /dev/null +++ b/packages/webapp/src/containers/FinancialStatements/AuditLog/AuditLogActionsBar.tsx @@ -0,0 +1,47 @@ +// @ts-nocheck +import React from 'react'; +import { Button, Classes, NavbarGroup, NavbarDivider } from '@blueprintjs/core'; +import classNames from 'classnames'; +import { DashboardActionsBar, Icon } from '@/components'; +import { useAuditLogContext } from './AuditLogProvider'; + +/** + * Audit Log Actions Bar + */ +function AuditLogActionsBar({ + isFilterDrawerOpen, + toggleFilterDrawer, +}) { + const { sheetRefresh } = useAuditLogContext(); + + const handleCustomizeClick = () => { + toggleFilterDrawer(); + }; + + const handleRecalcReport = () => { + sheetRefresh(); + }; + + return ( + + + + + + + + + ); +} + +export default AuditLogHeader; diff --git a/packages/webapp/src/containers/FinancialStatements/AuditLog/AuditLogProvider.tsx b/packages/webapp/src/containers/FinancialStatements/AuditLog/AuditLogProvider.tsx new file mode 100644 index 000000000..79c6446c4 --- /dev/null +++ b/packages/webapp/src/containers/FinancialStatements/AuditLog/AuditLogProvider.tsx @@ -0,0 +1,82 @@ +// @ts-nocheck +import React, { createContext, useCallback, useContext, useMemo } from 'react'; +import { flatten, map } from 'lodash'; +import { useAuditLogsInfinityQuery } from '@/hooks/query'; +import { IntersectionObserver } from '@/components'; + +function flattenInfinityPagesData(data) { + return flatten(map(data.pages, (page) => page.data)); +} + +// Context for Audit Log +const AuditLogContext = React.createContext(); + +const useAuditLogContext = () => useContext(AuditLogContext); + +/** + * Audit Log Provider + */ +function toHttpStringList(value) { + if (value == null || value === '') return undefined; + if (Array.isArray(value)) return value.length ? value : undefined; + return [value]; +} + +function AuditLogProvider({ query, children }) { + const httpQuery = useMemo(() => { + return { + pageSize: 20, + subject: toHttpStringList(query.subject), + action: toHttpStringList(query.action), + from: query.fromDate || undefined, + to: query.toDate || undefined, + }; + }, [query]); + + const { + data: auditLogsPages, + isLoading, + isFetching, + isFetchingNextPage, + fetchNextPage, + hasNextPage, + refetch, + } = useAuditLogsInfinityQuery(httpQuery); + + const auditLogs = useMemo( + () => + auditLogsPages + ? flattenInfinityPagesData(auditLogsPages) + : [], + [auditLogsPages], + ); + + const handleObserverInteract = useCallback(() => { + if (!isFetching && hasNextPage) { + fetchNextPage(); + } + }, [isFetching, hasNextPage, fetchNextPage]); + + const provider = { + auditLogs, + isLoading, + isFetching, + isFetchingNextPage, + hasNextPage, + fetchNextPage, + handleObserverInteract, + sheetRefresh: refetch, + httpQuery, + }; + + return ( + + {children} + + + ); +} + +export { AuditLogProvider, useAuditLogContext }; diff --git a/packages/webapp/src/containers/FinancialStatements/AuditLog/AuditLogReport.tsx b/packages/webapp/src/containers/FinancialStatements/AuditLog/AuditLogReport.tsx new file mode 100644 index 000000000..df40604b6 --- /dev/null +++ b/packages/webapp/src/containers/FinancialStatements/AuditLog/AuditLogReport.tsx @@ -0,0 +1,89 @@ +// @ts-nocheck +import React, { useCallback, useEffect, useState } from 'react'; +import intl from 'react-intl-universal'; +import { NonIdealState } from '@blueprintjs/core'; +import { + Card, + Can, + DashboardPageContent, + FinancialStatement, +} from '@/components'; +import { AbilitySubject, AuditLogAction } from '@/constants/abilityOption'; + +import { AuditLogProvider } from './AuditLogProvider'; +import AuditLogHeader from './AuditLogHeader'; +import AuditLogActionsBar from './AuditLogActionsBar'; +import { AuditLogLoadingBar } from './components'; +import { AuditLogBody } from './AuditLogBody'; +import { useAuditLogQuery } from './common'; + +/** + * Audit Log Report Content + */ +function AuditLogReportContent() { + const { query, setLocationQuery } = useAuditLogQuery(); + const [isFilterDrawerOpen, setIsFilterDrawerOpen] = useState(false); + + const handleFilterSubmit = useCallback( + (filter) => { + setLocationQuery(filter); + }, + [setLocationQuery] + ); + + const toggleFilterDrawer = useCallback((toggle) => { + setIsFilterDrawerOpen((prev) => + typeof toggle !== 'undefined' ? toggle : !prev + ); + }, []); + + // Hide filter drawer on unmount + useEffect(() => { + return () => setIsFilterDrawerOpen(false); + }, []); + + return ( + + + + + + + + + + + + ); +} + +/** + * Audit Log Report page (in Financial Reports section). + */ +function AuditLogReport() { + return ( + <> + + + + + + + + + + + + + ); +} + +export default AuditLogReport; diff --git a/packages/webapp/src/containers/FinancialStatements/AuditLog/AuditLogTable.tsx b/packages/webapp/src/containers/FinancialStatements/AuditLog/AuditLogTable.tsx new file mode 100644 index 000000000..c2579ca90 --- /dev/null +++ b/packages/webapp/src/containers/FinancialStatements/AuditLog/AuditLogTable.tsx @@ -0,0 +1,129 @@ +// @ts-nocheck +import React, { useMemo } from 'react'; +import intl from 'react-intl-universal'; +import { Spinner } from '@blueprintjs/core'; +import styled from 'styled-components'; +import { + FinancialSheet, + ReportDataTable, + TableFastCell, + TableVirtualizedListRows, + IntersectionObserver, +} from '@/components'; +import { TableStyle } from '@/constants'; +import { useAuditLogContext } from './AuditLogProvider'; + +// Dynamic columns for audit log +const useAuditLogTableColumns = () => { + return useMemo( + () => [ + { + Header: intl.get('audit_log.col_time'), + accessor: 'created_at_formatted', + width: 180, + textOverview: true, + }, + { + Header: intl.get('audit_log.col_user'), + accessor: 'user_name', + width: 150, + textOverview: true, + }, + { + Header: intl.get('audit_log.col_action'), + accessor: 'action', + width: 100, + textOverview: true, + }, + { + Header: intl.get('audit_log.col_subject'), + accessor: 'subject', + width: 120, + textOverview: true, + }, + { + Header: intl.get('audit_log.col_summary'), + accessor: 'summary', + width: 350, + textOverview: true, + Cell: ({ value }) => ( +
+ {value || ''} +
+ ), + }, + { + Header: intl.get('audit_log.col_ip'), + accessor: 'ip', + width: 120, + textOverview: true, + Cell: ({ value }) => value || '—', + }, + ], + [] + ); +}; + +const AuditLogDataTable = styled(ReportDataTable)` + --color-table-text-color: #252a31; + --color-table-border-color: #ececec; + + .bp4-dark & { + --color-table-text-color: var(--color-light-gray1); + --color-table-border-color: var(--color-dark-gray4); + } + + .tbody { + .tr .td { + padding-top: 0.2rem; + padding-bottom: 0.2rem; + } + .tr:not(.no-results) .td:not(:first-of-type) { + border-left: 1px solid var(--color-table-border-color); + } + .tr:last-child .td { + border-bottom: 1px solid var(--color-table-border-color); + } + } +`; + +/** + * Audit Log Table + */ +function AuditLogTable() { + const { auditLogs, isLoading, isFetchingNextPage, handleObserverInteract } = useAuditLogContext(); + const columns = useAuditLogTableColumns(); + + return ( + + + + ); +} + +export default AuditLogTable; diff --git a/packages/webapp/src/containers/FinancialStatements/AuditLog/common.tsx b/packages/webapp/src/containers/FinancialStatements/AuditLog/common.tsx new file mode 100644 index 000000000..b4b2b69c4 --- /dev/null +++ b/packages/webapp/src/containers/FinancialStatements/AuditLog/common.tsx @@ -0,0 +1,41 @@ +// @ts-nocheck +import React, { useMemo, useState } from 'react'; +import * as Yup from 'yup'; +import { transformToForm } from '@/utils'; + +// Default query for audit log +export const getDefaultAuditLogQuery = () => ({ + subject: [], + action: [], + fromDate: '', + toDate: '', +}); + +// Validation schema +export const getAuditLogQuerySchema = () => { + return Yup.object().shape({ + fromDate: Yup.date().optional(), + toDate: Yup.date().min(Yup.ref('fromDate')).optional(), + }); +}; + +// Parse query from URL +const parseAuditLogQuery = (locationQuery) => { + const defaultQuery = getDefaultAuditLogQuery(); + return { + ...defaultQuery, + ...transformToForm(locationQuery, defaultQuery), + }; +}; + +// Hook for managing query state +export const useAuditLogQuery = () => { + const [locationQuery, setLocationQuery] = useState({}); + + const query = useMemo( + () => parseAuditLogQuery(locationQuery), + [locationQuery] + ); + + return { query, setLocationQuery }; +}; diff --git a/packages/webapp/src/containers/FinancialStatements/AuditLog/components.tsx b/packages/webapp/src/containers/FinancialStatements/AuditLog/components.tsx new file mode 100644 index 000000000..3db95a3b5 --- /dev/null +++ b/packages/webapp/src/containers/FinancialStatements/AuditLog/components.tsx @@ -0,0 +1,18 @@ +// @ts-nocheck +import React from 'react'; +import { useAuditLogContext } from './AuditLogProvider'; +import FinancialLoadingBar from '../FinancialLoadingBar'; + +/** + * Audit Log Loading Bar + */ +export function AuditLogLoadingBar() { + const { isFetching, isFetchingNextPage } = useAuditLogContext(); + + if (!isFetching || isFetchingNextPage) return null; + return ( +
+ +
+ ); +} diff --git a/packages/webapp/src/hooks/query/auditLogs.tsx b/packages/webapp/src/hooks/query/auditLogs.tsx new file mode 100644 index 000000000..e5ac76d41 --- /dev/null +++ b/packages/webapp/src/hooks/query/auditLogs.tsx @@ -0,0 +1,103 @@ +// @ts-nocheck +import * as qs from 'qs'; +import { useInfiniteQuery } from 'react-query'; +import { useRequestQuery } from '../useQueryRequest'; +import useApiRequest from '../useRequest'; +import { normalizeApiPath } from '@/utils'; +import t from './types'; + +const qsArrayOptions = { skipNulls: true, arrayFormat: 'repeat' as const }; + +/** Normalize subject/action to a non-empty string[] or omit from query. */ +function auditLogStringListParam(value) { + if (value == null || value === '') return undefined; + if (Array.isArray(value)) return value.length ? value : undefined; + return [value]; +} + +/** + * Paginated audit log list (financial domain events). + */ +export function useAuditLogsQuery(filters, props) { + const query = qs.stringify( + { + page: filters.page ?? 1, + pageSize: filters.pageSize ?? 20, + subject: auditLogStringListParam(filters.subject), + action: auditLogStringListParam(filters.action), + userId: filters.userId || undefined, + from: filters.from || undefined, + to: filters.to || undefined, + }, + qsArrayOptions, + ); + + return useRequestQuery( + [t.AUDIT_LOGS, filters], + { method: 'get', url: `audit-logs?${query}` }, + { + select: (res) => res.data, + keepPreviousData: true, + ...props, + }, + ); +} + +/** + * Distinct subject/action values for audit log filter dropdowns. + */ +export function useAuditLogFilterOptionsQuery(props) { + return useRequestQuery( + [t.AUDIT_LOG_FILTER_OPTIONS], + { method: 'get', url: 'audit-logs/filter-options' }, + { + defaultData: { subjects: [], actions: [] }, + select: (res) => ({ + subjects: res.data?.subjects ?? [], + actions: res.data?.actions ?? [], + }), + staleTime: 5 * 60 * 1000, + ...props, + }, + ); +} + +/** + * Infinite audit log list with page-based pagination. + */ +export function useAuditLogsInfinityQuery(filters, infinityProps) { + const apiRequest = useApiRequest(); + + return useInfiniteQuery( + [t.AUDIT_LOGS, filters], + async ({ pageParam = 1 }) => { + const query = qs.stringify( + { + page: pageParam, + pageSize: filters.pageSize ?? 20, + subject: auditLogStringListParam(filters.subject), + action: auditLogStringListParam(filters.action), + userId: filters.userId || undefined, + from: filters.from || undefined, + to: filters.to || undefined, + }, + qsArrayOptions, + ); + + const response = await apiRequest.http({ + method: 'get', + url: `/api/${normalizeApiPath(`audit-logs?${query}`)}`, + }); + return response.data; + }, + { + getNextPageParam: (lastPage) => { + const { pagination } = lastPage; + return pagination.total > pagination.page_size * pagination.page + ? pagination.page + 1 + : undefined; + }, + ...infinityProps, + }, + ); +} diff --git a/packages/webapp/src/hooks/query/index.tsx b/packages/webapp/src/hooks/query/index.tsx index 82fb35b5a..025566193 100644 --- a/packages/webapp/src/hooks/query/index.tsx +++ b/packages/webapp/src/hooks/query/index.tsx @@ -39,3 +39,4 @@ export * from './warehousesTransfers'; export * from './plaid'; export * from './FinancialReports'; export * from './apiKeys'; +export * from './auditLogs'; diff --git a/packages/webapp/src/hooks/query/items.tsx b/packages/webapp/src/hooks/query/items.tsx index 73092d105..175a9ebac 100644 --- a/packages/webapp/src/hooks/query/items.tsx +++ b/packages/webapp/src/hooks/query/items.tsx @@ -124,7 +124,7 @@ export function useActivateItem(props) { const queryClient = useQueryClient(); const apiRequest = useApiRequest(); - return useMutation((id) => apiRequest.post(`items/${id}/activate`), { + return useMutation((id) => apiRequest.patch(`items/${id}/activate`), { onSuccess: (res, id) => { // Invalidate specific item. queryClient.invalidateQueries([t.ITEM, id]); @@ -143,7 +143,7 @@ export function useInactivateItem(props) { const queryClient = useQueryClient(); const apiRequest = useApiRequest(); - return useMutation((id) => apiRequest.post(`items/${id}/inactivate`), { + return useMutation((id) => apiRequest.patch(`items/${id}/inactivate`), { onSuccess: (res, id) => { // Invalidate specific item. queryClient.invalidateQueries([t.ITEM, id]); diff --git a/packages/webapp/src/hooks/query/types.tsx b/packages/webapp/src/hooks/query/types.tsx index 277c67942..90020c968 100644 --- a/packages/webapp/src/hooks/query/types.tsx +++ b/packages/webapp/src/hooks/query/types.tsx @@ -245,6 +245,14 @@ export const API_KEYS = { API_KEYS: 'API_KEYS', }; +const AUDIT_LOGS = { + AUDIT_LOGS: 'AUDIT_LOGS', +}; + +const AUDIT_LOG_FILTER_OPTIONS = { + AUDIT_LOG_FILTER_OPTIONS: 'AUDIT_LOG_FILTER_OPTIONS', +}; + export default { ...Authentication, ...ACCOUNTS, @@ -281,4 +289,6 @@ export default { ...TAX_RATES, ...EXCHANGE_RATE, ...API_KEYS, + ...AUDIT_LOGS, + ...AUDIT_LOG_FILTER_OPTIONS, }; diff --git a/packages/webapp/src/lang/en/index.json b/packages/webapp/src/lang/en/index.json index d29309048..c64b7dd38 100644 --- a/packages/webapp/src/lang/en/index.json +++ b/packages/webapp/src/lang/en/index.json @@ -242,6 +242,26 @@ "new_expenses": "New Expenses", "preferences": "Preferences", "auditing_system": "Auditing System", + "audit_log.no_access": "You do not have permission to view the audit log.", + "audit_log.empty": "No audit entries yet.", + "audit_log.filter_subject": "Subject", + "audit_log.filter_action": "Action", + "audit_log.filter_from": "From", + "audit_log.filter_to": "To", + "audit_log.apply_filters": "Apply", + "audit_log.col_time": "Time", + "audit_log.col_user": "User", + "audit_log.col_action": "Action", + "audit_log.col_subject": "Subject", + "audit_log.col_id": "ID", + "audit_log.col_summary": "Summary", + "audit_log.col_ip": "IP", + "audit_log.pagination": "Page {page} of {pages} ({total} total)", + "audit_log.prev": "Previous", + "audit_log.next": "Next", + "audit_log_report": "Audit Log", + "audit_log_report_desc": "View system audit log entries for financial transactions and configuration changes", + "system_reports": "System Reports", "all": "All", "organization": "Organization.", "check_your_email_for_a_link_to_reset": "Check your email for a link to reset your password. If it doesn’t appear within a few minutes, check your spam folder.", diff --git a/packages/webapp/src/routes/dashboard.tsx b/packages/webapp/src/routes/dashboard.tsx index ffd709df0..e6f31c447 100644 --- a/packages/webapp/src/routes/dashboard.tsx +++ b/packages/webapp/src/routes/dashboard.tsx @@ -496,6 +496,17 @@ export const getDashboardRoutes = () => [ sidebarExpand: false, subscriptionActive: [SUBSCRIPTION_TYPE.MAIN], }, + { + path: `/financial-reports/audit-log`, + component: lazy( + () => import('@/containers/FinancialStatements/AuditLog/AuditLogReport'), + ), + breadcrumb: intl.get('audit_log_report'), + pageTitle: intl.get('audit_log_report'), + backLink: true, + sidebarExpand: false, + subscriptionActive: [SUBSCRIPTION_TYPE.MAIN], + }, { path: '/financial-reports', component: lazy(