mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 00:10:57 +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.
162 lines
5.5 KiB
TypeScript
162 lines
5.5 KiB
TypeScript
import { Injectable } from '@nestjs/common';
|
|
import { BaseRepository } from '@edr/api-common';
|
|
import { InjectRepository } from '@nestjs/typeorm';
|
|
import { Repository } from 'typeorm';
|
|
import type { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity';
|
|
|
|
import { AuditLog } from './entities/audit-log.entity';
|
|
import type { AuditReferenceSource } from './audit-reference.registry';
|
|
|
|
export interface AuditLogQuery {
|
|
type?: string;
|
|
userId?: string;
|
|
method?: string;
|
|
isSuccess?: boolean;
|
|
resourceId?: string;
|
|
reference?: string;
|
|
userName?: string;
|
|
title?: string;
|
|
q?: string;
|
|
from?: Date;
|
|
to?: Date;
|
|
skip: number;
|
|
take: number;
|
|
}
|
|
|
|
@Injectable()
|
|
export class AuditLogRepository extends BaseRepository<AuditLog> {
|
|
constructor(
|
|
@InjectRepository(AuditLog)
|
|
private readonly auditLogRepository: Repository<AuditLog>,
|
|
) {
|
|
super(auditLogRepository);
|
|
}
|
|
|
|
/**
|
|
* Insert one audit row.
|
|
*
|
|
* `insert` rather than `save`: save would issue a SELECT first to decide
|
|
* between insert and update, which is wasted work for a table that is only
|
|
* ever appended to.
|
|
*/
|
|
async record(entry: Partial<AuditLog>): Promise<void> {
|
|
await this.auditLogRepository.insert(
|
|
entry as QueryDeepPartialEntity<AuditLog>,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Resolve the human identifier for one entity row (`WHERE id = $1`).
|
|
*
|
|
* `source` comes from the static `AUDIT_REFERENCE_SOURCES` registry — never
|
|
* from user input — so interpolating its table/column is safe; the id is
|
|
* bound as a parameter. Returns null when the row doesn't exist or the
|
|
* identifier column is empty.
|
|
*/
|
|
async lookupReference(
|
|
source: AuditReferenceSource,
|
|
id: string,
|
|
): Promise<string | null> {
|
|
const rows = await this.auditLogRepository.manager.query<
|
|
{ reference: string | null }[]
|
|
>(
|
|
`SELECT ${source.column}::varchar AS reference FROM ${source.table} WHERE id = $1::uuid`,
|
|
[id],
|
|
);
|
|
return rows[0]?.reference || null;
|
|
}
|
|
|
|
/**
|
|
* Paginated, filtered read. Newest first — every index on this table is
|
|
* ordered `created_at DESC` to match.
|
|
*
|
|
* Query builder rather than `findAndCount`: `q` needs an OR across four
|
|
* columns, and `reference` needs the `upper(...) LIKE` shape that matches
|
|
* the expression index — neither fits `FindOptionsWhere`.
|
|
*/
|
|
async search(query: AuditLogQuery): Promise<[AuditLog[], number]> {
|
|
const qb = this.auditLogRepository.createQueryBuilder('audit_log');
|
|
|
|
if (query.type) qb.andWhere('audit_log.type = :type', { type: query.type });
|
|
if (query.userId) qb.andWhere('audit_log.user_id = :userId', { userId: query.userId });
|
|
if (query.method) qb.andWhere('audit_log.method = :method', { method: query.method });
|
|
if (query.resourceId) {
|
|
qb.andWhere('audit_log.resource_id = :resourceId', { resourceId: query.resourceId });
|
|
}
|
|
if (query.isSuccess !== undefined) {
|
|
qb.andWhere('audit_log.is_success = :isSuccess', { isSuccess: query.isSuccess });
|
|
}
|
|
|
|
// Case-insensitive prefix match, shaped to hit idx_audit_logs_reference_upper.
|
|
// The explicit <> '' repeats the index's partial predicate — without it the
|
|
// planner cannot prove the partial index applies and falls back to a scan.
|
|
if (query.reference) {
|
|
qb.andWhere("audit_log.reference <> ''").andWhere(
|
|
"upper(audit_log.reference) LIKE upper(:reference) || '%'",
|
|
{ reference: escapeLike(query.reference) },
|
|
);
|
|
}
|
|
if (query.userName) {
|
|
qb.andWhere('audit_log.user_name ILIKE :userName', {
|
|
userName: `%${escapeLike(query.userName)}%`,
|
|
});
|
|
}
|
|
if (query.title) {
|
|
qb.andWhere('audit_log.title ILIKE :title', {
|
|
title: `%${escapeLike(query.title)}%`,
|
|
});
|
|
}
|
|
|
|
// One search box across the columns staff actually search by.
|
|
// ponytail: ILIKE %…% scans the time-bounded window; add pg_trgm GIN
|
|
// indexes if the table grows past a few million rows.
|
|
if (query.q) {
|
|
const q = `%${escapeLike(query.q)}%`;
|
|
qb.andWhere(
|
|
`(audit_log.reference ILIKE :q
|
|
OR audit_log.resource_id ILIKE :q
|
|
OR audit_log.user_name ILIKE :q
|
|
OR audit_log.title ILIKE :q)`,
|
|
{ q },
|
|
);
|
|
}
|
|
|
|
// Date range: either bound may be supplied alone.
|
|
if (query.from) qb.andWhere('audit_log.created_at >= :from', { from: query.from });
|
|
if (query.to) qb.andWhere('audit_log.created_at <= :to', { to: query.to });
|
|
|
|
return qb
|
|
.orderBy('audit_log.created_at', 'DESC')
|
|
.skip(query.skip)
|
|
.take(query.take)
|
|
.getManyAndCount();
|
|
}
|
|
|
|
/** Distinct entity types present, for populating a filter dropdown. */
|
|
async distinctTypes(): Promise<string[]> {
|
|
const rows = await this.auditLogRepository
|
|
.createQueryBuilder('audit_log')
|
|
.select('DISTINCT audit_log.type', 'type')
|
|
.orderBy('audit_log.type', 'ASC')
|
|
.getRawMany<{ type: string }>();
|
|
|
|
return rows.map((row) => row.type);
|
|
}
|
|
|
|
/** Distinct action titles present, for the action filter dropdown. */
|
|
async distinctTitles(): Promise<string[]> {
|
|
const rows = await this.auditLogRepository
|
|
.createQueryBuilder('audit_log')
|
|
.select('DISTINCT audit_log.title', 'title')
|
|
.orderBy('audit_log.title', 'ASC')
|
|
.getRawMany<{ title: string }>();
|
|
|
|
return rows.map((row) => row.title);
|
|
}
|
|
}
|
|
|
|
/** Escape LIKE wildcards so a literal `%`/`_` in the search text stays literal. */
|
|
function escapeLike(value: string): string {
|
|
return value.replace(/[\\%_]/g, (ch) => `\\${ch}`);
|
|
}
|