feat: implement audit logs

This commit is contained in:
Nathnael
2026-08-05 14:17:00 +00:00
parent d5622ed360
commit 595c165820
18 changed files with 533 additions and 2 deletions

View File

@@ -1,5 +1,17 @@
# Copy to .env for local/docker compose (not committed).
PORT=3001
# @tria-plc/auditlog's client interceptor stamps every AuditLog row's
# `application` from this env var directly, bypassing MezgebModule.forRoot's
# applicationName option (package quirk). audit.controller.ts reads the same
# var when filtering reads, so this can be anything as long as it's set.
APPLICATION_NAME=freight-api
# Also required for @tria-plc/auditlog: its producer (AuditClientModule)
# reads the RMQ URL at package IMPORT time, before MezgebModule.forRoot's
# rmqUrl option ever runs, so only an env var reaches it — an in-code
# override is too late. Without this, audit events are silently dropped
# (no error, nothing published). Point it at whatever broker/vhost your
# RabbitMQ actually has a user provisioned on.
RABBITMQ_URL=amqp://localhost:5672
# GT06 GPS tracker TCP listener port (raw TCP, must be reachable by tracker SIMs). 0 disables.
GT06_TCP_PORT=5023
DB_HOST=localhost

View File

@@ -16,6 +16,7 @@ import {
import { IamBaselineSeeder, IamSeedModule } from "@edr/iam-seed";
import { IamModule } from "@tria-plc/iamapi-common";
import { SharedAuthModule } from "@tria-plc/api-common/modules/auth/shared-auth.module";
import { MezgebModule } from "@tria-plc/auditlog";
import appConfig from "./config/app.config";
import databaseConfig from "./config/database.config";
@@ -105,9 +106,14 @@ import { LastMileModule } from "./modules/last-mile/last-mile.module";
import { InterchangeDocumentsModule } from "./modules/interchange-documents/interchange-documents.module";
import { ImportOperationsModule } from "./modules/import-operations/import-operations.module";
import { AiModule } from "./modules/ai/ai.module";
import { AuditModule } from "./modules/audit/audit.module";
import { LoggerMiddleware } from "./logger.middleware";
import { LoginAudienceMiddleware } from "./modules/auth/login-audience.middleware";
if (!process.env.APPLICATION_NAME) {
process.env.APPLICATION_NAME = "freight";
}
@Module({
imports: [
ConfigModule.forRoot({
@@ -155,6 +161,19 @@ import { LoginAudienceMiddleware } from "./modules/auth/login-audience.middlewar
return dataSource;
},
}),
// Request + entity-level audit logging over RabbitMQ (@tria-plc/auditlog).
// Must come after TypeOrmModule above so it picks up this app's DataSource.
// rmqUrl falls back the same way notifications.module.ts's RABBITMQ_URL
// does: the dev broker only provisions the `edr` user on the `payment`
// vhost (docker-compose's RABBITMQ_DEFAULT_USER/VHOST), so an unset
// RABBITMQ_URL must land there too, not on guest@'/' (403 ACCESS_REFUSED).
MezgebModule.forRoot({
applicationName: "freight-api",
rmqUrl:
process.env.RABBITMQ_URL ??
process.env.PAYMENT_RABBITMQ_URL ??
"amqp://localhost:5672",
}),
SharedAuthModule,
IamModule.forRoot({
applications: [EDR_FREIGHT_APPLICATION],
@@ -227,6 +246,7 @@ import { LoginAudienceMiddleware } from "./modules/auth/login-audience.middlewar
VerifaydaModule,
FleetHistoryModule,
AiModule,
AuditModule,
],
providers: [
EdrOrgSeeder,

View File

@@ -56,6 +56,11 @@ import { EmployeePositionActivePeriod } from "@tria-plc/iamapi-common/entities/i
import { UnitConfiguration } from "@tria-plc/iamapi-common/entities/iam/organization-structure/unit-configuration.entity";
import { Site } from "@tria-plc/iamapi-common/entities/iam/site/site.entity";
import { SiteSetting } from "@tria-plc/iamapi-common/entities/iam/site/site-setting.entity";
import { AuditLog, AuditLogCommand } from "@tria-plc/auditlog";
// @tria-plc/auditlog's entities live in node_modules, same as the iam ones —
// the glob below only matches this app's own src/**/*.entity.ts.
const auditEntities = [AuditLog, AuditLogCommand];
const iamEntities = [
UnitSetting,
@@ -177,7 +182,11 @@ export function buildDataSourceOptions(): DataSourceOptions {
return {
...buildConnectionOptions(),
schema: "public",
entities: [__dirname + "/../**/*.entity.{ts,js}", ...iamEntities],
entities: [
__dirname + "/../**/*.entity.{ts,js}",
...iamEntities,
...auditEntities,
],
migrations: [],
};
}

View File

@@ -10,6 +10,7 @@ import {
ResponseTransformInterceptor,
createValidationPipe,
} from "@edr/api-common";
import { getAuditLoggerConfig } from "@tria-plc/auditlog";
import { AppModule } from "./app.module";
@@ -160,6 +161,13 @@ export async function createFreightApp(): Promise<NestExpressApplication> {
app.useGlobalFilters(new HttpExceptionFilter());
app.useGlobalInterceptors(new ResponseTransformInterceptor());
// Audit listener: consumes the RMQ events MezgebModule's client interceptor
// (app.module.ts) emits and persists them via the AuditLogController /
// AuditLogCommandController @EventPattern handlers. Same queue config the
// client side uses, reused from the package so the two never drift apart.
app.connectMicroservice(getAuditLoggerConfig());
await app.startAllMicroservices();
const config = new DocumentBuilder()
.setTitle("EDR Freight API")
.setDescription("API for the EDR Freight Management application")

View File

@@ -0,0 +1,32 @@
import { Controller, Get, Query } from "@nestjs/common";
import { ApiOperation, ApiQuery, ApiTags } from "@nestjs/swagger";
import { BookingStaff } from "../../common/booking-guards";
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
import { AuditService } from "./audit.service";
@ApiTags("audit")
@Controller("audit")
@BookingStaff(FREIGHT_PERMS.audit.view)
export class AuditController {
constructor(private readonly auditService: AuditService) {}
@Get("logs")
@ApiOperation({ summary: "List freight-api audit log commands" })
@ApiQuery({ name: "skip", type: Number, required: false })
@ApiQuery({ name: "take", type: Number, required: false })
list(@Query("skip") skip?: string, @Query("take") take?: string) {
// Same fallback chain @tria-plc/auditlog's client interceptor uses to
// stamp AuditLog.application (mezgeb/client/client-audit.interceptor.js)
// — reading it here instead of a hardcoded literal means this can't
// silently drift out of sync with whatever APPLICATION_NAME/APP_NAME
// actually is at runtime.
const application =
process.env.APPLICATION_NAME ?? process.env.APP_NAME ?? "DEFAULT";
return this.auditService.list(
application,
skip !== undefined ? parseInt(skip, 10) : undefined,
take !== undefined ? parseInt(take, 10) : undefined,
);
}
}

View File

@@ -0,0 +1,13 @@
import { Module } from "@nestjs/common";
import { TypeOrmModule } from "@nestjs/typeorm";
import { AuditLogCommand } from "@tria-plc/auditlog";
import { AuditController } from "./audit.controller";
import { AuditService } from "./audit.service";
@Module({
imports: [TypeOrmModule.forFeature([AuditLogCommand])],
controllers: [AuditController],
providers: [AuditService],
})
export class AuditModule {}

View File

@@ -0,0 +1,59 @@
import { Injectable } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { Repository } from "typeorm";
import { AuditLogCommand } from "@tria-plc/auditlog";
export interface AuditLogListResult {
count: number;
items: AuditLogCommand[];
}
/**
* Own read path onto @tria-plc/auditlog's tables, gated by AuditController's
* @BookingStaff — the package's own AuditLogCommandController (mounted at
* /api/audit-log-commands) ships with no guards at all, so it can't be used
* directly for a permission-gated UI. Query mirrors the package's
* AuditLogCommandService.buildAuditLogQuery/getAllAuditLogs exactly.
*/
@Injectable()
export class AuditService {
constructor(
@InjectRepository(AuditLogCommand)
private readonly auditLogCommandRepository: Repository<AuditLogCommand>,
) {}
async list(
application: string,
skip = 0,
take = 10,
): Promise<AuditLogListResult> {
const [items, count] = await this.auditLogCommandRepository
.createQueryBuilder("audit_log_commands")
.leftJoinAndSelect("audit_log_commands.auditLog", "auditLog")
.andWhere(
"(audit_log_commands.auditLogId IS NULL OR auditLog.application = :application)",
{ application },
)
.andWhere(
"(audit_log_commands.auditLogId IS NULL OR auditLog.status = :status)",
{ status: "Commit" },
)
.select([
"audit_log_commands.id",
"audit_log_commands.createdAt",
"audit_log_commands.deletedAt",
"audit_log_commands.entityName",
"audit_log_commands.queryMethod",
"audit_log_commands.changes",
"audit_log_commands.payload",
"auditLog.id",
"auditLog.user",
])
.addOrderBy("audit_log_commands.createdAt", "DESC")
.skip(skip)
.take(take)
.getManyAndCount();
return { count, items };
}
}

View File

@@ -10,6 +10,7 @@ import {
import { EventEmitter2 } from "@nestjs/event-emitter";
import { DataSource, EntityManager, In } from "typeorm";
import { Booking } from "../bookings/entities/booking.entity";
import { CompaniesService } from "../companies/companies.service";
import { applyBookingRefDirectionScope } from "../user-trade-access/trade-scope.util";
import { PaymentService } from "../payment/payment.service";
@@ -1192,6 +1193,17 @@ export class BillingService {
.getRepository(Invoice)
.update({ id: invoice.id }, { paymentId: result.intentId });
// CBE_BILL: the bill reference IS the booking's PNR — the number the customer pays against
// at any CBE channel. Persist it on the booking so it survives the initiate response and
// shows on the booking/contract everywhere. The payment service reissues the same reference
// while the bill stays open, so re-initiating overwrites with an identical value.
const billReference = result.response.clientAction?.billReference;
if (billReference && invoice.source === Freight.InvoiceSource.Booking) {
await this.dataSource
.getRepository(Booking)
.update({ id: invoice.sourceId }, { pnrCode: billReference });
}
// Settlement is driven by the payment API (webhook/outbox → payment.succeeded);
// billing must not simulate it. Kept for local demos only.
// An OTP intent (CAC Bank) is NOT paid yet — the payer still has to enter the

View File

@@ -366,6 +366,7 @@ export const CONFIG_SETTINGS_PERMISSIONS: FreightPermissionSeed[] = [
perm('b4a00001-0001-4000-8000-000000000002', 'edr_freight_app:settings:file_upload:manage', 'Manage file-upload settings'),
perm('b4b00001-0001-4000-8000-000000000001', 'edr_freight_app:settings:dropdown:view', 'View dropdown settings'),
perm('b4b00001-0001-4000-8000-000000000002', 'edr_freight_app:settings:dropdown:manage', 'Manage dropdown settings'),
perm('b4c00001-0001-4000-8000-000000000001', 'edr_freight_app:audit:view', 'View audit logs'),
];
// M. Staff / IAM admin — NEW keys only. The employee_registration / role_assignment
@@ -700,6 +701,9 @@ export const FREIGHT_PERMS = {
manage: 'edr_freight_app:settings:dropdown:manage',
},
},
audit: {
view: 'edr_freight_app:audit:view',
},
staff: {
roles: {
view: 'edr_freight_app:staff:roles:view',

View File

@@ -7,6 +7,7 @@ import {
FileSignature,
FileText,
Hammer,
History,
LayoutDashboard,
LayoutGrid,
MapPin,
@@ -76,6 +77,7 @@ import ReportsHubPage from "./pages/reports/ReportsHubPage";
import ReportPage from "./pages/reports/ReportPage";
import AiBookingMockTestPage from "./pages/ai/AiBookingMockTestPage";
import PaymentsPage from "./pages/payments/PaymentsPage";
import AuditLogsPage from "./pages/audit/AuditLogsPage";
//import EmployeesPage from "./pages/dashboard/user-management/EmployeesPage";
import { RequirePermission } from "./components/auth/RequirePermission";
import {
@@ -582,6 +584,12 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
icon: <ScrollText />,
permission: FREIGHT_PERMS.admin,
},
{
label: "Audit logs",
href: "/dashboard/audit-logs",
icon: <History />,
permission: FREIGHT_PERMS.audit.view,
},
{
label: "Configuration",
href: "/dashboard/configuration",
@@ -1595,6 +1603,14 @@ const App = () => {
</RequirePermission>
}
/>
<Route
path="audit-logs"
element={
<RequirePermission permission={FREIGHT_PERMS.audit.view}>
<AuditLogsPage />
</RequirePermission>
}
/>
<Route
path="contract-templates"
element={

View File

@@ -184,6 +184,13 @@ const ROUTE_META: Array<{ prefix: string; meta: PageMeta }> = [
subtitle: "Manage dropdown options used across the platform",
},
},
{
prefix: "/dashboard/audit-logs",
meta: {
title: "Audit Logs",
subtitle: "Request and entity-level activity recorded across the freight API",
},
},
{
prefix: "/dashboard/configuration/contract-validity-periods",
meta: {

View File

@@ -309,6 +309,10 @@ export const URL_CONSTANTS = {
SUMMARY: "/payments/summary",
},
AUDIT: {
LOGS: "/audit/logs",
},
LOCOMOTIVES: {
BASE: "/locomotives",
BY_ID: (id: string) => `/locomotives/${id}`,

View File

@@ -276,6 +276,9 @@ export const FREIGHT_PERMS = {
manage: "edr_freight_app:settings:dropdown:manage",
},
},
audit: {
view: "edr_freight_app:audit:view",
},
staff: {
roles: {
view: "edr_freight_app:staff:roles:view",

View File

@@ -0,0 +1,185 @@
import { Badge, Box, Card, Stack, Text } from "@mantine/core";
import { useQuery } from "@tanstack/react-query";
import { PageContainer, PageHeader } from "@/components/page";
import { api } from "@/services/api";
import type {
AuditLogRow,
AuditQueryMethod,
AuditUser,
} from "@/services/audit.service";
import {
DataTable,
DataTableFooter,
usePagination,
type ColumnDef,
} from "@edr/ui-common";
const ACTION_LABELS: Record<AuditQueryMethod, string> = {
INSERT: "Created",
UPDATE: "Updated",
DELETE: "Deleted",
INSERT_CHILD: "Linked child",
DELETE_CHILD: "Unlinked child",
};
const ACTION_COLORS: Record<AuditQueryMethod, string> = {
INSERT: "edr-green",
UPDATE: "yellow",
DELETE: "red",
INSERT_CHILD: "indigo",
DELETE_CHILD: "gray",
};
function formatDateTime(iso: string): string {
const d = new Date(iso);
return Number.isNaN(d.getTime())
? "—"
: d.toLocaleString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
}
// The producer (@tria-plc/auditlog's ClientLoggerInterceptor) builds
// `name` from `${auditUser?.firstName} ${auditUser?.lastName}` — this app's
// user model only has a single `name` field, and unauthenticated/customer
// flows (e.g. Fayda verification) have no auditUser at all, so this literal
// "undefined undefined" ends up stored as-is. Filter it back out on render
// rather than showing raw garbage.
function formatUser(user: AuditUser | null | undefined): string {
const name = user?.name;
if (typeof name === "string" && /^undefined(\s+undefined)?$/.test(name.trim())) {
return "—";
}
return name ?? user?.id ?? "—";
}
function summarize(row: AuditLogRow): string {
if (row.changes?.length) {
return row.changes
.slice(0, 2)
.map((c) => c.field)
.join(", ") + (row.changes.length > 2 ? `, +${row.changes.length - 2} more` : "");
}
if (row.payload) {
return row.payload.name ?? row.payload.title ?? row.payload.id ?? "—";
}
return "—";
}
const tableHeader =
"text-xs font-semibold uppercase tracking-wide text-muted-foreground";
export default function AuditLogsPage() {
const { pagination, setPagination } = usePagination({ pageSize: 20 });
const filter = {
skip: pagination.pageIndex * pagination.pageSize,
take: pagination.pageSize,
};
const { data, isLoading, isError } = useQuery(
api.audit.list.queryOptions({ input: { filter } }),
);
const rows = data?.items ?? [];
const total = data?.count ?? 0;
const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize));
const columns: ColumnDef<AuditLogRow>[] = [
{
id: "time",
header: () => <span className={tableHeader}>Time</span>,
cell: ({ row }) => (
<span className="text-sm text-muted-foreground">
{formatDateTime(row.original.createdAt)}
</span>
),
},
{
id: "action",
header: () => <span className={tableHeader}>Action</span>,
cell: ({ row }) => (
<Badge
color={ACTION_COLORS[row.original.queryMethod] ?? "gray"}
variant="light"
radius="sm"
>
{ACTION_LABELS[row.original.queryMethod] ?? row.original.queryMethod}
</Badge>
),
},
{
id: "entity",
header: () => <span className={tableHeader}>Entity</span>,
cell: ({ row }) => (
<span className="font-mono text-sm text-foreground">
{row.original.entityName}
</span>
),
},
{
id: "user",
header: () => <span className={tableHeader}>User</span>,
cell: ({ row }) => (
<span className="text-sm text-foreground">
{formatUser(row.original.auditLog?.user)}
</span>
),
},
{
id: "summary",
header: () => <span className={tableHeader}>Summary</span>,
cell: ({ row }) => (
<span className="truncate text-sm text-muted-foreground">
{summarize(row.original)}
</span>
),
},
];
return (
<PageContainer>
<PageHeader
title="Audit Logs"
subtitle="Request and entity-level activity recorded across the freight API."
/>
<Card p={0}>
<Stack gap={0}>
<Box px="md" pt="md" pb="sm" w="100%">
<Text size="sm" c="dimmed">
{total} record{total !== 1 ? "s" : ""}
</Text>
</Box>
<Box style={{ overflowX: "auto" }} w="100%">
<DataTable
columns={columns}
data={rows}
status={isLoading ? "loading" : isError ? "error" : "success"}
pagination={{
pageIndex: pagination.pageIndex,
pageSize: pagination.pageSize,
pageCount,
totalCount: total,
}}
tableOptions={{
state: { pagination },
onPaginationChange: setPagination,
manualPagination: true,
pageCount,
}}
containerClassName="border-0 shadow-none bg-transparent"
footer={DataTableFooter}
/>
</Box>
</Stack>
</Card>
</PageContainer>
);
}

View File

@@ -163,6 +163,11 @@ import {
type SaveLocomotivePayload,
} from "./locomotives.service";
import { overviewService } from "./overview.service";
import {
auditService,
type AuditLogListFilter,
type PaginatedAuditLogs,
} from "./audit.service";
import { reportsService } from "./reports.service";
import type { ReportQueryInput, ReportResult } from "@/types/reports";
import {
@@ -2136,6 +2141,15 @@ export const api = {
),
},
audit: {
list: endpoint<{ filter?: AuditLogListFilter }, PaginatedAuditLogs>(
"audit",
"list",
({ filter }) => auditService.list(filter),
({ filter }) => ["audit", "list", filter ?? {}],
),
},
signatures: {
mySignature: endpoint<void, SavedSignature | null>(
"me",

View File

@@ -0,0 +1,61 @@
import { api as client } from "../auth/http";
import { unwrap } from "@/utils/endpoint";
import { URL_CONSTANTS } from "@/constants/URLS";
const A = URL_CONSTANTS.AUDIT;
// Shape from @tria-plc/auditlog's AuditLogCommandController — see
// local-packages/FRONTEND_GUIDE.md.
export type AuditQueryMethod =
| "INSERT"
| "UPDATE"
| "DELETE"
| "INSERT_CHILD"
| "DELETE_CHILD";
export interface AuditFieldChange {
field: string;
from: unknown;
to: unknown;
}
export interface AuditUser {
id?: string;
name?: string;
organizationId?: string;
organizationName?: string;
[key: string]: unknown;
}
export interface AuditLogRow {
id?: string;
createdAt: string;
deletedAt?: string | null;
entityName: string;
queryMethod: AuditQueryMethod;
changes?: AuditFieldChange[] | null;
payload?: { name?: string; title?: string; id?: string } | null;
auditLog?: { id?: string; user?: AuditUser | null };
}
export interface AuditLogListFilter {
skip?: number;
take?: number;
}
export interface PaginatedAuditLogs {
items: AuditLogRow[];
count: number;
}
export const auditService = {
list: async (filter?: AuditLogListFilter): Promise<PaginatedAuditLogs> => {
const params: Record<string, number | undefined> = {
skip: filter?.skip,
take: filter?.take,
};
const response = await client.get<PaginatedAuditLogs>(A.LOGS, { params });
const data = unwrap(response.data) as PaginatedAuditLogs;
return { items: data.items ?? [], count: data.count ?? 0 };
},
};

View File

@@ -24,7 +24,11 @@ describe("IntentsService CBE_BILL", () => {
let repository: jest.Mocked<
Pick<
IntentsRepository,
"create" | "findById" | "findByIdempotencyKey" | "update"
| "create"
| "findById"
| "findByIdempotencyKey"
| "findAllByReference"
| "update"
>
>;
let billReferenceService: { generate: jest.Mock };
@@ -46,6 +50,7 @@ describe("IntentsService CBE_BILL", () => {
create: jest.fn(async (data) => ({ id: "intent-1", ...data })),
findById: jest.fn(),
findByIdempotencyKey: jest.fn().mockResolvedValue(null),
findAllByReference: jest.fn().mockResolvedValue([]),
update: jest.fn(),
} as never;
billReferenceService = {
@@ -79,6 +84,47 @@ describe("IntentsService CBE_BILL", () => {
);
});
it("reuses the open bill instead of minting a second reference", async () => {
repository.findAllByReference.mockResolvedValue([
{
id: "intent-1",
provider: ProviderMethod.CBE_BILL,
status: ProviderPaymentStatus.REQUIRES_ACTION,
billReference: "000100000015",
amountMinor: 1500,
currency: "ETB",
clientAction: {
type: "SHOW_BILL_REFERENCE",
billReference: "000100000015",
},
},
] as never);
const snapshot = await service.initiate(request);
expect(snapshot.billReference).toBe("000100000015");
expect(billReferenceService.generate).not.toHaveBeenCalled();
expect(repository.create).not.toHaveBeenCalled();
});
it("mints a new bill when the amount changed", async () => {
repository.findAllByReference.mockResolvedValue([
{
id: "intent-1",
provider: ProviderMethod.CBE_BILL,
status: ProviderPaymentStatus.REQUIRES_ACTION,
billReference: "000100000015",
amountMinor: 900,
currency: "ETB",
},
] as never);
billReferenceService.generate.mockResolvedValue("000100000023");
const snapshot = await service.initiate(request);
expect(snapshot.billReference).toBe("000100000023");
});
it("rejects non-ETB currency (plan D8)", async () => {
await expect(
service.initiate({ ...request, currency: "DJF" }),

View File

@@ -163,6 +163,32 @@ export class IntentsService {
);
}
// The bill reference is issued ONCE per order: the domain app persists it (freight stores it
// as the booking's PNR) and the payer may already have written it down, so re-initiating the
// same open bill must hand back the same number. A different amount/currency means a
// different debt — /cbe/payment verifies the debited amount against the intent — so that
// case mints a fresh bill instead of silently repricing an outstanding one.
const open = (
await this.intentsRepository.findAllByReference(
request.service,
request.referenceType,
request.referenceId,
)
).find(
(i) =>
i.provider === ProviderMethod.CBE_BILL &&
i.status === ProviderPaymentStatus.REQUIRES_ACTION &&
!!i.billReference &&
i.amountMinor === request.amountMinor &&
i.currency === request.currency,
);
if (open) {
this.logger.log(
`intent ${open.id} reused for ${request.service}/${request.referenceType}/${request.referenceId} via CBE_BILL (bill ${open.billReference})`,
);
return this.toSnapshot(open);
}
const merchantOrderId = createMerchantOrderId();
const billReference = await this.billReferenceService.generate();
// expiresAt is the BOOKING's payment deadline passed by the domain app — never a provider