Files
edr-platform/apps/edr-freight-api/src/modules/audit/audit-endpoint-matcher.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

141 lines
4.6 KiB
TypeScript

import { AUDIT_ENDPOINTS, type AuditEndpointMeta } from './audit-endpoints';
/** What a matched request resolved to. */
export interface MatchedAuditEndpoint {
/** Human-readable action, e.g. "Approve contract". */
title: string;
/** Primary entity, e.g. "Contract". */
type: string;
/** The route template, e.g. `/api/contracts/:id/cancel`. */
routePath: string;
/** First path parameter of the template, when the route has one. */
resourceId: string | null;
}
interface CompiledRoute {
regex: RegExp;
/** Param names in capture-group order, e.g. ['id', 'stepId']. */
paramNames: string[];
routePath: string;
meta: AuditEndpointMeta;
/** Literal (non-parameter) segment count — used to rank specificity. */
staticSegments: number;
}
const ESCAPE_REGEX = /[.*+?^${}()|[\]\\]/g;
/**
* Two keys in AUDIT_ENDPOINTS point at the same path: one route is declared by
* two different controllers, so the generator suffixed the second with
* ` [modules/...controller.ts]` to keep both entries. Only the path itself is
* matchable, so the suffix is stripped here.
*/
function stripSourceSuffix(key: string): string {
const bracket = key.indexOf(' [');
return bracket === -1 ? key : key.slice(0, bracket);
}
/**
* Compile one `"/api/contracts/:id/cancel"` template into an anchored regex.
*
* A parameter matches a single path segment only (`[^/]+`), so
* `/api/contracts/:id` cannot swallow `/api/contracts/:id/cancel`.
*/
function compileTemplate(path: string): { regex: RegExp; paramNames: string[] } {
const paramNames: string[] = [];
const pattern = path
.split('/')
.map((segment) => {
if (!segment.startsWith(':')) {
return segment.replace(ESCAPE_REGEX, '\\$&');
}
paramNames.push(segment.slice(1));
return '([^/]+)';
})
.join('/');
return { regex: new RegExp(`^${pattern}$`), paramNames };
}
/**
* Method-bucketed lookup table for the audited routes.
*
* A direct `AUDIT_ENDPOINTS[url]` lookup cannot work: the keys are templates
* with `:params` while a live request carries real ids and a query string, so
* every parameterized route — most of the 488 — would miss. Templates are
* compiled to regexes once at module load and matched per request.
*
* Within a method, routes are ordered by literal-segment count descending, so
* a specific route always wins over a parameterized one that could also match
* (`/api/routes/:id/permanent` before `/api/routes/:id`).
*/
class AuditEndpointMatcher {
private readonly byMethod = new Map<string, CompiledRoute[]>();
constructor() {
for (const [key, meta] of Object.entries(AUDIT_ENDPOINTS)) {
const [method, rawPath] = stripSourceSuffix(key).split(' ');
if (!method || !rawPath) continue;
const { regex, paramNames } = compileTemplate(rawPath);
const bucket = this.byMethod.get(method) ?? [];
bucket.push({
regex,
paramNames,
routePath: rawPath,
meta,
staticSegments: rawPath
.split('/')
.filter((s) => s && !s.startsWith(':')).length,
});
this.byMethod.set(method, bucket);
}
for (const bucket of this.byMethod.values()) {
bucket.sort((a, b) => b.staticSegments - a.staticSegments);
}
}
/**
* Resolve a live request to its audit metadata, or null when the route is
* not audited (every GET, and anything absent from AUDIT_ENDPOINTS).
*
* `url` may include a query string; it is ignored for matching.
*/
match(method: string, url: string): MatchedAuditEndpoint | null {
const bucket = this.byMethod.get(method.toUpperCase());
if (!bucket) return null;
const path = stripQuery(url);
for (const route of bucket) {
const result = route.regex.exec(path);
if (!result) continue;
const [title, , type] = route.meta;
return {
title,
type,
routePath: route.routePath,
// The first path parameter is the affected record in this API's
// conventions (`/api/contracts/:id/...`). Routes with no parameter
// (a create) legitimately have no resource id yet.
resourceId: route.paramNames.length > 0 ? result[1] : null,
};
}
return null;
}
}
/** Strip query string and hash from a URL, leaving the path. */
export function stripQuery(url: string): string {
const queryIndex = url.indexOf('?');
const path = queryIndex === -1 ? url : url.slice(0, queryIndex);
const hashIndex = path.indexOf('#');
return hashIndex === -1 ? path : path.slice(0, hashIndex);
}
/** Compiled once at module load and shared by the interceptor. */
export const auditEndpointMatcher = new AuditEndpointMatcher();