mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 17:38:12 +00:00
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.
This commit is contained in:
189
apps/edr-freight-api/src/modules/audit/audit.interceptor.ts
Normal file
189
apps/edr-freight-api/src/modules/audit/audit.interceptor.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
import {
|
||||
CallHandler,
|
||||
ExecutionContext,
|
||||
HttpException,
|
||||
Injectable,
|
||||
NestInterceptor,
|
||||
} from '@nestjs/common';
|
||||
import { Observable, tap } from 'rxjs';
|
||||
import type { Request, Response } from 'express';
|
||||
|
||||
import { AuditService } from './audit.service';
|
||||
import {
|
||||
auditEndpointMatcher,
|
||||
type MatchedAuditEndpoint,
|
||||
} from './audit-endpoint-matcher';
|
||||
import {
|
||||
isAuditableActor,
|
||||
resolveAuditActor,
|
||||
type AuditActorSource,
|
||||
} from './audit-actor';
|
||||
import { redactUrlQuery, sanitizeRequestPayload } from './audit.sanitizer';
|
||||
|
||||
/** Methods that can change state. Everything else is never audited. */
|
||||
const AUDITED_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
|
||||
|
||||
/** `error_message` ceiling — stack traces do not belong in this column. */
|
||||
const MAX_ERROR_LENGTH = 2_000;
|
||||
|
||||
type RequestWithUser = Request & {
|
||||
user?: AuditActorSource;
|
||||
files?: unknown;
|
||||
file?: unknown;
|
||||
id?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Writes one `audit_logs` row per state-changing backoffice request.
|
||||
*
|
||||
* An interceptor rather than the two middlewares originally sketched, for one
|
||||
* decisive reason: Express middleware runs BEFORE guards, so `req.user` is not
|
||||
* populated yet. Both the backoffice-only rule and `user_id` would be
|
||||
* unavailable there. Interceptors run after guards and wrap the handler's
|
||||
* result, so a single class covers both halves — request context on the way in,
|
||||
* outcome on the way out — sharing one timer for `duration_ms`.
|
||||
*
|
||||
* Registered globally (see `audit.module.ts`), so new routes are covered
|
||||
* automatically as long as they appear in `AUDIT_ENDPOINTS`.
|
||||
*/
|
||||
@Injectable()
|
||||
export class AuditInterceptor implements NestInterceptor {
|
||||
constructor(private readonly auditService: AuditService) {}
|
||||
|
||||
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
|
||||
// Non-HTTP contexts (the RabbitMQ microservice transport) have no request.
|
||||
if (context.getType() !== 'http') return next.handle();
|
||||
|
||||
const httpContext = context.switchToHttp();
|
||||
const request = httpContext.getRequest<RequestWithUser>();
|
||||
|
||||
if (!AUDITED_METHODS.has(request.method)) return next.handle();
|
||||
|
||||
// Backoffice only. Customers and unauthenticated callers are skipped
|
||||
// outright — decided in `audit-actor.ts`, which reuses the same
|
||||
// `userType` discriminator as the permission guards.
|
||||
if (!isAuditableActor(request.user)) return next.handle();
|
||||
|
||||
const matched = auditEndpointMatcher.match(request.method, request.originalUrl);
|
||||
// Not in AUDIT_ENDPOINTS means the route is not a known auditable action;
|
||||
// recording it would produce rows with no title or entity.
|
||||
if (!matched) return next.handle();
|
||||
|
||||
const startedAt = Date.now();
|
||||
// The body is captured up front: handlers are free to mutate the DTO they
|
||||
// are given, so reading it after the fact can record post-mutation values.
|
||||
const requestPayload = sanitizeRequestPayload(
|
||||
request.body,
|
||||
request.files ?? request.file,
|
||||
);
|
||||
|
||||
return next.handle().pipe(
|
||||
tap({
|
||||
next: () => {
|
||||
const response = httpContext.getResponse<Response>();
|
||||
void this.write(request, matched, requestPayload, startedAt, {
|
||||
isSuccess: true,
|
||||
// Nest has not applied the handler's @HttpCode yet at this point
|
||||
// for some routes; statusCode on the response object is the value
|
||||
// actually being sent.
|
||||
statusCode: response.statusCode,
|
||||
errorMessage: null,
|
||||
});
|
||||
},
|
||||
error: (error: unknown) => {
|
||||
void this.write(request, matched, requestPayload, startedAt, {
|
||||
isSuccess: false,
|
||||
statusCode: resolveErrorStatus(error),
|
||||
errorMessage: resolveErrorMessage(error),
|
||||
});
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build and persist the row.
|
||||
*
|
||||
* Deliberately not awaited by `intercept`: the audit write must not add
|
||||
* latency to the request, and `AuditService.record` already swallows its own
|
||||
* failures so a rejected promise cannot surface as an unhandled rejection.
|
||||
*/
|
||||
private async write(
|
||||
request: RequestWithUser,
|
||||
matched: MatchedAuditEndpoint,
|
||||
requestPayload: Record<string, unknown> | null,
|
||||
startedAt: number,
|
||||
outcome: {
|
||||
isSuccess: boolean;
|
||||
statusCode: number | null;
|
||||
errorMessage: string | null;
|
||||
},
|
||||
): Promise<void> {
|
||||
const actor = resolveAuditActor(request.user as AuditActorSource);
|
||||
|
||||
await this.auditService.record({
|
||||
title: matched.title,
|
||||
method: request.method,
|
||||
// Full URL including query string, with sensitive query values redacted.
|
||||
url: redactUrlQuery(request.originalUrl),
|
||||
routePath: matched.routePath,
|
||||
type: matched.type,
|
||||
isSuccess: outcome.isSuccess,
|
||||
statusCode: outcome.statusCode,
|
||||
errorMessage: outcome.errorMessage,
|
||||
userId: actor.userId,
|
||||
userName: actor.userName,
|
||||
userRole: actor.userRole,
|
||||
resourceId: matched.resourceId,
|
||||
request: requestPayload,
|
||||
ipAddress: resolveIp(request),
|
||||
userAgent: request.headers['user-agent'] ?? null,
|
||||
requestId: resolveRequestId(request),
|
||||
durationMs: Date.now() - startedAt,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** HTTP status for the failure, falling back to 500 for non-HTTP errors. */
|
||||
function resolveErrorStatus(error: unknown): number {
|
||||
return error instanceof HttpException ? error.getStatus() : 500;
|
||||
}
|
||||
|
||||
/** Message only — stack traces belong in application logs, not this column. */
|
||||
function resolveErrorMessage(error: unknown): string | null {
|
||||
if (error instanceof HttpException) {
|
||||
const response = error.getResponse();
|
||||
const message =
|
||||
typeof response === 'string'
|
||||
? response
|
||||
: ((response as { message?: unknown })?.message ?? error.message);
|
||||
const text = Array.isArray(message) ? message.join('; ') : String(message);
|
||||
return text.slice(0, MAX_ERROR_LENGTH);
|
||||
}
|
||||
|
||||
if (error instanceof Error) return error.message.slice(0, MAX_ERROR_LENGTH);
|
||||
return error ? String(error).slice(0, MAX_ERROR_LENGTH) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Client IP. The API sits behind a reverse proxy, so `req.ip` is the proxy
|
||||
* unless `trust proxy` is set; the forwarded header is preferred and its first
|
||||
* entry (the original client) taken.
|
||||
*/
|
||||
function resolveIp(request: Request): string | null {
|
||||
const forwarded = request.headers['x-forwarded-for'];
|
||||
const raw = Array.isArray(forwarded) ? forwarded[0] : forwarded;
|
||||
const candidate = raw?.split(',')[0]?.trim() || request.ip;
|
||||
if (!candidate) return null;
|
||||
|
||||
// Normalize IPv4-mapped IPv6 (`::ffff:10.0.0.1`), which the `inet` column
|
||||
// accepts but which reads badly and breaks grouping by address.
|
||||
return candidate.startsWith('::ffff:') ? candidate.slice(7) : candidate;
|
||||
}
|
||||
|
||||
/** Correlation id from the proxy/tracing layer, when present. */
|
||||
function resolveRequestId(request: RequestWithUser): string | null {
|
||||
const header = request.headers['x-request-id'] ?? request.headers['x-correlation-id'];
|
||||
const value = Array.isArray(header) ? header[0] : header;
|
||||
return (value ?? request.id ?? null)?.toString().slice(0, 64) ?? null;
|
||||
}
|
||||
Reference in New Issue
Block a user