mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
- Implemented read-only locking for customer-requested container sizes and billing currency in the GlCreateBookingForm component. - Added functionality to lock partner quantities based on shipment requests in the ConsolidationPartnerPanel. - Introduced a new Leave action in the LogPassYardWorkModal to unassign bookings from trains. - Enhanced the AuditLogsPage to support filtering by action and added a Go button for direct navigation to entity detail pages. - Updated WagonCancellationsPage to handle odd-20ft credits requiring partner selection during rebooking. - Improved TrainScheduleV2DetailPage to allow manual loading of cargo and display warnings for unassigned bookings. - Added a new reference field to the audit logs for better searchability and tracking of actions. - Created a migration to add the reference column to the audit logs table and established an index for efficient querying. - Defined a registry for audit reference sources to streamline the retrieval of human identifiers for various entities.
114 lines
3.9 KiB
TypeScript
114 lines
3.9 KiB
TypeScript
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
|
|
import { PaginatedResponse } from '@edr/types';
|
|
|
|
import { AuditLog } from './entities/audit-log.entity';
|
|
import { AuditLogRepository } from './audit-log.repository';
|
|
import { AuditLogQueryDto } from './dto/audit-log-query.dto';
|
|
import {
|
|
AUDIT_REFERENCE_SOURCES,
|
|
UUID_PATTERN,
|
|
} from './audit-reference.registry';
|
|
import {
|
|
buildPaginationMeta,
|
|
normalizePagination,
|
|
} from '../../common/utils/pagination.util';
|
|
|
|
@Injectable()
|
|
export class AuditService {
|
|
private readonly logger = new Logger(AuditService.name);
|
|
|
|
constructor(private readonly auditLogRepository: AuditLogRepository) {}
|
|
|
|
/**
|
|
* Persist one audit row, swallowing any failure.
|
|
*
|
|
* An audit write must never turn a successful business action into an error
|
|
* for the user: if this table is full, misconfigured or mid-migration,
|
|
* contract approvals still need to work. Failures are logged so the gap is
|
|
* visible in application logs rather than silent.
|
|
*/
|
|
async record(entry: Partial<AuditLog>): Promise<void> {
|
|
try {
|
|
entry.reference = await this.resolveReference(entry.type, entry.resourceId);
|
|
await this.auditLogRepository.record(entry);
|
|
} catch (error) {
|
|
this.logger.error(
|
|
`Failed to write audit log for ${entry.method} ${entry.routePath}: ${
|
|
error instanceof Error ? error.message : String(error)
|
|
}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Best-effort human identifier (booking reference, train number, …) for the
|
|
* entity the action touched — one primary-key lookup against the table
|
|
* registered for the type. Always returns a string: '' when the type has no
|
|
* registered source, the id isn't a uuid (template codes), the row is gone,
|
|
* or the lookup itself fails. A missing reference must never cost the audit
|
|
* row, so failures degrade to '' rather than throwing.
|
|
*/
|
|
private async resolveReference(
|
|
type: string | undefined,
|
|
resourceId: string | null | undefined,
|
|
): Promise<string> {
|
|
const source = type ? AUDIT_REFERENCE_SOURCES[type] : undefined;
|
|
if (!source || !resourceId || !UUID_PATTERN.test(resourceId)) return '';
|
|
|
|
try {
|
|
const reference = await this.auditLogRepository.lookupReference(source, resourceId);
|
|
return reference?.slice(0, 64) ?? '';
|
|
} catch (error) {
|
|
this.logger.warn(
|
|
`Reference lookup failed for ${type} ${resourceId}: ${
|
|
error instanceof Error ? error.message : String(error)
|
|
}`,
|
|
);
|
|
return '';
|
|
}
|
|
}
|
|
|
|
/** Paginated, filtered audit history, newest first. */
|
|
async search(query: AuditLogQueryDto): Promise<PaginatedResponse<AuditLog>> {
|
|
const { page, pageSize, skip, take } = normalizePagination(query);
|
|
|
|
const from = query.from ? new Date(query.from) : undefined;
|
|
const to = query.to ? new Date(query.to) : undefined;
|
|
|
|
// A reversed range silently returns zero rows, which reads as "nothing
|
|
// happened" rather than "your filter is wrong" — reject it explicitly.
|
|
if (from && to && from > to) {
|
|
throw new BadRequestException('`from` must be earlier than `to`');
|
|
}
|
|
|
|
const [items, total] = await this.auditLogRepository.search({
|
|
type: query.type,
|
|
userId: query.userId,
|
|
method: query.method,
|
|
resourceId: query.resourceId,
|
|
reference: query.reference,
|
|
userName: query.userName,
|
|
title: query.title,
|
|
q: query.q,
|
|
isSuccess:
|
|
query.isSuccess === undefined ? undefined : query.isSuccess === 'true',
|
|
from,
|
|
to,
|
|
skip,
|
|
take,
|
|
});
|
|
|
|
return { items, meta: buildPaginationMeta(total, page, pageSize) };
|
|
}
|
|
|
|
/** Distinct entity types, for the filter dropdown on the audit screen. */
|
|
async listTypes(): Promise<string[]> {
|
|
return this.auditLogRepository.distinctTypes();
|
|
}
|
|
|
|
/** Distinct action titles, for the action filter dropdown. */
|
|
async listActions(): Promise<string[]> {
|
|
return this.auditLogRepository.distinctTitles();
|
|
}
|
|
}
|