Files
edr-platform/apps/edr-freight-api/src/modules/audit/entities/audit-log.entity.ts
marshalyordanos 5da36eb128 feat: add wagon usage computation and maintenance logging features
- 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.
2026-08-12 09:36:50 +03:00

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;
}