wip
This commit is contained in:
@@ -33,16 +33,16 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-s3": "^3.576.0",
|
"@aws-sdk/client-s3": "^3.576.0",
|
||||||
"@aws-sdk/s3-request-presigner": "^3.583.0",
|
"@aws-sdk/s3-request-presigner": "^3.583.0",
|
||||||
"@bigcapital/email-components": "*",
|
"@bigcapital/email-components": "workspace:*",
|
||||||
"@bigcapital/pdf-templates": "*",
|
"@bigcapital/pdf-templates": "workspace:*",
|
||||||
"@bigcapital/utils": "*",
|
"@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",
|
"@casl/ability": "^5.4.3",
|
||||||
"@lemonsqueezy/lemonsqueezy.js": "^2.2.0",
|
"@lemonsqueezy/lemonsqueezy.js": "^2.2.0",
|
||||||
"@liaoliaots/nestjs-redis": "^10.0.0",
|
"@liaoliaots/nestjs-redis": "^10.0.0",
|
||||||
"@nest-lab/throttler-storage-redis": "^1.1.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/bull": "^10.2.1",
|
||||||
"@nestjs/bullmq": "^10.2.2",
|
"@nestjs/bullmq": "^10.2.2",
|
||||||
"@nestjs/cache-manager": "^2.2.2",
|
"@nestjs/cache-manager": "^2.2.2",
|
||||||
@@ -69,7 +69,7 @@
|
|||||||
"async": "^3.2.0",
|
"async": "^3.2.0",
|
||||||
"async-mutex": "^0.5.0",
|
"async-mutex": "^0.5.0",
|
||||||
"axios": "^1.6.0",
|
"axios": "^1.6.0",
|
||||||
"bcrypt": "^5.1.1",
|
"bcrypt": "5.1.1",
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
"bluebird": "^3.7.2",
|
"bluebird": "^3.7.2",
|
||||||
"bull": "^4.16.3",
|
"bull": "^4.16.3",
|
||||||
|
|||||||
@@ -95,6 +95,14 @@ export const events = {
|
|||||||
onActivated: 'onAccountActivated',
|
onActivated: 'onAccountActivated',
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Contacts service.
|
||||||
|
*/
|
||||||
|
contacts: {
|
||||||
|
onActivated: 'onContactActivated',
|
||||||
|
onInactivated: 'onContactInactivated',
|
||||||
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Manual journals service.
|
* Manual journals service.
|
||||||
*/
|
*/
|
||||||
|
|||||||
+17
@@ -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');
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -147,6 +147,8 @@ export interface IAccountEventDeletePayload {
|
|||||||
export interface IAccountEventActivatedPayload {
|
export interface IAccountEventActivatedPayload {
|
||||||
tenantId: number;
|
tenantId: number;
|
||||||
accountId: number;
|
accountId: number;
|
||||||
|
activate: boolean;
|
||||||
|
account: IAccount;
|
||||||
trx: Knex.Transaction;
|
trx: Knex.Transaction;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -155,6 +155,18 @@ export interface IItemEventDeletedPayload {
|
|||||||
trx: Knex.Transaction;
|
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 {
|
export enum ItemAction {
|
||||||
CREATE = 'Create',
|
CREATE = 'Create',
|
||||||
EDIT = 'Edit',
|
EDIT = 'Edit',
|
||||||
|
|||||||
@@ -67,6 +67,8 @@ export interface IAccountEventDeletePayload {
|
|||||||
|
|
||||||
export interface IAccountEventActivatedPayload {
|
export interface IAccountEventActivatedPayload {
|
||||||
accountId: number;
|
accountId: number;
|
||||||
|
activate: boolean;
|
||||||
|
account: Account;
|
||||||
trx: Knex.Transaction;
|
trx: Knex.Transaction;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -53,6 +53,8 @@ export class ActivateAccount {
|
|||||||
// Triggers `onAccountActivated` event.
|
// Triggers `onAccountActivated` event.
|
||||||
this.eventEmitter.emitAsync(events.accounts.onActivated, {
|
this.eventEmitter.emitAsync(events.accounts.onActivated, {
|
||||||
accountId,
|
accountId,
|
||||||
|
activate,
|
||||||
|
account: oldAccount,
|
||||||
trx,
|
trx,
|
||||||
} as IAccountEventActivatedPayload);
|
} as IAccountEventActivatedPayload);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -102,6 +102,7 @@ import { BankingCategorizeModule } from '../BankingCategorize/BankingCategorize.
|
|||||||
import { TenantModelsInitializeModule } from '../Tenancy/TenantModelsInitialize.module';
|
import { TenantModelsInitializeModule } from '../Tenancy/TenantModelsInitialize.module';
|
||||||
import { BillLandedCostsModule } from '../BillLandedCosts/BillLandedCosts.module';
|
import { BillLandedCostsModule } from '../BillLandedCosts/BillLandedCosts.module';
|
||||||
import { SocketModule } from '../Socket/Socket.module';
|
import { SocketModule } from '../Socket/Socket.module';
|
||||||
|
import { EEModule } from '../EE/EE.module';
|
||||||
import { ThrottlerGuard } from '@nestjs/throttler';
|
import { ThrottlerGuard } from '@nestjs/throttler';
|
||||||
import { AppThrottleModule } from './AppThrottle.module';
|
import { AppThrottleModule } from './AppThrottle.module';
|
||||||
|
|
||||||
@@ -256,6 +257,7 @@ import { AppThrottleModule } from './AppThrottle.module';
|
|||||||
UsersModule,
|
UsersModule,
|
||||||
ContactsModule,
|
ContactsModule,
|
||||||
SocketModule,
|
SocketModule,
|
||||||
|
EEModule,
|
||||||
],
|
],
|
||||||
controllers: [AppController],
|
controllers: [AppController],
|
||||||
providers: [
|
providers: [
|
||||||
|
|||||||
@@ -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<string, unknown> | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AuditLogService {
|
||||||
|
constructor(
|
||||||
|
private readonly cls: ClsService,
|
||||||
|
@Inject(AuditLog.name)
|
||||||
|
private readonly auditLogModel: TenantModelProxy<typeof AuditLog>,
|
||||||
|
@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<void> {
|
||||||
|
const userId = this.cls.get<number>('userId') ?? null;
|
||||||
|
const ip = (this.cls.get<string>('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<string, unknown> | null | undefined,
|
||||||
|
): Record<string, unknown> | 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' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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<string, unknown> | 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' },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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, any>): string | null => {
|
||||||
|
if (!item.tenantUser) return null;
|
||||||
|
const u = item.tenantUser as Record<string, string>;
|
||||||
|
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, any>): string | null => {
|
||||||
|
if (!item.tenantUser) return null;
|
||||||
|
const u = item.tenantUser as Record<string, string>;
|
||||||
|
const email =
|
||||||
|
u.email || u.emailAddress || u.email_address || '';
|
||||||
|
return email || null;
|
||||||
|
};
|
||||||
|
|
||||||
|
protected action = (item: Record<string, any>): string => {
|
||||||
|
return formatAction(item.action, this.context.i18n.t.bind(this.context.i18n));
|
||||||
|
};
|
||||||
|
|
||||||
|
protected subject = (item: Record<string, any>): string => {
|
||||||
|
return formatSubject(item.subject, this.context.i18n.t.bind(this.context.i18n));
|
||||||
|
};
|
||||||
|
|
||||||
|
protected summary = (item: Record<string, any>): string => {
|
||||||
|
return formatMetadataSummary(
|
||||||
|
item.metadata,
|
||||||
|
item.subject,
|
||||||
|
this.context.i18n.t.bind(this.context.i18n),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
protected createdAt = (item: Record<string, any>): string => {
|
||||||
|
const raw = item.createdAt;
|
||||||
|
if (typeof raw === 'string') return raw;
|
||||||
|
return (raw as Date)?.toISOString?.() ?? String(raw);
|
||||||
|
};
|
||||||
|
|
||||||
|
protected createdAtFormatted = (item: Record<string, any>): string => {
|
||||||
|
const createdAtStr = this.createdAt(item);
|
||||||
|
return moment(createdAtStr).format('YYYY-MM-DD HH:mm:ss');
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,374 @@
|
|||||||
|
export type TranslateFn = (key: string, options?: { args?: Record<string, any> }) => 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<string, unknown> | null,
|
||||||
|
subject: string,
|
||||||
|
t: TranslateFn = defaultT,
|
||||||
|
): string {
|
||||||
|
if (metadata == null) return '';
|
||||||
|
|
||||||
|
const formatters: Record<string, (m: Record<string, unknown>) => 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(', ');
|
||||||
|
}
|
||||||
@@ -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<string, unknown> | null;
|
||||||
|
summary: string;
|
||||||
|
ip: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
createdAtFormatted: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class GetAuditLogsService {
|
||||||
|
constructor(
|
||||||
|
@Inject(AuditLog.name)
|
||||||
|
private readonly auditLogModel: TenantModelProxy<typeof AuditLog>,
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<string, unknown>,
|
||||||
|
) {
|
||||||
|
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<string, unknown> = { 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 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export enum AuditLogAction {
|
||||||
|
View = 'View',
|
||||||
|
}
|
||||||
@@ -20,6 +20,7 @@ export class UncategorizedBankTransaction extends TenantBaseModel {
|
|||||||
readonly pending: boolean;
|
readonly pending: boolean;
|
||||||
readonly categorizeRefId!: number;
|
readonly categorizeRefId!: number;
|
||||||
readonly categorizeRefType!: string;
|
readonly categorizeRefType!: string;
|
||||||
|
readonly currencyCode!: string;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Table name.
|
* Table name.
|
||||||
|
|||||||
+2
@@ -47,6 +47,7 @@ export class ExcludeBankTransactionService {
|
|||||||
return this.uow.withTransaction(async (trx: Knex.Transaction) => {
|
return this.uow.withTransaction(async (trx: Knex.Transaction) => {
|
||||||
await this.eventEmitter.emitAsync(events.bankTransactions.onExcluding, {
|
await this.eventEmitter.emitAsync(events.bankTransactions.onExcluding, {
|
||||||
uncategorizedTransactionId,
|
uncategorizedTransactionId,
|
||||||
|
uncategorizedTransaction: oldUncategorizedTransaction,
|
||||||
trx,
|
trx,
|
||||||
} as IBankTransactionUnexcludingEventPayload);
|
} as IBankTransactionUnexcludingEventPayload);
|
||||||
|
|
||||||
@@ -59,6 +60,7 @@ export class ExcludeBankTransactionService {
|
|||||||
|
|
||||||
await this.eventEmitter.emitAsync(events.bankTransactions.onExcluded, {
|
await this.eventEmitter.emitAsync(events.bankTransactions.onExcluded, {
|
||||||
uncategorizedTransactionId,
|
uncategorizedTransactionId,
|
||||||
|
uncategorizedTransaction: oldUncategorizedTransaction,
|
||||||
trx,
|
trx,
|
||||||
} as IBankTransactionUnexcludedEventPayload);
|
} as IBankTransactionUnexcludedEventPayload);
|
||||||
});
|
});
|
||||||
|
|||||||
+2
@@ -50,6 +50,7 @@ export class UnexcludeBankTransactionService {
|
|||||||
return this.uow.withTransaction(async (trx: Knex.Transaction) => {
|
return this.uow.withTransaction(async (trx: Knex.Transaction) => {
|
||||||
await this.eventEmitter.emitAsync(events.bankTransactions.onUnexcluding, {
|
await this.eventEmitter.emitAsync(events.bankTransactions.onUnexcluding, {
|
||||||
uncategorizedTransactionId,
|
uncategorizedTransactionId,
|
||||||
|
uncategorizedTransaction: oldUncategorizedTransaction,
|
||||||
trx,
|
trx,
|
||||||
} as IBankTransactionUnexcludingEventPayload);
|
} as IBankTransactionUnexcludingEventPayload);
|
||||||
|
|
||||||
@@ -62,6 +63,7 @@ export class UnexcludeBankTransactionService {
|
|||||||
|
|
||||||
await this.eventEmitter.emitAsync(events.bankTransactions.onUnexcluded, {
|
await this.eventEmitter.emitAsync(events.bankTransactions.onUnexcluded, {
|
||||||
uncategorizedTransactionId,
|
uncategorizedTransactionId,
|
||||||
|
uncategorizedTransaction: oldUncategorizedTransaction,
|
||||||
trx,
|
trx,
|
||||||
} as IBankTransactionUnexcludedEventPayload);
|
} as IBankTransactionUnexcludedEventPayload);
|
||||||
});
|
});
|
||||||
|
|||||||
+4
@@ -1,4 +1,5 @@
|
|||||||
import { Knex } from "knex";
|
import { Knex } from "knex";
|
||||||
|
import { UncategorizedBankTransaction } from "@/modules/BankingTransactions/models/UncategorizedBankTransaction";
|
||||||
|
|
||||||
export interface ExcludedBankTransactionsQuery {
|
export interface ExcludedBankTransactionsQuery {
|
||||||
page?: number;
|
page?: number;
|
||||||
@@ -17,14 +18,17 @@ export interface IBankTransactionUnexcludingEventPayload {
|
|||||||
|
|
||||||
export interface IBankTransactionUnexcludedEventPayload {
|
export interface IBankTransactionUnexcludedEventPayload {
|
||||||
uncategorizedTransactionId: number;
|
uncategorizedTransactionId: number;
|
||||||
|
uncategorizedTransaction?: UncategorizedBankTransaction;
|
||||||
trx?: Knex.Transaction
|
trx?: Knex.Transaction
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IBankTransactionExcludingEventPayload {
|
export interface IBankTransactionExcludingEventPayload {
|
||||||
uncategorizedTransactionId: number;
|
uncategorizedTransactionId: number;
|
||||||
|
uncategorizedTransaction?: UncategorizedBankTransaction;
|
||||||
trx?: Knex.Transaction
|
trx?: Knex.Transaction
|
||||||
}
|
}
|
||||||
export interface IBankTransactionExcludedEventPayload {
|
export interface IBankTransactionExcludedEventPayload {
|
||||||
uncategorizedTransactionId: number;
|
uncategorizedTransactionId: number;
|
||||||
|
uncategorizedTransaction?: UncategorizedBankTransaction;
|
||||||
trx?: Knex.Transaction
|
trx?: Knex.Transaction
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,13 +2,17 @@ import { ServiceError } from '@/modules/Items/ServiceError';
|
|||||||
import { Contact } from '../models/Contact';
|
import { Contact } from '../models/Contact';
|
||||||
import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel';
|
import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel';
|
||||||
import { Inject, Injectable } from '@nestjs/common';
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
|
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||||
import { ERRORS } from '../Contacts.constants';
|
import { ERRORS } from '../Contacts.constants';
|
||||||
|
import { events } from '@/common/events/events';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ActivateContactService {
|
export class ActivateContactService {
|
||||||
constructor(
|
constructor(
|
||||||
@Inject(Contact.name)
|
@Inject(Contact.name)
|
||||||
private readonly contactModel: TenantModelProxy<typeof Contact>,
|
private readonly contactModel: TenantModelProxy<typeof Contact>,
|
||||||
|
|
||||||
|
private readonly eventEmitter: EventEmitter2,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async activateContact(contactId: number) {
|
async activateContact(contactId: number) {
|
||||||
@@ -24,5 +28,11 @@ export class ActivateContactService {
|
|||||||
.query()
|
.query()
|
||||||
.findById(contactId)
|
.findById(contactId)
|
||||||
.update({ active: true });
|
.update({ active: true });
|
||||||
|
|
||||||
|
// Triggers `onContactActivated` event.
|
||||||
|
await this.eventEmitter.emitAsync(events.contacts.onActivated, {
|
||||||
|
contactId,
|
||||||
|
contact,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,12 +3,16 @@ import { ServiceError } from '@/modules/Items/ServiceError';
|
|||||||
import { Contact } from '../models/Contact';
|
import { Contact } from '../models/Contact';
|
||||||
import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel';
|
import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel';
|
||||||
import { ERRORS } from '../Contacts.constants';
|
import { ERRORS } from '../Contacts.constants';
|
||||||
|
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||||
|
import { events } from '@/common/events/events';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class InactivateContactService {
|
export class InactivateContactService {
|
||||||
constructor(
|
constructor(
|
||||||
@Inject(Contact.name)
|
@Inject(Contact.name)
|
||||||
private readonly contactModel: TenantModelProxy<typeof Contact>,
|
private readonly contactModel: TenantModelProxy<typeof Contact>,
|
||||||
|
|
||||||
|
private readonly eventEmitter: EventEmitter2,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async inactivateContact(contactId: number) {
|
async inactivateContact(contactId: number) {
|
||||||
@@ -24,5 +28,11 @@ export class InactivateContactService {
|
|||||||
.query()
|
.query()
|
||||||
.findById(contactId)
|
.findById(contactId)
|
||||||
.update({ active: false });
|
.update({ active: false });
|
||||||
|
|
||||||
|
// Triggers `onContactInactivated` event.
|
||||||
|
await this.eventEmitter.emitAsync(events.contacts.onInactivated, {
|
||||||
|
contactId,
|
||||||
|
contact,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<string, unknown> | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AuditLogService {
|
||||||
|
constructor(
|
||||||
|
private readonly cls: ClsService,
|
||||||
|
@Inject(AuditLog.name)
|
||||||
|
private readonly auditLogModel: TenantModelProxy<typeof AuditLog>,
|
||||||
|
@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<void> {
|
||||||
|
const userId = this.cls.get<number>('userId') ?? null;
|
||||||
|
const ip = (this.cls.get<string>('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<string, unknown> | null | undefined,
|
||||||
|
): Record<string, unknown> | 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' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {}
|
||||||
@@ -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[];
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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<string, unknown> | 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;
|
||||||
|
}
|
||||||
@@ -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<string, unknown> | 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' },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<typeof AuditLog>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async getFilterOptions(): Promise<AuditLogFilterOptions> {
|
||||||
|
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) })),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<string, unknown> | null;
|
||||||
|
summary: string;
|
||||||
|
ip: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
createdAtFormatted: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class GetAuditLogsService {
|
||||||
|
constructor(
|
||||||
|
@Inject(AuditLog.name)
|
||||||
|
private readonly auditLogModel: TenantModelProxy<typeof AuditLog>,
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
+1180
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,3 @@
|
|||||||
|
export enum AuditLogAction {
|
||||||
|
View = 'View',
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { AuditLogsModule } from './AuditLogs/AuditLogs.module';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [AuditLogsModule],
|
||||||
|
exports: [AuditLogsModule],
|
||||||
|
})
|
||||||
|
export class EEModule {}
|
||||||
@@ -4,6 +4,7 @@ import { Inject, Injectable } from '@nestjs/common';
|
|||||||
import { Item } from './models/Item';
|
import { Item } from './models/Item';
|
||||||
import { UnitOfWork } from '../Tenancy/TenancyDB/UnitOfWork.service';
|
import { UnitOfWork } from '../Tenancy/TenancyDB/UnitOfWork.service';
|
||||||
import { events } from '@/common/events/events';
|
import { events } from '@/common/events/events';
|
||||||
|
import { IItemEventActivatedPayload } from '@/interfaces/Item';
|
||||||
import { TenantModelProxy } from '../System/models/TenantBaseModel';
|
import { TenantModelProxy } from '../System/models/TenantBaseModel';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -39,7 +40,11 @@ export class ActivateItemService {
|
|||||||
.patch({ active: true });
|
.patch({ active: true });
|
||||||
|
|
||||||
// Triggers `onItemActivated` event.
|
// Triggers `onItemActivated` event.
|
||||||
await this.eventEmitter.emitAsync(events.item.onActivated, {});
|
await this.eventEmitter.emitAsync(events.item.onActivated, {
|
||||||
|
itemId,
|
||||||
|
item: oldItem,
|
||||||
|
trx,
|
||||||
|
} as IItemEventActivatedPayload);
|
||||||
}, trx);
|
}, trx);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Knex } from 'knex';
|
|||||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||||
import { Item } from './models/Item';
|
import { Item } from './models/Item';
|
||||||
import { events } from '@/common/events/events';
|
import { events } from '@/common/events/events';
|
||||||
|
import { IItemEventInactivatedPayload } from '@/interfaces/Item';
|
||||||
import { UnitOfWork } from '../Tenancy/TenancyDB/UnitOfWork.service';
|
import { UnitOfWork } from '../Tenancy/TenancyDB/UnitOfWork.service';
|
||||||
import { TenantModelProxy } from '../System/models/TenantBaseModel';
|
import { TenantModelProxy } from '../System/models/TenantBaseModel';
|
||||||
|
|
||||||
@@ -38,7 +39,11 @@ export class InactivateItem {
|
|||||||
.patch({ active: false });
|
.patch({ active: false });
|
||||||
|
|
||||||
// Triggers `onItemInactivated` event.
|
// 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);
|
}, trx);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { BillAction } from "../Bills/Bills.types";
|
|||||||
import { AbilitySubject, ISubjectAbilitiesSchema, ISubjectAbilitySchema } from "./Roles.types";
|
import { AbilitySubject, ISubjectAbilitiesSchema, ISubjectAbilitySchema } from "./Roles.types";
|
||||||
import { PaymentReceiveAction } from "../PaymentReceived/types/PaymentReceived.types";
|
import { PaymentReceiveAction } from "../PaymentReceived/types/PaymentReceived.types";
|
||||||
import { PreferencesAction } from "../Settings/Settings.types";
|
import { PreferencesAction } from "../Settings/Settings.types";
|
||||||
|
import { AuditLogAction } from "../EE/AuditLogs/types/AuditLogs.types";
|
||||||
|
|
||||||
export const AbilitySchema: ISubjectAbilitiesSchema[] = [
|
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' },
|
||||||
|
],
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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<RequiredPermission>(
|
||||||
|
REQUIRED_PERMISSION_KEY,
|
||||||
|
[context.getHandler(), context.getClass()],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!requiredPermission) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const request = context.switchToHttp().getRequest<Request>();
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
@@ -60,7 +60,11 @@ export enum AbilitySubject {
|
|||||||
CreditNote = 'CreditNode',
|
CreditNote = 'CreditNode',
|
||||||
VendorCredit = 'VendorCredit',
|
VendorCredit = 'VendorCredit',
|
||||||
Project = 'Project',
|
Project = 'Project',
|
||||||
TaxRate = 'TaxRate'
|
TaxRate = 'TaxRate',
|
||||||
|
AuditLog = 'AuditLog',
|
||||||
|
Role = 'Role',
|
||||||
|
Warehouse = 'Warehouse',
|
||||||
|
Branch = 'Branch',
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IRoleCreatedPayload {
|
export interface IRoleCreatedPayload {
|
||||||
|
|||||||
@@ -3,9 +3,9 @@
|
|||||||
"version": "0.10.2",
|
"version": "0.10.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@bigcapital/email-components": "*",
|
"@bigcapital/email-components": "workspace:*",
|
||||||
"@bigcapital/pdf-templates": "*",
|
"@bigcapital/pdf-templates": "workspace:*",
|
||||||
"@bigcapital/utils": "*",
|
"@bigcapital/utils": "workspace:*",
|
||||||
"@blueprintjs-formik/core": "^0.3.7",
|
"@blueprintjs-formik/core": "^0.3.7",
|
||||||
"@blueprintjs-formik/datetime": "^0.4.0",
|
"@blueprintjs-formik/datetime": "^0.4.0",
|
||||||
"@blueprintjs-formik/select": "^0.4.5",
|
"@blueprintjs-formik/select": "^0.4.5",
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ export function FinancialSheet({
|
|||||||
() => getBasisLabel(basis),
|
() => getBasisLabel(basis),
|
||||||
[getBasisLabel, basis],
|
[getBasisLabel, basis],
|
||||||
);
|
);
|
||||||
|
const hasHead = companyName || sheetType || dateText;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FinancialSheetRoot
|
<FinancialSheetRoot
|
||||||
@@ -51,10 +52,13 @@ export function FinancialSheet({
|
|||||||
fullWidth={fullWidth}
|
fullWidth={fullWidth}
|
||||||
className={className}
|
className={className}
|
||||||
>
|
>
|
||||||
{companyName && <FinancialSheetTitle>{companyName}</FinancialSheetTitle>}
|
{hasHead && (
|
||||||
{sheetType && <FinancialSheetType>{sheetType}</FinancialSheetType>}
|
<div>
|
||||||
|
{companyName && <FinancialSheetTitle>{companyName}</FinancialSheetTitle>}
|
||||||
{dateText && <FinancialSheetDate>{dateText}</FinancialSheetDate>}
|
{sheetType && <FinancialSheetType>{sheetType}</FinancialSheetType>}
|
||||||
|
{dateText && <FinancialSheetDate>{dateText}</FinancialSheetDate>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<FinancialSheetTable>{children}</FinancialSheetTable>
|
<FinancialSheetTable>{children}</FinancialSheetTable>
|
||||||
<FinancialSheetAccountingBasis>
|
<FinancialSheetAccountingBasis>
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export const FinancialSheetRoot = styled.div`
|
|||||||
min-height: 400px;
|
min-height: 400px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
gap: 24px;
|
||||||
|
|
||||||
${(props) =>
|
${(props) =>
|
||||||
props.fullWidth &&
|
props.fullWidth &&
|
||||||
@@ -73,9 +74,7 @@ export const FinancialSheetFooter = styled.div`
|
|||||||
padding-left: 10px;
|
padding-left: 10px;
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
export const FinancialSheetTable = styled.div`
|
export const FinancialSheetTable = styled.div``;
|
||||||
margin-top: 24px;
|
|
||||||
`;
|
|
||||||
export const FinancialSheetFooterBasis = styled.span``;
|
export const FinancialSheetFooterBasis = styled.span``;
|
||||||
export const FinancialSheetFooterCurrentTime = styled.span``;
|
export const FinancialSheetFooterCurrentTime = styled.span``;
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ export const AbilitySubject = {
|
|||||||
Project: 'Project',
|
Project: 'Project',
|
||||||
TaxRate: 'TaxRate',
|
TaxRate: 'TaxRate',
|
||||||
BankRule: 'BankRule',
|
BankRule: 'BankRule',
|
||||||
|
AuditLog: 'AuditLog',
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ItemAction = {
|
export const ItemAction = {
|
||||||
@@ -202,3 +203,7 @@ export const BankRuleAction = {
|
|||||||
Edit: 'Edit',
|
Edit: 'Edit',
|
||||||
Delete: 'Delete',
|
Delete: 'Delete',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const AuditLogAction = {
|
||||||
|
View: 'View',
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// @ts-nocheck
|
// @ts-nocheck
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { FormattedMessage as T } from '@/components';
|
import { FormattedMessage as T } from '@/components';
|
||||||
import { ReportsAction, AbilitySubject } from '@/constants/abilityOption';
|
import { ReportsAction, AbilitySubject, AuditLogAction } from '@/constants/abilityOption';
|
||||||
|
|
||||||
export const financialReportMenus = [
|
export const financialReportMenus = [
|
||||||
{
|
{
|
||||||
@@ -194,4 +194,16 @@ export const financialReportMenus = [
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
sectionTitle: <T id={'system_reports'} />,
|
||||||
|
reports: [
|
||||||
|
{
|
||||||
|
title: <T id={'audit_log_report'} />,
|
||||||
|
desc: <T id={'audit_log_report_desc'} />,
|
||||||
|
link: '/financial-reports/audit-log',
|
||||||
|
subject: AbilitySubject.AuditLog,
|
||||||
|
ability: AuditLogAction.View,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<DashboardActionsBar>
|
||||||
|
<NavbarGroup>
|
||||||
|
<Button
|
||||||
|
className={classNames(Classes.MINIMAL)}
|
||||||
|
text={"Reload"}
|
||||||
|
onClick={handleRecalcReport}
|
||||||
|
icon={<Icon icon="refresh-16" iconSize={16} />}
|
||||||
|
/>
|
||||||
|
<NavbarDivider />
|
||||||
|
<Button
|
||||||
|
className={classNames(Classes.MINIMAL)}
|
||||||
|
icon={<Icon icon="cog-16" iconSize={16} />}
|
||||||
|
text={"Filter"}
|
||||||
|
onClick={handleCustomizeClick}
|
||||||
|
active={isFilterDrawerOpen}
|
||||||
|
/>
|
||||||
|
</NavbarGroup>
|
||||||
|
</DashboardActionsBar>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AuditLogActionsBar;
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
// @ts-nocheck
|
||||||
|
import React from 'react';
|
||||||
|
import { Spinner } from '@blueprintjs/core';
|
||||||
|
import { FinancialReportBody } from '../FinancialReportPage';
|
||||||
|
import { useAuditLogContext } from './AuditLogProvider';
|
||||||
|
import AuditLogTable from './AuditLogTable';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Audit Log Body
|
||||||
|
*/
|
||||||
|
function AuditLogBody() {
|
||||||
|
const { isLoading } = useAuditLogContext();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FinancialReportBody>
|
||||||
|
{isLoading ? (
|
||||||
|
<div style={{ padding: 20 }}>
|
||||||
|
<Spinner size={24} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<AuditLogTable />
|
||||||
|
)}
|
||||||
|
</FinancialReportBody>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { AuditLogBody };
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
// @ts-nocheck
|
||||||
|
import React, { useMemo } from 'react';
|
||||||
|
import intl from 'react-intl-universal';
|
||||||
|
import moment from 'moment';
|
||||||
|
import { Button, Tabs, Tab, DrawerSize, Position } from '@blueprintjs/core';
|
||||||
|
import styled from 'styled-components';
|
||||||
|
import { Formik, Form } from 'formik';
|
||||||
|
import {
|
||||||
|
FormattedMessage as T,
|
||||||
|
FFormGroup,
|
||||||
|
FDateInput,
|
||||||
|
} from '@/components';
|
||||||
|
import { FMultiSelect } from '@/components/Forms';
|
||||||
|
import { useAuditLogFilterOptionsQuery } from '@/hooks/query';
|
||||||
|
import { saveInvoke, transformToForm } from '@/utils';
|
||||||
|
import FinancialStatementHeader from '../FinancialStatementHeader';
|
||||||
|
import { getDefaultAuditLogQuery, getAuditLogQuerySchema } from './common';
|
||||||
|
|
||||||
|
function normalizeStringListField(value) {
|
||||||
|
return Array.isArray(value) ? value : value ? [value] : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const auditLogSelectItemPredicate = (query, item) => {
|
||||||
|
const q = (query || '').toLowerCase();
|
||||||
|
const name = (item?.name ?? '').toLowerCase();
|
||||||
|
return name.includes(q);
|
||||||
|
};
|
||||||
|
|
||||||
|
const AuditLogDrawerHeader = styled(FinancialStatementHeader)`
|
||||||
|
.bp4-drawer {
|
||||||
|
max-height: 350px;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Audit Log Header - Filter drawer
|
||||||
|
*/
|
||||||
|
function AuditLogHeader({ onSubmitFilter, pageFilter, isFilterDrawerOpen, toggleFilterDrawer }) {
|
||||||
|
const { data: filterOptions, isLoading: isFilterOptionsLoading } =
|
||||||
|
useAuditLogFilterOptionsQuery({
|
||||||
|
enabled: isFilterDrawerOpen,
|
||||||
|
});
|
||||||
|
|
||||||
|
const subjectSelectItems = useMemo(() => {
|
||||||
|
const byValue = new Map();
|
||||||
|
for (const s of filterOptions.subjects ?? []) {
|
||||||
|
byValue.set(s.key, { value: s.key, name: s.label });
|
||||||
|
}
|
||||||
|
for (const s of normalizeStringListField(pageFilter.subject)) {
|
||||||
|
if (s && !byValue.has(s)) {
|
||||||
|
byValue.set(s, { value: s, name: s });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Array.from(byValue.values()).sort((a, b) =>
|
||||||
|
a.name.localeCompare(b.name),
|
||||||
|
);
|
||||||
|
}, [filterOptions.subjects, pageFilter.subject]);
|
||||||
|
|
||||||
|
const actionSelectItems = useMemo(() => {
|
||||||
|
const byValue = new Map();
|
||||||
|
for (const a of filterOptions.actions ?? []) {
|
||||||
|
byValue.set(a.key, { value: a.key, name: a.label });
|
||||||
|
}
|
||||||
|
for (const act of normalizeStringListField(pageFilter.action)) {
|
||||||
|
if (act && !byValue.has(act)) {
|
||||||
|
byValue.set(act, { value: act, name: act });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Array.from(byValue.values()).sort((a, b) =>
|
||||||
|
a.name.localeCompare(b.name),
|
||||||
|
);
|
||||||
|
}, [filterOptions.actions, pageFilter.action]);
|
||||||
|
|
||||||
|
const defaultValues = getDefaultAuditLogQuery();
|
||||||
|
|
||||||
|
const initialValues = transformToForm(
|
||||||
|
{
|
||||||
|
...defaultValues,
|
||||||
|
...pageFilter,
|
||||||
|
fromDate: pageFilter.fromDate ? moment(pageFilter.fromDate).toDate() : '',
|
||||||
|
toDate: pageFilter.toDate ? moment(pageFilter.toDate).toDate() : '',
|
||||||
|
},
|
||||||
|
defaultValues
|
||||||
|
);
|
||||||
|
|
||||||
|
const validationSchema = getAuditLogQuerySchema();
|
||||||
|
|
||||||
|
const handleSubmit = (values, { setSubmitting }) => {
|
||||||
|
const parsedFilter = {
|
||||||
|
...values,
|
||||||
|
subject: normalizeStringListField(values.subject),
|
||||||
|
action: normalizeStringListField(values.action),
|
||||||
|
fromDate: values.fromDate ? moment(values.fromDate).format('YYYY-MM-DD') : '',
|
||||||
|
toDate: values.toDate ? moment(values.toDate).format('YYYY-MM-DD') : '',
|
||||||
|
};
|
||||||
|
saveInvoke(onSubmitFilter, parsedFilter);
|
||||||
|
toggleFilterDrawer(false);
|
||||||
|
setSubmitting(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancelClick = () => {
|
||||||
|
toggleFilterDrawer(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDrawerClose = () => {
|
||||||
|
toggleFilterDrawer(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuditLogDrawerHeader
|
||||||
|
isOpen={isFilterDrawerOpen}
|
||||||
|
drawerProps={{ onClose: handleDrawerClose }}
|
||||||
|
>
|
||||||
|
<Formik
|
||||||
|
validationSchema={validationSchema}
|
||||||
|
initialValues={initialValues}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
>
|
||||||
|
<Form>
|
||||||
|
<Tabs animate={true} vertical={true} renderActiveTabPanelOnly={true}>
|
||||||
|
<Tab
|
||||||
|
id="general"
|
||||||
|
title={<T id={'general'} />}
|
||||||
|
panel={
|
||||||
|
<div style={{ maxWidth: '400px' }}>
|
||||||
|
<FFormGroup
|
||||||
|
name="subject"
|
||||||
|
label={intl.get('audit_log.filter_subject')}
|
||||||
|
fastField
|
||||||
|
>
|
||||||
|
<FMultiSelect
|
||||||
|
name="subject"
|
||||||
|
items={subjectSelectItems}
|
||||||
|
valueAccessor="value"
|
||||||
|
textAccessor="name"
|
||||||
|
tagAccessor="name"
|
||||||
|
itemPredicate={auditLogSelectItemPredicate}
|
||||||
|
placeholder={intl.get('all')}
|
||||||
|
popoverProps={{ minimal: true }}
|
||||||
|
disabled={isFilterOptionsLoading}
|
||||||
|
fill
|
||||||
|
resetOnSelect
|
||||||
|
fastField
|
||||||
|
/>
|
||||||
|
</FFormGroup>
|
||||||
|
|
||||||
|
<FFormGroup
|
||||||
|
name="action"
|
||||||
|
label={intl.get('audit_log.filter_action')}
|
||||||
|
fastField
|
||||||
|
>
|
||||||
|
<FMultiSelect
|
||||||
|
name="action"
|
||||||
|
items={actionSelectItems}
|
||||||
|
valueAccessor="value"
|
||||||
|
textAccessor="name"
|
||||||
|
tagAccessor="name"
|
||||||
|
itemPredicate={auditLogSelectItemPredicate}
|
||||||
|
placeholder={intl.get('all')}
|
||||||
|
popoverProps={{ minimal: true }}
|
||||||
|
disabled={isFilterOptionsLoading}
|
||||||
|
fill
|
||||||
|
resetOnSelect
|
||||||
|
fastField
|
||||||
|
/>
|
||||||
|
</FFormGroup>
|
||||||
|
|
||||||
|
<FFormGroup
|
||||||
|
name="fromDate"
|
||||||
|
label={intl.get('audit_log.filter_from')}
|
||||||
|
fastField
|
||||||
|
>
|
||||||
|
<FDateInput
|
||||||
|
name="fromDate"
|
||||||
|
popoverProps={{ position: Position.BOTTOM, minimal: true }}
|
||||||
|
formatDate={(date) => date.toLocaleDateString()}
|
||||||
|
parseDate={(str) => new Date(str)}
|
||||||
|
inputProps={{ fill: true }}
|
||||||
|
fastField
|
||||||
|
/>
|
||||||
|
</FFormGroup>
|
||||||
|
|
||||||
|
<FFormGroup
|
||||||
|
name="toDate"
|
||||||
|
label={intl.get('audit_log.filter_to')}
|
||||||
|
fill
|
||||||
|
fastField
|
||||||
|
>
|
||||||
|
<FDateInput
|
||||||
|
name="toDate"
|
||||||
|
type="date"
|
||||||
|
popoverProps={{ position: Position.BOTTOM, minimal: true }}
|
||||||
|
formatDate={(date) => date.toLocaleDateString()}
|
||||||
|
parseDate={(str) => new Date(str)}
|
||||||
|
inputProps={{ fill: true }}
|
||||||
|
fastField
|
||||||
|
/>
|
||||||
|
</FFormGroup>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
<div className="financial-header-drawer__footer">
|
||||||
|
<Button className={'mr1'} intent="primary" type="submit">
|
||||||
|
<T id={'calculate_report'} />
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleCancelClick} minimal={true}>
|
||||||
|
<T id={'cancel'} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Form>
|
||||||
|
</Formik>
|
||||||
|
</AuditLogDrawerHeader>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AuditLogHeader;
|
||||||
@@ -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 (
|
||||||
|
<AuditLogContext.Provider value={provider}>
|
||||||
|
{children}
|
||||||
|
<IntersectionObserver
|
||||||
|
onIntersect={handleObserverInteract}
|
||||||
|
/>
|
||||||
|
</AuditLogContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { AuditLogProvider, useAuditLogContext };
|
||||||
@@ -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 (
|
||||||
|
<AuditLogProvider query={query}>
|
||||||
|
<AuditLogActionsBar
|
||||||
|
isFilterDrawerOpen={isFilterDrawerOpen}
|
||||||
|
toggleFilterDrawer={toggleFilterDrawer}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DashboardPageContent>
|
||||||
|
<FinancialStatement>
|
||||||
|
<AuditLogHeader
|
||||||
|
pageFilter={query}
|
||||||
|
onSubmitFilter={handleFilterSubmit}
|
||||||
|
isFilterDrawerOpen={isFilterDrawerOpen}
|
||||||
|
toggleFilterDrawer={toggleFilterDrawer}
|
||||||
|
/>
|
||||||
|
<AuditLogLoadingBar />
|
||||||
|
<AuditLogBody />
|
||||||
|
</FinancialStatement>
|
||||||
|
</DashboardPageContent>
|
||||||
|
</AuditLogProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Audit Log Report page (in Financial Reports section).
|
||||||
|
*/
|
||||||
|
function AuditLogReport() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Can I={AuditLogAction.View} a={AbilitySubject.AuditLog}>
|
||||||
|
<AuditLogReportContent />
|
||||||
|
</Can>
|
||||||
|
|
||||||
|
<Can not I={AuditLogAction.View} a={AbilitySubject.AuditLog}>
|
||||||
|
<DashboardPageContent>
|
||||||
|
<Card style={{ padding: 20 }}>
|
||||||
|
<NonIdealState title={intl.get('audit_log.no_access')} />
|
||||||
|
</Card>
|
||||||
|
</DashboardPageContent>
|
||||||
|
</Can>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AuditLogReport;
|
||||||
@@ -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 }) => (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
maxWidth: 330,
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}
|
||||||
|
title={value || ''}
|
||||||
|
>
|
||||||
|
{value || ''}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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 (
|
||||||
|
<FinancialSheet
|
||||||
|
loading={isLoading}
|
||||||
|
fullWidth={true}
|
||||||
|
currentDate={false}
|
||||||
|
>
|
||||||
|
<AuditLogDataTable
|
||||||
|
noResults={intl.get('audit_log.empty')}
|
||||||
|
columns={columns}
|
||||||
|
data={auditLogs}
|
||||||
|
virtualizedRows={true}
|
||||||
|
fixedItemSize={30}
|
||||||
|
fixedSizeHeight={1000}
|
||||||
|
sticky={true}
|
||||||
|
TableRowsRenderer={TableVirtualizedListRows}
|
||||||
|
vListrowHeight={28}
|
||||||
|
vListOverscanRowCount={2}
|
||||||
|
TableCellRenderer={TableFastCell}
|
||||||
|
styleName={TableStyle.Constrant}
|
||||||
|
/>
|
||||||
|
</FinancialSheet>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AuditLogTable;
|
||||||
@@ -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 };
|
||||||
|
};
|
||||||
@@ -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 (
|
||||||
|
<div className={'financial-progressbar'}>
|
||||||
|
<FinancialLoadingBar />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -39,3 +39,4 @@ export * from './warehousesTransfers';
|
|||||||
export * from './plaid';
|
export * from './plaid';
|
||||||
export * from './FinancialReports';
|
export * from './FinancialReports';
|
||||||
export * from './apiKeys';
|
export * from './apiKeys';
|
||||||
|
export * from './auditLogs';
|
||||||
|
|||||||
@@ -124,7 +124,7 @@ export function useActivateItem(props) {
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const apiRequest = useApiRequest();
|
const apiRequest = useApiRequest();
|
||||||
|
|
||||||
return useMutation((id) => apiRequest.post(`items/${id}/activate`), {
|
return useMutation((id) => apiRequest.patch(`items/${id}/activate`), {
|
||||||
onSuccess: (res, id) => {
|
onSuccess: (res, id) => {
|
||||||
// Invalidate specific item.
|
// Invalidate specific item.
|
||||||
queryClient.invalidateQueries([t.ITEM, id]);
|
queryClient.invalidateQueries([t.ITEM, id]);
|
||||||
@@ -143,7 +143,7 @@ export function useInactivateItem(props) {
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const apiRequest = useApiRequest();
|
const apiRequest = useApiRequest();
|
||||||
|
|
||||||
return useMutation((id) => apiRequest.post(`items/${id}/inactivate`), {
|
return useMutation((id) => apiRequest.patch(`items/${id}/inactivate`), {
|
||||||
onSuccess: (res, id) => {
|
onSuccess: (res, id) => {
|
||||||
// Invalidate specific item.
|
// Invalidate specific item.
|
||||||
queryClient.invalidateQueries([t.ITEM, id]);
|
queryClient.invalidateQueries([t.ITEM, id]);
|
||||||
|
|||||||
@@ -245,6 +245,14 @@ export const API_KEYS = {
|
|||||||
API_KEYS: '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 {
|
export default {
|
||||||
...Authentication,
|
...Authentication,
|
||||||
...ACCOUNTS,
|
...ACCOUNTS,
|
||||||
@@ -281,4 +289,6 @@ export default {
|
|||||||
...TAX_RATES,
|
...TAX_RATES,
|
||||||
...EXCHANGE_RATE,
|
...EXCHANGE_RATE,
|
||||||
...API_KEYS,
|
...API_KEYS,
|
||||||
|
...AUDIT_LOGS,
|
||||||
|
...AUDIT_LOG_FILTER_OPTIONS,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -242,6 +242,26 @@
|
|||||||
"new_expenses": "New Expenses",
|
"new_expenses": "New Expenses",
|
||||||
"preferences": "Preferences",
|
"preferences": "Preferences",
|
||||||
"auditing_system": "Auditing System",
|
"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",
|
"all": "All",
|
||||||
"organization": "Organization.",
|
"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.",
|
"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.",
|
||||||
|
|||||||
@@ -496,6 +496,17 @@ export const getDashboardRoutes = () => [
|
|||||||
sidebarExpand: false,
|
sidebarExpand: false,
|
||||||
subscriptionActive: [SUBSCRIPTION_TYPE.MAIN],
|
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',
|
path: '/financial-reports',
|
||||||
component: lazy(
|
component: lazy(
|
||||||
|
|||||||
Reference in New Issue
Block a user