mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +00:00
- Implemented utility to calculate wagon usage metrics for train schedules. - Created for sending wagons to maintenance with optional notes. - Added unit tests for train builder maintenance functionalities, including formatting train run labels and building maintenance notes. - Developed component for merging train schedules with detailed previews and reasons for merging. - Introduced component for selecting wagons with search functionality and selection limits. - Created for displaying and filtering audit logs, including detailed views of individual log entries. - Added for handling API interactions related to audit logs, including fetching logs and entity types.
133 lines
5.3 KiB
TypeScript
133 lines
5.3 KiB
TypeScript
import { Column, CreateDateColumn, Entity, Index, PrimaryGeneratedColumn } from 'typeorm';
|
|
|
|
/**
|
|
* One backoffice action against a state-changing endpoint.
|
|
*
|
|
* Deliberately does NOT extend `BaseEntity`, which is the repo standard
|
|
* everywhere else. `BaseEntity` carries `updatedAt` and `deletedAt`, and both
|
|
* are wrong here:
|
|
*
|
|
* - `updatedAt` implies an audit row can be edited. A record that can be
|
|
* rewritten after the fact is not evidence.
|
|
* - `deletedAt` (soft delete) would let anyone who can delete erase their own
|
|
* trail, and TypeORM would then hide those rows from every default query —
|
|
* the failure would be silent, which is the worst property an audit log can
|
|
* have.
|
|
*
|
|
* Rows are insert-only: nothing in this module updates or deletes them.
|
|
*
|
|
* `userId` is a bare uuid with NO foreign key into the `iam` schema. Two
|
|
* reasons: cross-schema FKs are forbidden platform-wide, and a FK would let
|
|
* deleting a user cascade away the record of what that user did — exactly
|
|
* backwards. `userName` / `userRole` are point-in-time snapshots for the same
|
|
* reason: resolving them at read time would rewrite history whenever somebody
|
|
* is renamed or changes role.
|
|
*/
|
|
@Entity({ schema: 'freight', name: 'audit_logs' })
|
|
// Every audit query is time-bounded, so created_at leads most indexes.
|
|
@Index('IDX_audit_logs_created_at', ['createdAt'])
|
|
@Index('IDX_audit_logs_user_id_created_at', ['userId', 'createdAt'])
|
|
@Index('IDX_audit_logs_type_created_at', ['type', 'createdAt'])
|
|
@Index('IDX_audit_logs_type_resource_id', ['type', 'resourceId'])
|
|
@Index('IDX_audit_logs_route_path_created_at', ['routePath', 'createdAt'])
|
|
export class AuditLog {
|
|
@PrimaryGeneratedColumn('uuid')
|
|
id!: string;
|
|
|
|
/**
|
|
* Human-readable action, e.g. "Approve contract" — taken from the matched
|
|
* entry in `AUDIT_ENDPOINTS`, which sources it from each route's
|
|
* `@ApiOperation` summary.
|
|
*/
|
|
@Column({ name: 'title', type: 'varchar', length: 255 })
|
|
title!: string;
|
|
|
|
@Column({ name: 'method', type: 'varchar', length: 10 })
|
|
method!: string;
|
|
|
|
/**
|
|
* The URL as actually called, real ids and query string included
|
|
* (`/api/contracts/abc-123/cancel?force=true`). Query values run through the
|
|
* same redaction pass as the body, so a `?token=` never lands here.
|
|
*/
|
|
@Column({ name: 'url', type: 'text' })
|
|
url!: string;
|
|
|
|
/**
|
|
* The route template (`/api/contracts/:id/cancel`).
|
|
*
|
|
* `url` alone cannot be grouped — every contract cancel is a distinct string.
|
|
* This column is the join key back to `AUDIT_ENDPOINTS` and makes
|
|
* "every contract cancellation" one indexed query instead of a regex scan.
|
|
*/
|
|
@Column({ name: 'route_path', type: 'varchar', length: 255, nullable: true })
|
|
routePath?: string | null;
|
|
|
|
/** Primary entity the action touched: `Contract`, `Booking`, `Locomotive`. */
|
|
@Column({ name: 'type', type: 'varchar', length: 50 })
|
|
type!: string;
|
|
|
|
@Column({ name: 'is_success', type: 'boolean' })
|
|
isSuccess!: boolean;
|
|
|
|
/** IAM user id. Nullable by design — see the class comment. */
|
|
@Column({ name: 'user_id', type: 'uuid', nullable: true })
|
|
userId?: string | null;
|
|
|
|
/**
|
|
* Id of the affected record, recovered from the first path parameter of the
|
|
* matched template.
|
|
*
|
|
* `varchar`, not `uuid`: not every identifier is a uuid
|
|
* (`/api/contract-templates/:code`), and a create has no id at all until it
|
|
* succeeds. A `uuid NOT NULL` column would throw during the write and lose
|
|
* the audit row rather than the id.
|
|
*/
|
|
@Column({ name: 'resource_id', type: 'varchar', length: 64, nullable: true })
|
|
resourceId?: string | null;
|
|
|
|
/**
|
|
* Sanitized request body. Secrets are replaced with `[REDACTED]` and uploads
|
|
* are reduced to `{ __file, originalName, mimeType, size }` descriptors —
|
|
* never raw bytes. See `audit.sanitizer.ts`.
|
|
*/
|
|
@Column({ name: 'request', type: 'jsonb', nullable: true })
|
|
request?: Record<string, unknown> | null;
|
|
|
|
/**
|
|
* `isSuccess` alone cannot separate 403 (denied — the security signal worth
|
|
* alerting on) from 500 (broke). Both are simply `false`.
|
|
*/
|
|
@Column({ name: 'status_code', type: 'smallint', nullable: true })
|
|
statusCode?: number | null;
|
|
|
|
@Column({ name: 'error_message', type: 'text', nullable: true })
|
|
errorMessage?: string | null;
|
|
|
|
/** Snapshot of the actor's display name at the time of the action. */
|
|
@Column({ name: 'user_name', type: 'varchar', length: 150, nullable: true })
|
|
userName?: string | null;
|
|
|
|
/** Snapshot of the actor's role at the time of the action. */
|
|
@Column({ name: 'user_role', type: 'varchar', length: 100, nullable: true })
|
|
userRole?: string | null;
|
|
|
|
/** Non-repudiation: the first thing asked in any incident review. */
|
|
@Column({ name: 'ip_address', type: 'inet', nullable: true })
|
|
ipAddress?: string | null;
|
|
|
|
/** Helps separate a real browser session from a script using a stolen token. */
|
|
@Column({ name: 'user_agent', type: 'text', nullable: true })
|
|
userAgent?: string | null;
|
|
|
|
/** Correlates this row with application logs/traces for the same request. */
|
|
@Column({ name: 'request_id', type: 'varchar', length: 64, nullable: true })
|
|
requestId?: string | null;
|
|
|
|
@Column({ name: 'duration_ms', type: 'integer', nullable: true })
|
|
durationMs?: number | null;
|
|
|
|
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
|
createdAt!: Date;
|
|
}
|