Merge pull request #1338 from Tria-plc/alpha

feat: ( audit ) resolve the actor from the session and audit all back…
This commit is contained in:
Abubeker Yasin
2026-08-18 16:19:15 +03:00
committed by GitHub
32 changed files with 3068 additions and 420 deletions

View File

@@ -0,0 +1,17 @@
-- The audit trail has to answer "who did this" without a cross-schema join: Prisma only knows
-- the `passenger` schema, and an IAM user that is later deleted would take the answer with it.
-- So the actor's name and phone are denormalized onto the row at write time, the same way
-- SeatBlock.blockedByName was added for the blocked-seat report.
--
-- Purely additive: both columns are nullable and the index is new, so rows written before this
-- migration keep working and simply report as System / Unknown in the backoffice.
-- AlterTable
ALTER TABLE "passenger"."AuditLog"
ADD COLUMN IF NOT EXISTS "userName" TEXT,
ADD COLUMN IF NOT EXISTS "userPhone" TEXT;
-- CreateIndex: the default Audit Logs listing sorts createdAt DESC with no other filter, which
-- neither existing composite index serves. Broadening audit coverage materially increases the
-- row count behind that sort.
CREATE INDEX IF NOT EXISTS "AuditLog_createdAt_idx" ON "passenger"."AuditLog"("createdAt");

View File

@@ -1325,6 +1325,11 @@ model ExcessBaggageCharge {
model AuditLog {
id String @id @default(uuid())
iamUserId String?
/// Actor's display name, denormalized at write time so a reader needs no cross-schema
/// lookup into iam.users and the trail survives the IAM user being deleted.
userName String?
/// Actor's contact number, denormalized for the same reason.
userPhone String?
action String
entityType String
entityId String?
@@ -1335,6 +1340,7 @@ model AuditLog {
createdAt DateTime @default(now())
@@index([iamUserId, createdAt])
@@index([entityType, entityId])
@@index([createdAt])
@@schema("passenger")
}

View File

@@ -21,6 +21,7 @@ export interface ActingUserClaims {
name?: ActingUserName | string | null;
username?: string;
email?: string;
phoneNumber?: string;
}
/** The minimal Express request shape needed to reach the authenticated user. */
@@ -34,6 +35,8 @@ export interface ActingUser {
id: string;
/** Human-readable name, denormalized alongside the id. */
name: string;
/** Contact number, denormalized alongside the id. Absent on tokens that omit it. */
phone?: string;
}
/**
@@ -48,7 +51,11 @@ export function resolveActingUser(req: RequestWithActingUser): ActingUser | null
const id = claims?.id ?? claims?.sub;
if (!id) return null;
return { id, name: resolveActingUserName(claims) };
return {
id,
name: resolveActingUserName(claims),
phone: claims?.phoneNumber?.trim() || undefined,
};
}
function resolveActingUserName(claims: ActingUserClaims | undefined): string {

View File

@@ -0,0 +1,124 @@
/**
* Building the `oldData` / `newData` payloads on an audit row.
*
* Audit rows are retained for a year and readable by anyone with `audit:view`, so they must
* carry enough to explain a change and nothing more. Dumping a whole Prisma row is the easy
* mistake: it drags along payment tokens, contact details and identity documents that the
* reader never needed. `snapshot()` inverts the default — you list what to keep.
*/
/**
* Words that must never reach an audit row, whatever the caller asked for.
*
* Matched against the key's own words rather than as raw substrings — a substring test would
* redact `shipping` for containing "pin" and miss nothing in exchange.
*/
const DENIED_WORDS = new Set([
'password',
'passcode',
'secret',
'token',
'otp',
'pin',
'authorization',
'auth',
'apikey',
'passport',
'nationalid',
'fayda',
'ssn',
]);
/** Value written in place of a denied field, so the reader knows something was withheld. */
const REDACTED = '[redacted]';
/**
* Splits `paymentToken`, `payment_token` and `PAYMENT_TOKEN` alike into ['payment', 'token'],
* then reports whether any word is denied.
*/
function isDeniedKey(key: string): boolean {
const words = key
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
.split(/[^A-Za-z0-9]+/)
.map((w) => w.toLowerCase())
.filter(Boolean);
// `nationalId` splits into ['national','id'], so check the joined form too.
return words.some((w) => DENIED_WORDS.has(w)) || DENIED_WORDS.has(words.join(''));
}
/**
* Picks `keys` off `entity`, dropping anything absent and redacting anything sensitive.
*
* Returns `undefined` for a null/undefined entity so a missing "before" state simply omits
* `oldData` rather than storing `{}`.
*/
export function snapshot<T extends object>(
entity: T | null | undefined,
keys: readonly (keyof T & string)[],
): Record<string, unknown> | undefined {
if (!entity) return undefined;
const out: Record<string, unknown> = {};
for (const key of keys) {
const value = (entity as Record<string, unknown>)[key];
if (value === undefined) continue;
out[key] = isDeniedKey(key) ? REDACTED : normalize(value);
}
return out;
}
/**
* Same guarantees as {@link snapshot} for a payload assembled by hand rather than picked off an
* entity — the sweeps and gate branches build their context inline.
*/
export function auditPayload(
data: Record<string, unknown> | null | undefined,
): Record<string, unknown> | undefined {
if (!data) return undefined;
const out: Record<string, unknown> = {};
for (const [key, value] of Object.entries(data)) {
if (value === undefined) continue;
out[key] = isDeniedKey(key) ? REDACTED : normalize(value);
}
return out;
}
/**
* Prisma hands back `Date` and `Decimal` objects. `JSON.stringify` would render a Decimal as
* `{}`, so flatten both to something a reader can compare across two snapshots.
*/
function normalize(value: unknown): unknown {
if (value instanceof Date) return value.toISOString();
if (value && typeof value === 'object' && typeof (value as any).toFixed === 'function') {
return (value as any).toString();
}
return value;
}
/**
* Reduces a before/after pair to only the fields that actually moved.
*
* Used where the "before" row is wide but the edit is narrow (a schedule status flip, a fare
* price change) so the audit row shows the change rather than burying it.
*/
export function changedFields(
before: Record<string, unknown> | undefined,
after: Record<string, unknown> | undefined,
): { oldData?: Record<string, unknown>; newData?: Record<string, unknown> } {
if (!before || !after) return { oldData: before, newData: after };
const oldData: Record<string, unknown> = {};
const newData: Record<string, unknown> = {};
for (const key of new Set([...Object.keys(before), ...Object.keys(after)])) {
if (JSON.stringify(before[key]) === JSON.stringify(after[key])) continue;
oldData[key] = before[key];
newData[key] = after[key];
}
return {
oldData: Object.keys(oldData).length ? oldData : undefined,
newData: Object.keys(newData).length ? newData : undefined,
};
}

View File

@@ -0,0 +1,91 @@
/**
* The closed vocabulary for audit rows.
*
* `AuditLog.action` and `AuditLog.entityType` are plain `String` columns, so nothing at the
* database level stops a caller inventing `created` or `schedule_updated`. These two maps are
* the convention: uppercase, semantic, and imported rather than typed inline. The pre-existing
* rows use `CREATE`/`UPDATE`/`DELETE`/`VERIFY`, so those keep their meaning and the rest extend
* them — no historical row changes shape.
*/
export const AUDIT_ACTIONS = {
// ── CRUD ───────────────────────────────────────────────────────────────────
CREATE: 'CREATE',
UPDATE: 'UPDATE',
DELETE: 'DELETE',
/** Undo of a soft delete (train restore, seat undo-remove, ticket restore). */
RESTORE: 'RESTORE',
// ── Explicit state transitions ─────────────────────────────────────────────
/** A status column moved. Prefer this over UPDATE when status is the point of the change. */
STATUS_CHANGE: 'STATUS_CHANGE',
CANCEL: 'CANCEL',
// ── Seats & coaches ────────────────────────────────────────────────────────
BLOCK: 'BLOCK',
UNBLOCK: 'UNBLOCK',
ASSIGN: 'ASSIGN',
UNASSIGN: 'UNASSIGN',
// ── Charges & money ────────────────────────────────────────────────────────
WAIVE: 'WAIVE',
PAY: 'PAY',
REFUND: 'REFUND',
/** Re-sending a payment link — no state change, but it re-exposes a payment token. */
RESEND: 'RESEND',
// ── Gate ───────────────────────────────────────────────────────────────────
BOARD: 'BOARD',
BOARD_DENIED: 'BOARD_DENIED',
/**
* Retained for backward-compatible filtering of gate rows written before BOARD existed.
* New code uses BOARD / BOARD_DENIED.
*/
VERIFY: 'VERIFY',
// ── Bulk / sweeps ──────────────────────────────────────────────────────────
BULK_CREATE: 'BULK_CREATE',
BULK_UPDATE: 'BULK_UPDATE',
SYNC: 'SYNC',
IMPORT: 'IMPORT',
} as const;
export type AuditAction = (typeof AUDIT_ACTIONS)[keyof typeof AUDIT_ACTIONS];
/**
* Entity names as they appear in `AuditLog.entityType`. These mirror the Prisma model names so
* a reader can go from an audit row straight to the table, and they back the backoffice
* Audit Logs filter dropdown.
*/
export const AUDIT_ENTITIES = {
// Master data
Station: 'Station',
Train: 'Train',
Coach: 'Coach',
CoachType: 'CoachType',
Seat: 'Seat',
SeatClass: 'SeatClass',
Route: 'Route',
RouteStop: 'RouteStop',
RouteCoachTemplate: 'RouteCoachTemplate',
Schedule: 'Schedule',
ScheduleFare: 'ScheduleFare',
CoachAssignment: 'CoachAssignment',
// Finance / tariff
FareRule: 'FareRule',
RouteFareRule: 'RouteFareRule',
SegmentFareRule: 'SegmentFareRule',
Currency: 'Currency',
Payment: 'Payment',
PaymentMethod: 'PaymentMethod',
SupplementaryCharge: 'SupplementaryCharge',
ExcessBaggageCharge: 'ExcessBaggageCharge',
BaggageAllowance: 'BaggageAllowance',
// Operations
Ticket: 'Ticket',
Booking: 'Booking',
} as const;
export type AuditEntity = (typeof AUDIT_ENTITIES)[keyof typeof AUDIT_ENTITIES];

View File

@@ -0,0 +1,224 @@
import { AuditService } from './audit.service';
import { snapshot, auditPayload, changedFields } from './audit-snapshot';
/**
* The audit primitive is the reason ~47 of the 57 existing call sites wrote a NULL actor: it
* held the request (for IP/user-agent) but never read `request.user`. These pin the fix — the
* actor is resolved from the guarded session, callers cannot be trusted to pass it, and a
* logging failure never propagates into the business operation it describes.
*/
describe('AuditService', () => {
const REQUEST = {
user: {
id: 'iam-user-1',
name: { en: 'Abebe Kebede', am: 'አበበ ከበደ' },
username: 'abebe.k',
email: 'abebe@edr.et',
phoneNumber: '+251911223344',
},
headers: {
'x-forwarded-for': '10.1.2.3, 172.16.0.1',
'user-agent': 'Mozilla/5.0 (Backoffice)',
},
socket: { remoteAddress: '127.0.0.1' },
};
const build = (request?: any) => {
const create = jest.fn().mockResolvedValue({});
const prisma = { auditLog: { create, count: jest.fn(), findMany: jest.fn(), findUnique: jest.fn() } };
return { service: new AuditService(prisma as any, request), create, prisma };
};
const written = (create: jest.Mock) => create.mock.calls[0][0].data;
describe('actor resolution', () => {
it('takes the actor from the authenticated request when the caller omits one', async () => {
const { service, create } = build(REQUEST);
await service.log({ action: 'CREATE', entityType: 'Station', entityId: 'st-1' });
expect(written(create)).toMatchObject({
iamUserId: 'iam-user-1',
userName: 'Abebe Kebede',
userPhone: '+251911223344',
});
});
it('ignores nothing the request says — a body cannot smuggle in a different actor', async () => {
const { service, create } = build({
...REQUEST,
// A request body field never reaches AuditService; only `request.user` does.
body: { validatorId: 'someone-else', waivedBy: 'not-me' },
});
await service.log({ action: 'BOARD', entityType: 'Ticket', entityId: 't-1' });
expect(written(create).iamUserId).toBe('iam-user-1');
});
it('lets a system path pass its own actor explicitly', async () => {
const { service, create } = build(REQUEST);
await service.log({
action: 'STATUS_CHANGE',
entityType: 'Schedule',
entityId: 'sc-1',
userId: 'SYSTEM',
userName: 'Reconciliation cron',
});
expect(written(create)).toMatchObject({ iamUserId: 'SYSTEM', userName: 'Reconciliation cron' });
});
it('records a null actor outside an HTTP request rather than inventing one', async () => {
const { service, create } = build(undefined);
await service.log({ action: 'PAY', entityType: 'ExcessBaggageCharge', entityId: 'c-1' });
expect(written(create)).toMatchObject({ iamUserId: null, userName: null, userPhone: null });
});
it('falls back through the name shapes IAM actually returns', async () => {
const { service, create } = build({ user: { sub: 'legacy-id', username: 'gate.agent' }, headers: {} });
await service.log({ action: 'BOARD', entityType: 'Ticket', entityId: 't-2' });
expect(written(create)).toMatchObject({ iamUserId: 'legacy-id', userName: 'gate.agent' });
});
});
describe('request metadata', () => {
it('captures the client IP and user-agent', async () => {
const { service, create } = build(REQUEST);
await service.log({ action: 'UPDATE', entityType: 'Train', entityId: 'tr-1' });
expect(written(create)).toMatchObject({
ipAddress: '10.1.2.3',
userAgent: 'Mozilla/5.0 (Backoffice)',
});
});
it('still writes the row when the request carries no headers', async () => {
// Queue consumers resolve this service with a bare stub; reading through `headers`
// unguarded used to throw and silently drop the whole row.
const { service, create } = build({ user: { id: 'iam-user-1' }, ip: '10.9.9.9' });
await service.log({ action: 'PAY', entityType: 'SupplementaryCharge', entityId: 'sc-1' });
expect(create).toHaveBeenCalledTimes(1);
expect(written(create)).toMatchObject({ ipAddress: '10.9.9.9', userAgent: '' });
});
it('leaves IP and user-agent blank with no request at all', async () => {
const { service, create } = build(undefined);
await service.log({ action: 'DELETE', entityType: 'Seat', entityId: 's-1' });
expect(written(create)).toMatchObject({ ipAddress: '', userAgent: '' });
});
});
describe('failure containment', () => {
it('swallows a write failure instead of breaking the operation it describes', async () => {
const create = jest.fn().mockRejectedValue(new Error('db down'));
const service = new AuditService({ auditLog: { create } } as any, REQUEST);
await expect(
service.log({ action: 'CREATE', entityType: 'Station', entityId: 'st-1' }),
).resolves.toBeUndefined();
});
});
describe('getLogs', () => {
const withRows = () => {
const findMany = jest.fn().mockResolvedValue([]);
const count = jest.fn().mockResolvedValue(0);
const prisma = { auditLog: { findMany, count, create: jest.fn() } };
return { service: new AuditService(prisma as any, REQUEST), findMany, count };
};
it('caps the page size so a caller cannot ask for the whole table', async () => {
const { service, findMany } = withRows();
await service.getLogs({ limit: 5000 });
expect(findMany.mock.calls[0][0].take).toBe(200);
});
it('defaults to the newest 50 rows', async () => {
const { service, findMany } = withRows();
await service.getLogs();
expect(findMany.mock.calls[0][0]).toMatchObject({
take: 50,
skip: 0,
orderBy: { createdAt: 'desc' },
});
});
it('filters by exact actor and a date range', async () => {
const { service, findMany } = withRows();
await service.getLogs({ iamUserId: 'iam-user-1', from: '2026-08-01', to: '2026-08-18' });
const where = findMany.mock.calls[0][0].where;
expect(where.iamUserId).toBe('iam-user-1');
expect(where.createdAt.gte).toEqual(new Date('2026-08-01'));
expect(where.createdAt.lte).toEqual(new Date('2026-08-18'));
});
it('ignores an unparseable date instead of returning nothing', async () => {
const { service, findMany } = withRows();
await service.getLogs({ from: 'not-a-date' });
expect(findMany.mock.calls[0][0].where.createdAt).toBeUndefined();
});
it('searches the denormalized actor name and phone, not just ids', async () => {
const { service, findMany } = withRows();
await service.getLogs({ search: 'abebe' });
const fields = findMany.mock.calls[0][0].where.OR.map((c: any) => Object.keys(c)[0]);
expect(fields).toEqual(
expect.arrayContaining(['entityId', 'iamUserId', 'userName', 'userPhone']),
);
});
});
});
describe('audit snapshot helpers', () => {
it('keeps only the listed fields', () => {
const entity = { id: 'x', name: 'Dire Dawa', code: 'DD', internalNote: 'do not log' };
expect(snapshot(entity, ['name', 'code'])).toEqual({ name: 'Dire Dawa', code: 'DD' });
});
it('drops absent fields rather than writing undefined', () => {
expect(snapshot({ name: 'X', code: undefined } as any, ['name', 'code'])).toEqual({ name: 'X' });
});
it('returns undefined for a missing entity so oldData is simply omitted', () => {
expect(snapshot(null, ['name'] as any)).toBeUndefined();
});
it('redacts credentials even when a caller asks for them', () => {
const charge = { paymentToken: 'tok-live-1', amountMinor: 1000 } as any;
expect(snapshot(charge, ['paymentToken', 'amountMinor'])).toEqual({
paymentToken: '[redacted]',
amountMinor: 1000,
});
});
it('redacts on word boundaries, not raw substrings', () => {
// "shipping" contains the letters of "pin"; it is not a credential.
const row = { shippingNote: 'leave at gate', otp: '4530', national_id: 'ETH-1' } as any;
expect(auditPayload(row)).toEqual({
shippingNote: 'leave at gate',
otp: '[redacted]',
national_id: '[redacted]',
});
});
it('flattens dates so two snapshots can be compared', () => {
const at = new Date('2026-08-18T06:00:00.000Z');
expect(snapshot({ departureAt: at } as any, ['departureAt'])).toEqual({
departureAt: '2026-08-18T06:00:00.000Z',
});
});
it('reduces a before/after pair to the fields that actually moved', () => {
const before = { status: 'SCHEDULED', departureTime: '08:00' };
const after = { status: 'CANCELLED', departureTime: '08:00' };
expect(changedFields(before, after)).toEqual({
oldData: { status: 'SCHEDULED' },
newData: { status: 'CANCELLED' },
});
});
});

View File

@@ -1,6 +1,38 @@
import { Injectable, Inject, Logger, Optional } from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { PrismaService } from './prisma.service';
import { AuditAction } from './audit.actions';
import { resolveActingUser } from './acting-user';
export interface AuditLogInput {
/**
* Overrides the actor. Leave it out on any request-scoped path — `log()` resolves the
* authenticated IAM user from the request, which is the only source a caller cannot forge.
*/
userId?: string;
userName?: string;
userPhone?: string;
/** See `AUDIT_ACTIONS`. Widened to `string` so the 57 pre-existing call sites still compile. */
action: AuditAction | string;
entityType: string;
entityId?: string;
oldData?: any;
newData?: any;
}
export interface AuditLogFilters {
search?: string;
action?: string;
entityType?: string;
/** Exact IAM user id — "everything this staff member did". */
iamUserId?: string;
/** Inclusive lower bound on `createdAt`. */
from?: Date | string;
/** Inclusive upper bound on `createdAt`. */
to?: Date | string;
limit?: number;
offset?: number;
}
@Injectable()
export class AuditService {
@@ -10,21 +42,21 @@ export class AuditService {
@Optional() @Inject(REQUEST) private request?: any,
) {}
async log(input: {
userId?: string;
action: 'CREATE' | 'UPDATE' | 'DELETE' | 'LOGIN' | 'LOGOUT' | 'VERIFY' | string;
entityType: string;
entityId?: string;
oldData?: any;
newData?: any;
}) {
async log(input: AuditLogInput) {
try {
// The actor is read off the guarded request rather than trusted from the caller, so a
// request body can never decide whose name lands in the trail. Callers may still pass
// `userId` explicitly for system paths (crons, queue consumers) that have no request.
const actor = this.request ? resolveActingUser(this.request) : null;
const ipAddress = this.getIpAddress();
const userAgent = this.getUserAgent();
await this.prisma.auditLog.create({
data: {
iamUserId: input.userId,
iamUserId: input.userId ?? actor?.id ?? null,
userName: input.userName ?? actor?.name ?? null,
userPhone: input.userPhone ?? actor?.phone ?? null,
action: input.action,
entityType: input.entityType,
entityId: input.entityId,
@@ -41,11 +73,14 @@ export class AuditService {
}
private getIpAddress(): string {
if (!this.request) return '';
// `headers` is absent on non-HTTP contexts (queue consumers resolve this service with a
// bare request stub); reading through it unguarded used to throw and silently drop the row.
const headers = this.request?.headers;
if (!headers) return this.request?.ip || '';
return (
this.request.headers['x-forwarded-for']?.split(',')[0].trim() ||
this.request.headers['x-real-ip'] ||
headers['x-forwarded-for']?.split(',')[0].trim() ||
headers['x-real-ip'] ||
this.request.connection?.remoteAddress ||
this.request.socket?.remoteAddress ||
this.request.ip ||
@@ -57,13 +92,15 @@ export class AuditService {
return this.request?.headers?.['user-agent'] || '';
}
async getLogs(filters: any = {}) {
async getLogs(filters: AuditLogFilters = {}) {
const where: any = {};
if (filters.search) {
where.OR = [
{ entityId: { contains: filters.search, mode: 'insensitive' } },
{ iamUserId: { contains: filters.search, mode: 'insensitive' } },
{ userName: { contains: filters.search, mode: 'insensitive' } },
{ userPhone: { contains: filters.search, mode: 'insensitive' } },
];
}
@@ -75,6 +112,15 @@ export class AuditService {
where.entityType = filters.entityType;
}
if (filters.iamUserId) {
where.iamUserId = filters.iamUserId;
}
const createdAt = this.dateRange(filters.from, filters.to);
if (createdAt) {
where.createdAt = createdAt;
}
const limit = Math.min(filters.limit ?? 50, 200);
const offset = filters.offset ?? 0;
@@ -94,4 +140,23 @@ export class AuditService {
async getLog(id: string) {
return this.prisma.auditLog.findUnique({ where: { id } });
}
/** Builds a Prisma date filter, ignoring bounds that don't parse. */
private dateRange(from?: Date | string, to?: Date | string) {
const range: { gte?: Date; lte?: Date } = {};
const lower = this.toDate(from);
if (lower) range.gte = lower;
const upper = this.toDate(to);
if (upper) range.lte = upper;
return Object.keys(range).length ? range : undefined;
}
private toDate(value?: Date | string): Date | undefined {
if (!value) return undefined;
const date = value instanceof Date ? value : new Date(value);
return Number.isNaN(date.getTime()) ? undefined : date;
}
}

View File

@@ -1,8 +1,9 @@
import { Controller, Get, Param, Query } from '@nestjs/common';
import { Controller, Get, Param, ParseIntPipe, Query } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth, ApiQuery } from '@nestjs/swagger';
import { AuditService } from '../../common/audit.service';
import { PassengerStaff } from '../../common/passenger-guards';
import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry';
import { AUDIT_ACTIONS, AUDIT_ENTITIES } from '../../common/audit.actions';
@ApiTags('Audit')
@Controller('audit')
@@ -16,24 +17,52 @@ export class AuditController {
summary: 'Get audit logs',
description: 'Retrieve system audit logs with optional filtering',
})
@ApiQuery({ name: 'search', required: false, description: 'Search by user email or entity ID' })
@ApiQuery({ name: 'action', required: false, description: 'Filter by action (CREATE, UPDATE, DELETE, etc.)' })
@ApiQuery({ name: 'entityType', required: false, description: 'Filter by entity type (Booking, Station, etc.)' })
@ApiQuery({ name: 'search', required: false, description: 'Match entity ID, actor ID, actor name, or actor phone' })
@ApiQuery({ name: 'action', required: false, description: 'Filter by action (CREATE, UPDATE, DELETE, BOARD, WAIVE, ...)' })
@ApiQuery({ name: 'entityType', required: false, description: 'Filter by entity type (Booking, Station, Ticket, ...)' })
@ApiQuery({ name: 'iamUserId', required: false, description: 'Exact IAM user id — everything one staff member did' })
@ApiQuery({ name: 'from', required: false, description: 'Earliest createdAt (ISO 8601), inclusive' })
@ApiQuery({ name: 'to', required: false, description: 'Latest createdAt (ISO 8601), inclusive' })
@ApiQuery({ name: 'limit', required: false, type: Number, description: 'Page size (default 50, max 200)' })
@ApiQuery({ name: 'offset', required: false, type: Number, description: 'Rows to skip (default 0)' })
async getLogs(
@Query('search') search?: string,
@Query('action') action?: string,
@Query('entityType') entityType?: string,
@Query('iamUserId') iamUserId?: string,
@Query('from') from?: string,
@Query('to') to?: string,
// The service has always implemented paging; the controller simply never forwarded it, which
// pinned the backoffice page and its CSV export to the 50 newest rows.
@Query('limit', new ParseIntPipe({ optional: true })) limit?: number,
@Query('offset', new ParseIntPipe({ optional: true })) offset?: number,
) {
const filters = {
const result = await this.auditService.getLogs({
search: search || undefined,
action: action || undefined,
entityType: entityType || undefined,
};
const result = await this.auditService.getLogs(filters);
iamUserId: iamUserId || undefined,
from: from || undefined,
to: to || undefined,
limit,
offset,
});
return { items: result.data, total: result.total, limit: result.limit, offset: result.offset };
}
/**
* The vocabularies the writers use, so the backoffice filters stay in step with what the API
* actually records instead of drifting behind a hand-maintained list.
*/
@Get('vocabulary')
@ApiOperation({ summary: 'Audit action and entity-type vocabularies' })
getVocabulary() {
return {
actions: Object.values(AUDIT_ACTIONS),
entityTypes: Object.values(AUDIT_ENTITIES),
};
}
@Get('logs/:id')
@ApiOperation({ summary: 'Get audit log by ID' })
async getLog(@Param('id') id: string) {

View File

@@ -3,6 +3,7 @@ import { PrismaService } from '../../common/prisma.service';
import { CurrencyService } from '../currency/currency.service';
import { CreateCurrencyDto, UpdateCurrencyDto } from './currencies.dto';
import { AuditService } from '../../common/audit.service';
import { AUDIT_ACTIONS, AUDIT_ENTITIES } from '../../common/audit.actions';
@Injectable()
export class CurrenciesService {
@@ -59,7 +60,17 @@ export class CurrenciesService {
},
});
await this.auditService.log({ action: 'CREATE', entityType: 'Currency', entityId: rate.id, newData: { code, exchangeRate } });
await this.auditService.log({
action: AUDIT_ACTIONS.CREATE,
entityType: AUDIT_ENTITIES.Currency,
entityId: rate.id,
newData: {
fromCurrency: rate.fromCurrency,
toCurrency: rate.toCurrency,
rate: Number(rate.rate),
source: rate.source,
},
});
return {
id: rate.id,
code: rate.toCurrency,
@@ -95,7 +106,22 @@ export class CurrenciesService {
'MANUAL',
);
await this.auditService.log({ action: 'UPDATE', entityType: 'Currency', entityId: updated.id, newData: { exchangeRate: Number(updated.rate) } });
await this.auditService.log({
action: AUDIT_ACTIONS.UPDATE,
entityType: AUDIT_ENTITIES.Currency,
entityId: updated.id,
oldData: {
fromCurrency: existing.fromCurrency,
toCurrency: existing.toCurrency,
rate: Number(existing.rate),
},
newData: {
fromCurrency: updated.fromCurrency,
toCurrency: updated.toCurrency,
rate: Number(updated.rate),
source: updated.source,
},
});
return {
id: updated.id,
code: updated.toCurrency,
@@ -112,6 +138,8 @@ export class CurrenciesService {
async syncExchangeRates() {
// Placeholder: in production this would fetch from an external FX API.
// For now, return the current rates as-is.
// Deliberately unaudited: this writes nothing today. Instrument it in the same commit that
// gives it a real external fetch, otherwise the trail claims a change that never happened.
const currencies = await this.getAllCurrencies();
return { synced: true, rates: currencies };
}
@@ -129,7 +157,16 @@ export class CurrenciesService {
await this.prisma.currencyExchangeRate.deleteMany({
where: { fromCurrency: existing.fromCurrency, toCurrency: existing.toCurrency },
});
await this.auditService.log({ action: 'DELETE', entityType: 'Currency', entityId: id, oldData: { toCurrency: existing.toCurrency } });
await this.auditService.log({
action: AUDIT_ACTIONS.DELETE,
entityType: AUDIT_ENTITIES.Currency,
entityId: id,
oldData: {
fromCurrency: existing.fromCurrency,
toCurrency: existing.toCurrency,
rate: Number(existing.rate),
},
});
return { message: 'Currency deleted successfully' };
}

View File

@@ -0,0 +1,229 @@
import { ExcessBaggageService } from './excess-baggage.service';
/**
* Excess baggage settles through two paths — the in-app one (`markPaid`, reached from
* `initiatePayment`/`confirmOtp`) and the payment webhook. The in-app path used to write no
* audit row at all, so whether a settlement was recorded depended on which route reached it
* first. Both now claim the transition conditionally, so it lands exactly once either way.
*/
describe('ExcessBaggageService — audit', () => {
const CHARGE_ID = 'ebc-1';
const BOOKING_ID = 'booking-1';
let prisma: Record<string, any>;
let audit: { log: jest.Mock };
let service: ExcessBaggageService;
const build = (charge: Record<string, any> = {}) => {
const row = {
id: CHARGE_ID,
bookingId: BOOKING_ID,
agentId: 'iam-agent-1',
excessWeightKg: 8,
feePerKgMinor: 5000,
totalMinor: 40000,
currency: 'ETB',
status: 'PENDING',
paymentToken: 'tok-live-secret',
contactPhone: '+251911223344',
contactEmail: 'passenger@example.com',
expiresAt: new Date(Date.now() + 30 * 60 * 1000),
booking: { bookingRef: 'EDR-0001', passengerId: 'p-1' },
...charge,
};
prisma = {
excessBaggageCharge: {
findUnique: jest.fn().mockResolvedValue(row),
// Default: this caller wins the race and flips the row.
updateMany: jest.fn().mockResolvedValue({ count: 1 }),
update: jest.fn().mockResolvedValue({ ...row, status: 'WAIVED' }),
delete: jest.fn().mockResolvedValue(row),
create: jest.fn().mockResolvedValue(row),
},
baggageAllowance: {
findFirst: jest.fn().mockResolvedValue(null),
findUnique: jest.fn().mockResolvedValue(null),
create: jest.fn(),
update: jest.fn(),
deleteMany: jest.fn().mockResolvedValue({ count: 1 }),
},
};
audit = { log: jest.fn().mockResolvedValue(undefined) };
// Constructor order: prisma, audit, currency, paymentClient, notifications, sms, email.
service = new ExcessBaggageService(
prisma as any,
audit as any,
{} as any,
{} as any,
{} as any,
{ sendSms: jest.fn() } as any,
{ sendEmail: jest.fn() } as any,
);
return row;
};
const rows = () => audit.log.mock.calls.map((c) => c[0]);
const byAction = (action: string) => rows().filter((r) => r.action === action);
describe('markPaid', () => {
it('writes one PAY row when it actually flips the charge', async () => {
build();
await service.markPaid(CHARGE_ID, 'TXN-77');
expect(byAction('PAY')).toHaveLength(1);
expect(byAction('PAY')[0]).toMatchObject({
entityType: 'ExcessBaggageCharge',
entityId: CHARGE_ID,
oldData: { status: 'PENDING' },
});
expect(byAction('PAY')[0].newData).toMatchObject({
status: 'PAID',
bookingId: BOOKING_ID,
totalMinor: 40000,
providerTxnId: 'TXN-77',
});
});
it('writes nothing when the webhook already claimed the transition', async () => {
build();
// count: 0 means another caller flipped the row first and already logged it.
prisma.excessBaggageCharge.updateMany.mockResolvedValue({ count: 0 });
await service.markPaid(CHARGE_ID, 'TXN-77');
expect(byAction('PAY')).toHaveLength(0);
});
it('is a no-op on a charge already read as PAID', async () => {
build({ status: 'PAID' });
await service.markPaid(CHARGE_ID);
expect(audit.log).not.toHaveBeenCalled();
expect(prisma.excessBaggageCharge.updateMany).not.toHaveBeenCalled();
});
it('never puts the payment token on the row', async () => {
build();
await service.markPaid(CHARGE_ID, 'TXN-77');
expect(JSON.stringify(rows())).not.toContain('tok-live-secret');
});
});
describe('waiveCharge', () => {
it('records WAIVE with the status it moved from', async () => {
build();
await service.waiveCharge(CHARGE_ID, { waivedBy: 'Supervisor Bob', waivedReason: 'goodwill' });
expect(byAction('WAIVE')).toHaveLength(1);
expect(byAction('WAIVE')[0]).toMatchObject({
entityType: 'ExcessBaggageCharge',
entityId: CHARGE_ID,
oldData: { status: 'PENDING' },
});
});
it('does not let the request body become the audit actor', async () => {
build();
await service.waiveCharge(CHARGE_ID, { waivedBy: 'somebody-else', waivedReason: 'x' });
// `waivedBy` is descriptive context only; the actor comes from the session inside
// AuditService, so no call site sets `userId`.
const row = byAction('WAIVE')[0];
expect(row.userId).toBeUndefined();
expect(row.newData.waivedBy).toBe('somebody-else');
});
it('records nothing when the waiver is refused', async () => {
build({ status: 'PAID' });
await expect(
service.waiveCharge(CHARGE_ID, { waivedBy: 'Bob' }),
).rejects.toThrow();
expect(audit.log).not.toHaveBeenCalled();
});
});
describe('deleteCharge', () => {
it('records the hard delete of a money record', async () => {
build();
await service.deleteCharge(CHARGE_ID);
expect(byAction('DELETE')).toHaveLength(1);
expect(byAction('DELETE')[0].oldData).toMatchObject({
bookingId: BOOKING_ID,
totalMinor: 40000,
status: 'PENDING',
});
});
});
describe('baggage allowances (Tariff Rates)', () => {
it('records a CREATE when no allowance existed', async () => {
build();
prisma.baggageAllowance.create.mockResolvedValue({
id: 'ba-1',
seatClassId: 'sc-1',
maxWeightKg: 20,
maxPiecesCount: 2,
excessFeePerKg: 5000,
});
await service.upsertAllowance({ seatClassId: 'sc-1', maxWeightKg: 20, excessFeePerKg: 5000 });
expect(byAction('CREATE')).toHaveLength(1);
expect(byAction('CREATE')[0]).toMatchObject({ entityType: 'BaggageAllowance', entityId: 'ba-1' });
});
it('records an UPDATE with the previous fee when one already existed', async () => {
build();
prisma.baggageAllowance.findFirst.mockResolvedValue({
id: 'ba-1',
seatClassId: 'sc-1',
maxWeightKg: 20,
maxPiecesCount: 2,
excessFeePerKg: 5000,
});
prisma.baggageAllowance.update.mockResolvedValue({
id: 'ba-1',
seatClassId: 'sc-1',
maxWeightKg: 25,
maxPiecesCount: 2,
excessFeePerKg: 7500,
});
await service.upsertAllowance({ seatClassId: 'sc-1', maxWeightKg: 25, excessFeePerKg: 7500 });
const row = byAction('UPDATE')[0];
expect(row).toMatchObject({ entityType: 'BaggageAllowance', entityId: 'ba-1' });
expect(row.oldData).toMatchObject({ excessFeePerKg: 5000 });
expect(row.newData).toMatchObject({ excessFeePerKg: 7500 });
});
it('records the deletion of an allowance', async () => {
build();
prisma.baggageAllowance.findUnique.mockResolvedValue({
id: 'ba-1',
seatClassId: 'sc-1',
maxWeightKg: 20,
maxPiecesCount: 2,
excessFeePerKg: 5000,
});
await service.deleteAllowance('ba-1');
expect(byAction('DELETE')).toHaveLength(1);
expect(byAction('DELETE')[0].oldData).toMatchObject({ excessFeePerKg: 5000 });
});
});
describe('reads', () => {
it('writes nothing when listing allowances', async () => {
build();
prisma.baggageAllowance.findMany = jest.fn().mockResolvedValue([]);
prisma.seatClass = { findMany: jest.fn().mockResolvedValue([]) };
await service.getAllowances();
expect(audit.log).not.toHaveBeenCalled();
});
});
});

View File

@@ -36,6 +36,7 @@ describe('ExcessBaggageService — charge currency', () => {
excessBaggageCharge: {
findUnique: jest.fn().mockResolvedValue(charge),
update: jest.fn().mockResolvedValue({ ...charge, status: 'PAID' }),
updateMany: jest.fn().mockResolvedValue({ count: 1 }),
},
paymentMethod: { findUnique: jest.fn() },
currencyExchangeRate: {
@@ -188,6 +189,7 @@ describe('ExcessBaggageService — CAC Bank OTP debit', () => {
excessBaggageCharge: {
findUnique: jest.fn().mockResolvedValue(charge),
update: jest.fn().mockResolvedValue({ ...charge, status: 'PAID' }),
updateMany: jest.fn().mockResolvedValue({ count: 1 }),
},
paymentMethod: {
findUnique: jest
@@ -263,9 +265,9 @@ describe('ExcessBaggageService — CAC Bank OTP debit', () => {
CHARGE_ID,
);
expect(paymentClient.confirmOtp).toHaveBeenCalledWith('intent-1', '4530');
expect(prisma.excessBaggageCharge.update).toHaveBeenCalledWith(
expect(prisma.excessBaggageCharge.updateMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: CHARGE_ID },
where: expect.objectContaining({ id: CHARGE_ID }),
data: expect.objectContaining({ status: 'PAID' }),
}),
);
@@ -281,6 +283,7 @@ describe('ExcessBaggageService — CAC Bank OTP debit', () => {
const result = await service.confirmOtp(TOKEN, '0000');
expect(prisma.excessBaggageCharge.update).not.toHaveBeenCalled();
expect(prisma.excessBaggageCharge.updateMany).not.toHaveBeenCalled();
expect(result).toMatchObject({ status: 'REQUIRES_ACTION' });
});
@@ -339,6 +342,7 @@ describe('ExcessBaggageService — CBE bill', () => {
excessBaggageCharge: {
findUnique: jest.fn().mockResolvedValue(charge),
update: jest.fn().mockResolvedValue(charge),
updateMany: jest.fn().mockResolvedValue({ count: 1 }),
},
booking: {
findUnique: jest.fn().mockResolvedValue({

View File

@@ -29,7 +29,7 @@ export class ExcessBaggageAgentController {
@Post()
@ApiOperation({ summary: 'Log excess baggage charge and optionally collect cash' })
logCharge(@Request() req: any, @Body() dto: LogExcessBaggageDto) {
dto.agentId = req.user?.id ?? req.user?.sub ?? dto.agentId;
dto.agentId = req.user?.id ?? req.user?.sub ?? '';
return this.service.logCharge(dto);
}

View File

@@ -6,6 +6,15 @@ import {
} from '@nestjs/common';
import { PrismaService } from '../../common/prisma.service';
import { AuditService } from '../../common/audit.service';
import { AUDIT_ACTIONS, AUDIT_ENTITIES } from '../../common/audit.actions';
import { snapshot } from '../../common/audit-snapshot';
const ALLOWANCE_AUDIT_FIELDS = [
'seatClassId',
'maxWeightKg',
'maxPiecesCount',
'excessFeePerKg',
] as const;
import { CurrencyService } from '../currency/currency.service';
import { PaymentClientService } from '../payments/payment-client.service';
import { NotificationsService } from '../notifications/notifications.service';
@@ -131,7 +140,24 @@ export class ExcessBaggageService {
await this.sendPaymentLink(charge, booking, contactPhone, contactEmail);
}
await this.auditService.log({ action: 'CREATE', entityType: 'ExcessBaggageCharge', entityId: charge.id, newData: { bookingId: booking.id, excessWeightKg: dto.excessWeightKg, totalMinor, status } });
// The charge row carries contactPhone/contactEmail for the payment link; those stay out of
// the audit payload, which needs only the money and who raised it.
await this.auditService.log({
action: AUDIT_ACTIONS.CREATE,
entityType: AUDIT_ENTITIES.ExcessBaggageCharge,
entityId: charge.id,
newData: {
bookingId: booking.id,
bookingRef: booking.bookingRef,
excessWeightKg: dto.excessWeightKg,
feePerKgMinor,
totalMinor,
currency: charge.currency,
status,
agentId: charge.agentId || null,
collectCash: dto.collectCash ?? false,
},
});
return charge;
}
@@ -431,10 +457,34 @@ export class ExcessBaggageService {
const charge = await this.prisma.excessBaggageCharge.findUnique({ where: { id: chargeId } });
if (!charge) throw new NotFoundException('Charge not found');
if (charge.status === 'PAID') return charge;
return this.prisma.excessBaggageCharge.update({
where: { id: chargeId },
// Conditional claim: PaymentsService.handleExcessBaggageChargeEvent drives the same
// transition from the webhook. Whichever caller actually flips the row writes the audit
// event, so the settlement is recorded exactly once regardless of which path won.
const { count } = await this.prisma.excessBaggageCharge.updateMany({
where: { id: chargeId, status: { notIn: ['PAID', 'CASH_COLLECTED'] } },
data: { status: 'PAID', paidAt: new Date() },
});
const updated = await this.prisma.excessBaggageCharge.findUnique({ where: { id: chargeId } });
if (count === 1) {
await this.auditService.log({
action: AUDIT_ACTIONS.PAY,
entityType: AUDIT_ENTITIES.ExcessBaggageCharge,
entityId: chargeId,
oldData: { status: charge.status },
newData: {
status: 'PAID',
bookingId: charge.bookingId,
totalMinor: charge.totalMinor,
currency: charge.currency,
providerTxnId: providerTxnId ?? null,
},
});
}
return updated!;
}
async waiveCharge(id: string, dto: WaiveChargeDto) {
@@ -447,7 +497,22 @@ export class ExcessBaggageService {
where: { id },
data: { status: 'WAIVED', waivedBy: dto.waivedBy, waivedReason: dto.waivedReason },
});
await this.auditService.log({ action: 'UPDATE', entityType: 'ExcessBaggageCharge', entityId: id, newData: { status: 'WAIVED', waivedBy: dto.waivedBy, waivedReason: dto.waivedReason } });
// `dto.waivedBy` is a client-supplied label kept for the business column; the audit actor
// is resolved from the session by AuditService, so the two cannot disagree about who acted.
await this.auditService.log({
action: AUDIT_ACTIONS.WAIVE,
entityType: AUDIT_ENTITIES.ExcessBaggageCharge,
entityId: id,
oldData: { status: charge.status },
newData: {
status: 'WAIVED',
bookingId: charge.bookingId,
totalMinor: charge.totalMinor,
currency: charge.currency,
waivedBy: dto.waivedBy,
waivedReason: dto.waivedReason,
},
});
return waived;
}
@@ -466,6 +531,16 @@ export class ExcessBaggageService {
data: { expiresAt: new Date(Date.now() + CHARGE_TTL_MS) },
});
await this.sendPaymentLink(updatedCharge, charge.booking, charge.contactPhone, charge.contactEmail);
await this.auditService.log({
action: AUDIT_ACTIONS.RESEND,
entityType: AUDIT_ENTITIES.ExcessBaggageCharge,
entityId: id,
oldData: { expiresAt: charge.expiresAt?.toISOString() },
newData: {
bookingRef: charge.booking.bookingRef,
expiresAt: updatedCharge.expiresAt?.toISOString(),
},
});
return { sent: true };
}
@@ -524,30 +599,79 @@ export class ExcessBaggageService {
async upsertAllowance(dto: { seatClassId: string; maxWeightKg?: number; maxPiecesCount?: number; excessFeePerKg: number }) {
const existing = await this.prisma.baggageAllowance.findFirst({ where: { seatClassId: dto.seatClassId } });
if (existing) {
return this.prisma.baggageAllowance.update({
const updated = await this.prisma.baggageAllowance.update({
where: { id: existing.id },
data: { maxWeightKg: dto.maxWeightKg ?? 0, maxPiecesCount: dto.maxPiecesCount ?? 0, excessFeePerKg: dto.excessFeePerKg },
});
// An upsert, so report the edit rather than always claiming a create — this is a tariff
// change and the previous fee is the whole point of the row.
await this.auditService.log({
action: AUDIT_ACTIONS.UPDATE,
entityType: AUDIT_ENTITIES.BaggageAllowance,
entityId: existing.id,
oldData: snapshot(existing, ALLOWANCE_AUDIT_FIELDS),
newData: snapshot(updated, ALLOWANCE_AUDIT_FIELDS),
});
return updated;
}
return this.prisma.baggageAllowance.create({
const created = await this.prisma.baggageAllowance.create({
data: { seatClassId: dto.seatClassId, maxWeightKg: dto.maxWeightKg ?? 0, maxPiecesCount: dto.maxPiecesCount ?? 0, excessFeePerKg: dto.excessFeePerKg },
});
await this.auditService.log({
action: AUDIT_ACTIONS.CREATE,
entityType: AUDIT_ENTITIES.BaggageAllowance,
entityId: created.id,
newData: snapshot(created, ALLOWANCE_AUDIT_FIELDS),
});
return created;
}
async updateAllowance(id: string, dto: Partial<{ maxWeightKg: number; maxPiecesCount: number; excessFeePerKg: number }>) {
return this.prisma.baggageAllowance.update({ where: { id }, data: dto });
const existing = await this.prisma.baggageAllowance.findUnique({ where: { id } });
if (!existing) throw new NotFoundException('Baggage allowance not found');
const updated = await this.prisma.baggageAllowance.update({ where: { id }, data: dto });
await this.auditService.log({
action: AUDIT_ACTIONS.UPDATE,
entityType: AUDIT_ENTITIES.BaggageAllowance,
entityId: id,
oldData: snapshot(existing, ALLOWANCE_AUDIT_FIELDS),
newData: snapshot(updated, ALLOWANCE_AUDIT_FIELDS),
});
return updated;
}
async deleteAllowance(id: string) {
const existing = await this.prisma.baggageAllowance.findUnique({ where: { id } });
await this.prisma.baggageAllowance.deleteMany({ where: { id } });
if (existing) {
await this.auditService.log({
action: AUDIT_ACTIONS.DELETE,
entityType: AUDIT_ENTITIES.BaggageAllowance,
entityId: id,
oldData: snapshot(existing, ALLOWANCE_AUDIT_FIELDS),
});
}
return { deleted: true };
}
async deleteCharge(id: string) {
const charge = await this.prisma.excessBaggageCharge.findUnique({ where: { id } });
if (!charge) throw new NotFoundException('Charge not found');
await this.prisma.excessBaggageCharge.delete({ where: { id } });
// Hard delete of a money record — previously silent.
await this.auditService.log({
action: AUDIT_ACTIONS.DELETE,
entityType: AUDIT_ENTITIES.ExcessBaggageCharge,
entityId: id,
oldData: {
bookingId: charge.bookingId,
excessWeightKg: charge.excessWeightKg,
totalMinor: charge.totalMinor,
currency: charge.currency,
status: charge.status,
},
});
return { deleted: true };
}
}

View File

@@ -4,6 +4,37 @@ import { CreateTrainDto, CreateCoachDto, UpdateCoachDto, AssignCoachDto, ListCoa
import { SeatKind } from '@prisma/client';
import { DeleteOperationException } from '../../common/exceptions/delete-operation.exception';
import { AuditService } from '../../common/audit.service';
import { AUDIT_ACTIONS, AUDIT_ENTITIES } from '../../common/audit.actions';
import { snapshot } from '../../common/audit-snapshot';
/** Fields carried into audit rows, per entity. Deliberately narrow — see audit-snapshot.ts. */
const COACH_TYPE_AUDIT_FIELDS = ['code', 'name', 'type'] as const;
const SEAT_CLASS_AUDIT_FIELDS = [
'coachTypeId',
'name',
'description',
'baseFareMinor',
'premiumMinor',
'insuranceFeeMinor',
'isActive',
] as const;
const TRAIN_AUDIT_FIELDS = [
'number',
'name',
'operatorId',
'operatorName',
'description',
'isActive',
] as const;
const COACH_AUDIT_FIELDS = [
'number',
'coachTypeId',
'arrangement',
'capacity',
'status',
'sequence',
] as const;
const COACH_ASSIGNMENT_AUDIT_FIELDS = ['scheduleId', 'coachId', 'positionNumber', 'isOperational'] as const;
// Parses '2+2' → [2, 2], '2+2+2' → [2, 2, 2]
function parseArrangement(arrangement: string): number[] {
@@ -151,7 +182,7 @@ export class FleetService {
constructor(private prisma: PrismaService, private auditService: AuditService) {}
async createCoachType(dto: CreateCoachTypeDto) {
return this.prisma.coachType.create({
const coachType = await this.prisma.coachType.create({
data: {
code: dto.code,
name: dto.name,
@@ -162,6 +193,13 @@ export class FleetService {
coaches: true,
},
});
await this.auditService.log({
action: AUDIT_ACTIONS.CREATE,
entityType: AUDIT_ENTITIES.CoachType,
entityId: coachType.id,
newData: snapshot(coachType, COACH_TYPE_AUDIT_FIELDS),
});
return coachType;
}
async getCoachTypes() {
@@ -183,7 +221,7 @@ export class FleetService {
if (dto.name !== undefined) data.name = dto.name;
if (dto.type !== undefined) data.type = dto.type;
return this.prisma.coachType.update({
const updated = await this.prisma.coachType.update({
where: { id },
data,
include: {
@@ -191,6 +229,14 @@ export class FleetService {
coaches: true,
},
});
await this.auditService.log({
action: AUDIT_ACTIONS.UPDATE,
entityType: AUDIT_ENTITIES.CoachType,
entityId: id,
oldData: snapshot(coachType, COACH_TYPE_AUDIT_FIELDS),
newData: snapshot(updated, COACH_TYPE_AUDIT_FIELDS),
});
return updated;
}
async deleteCoachType(id: string) {
@@ -224,11 +270,18 @@ export class FleetService {
throw new DeleteOperationException('Coach Type', coachType.name, constraints);
}
return this.prisma.coachType.delete({ where: { id } });
const deleted = await this.prisma.coachType.delete({ where: { id } });
await this.auditService.log({
action: AUDIT_ACTIONS.DELETE,
entityType: AUDIT_ENTITIES.CoachType,
entityId: id,
oldData: snapshot(coachType, COACH_TYPE_AUDIT_FIELDS),
});
return deleted;
}
async createClass(dto: CreateClassDto) {
return this.prisma.seatClass.create({
const seatClass = await this.prisma.seatClass.create({
data: {
coachTypeId: dto.coachTypeId,
name: dto.name,
@@ -239,6 +292,13 @@ export class FleetService {
...(dto.isActive !== undefined && { isActive: dto.isActive }),
},
});
await this.auditService.log({
action: AUDIT_ACTIONS.CREATE,
entityType: AUDIT_ENTITIES.SeatClass,
entityId: seatClass.id,
newData: snapshot(seatClass, SEAT_CLASS_AUDIT_FIELDS),
});
return seatClass;
}
async getClasses(coachTypeId?: string) {
@@ -267,11 +327,19 @@ export class FleetService {
updateData.isActive = dto.isActive;
}
return this.prisma.seatClass.update({
const updated = await this.prisma.seatClass.update({
where: { id },
data: updateData,
include: { coachType: true },
});
await this.auditService.log({
action: AUDIT_ACTIONS.UPDATE,
entityType: AUDIT_ENTITIES.SeatClass,
entityId: id,
oldData: snapshot(seatClass, SEAT_CLASS_AUDIT_FIELDS),
newData: snapshot(updated, SEAT_CLASS_AUDIT_FIELDS),
});
return updated;
}
async deleteClass(id: string, cascade = false) {
@@ -308,7 +376,15 @@ export class FleetService {
await this.prisma.segmentFareRule.deleteMany({ where: { seatClassId: id } });
}
return this.prisma.seatClass.delete({ where: { id } });
const deleted = await this.prisma.seatClass.delete({ where: { id } });
await this.auditService.log({
action: AUDIT_ACTIONS.DELETE,
entityType: AUDIT_ENTITIES.SeatClass,
entityId: id,
oldData: snapshot(seatClass, SEAT_CLASS_AUDIT_FIELDS),
newData: { cascade },
});
return deleted;
}
createSeatClass(dto: CreateClassDto) {
@@ -345,7 +421,12 @@ export class FleetService {
isActive: dto.isActive ?? true,
},
});
await this.auditService.log({ action: 'CREATE', entityType: 'Train', entityId: train.id, newData: { number: train.number, name: train.name } });
await this.auditService.log({
action: AUDIT_ACTIONS.CREATE,
entityType: AUDIT_ENTITIES.Train,
entityId: train.id,
newData: snapshot(train, TRAIN_AUDIT_FIELDS),
});
return train;
}
@@ -363,7 +444,13 @@ export class FleetService {
...(dto.isActive !== undefined && { isActive: dto.isActive }),
},
});
await this.auditService.log({ action: 'UPDATE', entityType: 'Train', entityId: id, newData: { number: dto.number, name: dto.name } });
await this.auditService.log({
action: AUDIT_ACTIONS.UPDATE,
entityType: AUDIT_ENTITIES.Train,
entityId: id,
oldData: snapshot(train, TRAIN_AUDIT_FIELDS),
newData: snapshot(updated, TRAIN_AUDIT_FIELDS),
});
return updated;
}
@@ -442,14 +529,28 @@ export class FleetService {
}
const deleted = await this.prisma.train.delete({ where: { id } });
await this.auditService.log({ action: 'DELETE', entityType: 'Train', entityId: id, oldData: { number: train.number, name: train.name } });
await this.auditService.log({
action: AUDIT_ACTIONS.DELETE,
entityType: AUDIT_ENTITIES.Train,
entityId: id,
oldData: snapshot(train, TRAIN_AUDIT_FIELDS),
newData: { cascade },
});
return deleted;
}
async restoreTrain(id: string) {
const train = await this.prisma.train.findUnique({ where: { id } });
if (!train) throw new NotFoundException('Train not found');
return this.prisma.train.update({ where: { id }, data: { isActive: true } });
const restored = await this.prisma.train.update({ where: { id }, data: { isActive: true } });
await this.auditService.log({
action: AUDIT_ACTIONS.RESTORE,
entityType: AUDIT_ENTITIES.Train,
entityId: id,
oldData: { isActive: train.isActive },
newData: { isActive: true },
});
return restored;
}
async getCoach(id: string) {
@@ -526,7 +627,12 @@ export class FleetService {
await this.prisma.seat.createMany({ data: seats });
}
await this.auditService.log({ action: 'CREATE', entityType: 'Coach', entityId: coach.id, newData: { number: coach.number, capacity: coach.capacity } });
await this.auditService.log({
action: AUDIT_ACTIONS.CREATE,
entityType: AUDIT_ENTITIES.Coach,
entityId: coach.id,
newData: snapshot(coach, COACH_AUDIT_FIELDS),
});
return coach;
}
@@ -545,7 +651,13 @@ export class FleetService {
},
include: { coachType: true },
});
await this.auditService.log({ action: 'UPDATE', entityType: 'Coach', entityId: id, newData: { number: dto.number, status: dto.status } });
await this.auditService.log({
action: AUDIT_ACTIONS.UPDATE,
entityType: AUDIT_ENTITIES.Coach,
entityId: id,
oldData: snapshot(coach, COACH_AUDIT_FIELDS),
newData: snapshot(updated, COACH_AUDIT_FIELDS),
});
return updated;
}
@@ -622,7 +734,13 @@ export class FleetService {
await this.prisma.seat.deleteMany({ where: { coachId: id } });
const deleted = await this.prisma.coach.delete({ where: { id } });
await this.auditService.log({ action: 'DELETE', entityType: 'Coach', entityId: id, oldData: { number: coach.number } });
await this.auditService.log({
action: AUDIT_ACTIONS.DELETE,
entityType: AUDIT_ENTITIES.Coach,
entityId: id,
oldData: snapshot(coach, COACH_AUDIT_FIELDS),
newData: { cascade },
});
return deleted;
}
@@ -634,13 +752,30 @@ export class FleetService {
if (!schedule) throw new NotFoundException('Schedule not found');
if (!coach) throw new NotFoundException('Coach not found');
if (coach.status !== 'ACTIVE') throw new BadRequestException('Coach is not active');
return this.prisma.coachAssignment.create({ data: dto });
const assignment = await this.prisma.coachAssignment.create({ data: dto });
await this.auditService.log({
action: AUDIT_ACTIONS.ASSIGN,
entityType: AUDIT_ENTITIES.CoachAssignment,
entityId: assignment.id,
newData: {
...snapshot(assignment, COACH_ASSIGNMENT_AUDIT_FIELDS),
coachNumber: coach.number,
},
});
return assignment;
}
async removeAssignment(id: string) {
const assignment = await this.prisma.coachAssignment.findUnique({ where: { id } });
if (!assignment) throw new NotFoundException('Assignment not found');
return this.prisma.coachAssignment.delete({ where: { id } });
const deleted = await this.prisma.coachAssignment.delete({ where: { id } });
await this.auditService.log({
action: AUDIT_ACTIONS.UNASSIGN,
entityType: AUDIT_ENTITIES.CoachAssignment,
entityId: id,
oldData: snapshot(assignment, COACH_ASSIGNMENT_AUDIT_FIELDS),
});
return deleted;
}
async generateSeatMapPreview(dto: GenerateSeatMapDto) {

View File

@@ -9,6 +9,7 @@ import {
Patch,
Post,
Query,
Req,
Res,
SetMetadata,
UseGuards,
@@ -38,6 +39,7 @@ import {
} from "./payments.dto";
import { PassengerStaff } from "../../common/passenger-guards";
import { PASSENGER_PERMS } from "../../seed/passenger-permissions.registry";
import { resolveActingUser } from "../../common/acting-user";
import { resolveAllowedOrigin } from "../../common/utils/redirect-origin.util";
import { SupplementaryChargesService } from "./supplementary-charges.service";
import { IsString, IsInt, IsOptional, Min, IsEnum, IsIn } from "class-validator";
@@ -393,13 +395,13 @@ export class PaymentsController {
@PassengerStaff([PASSENGER_PERMS.payments.manage, PASSENGER_PERMS.admin])
@ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Raise a supplementary charge for an underpayment (staff only)' })
createSupplementaryCharge(
@Body() dto: CreateSupplementaryChargeDto,
@Headers('x-iam-user-id') iamUserId?: string,
) {
createSupplementaryCharge(@Body() dto: CreateSupplementaryChargeDto, @Req() req: any) {
// Actor comes from the guarded session, not the client-settable `x-iam-user-id` header it
// used to read (which defaulted to the literal string 'staff').
const actor = resolveActingUser(req);
return this.supplementaryService.create({
...dto,
createdBy: iamUserId ?? 'staff',
createdBy: actor?.id ?? 'staff',
});
}
@@ -517,9 +519,10 @@ export class PaymentsController {
waiveSupplementaryCharge(
@Param('id') id: string,
@Body() dto: WaiveSupplementaryChargeDto,
@Headers('x-iam-user-id') iamUserId?: string,
@Req() req: any,
) {
return this.supplementaryService.waive(id, dto.notes ?? '', iamUserId ?? 'staff');
const actor = resolveActingUser(req);
return this.supplementaryService.waive(id, dto.notes ?? '', actor?.id ?? 'staff');
}
@Post('supplementary/:id/resend')

View File

@@ -49,10 +49,14 @@ describe("PaymentsService", () => {
excessBaggageCharge: {
findUnique: jest.fn(),
update: jest.fn(),
// The charge handlers claim the PAID transition with a conditional updateMany so the
// in-app path and this webhook cannot both write an audit row for one settlement.
updateMany: jest.fn().mockResolvedValue({ count: 1 }),
},
supplementaryCharge: {
findUnique: jest.fn(),
update: jest.fn(),
updateMany: jest.fn().mockResolvedValue({ count: 1 }),
},
currencyExchangeRate: {
findFirst: jest.fn(),
@@ -600,9 +604,9 @@ describe("PaymentsService", () => {
const result = await service.handlePaymentEvent(succeededEvent());
expect(mockPrisma.excessBaggageCharge.update).toHaveBeenCalledWith(
expect(mockPrisma.excessBaggageCharge.updateMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: CHARGE_ID },
where: expect.objectContaining({ id: CHARGE_ID }),
data: expect.objectContaining({ status: "PAID" }),
}),
);
@@ -617,7 +621,7 @@ describe("PaymentsService", () => {
await service.handlePaymentEvent(succeededEvent());
expect(mockPrisma.excessBaggageCharge.update).toHaveBeenCalledWith(
expect(mockPrisma.excessBaggageCharge.updateMany).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ status: "PAID" }),
}),
@@ -632,7 +636,7 @@ describe("PaymentsService", () => {
const result = await service.handlePaymentEvent(succeededEvent());
expect(mockPrisma.excessBaggageCharge.update).not.toHaveBeenCalled();
expect(mockPrisma.excessBaggageCharge.updateMany).not.toHaveBeenCalled();
expect(result).toEqual({ processed: true, alreadyFinalized: true });
});
@@ -647,7 +651,7 @@ describe("PaymentsService", () => {
succeededEvent({ amountMinor: 1625, currency: "DJF" }),
);
expect(mockPrisma.excessBaggageCharge.update).toHaveBeenCalledWith(
expect(mockPrisma.excessBaggageCharge.updateMany).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ status: "PAID" }),
}),

View File

@@ -43,6 +43,17 @@ import {
} from "./payment-client.service";
import { CurrencyService } from "../currency/currency.service";
import { AuditService } from "../../common/audit.service";
import { AUDIT_ACTIONS, AUDIT_ENTITIES } from "../../common/audit.actions";
const PAYMENT_METHOD_AUDIT_FIELDS = [
"type",
"displayName",
"region",
"currency",
"providerId",
"enabled",
"sortOrder",
] as const;
import { rebaseUrlOrigin } from "../../common/utils/redirect-origin.util";
import {
PaymentService as PaymentServiceEnum,
@@ -92,6 +103,18 @@ export class PaymentsService {
});
if (!intent) throw new NotFoundException("Payment intent not found");
await this.prisma.paymentIntent.delete({ where: { id } });
await this.auditService.log({
action: AUDIT_ACTIONS.DELETE,
entityType: AUDIT_ENTITIES.Payment,
entityId: id,
oldData: {
bookingId: intent.bookingId,
status: intent.status,
amountMinor: intent.amountMinor,
currency: intent.currency,
method: intent.method,
},
});
return { deleted: true, id };
}
@@ -1086,16 +1109,27 @@ export class PaymentsService {
data: { status: "CANCELLED" },
});
}
// The intent is moved to CANCELLED, not "REFUNDED" — recording the latter made the audit
// row contradict the row it describes.
await this.auditService.log({
action: "UPDATE",
entityType: "Payment",
action: AUDIT_ACTIONS.REFUND,
entityType: AUDIT_ENTITIES.Payment,
entityId: intent.id,
newData: { status: "REFUNDED", bookingId: dto.bookingId },
oldData: { status: intent.status, bookingStatus: booking?.status },
newData: {
status: "CANCELLED",
bookingId: dto.bookingId,
bookingRef: booking?.bookingRef,
bookingStatus: booking ? "CANCELLED" : undefined,
amountMinor: intent.amountMinor,
currency: intent.currency,
reason: dto.reason,
},
});
return { refunded: true, bookingRef: booking?.bookingRef };
}
addPaymentMethod(dto: AddPaymentMethodDto) {
async addPaymentMethod(dto: AddPaymentMethodDto) {
const data = {
type: dto.type as unknown as PaymentMethodType,
displayName: dto.displayName,
@@ -1105,11 +1139,37 @@ export class PaymentsService {
enabled: dto.enabled ?? true,
sortOrder: dto.sortOrder ?? 0,
};
return this.prisma.paymentMethod.upsert({
// This is an upsert keyed on `type`, so "add" silently overwrites an existing method. The
// audit row reports which of the two actually happened rather than always claiming a create.
const existing = await this.prisma.paymentMethod.findUnique({
where: { type: data.type },
});
const method = await this.prisma.paymentMethod.upsert({
where: { type: data.type },
update: data,
create: data,
});
await this.auditService.log({
action: existing ? AUDIT_ACTIONS.UPDATE : AUDIT_ACTIONS.CREATE,
entityType: AUDIT_ENTITIES.PaymentMethod,
entityId: method.id,
oldData: existing ? this.paymentMethodSnapshot(existing) : undefined,
newData: this.paymentMethodSnapshot(method),
});
return method;
}
private paymentMethodSnapshot(method: Record<string, unknown>) {
return Object.fromEntries(
PAYMENT_METHOD_AUDIT_FIELDS.filter((k) => method[k] !== undefined).map((k) => [
k,
method[k],
]),
);
}
async updatePaymentMethod(id: string, dto: Partial<AddPaymentMethodDto>) {
@@ -1127,10 +1187,20 @@ export class PaymentsService {
if (dto.enabled !== undefined) updateData.enabled = dto.enabled;
if (dto.sortOrder !== undefined) updateData.sortOrder = dto.sortOrder;
return this.prisma.paymentMethod.update({
const updated = await this.prisma.paymentMethod.update({
where: { id },
data: updateData,
});
await this.auditService.log({
action: AUDIT_ACTIONS.UPDATE,
entityType: AUDIT_ENTITIES.PaymentMethod,
entityId: id,
oldData: this.paymentMethodSnapshot(existing),
newData: this.paymentMethodSnapshot(updated),
});
return updated;
}
getSupportedPaymentMethods(region?: PaymentRegionEnum) {
@@ -1523,21 +1593,34 @@ export class PaymentsService {
}
if (charge.status === "PAID")
return { processed: true, alreadyFinalized: true };
await this.prisma.supplementaryCharge.update({
where: { id: charge.id },
// Conditional claim, not a plain update: SupplementaryChargesService.markPaid can be
// driving the same transition from the in-app path. Whichever caller flips the row writes
// the audit event; the loser writes nothing, so the trail holds exactly one PAID row.
const { count } = await this.prisma.supplementaryCharge.updateMany({
where: { id: charge.id, status: { not: "PAID" } },
data: {
status: "PAID",
paidAt: new Date(),
providerTxnId: event.providerTxnId ?? null,
},
});
await this.auditService.log({
action: "UPDATE",
entityType: "SupplementaryCharge",
entityId: charge.id,
newData: { status: "PAID", providerTxnId: event.providerTxnId },
});
return { processed: true };
if (count === 1) {
await this.auditService.log({
action: AUDIT_ACTIONS.PAY,
entityType: AUDIT_ENTITIES.SupplementaryCharge,
entityId: charge.id,
oldData: { status: charge.status },
newData: {
status: "PAID",
bookingId: charge.bookingId,
providerTxnId: event.providerTxnId,
settledAmount: event.amountMinor,
settledCurrency: event.currency,
},
});
}
return { processed: true, alreadyFinalized: count === 0 };
}
/**
@@ -1584,8 +1667,10 @@ export class PaymentsService {
);
}
await this.prisma.excessBaggageCharge.update({
where: { id: charge.id },
// Conditional claim for the same reason as the supplementary handler above:
// ExcessBaggageService.markPaid drives this transition from the in-app path.
const { count } = await this.prisma.excessBaggageCharge.updateMany({
where: { id: charge.id, status: { notIn: ["PAID", "CASH_COLLECTED"] } },
data: {
status: "PAID",
// The provider's own capture time, not when this event happened to be processed — a
@@ -1593,13 +1678,19 @@ export class PaymentsService {
paidAt: event.paidAt ? new Date(event.paidAt) : new Date(),
},
});
if (count === 0) {
return { processed: true, alreadyFinalized: true };
}
await this.auditService.log({
action: "UPDATE",
entityType: "ExcessBaggageCharge",
action: AUDIT_ACTIONS.PAY,
entityType: AUDIT_ENTITIES.ExcessBaggageCharge,
entityId: charge.id,
oldData: { status: charge.status },
newData: {
status: "PAID",
bookingId: charge.bookingId,
providerTxnId: event.providerTxnId,
settledAmount: event.amountMinor,
settledCurrency: event.currency,
@@ -1784,6 +1875,9 @@ export class PaymentsService {
let intent = await this.prisma.paymentIntent.findUnique({
where: { bookingId },
});
// Captured before the block below rewrites it, so the audit row can show what the override
// moved the payment away from.
const previousStatus = intent?.status ?? null;
if (!intent) {
intent = await this.prisma.paymentIntent.create({
data: {
@@ -1822,17 +1916,23 @@ export class PaymentsService {
providerTxnId: dto.paymentReference ?? intent.providerTxnId ?? undefined,
force: true,
}).then(async (result) => {
await this.auditService.log({
action: "UPDATE",
entityType: "Payment",
entityId: intent.id,
newData: {
status: "FORCE_CONFIRMED",
bookingId,
paymentMethod: dto.paymentMethod,
paymentReference: dto.paymentReference,
},
});
// finalizePaymentSuccess reports `alreadyFinalized` when the booking was already
// confirmed — logging a forced confirmation there would record an override that changed
// nothing.
if (!result?.alreadyFinalized) {
await this.auditService.log({
action: AUDIT_ACTIONS.STATUS_CHANGE,
entityType: AUDIT_ENTITIES.Payment,
entityId: intent.id,
oldData: { status: previousStatus },
newData: {
status: "FORCE_CONFIRMED",
bookingId,
paymentMethod: dto.paymentMethod,
paymentReference: dto.paymentReference,
},
});
}
return result;
});
}

View File

@@ -0,0 +1,195 @@
import { SupplementaryChargesService } from './supplementary-charges.service';
/**
* Supplementary charges are raised by staff against a confirmed booking, so "who raised this,
* against which booking, and who later waived it" has to survive in AuditLog.
*
* The actor used to come from an `x-iam-user-id` request header defaulting to the literal
* string 'staff' — a client-settable value in the column meant to identify a person.
*/
describe('SupplementaryChargesService — audit', () => {
const CHARGE_ID = 'sc-1';
const BOOKING_ID = 'booking-1';
const BOOKING_REF = 'EDR-0001';
let prisma: Record<string, any>;
let audit: { log: jest.Mock };
let service: SupplementaryChargesService;
const build = (charge: Record<string, any> = {}) => {
const row = {
id: CHARGE_ID,
bookingId: BOOKING_ID,
reason: 'UNDERPAYMENT',
amountMinor: 25000,
currency: 'ETB',
status: 'PENDING',
paymentToken: 'tok-live-secret',
notes: null,
createdBy: 'iam-staff-1',
expiresAt: new Date(Date.now() + 72 * 60 * 60 * 1000),
booking: { bookingRef: BOOKING_REF, contactPhone: '+251911223344', contactEmail: 'p@example.com' },
...charge,
};
prisma = {
supplementaryCharge: {
findUnique: jest.fn().mockResolvedValue(row),
create: jest.fn().mockResolvedValue(row),
update: jest.fn().mockResolvedValue({ ...row, status: 'WAIVED' }),
updateMany: jest.fn().mockResolvedValue({ count: 1 }),
},
booking: {
findUnique: jest.fn().mockResolvedValue({
id: BOOKING_ID,
bookingRef: BOOKING_REF,
status: 'CONFIRMED',
contactPhone: '+251911223344',
contactEmail: 'p@example.com',
passenger: { user: { phone: null, email: null } },
}),
},
};
audit = { log: jest.fn().mockResolvedValue(undefined) };
// Constructor order: prisma, audit, sms, email, paymentClient, currency.
service = new SupplementaryChargesService(
prisma as any,
audit as any,
{ sendSms: jest.fn() } as any,
{ sendEmail: jest.fn() } as any,
{} as any,
{} as any,
);
return row;
};
const rows = () => audit.log.mock.calls.map((c) => c[0]);
const byAction = (action: string) => rows().filter((r) => r.action === action);
describe('create', () => {
it('records one CREATE naming the booking the charge belongs to', async () => {
build();
await service.create({
bookingRef: BOOKING_REF,
amountMinor: 25000,
reason: 'UNDERPAYMENT',
createdBy: 'iam-staff-1',
});
expect(byAction('CREATE')).toHaveLength(1);
expect(byAction('CREATE')[0]).toMatchObject({
entityType: 'SupplementaryCharge',
entityId: CHARGE_ID,
});
expect(byAction('CREATE')[0].newData).toMatchObject({
bookingId: BOOKING_ID,
bookingRef: BOOKING_REF,
amountMinor: 25000,
reason: 'UNDERPAYMENT',
createdBy: 'iam-staff-1',
});
});
it('never writes the payment token into the row', async () => {
build();
await service.create({
bookingRef: BOOKING_REF,
amountMinor: 25000,
reason: 'UNDERPAYMENT',
createdBy: 'iam-staff-1',
});
expect(JSON.stringify(rows())).not.toContain('tok-live-secret');
});
it('records nothing when the booking is not chargeable', async () => {
build();
prisma.booking.findUnique.mockResolvedValue({
id: BOOKING_ID,
bookingRef: BOOKING_REF,
status: 'PENDING_PAYMENT',
});
await expect(
service.create({
bookingRef: BOOKING_REF,
amountMinor: 25000,
reason: 'UNDERPAYMENT',
createdBy: 'iam-staff-1',
}),
).rejects.toThrow();
expect(audit.log).not.toHaveBeenCalled();
});
});
describe('waive', () => {
it('records WAIVE rather than a generic UPDATE', async () => {
build();
await service.waive(CHARGE_ID, 'goodwill', 'iam-staff-1');
expect(byAction('WAIVE')).toHaveLength(1);
expect(byAction('UPDATE')).toHaveLength(0);
expect(byAction('WAIVE')[0]).toMatchObject({
entityType: 'SupplementaryCharge',
entityId: CHARGE_ID,
oldData: { status: 'PENDING' },
});
expect(byAction('WAIVE')[0].newData).toMatchObject({ status: 'WAIVED', waivedBy: 'iam-staff-1' });
});
it('records nothing when waiving a paid charge is refused', async () => {
build({ status: 'PAID' });
await expect(service.waive(CHARGE_ID, 'x', 'iam-staff-1')).rejects.toThrow();
expect(audit.log).not.toHaveBeenCalled();
});
});
describe('markPaid', () => {
it('records PAY once when it claims the transition', async () => {
build();
await service.markPaid(CHARGE_ID, 'TXN-9');
expect(byAction('PAY')).toHaveLength(1);
expect(byAction('PAY')[0]).toMatchObject({
entityType: 'SupplementaryCharge',
entityId: CHARGE_ID,
oldData: { status: 'PENDING' },
});
});
it('stays silent when the webhook already claimed it', async () => {
build();
prisma.supplementaryCharge.updateMany.mockResolvedValue({ count: 0 });
await service.markPaid(CHARGE_ID, 'TXN-9');
expect(byAction('PAY')).toHaveLength(0);
});
it('is a no-op on a charge already read as PAID', async () => {
build({ status: 'PAID' });
await service.markPaid(CHARGE_ID);
expect(audit.log).not.toHaveBeenCalled();
expect(prisma.supplementaryCharge.updateMany).not.toHaveBeenCalled();
});
});
describe('actor', () => {
it('never sets userId at the call site — AuditService reads the session', async () => {
build();
await service.waive(CHARGE_ID, 'goodwill', 'client-supplied');
expect(rows().every((r) => r.userId === undefined)).toBe(true);
});
});
describe('reads', () => {
it('writes nothing when listing charges', async () => {
build();
prisma.supplementaryCharge.findMany = jest.fn().mockResolvedValue([]);
prisma.supplementaryCharge.count = jest.fn().mockResolvedValue(0);
await service.getAll({});
expect(audit.log).not.toHaveBeenCalled();
});
});
});

View File

@@ -1,6 +1,7 @@
import { Injectable, NotFoundException, BadRequestException, Logger } from '@nestjs/common';
import { PrismaService } from '../../common/prisma.service';
import { AuditService } from '../../common/audit.service';
import { AUDIT_ACTIONS, AUDIT_ENTITIES } from '../../common/audit.actions';
import { SmsClientService } from '../notifications/sms-client.service';
import { EmailClientService } from '../notifications/email-client.service';
import { PaymentClientService } from './payment-client.service';
@@ -80,10 +81,20 @@ export class SupplementaryChargesService {
await this.sendLink(charge, booking.bookingRef, phone, email);
await this.auditService.log({
action: 'CREATE',
entityType: 'SupplementaryCharge',
action: AUDIT_ACTIONS.CREATE,
entityType: AUDIT_ENTITIES.SupplementaryCharge,
entityId: charge.id,
newData: { bookingRef: dto.bookingRef, amountMinor: dto.amountMinor, reason: dto.reason },
newData: {
bookingId: booking.id,
bookingRef: dto.bookingRef,
amountMinor: dto.amountMinor,
currency: charge.currency,
reason: dto.reason,
notes: dto.notes,
createdBy: dto.createdBy,
status: charge.status,
expiresAt: charge.expiresAt?.toISOString(),
},
});
return charge;
}
@@ -134,12 +145,34 @@ export class SupplementaryChargesService {
const charge = await this.prisma.supplementaryCharge.findUnique({ where: { id } });
if (!charge) throw new NotFoundException('Charge not found');
if (charge.status === 'PAID') return charge;
const updated = await this.prisma.supplementaryCharge.update({
where: { id },
// Conditional update rather than a plain update: this transition is also reachable from the
// payment webhook, and claiming it atomically means exactly one of the two racing callers
// writes the audit row.
const { count } = await this.prisma.supplementaryCharge.updateMany({
where: { id, status: { not: 'PAID' } },
data: { status: 'PAID', paidAt: new Date(), providerTxnId: providerTxnId ?? null },
});
await this.auditService.log({ action: 'UPDATE', entityType: 'SupplementaryCharge', entityId: id, newData: { status: 'PAID' } });
return updated;
const updated = await this.prisma.supplementaryCharge.findUnique({ where: { id } });
if (count === 1) {
await this.auditService.log({
action: AUDIT_ACTIONS.PAY,
entityType: AUDIT_ENTITIES.SupplementaryCharge,
entityId: id,
oldData: { status: charge.status },
newData: {
status: 'PAID',
bookingId: charge.bookingId,
amountMinor: charge.amountMinor,
currency: charge.currency,
providerTxnId: providerTxnId ?? null,
},
});
}
return updated!;
}
/**
@@ -354,7 +387,20 @@ export class SupplementaryChargesService {
where: { id },
data: { status: 'WAIVED', notes },
});
await this.auditService.log({ action: 'UPDATE', entityType: 'SupplementaryCharge', entityId: id, newData: { status: 'WAIVED', waivedBy, notes } });
await this.auditService.log({
action: AUDIT_ACTIONS.WAIVE,
entityType: AUDIT_ENTITIES.SupplementaryCharge,
entityId: id,
oldData: { status: charge.status },
newData: {
status: 'WAIVED',
bookingId: charge.bookingId,
amountMinor: charge.amountMinor,
currency: charge.currency,
waivedBy,
notes,
},
});
return updated;
}
@@ -370,6 +416,19 @@ export class SupplementaryChargesService {
data: { expiresAt: new Date(Date.now() + CHARGE_TTL_MS) },
});
await this.sendLink(updated, charge.booking.bookingRef, charge.booking.contactPhone, charge.booking.contactEmail);
// Re-exposes a live payment token and extends its deadline, so it is a state change worth
// attributing even though the charge's status is unchanged. Contact details stay out of the
// row — only the fact that a link was re-sent.
await this.auditService.log({
action: AUDIT_ACTIONS.RESEND,
entityType: AUDIT_ENTITIES.SupplementaryCharge,
entityId: id,
oldData: { expiresAt: charge.expiresAt?.toISOString() },
newData: {
bookingRef: charge.booking.bookingRef,
expiresAt: updated.expiresAt?.toISOString(),
},
});
return { sent: true };
}

View File

@@ -32,6 +32,9 @@ describe('SupplementaryChargesService — payment methods', () => {
supplementaryCharge: {
findUnique: jest.fn().mockResolvedValue(charge),
update: jest.fn().mockResolvedValue({ ...charge, status: 'PAID' }),
// markPaid claims the PAID transition conditionally so it cannot double-log with the
// webhook path; count: 1 means this caller won.
updateMany: jest.fn().mockResolvedValue({ count: 1 }),
},
booking: {
findUnique: jest.fn().mockResolvedValue({
@@ -163,7 +166,7 @@ describe('SupplementaryChargesService — payment methods', () => {
CHARGE_ID,
);
expect(paymentClient.confirmOtp).toHaveBeenCalledWith('intent-1', '4530');
expect(prisma.supplementaryCharge.update).toHaveBeenCalledWith(
expect(prisma.supplementaryCharge.updateMany).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
status: 'PAID',
@@ -181,6 +184,7 @@ describe('SupplementaryChargesService — payment methods', () => {
});
await service.confirmOtp(TOKEN, '0000');
expect(prisma.supplementaryCharge.update).not.toHaveBeenCalled();
expect(prisma.supplementaryCharge.updateMany).not.toHaveBeenCalled();
});
it('is idempotent once already paid', async () => {

View File

@@ -3,6 +3,26 @@ import { PrismaService } from '../../common/prisma.service';
import { CreateRouteDto, AddRouteStopDto, UpdateRouteDto, SetRouteCoachTemplateDto } from './routes.dto';
import { DeleteOperationException } from '../../common/exceptions/delete-operation.exception';
import { AuditService } from '../../common/audit.service';
import { AUDIT_ACTIONS, AUDIT_ENTITIES } from '../../common/audit.actions';
import { snapshot } from '../../common/audit-snapshot';
const ROUTE_AUDIT_FIELDS = [
'code',
'name',
'description',
'active',
'effectiveFrom',
'effectiveUntil',
'checkinMinutesBefore',
] as const;
const ROUTE_STOP_AUDIT_FIELDS = [
'routeId',
'stationId',
'sequence',
'distanceKm',
'checkinMinutesBefore',
'travelMinutesToStop',
] as const;
import { parseEthiopianTime } from '../../common/utils/timezone.utils';
import { computePlannedStopTimes } from '../../common/utils/schedule-times.utils';
@@ -75,7 +95,12 @@ export class RoutesService {
},
include: { stops: { include: { route: false }, orderBy: { sequence: 'asc' } } },
});
await this.auditService.log({ action: 'CREATE', entityType: 'Route', entityId: route.id, newData: { code: route.code, name: route.name } });
await this.auditService.log({
action: AUDIT_ACTIONS.CREATE,
entityType: AUDIT_ENTITIES.Route,
entityId: route.id,
newData: snapshot(route, ROUTE_AUDIT_FIELDS),
});
return route;
}
@@ -165,11 +190,23 @@ export class RoutesService {
}
}
await this.auditService.log({ action: 'UPDATE', entityType: 'Route', entityId: id, newData: { name: dto.name, active: dto.active } });
return this.prisma.route.findUnique({
const updated = await this.prisma.route.findUnique({
where: { id },
include: { stops: { orderBy: { sequence: 'asc' } } },
});
await this.auditService.log({
action: AUDIT_ACTIONS.UPDATE,
entityType: AUDIT_ENTITIES.Route,
entityId: id,
oldData: snapshot(route, ROUTE_AUDIT_FIELDS),
newData: {
...snapshot(updated, ROUTE_AUDIT_FIELDS),
// Stop edits arrive as a full replacement, so record the resulting shape rather than
// every row — the RouteStop rows themselves are audited on the dedicated endpoints.
...(dto.stops && dto.stops.length >= 2 ? { stopsReplaced: dto.stops.length } : {}),
},
});
return updated;
}
async deleteRoute(id: string, cascade = false) {
@@ -254,7 +291,13 @@ export class RoutesService {
}
await this.prisma.route.delete({ where: { id } });
await this.auditService.log({ action: 'DELETE', entityType: 'Route', entityId: id, oldData: { code: route.code, name: route.name } });
await this.auditService.log({
action: AUDIT_ACTIONS.DELETE,
entityType: AUDIT_ENTITIES.Route,
entityId: id,
oldData: snapshot(route, ROUTE_AUDIT_FIELDS),
newData: { cascade },
});
return { deleted: true, id };
}
@@ -276,7 +319,7 @@ export class RoutesService {
const otherStops = await this.prisma.routeStop.findMany({ where: { routeId } });
this.validateStopDistances([...otherStops, { sequence: dto.sequence, stationId: dto.stationId, distanceKm: dto.distanceKm }]);
return this.prisma.routeStop.create({
const stop = await this.prisma.routeStop.create({
data: {
routeId,
stationId: dto.stationId,
@@ -286,6 +329,13 @@ export class RoutesService {
travelMinutesToStop: dto.travelMinutesToStop ?? null,
},
});
await this.auditService.log({
action: AUDIT_ACTIONS.CREATE,
entityType: AUDIT_ENTITIES.RouteStop,
entityId: stop.id,
newData: { ...snapshot(stop, ROUTE_STOP_AUDIT_FIELDS), stationName: station.name },
});
return stop;
}
async removeStop(routeId: string, sequence: number) {
@@ -298,6 +348,12 @@ export class RoutesService {
if (total <= 2) throw new BadRequestException('A route must retain at least 2 stops');
await this.prisma.routeStop.delete({ where: { routeId_sequence: { routeId, sequence } } });
await this.auditService.log({
action: AUDIT_ACTIONS.DELETE,
entityType: AUDIT_ENTITIES.RouteStop,
entityId: stop.id,
oldData: snapshot(stop, ROUTE_STOP_AUDIT_FIELDS),
});
return { deleted: true, sequence };
}
@@ -351,18 +407,48 @@ export class RoutesService {
const positions = dto.coaches.map(c => c.positionNumber);
if (new Set(positions).size !== positions.length) throw new BadRequestException('Duplicate positionNumber values');
const previous = await this.prisma.routeCoachTemplate.findMany({
where: { routeId },
orderBy: { positionNumber: 'asc' },
select: { coachId: true, positionNumber: true },
});
await this.prisma.routeCoachTemplate.deleteMany({ where: { routeId } });
await this.prisma.routeCoachTemplate.createMany({
data: dto.coaches.map(c => ({ routeId, coachId: c.coachId, positionNumber: c.positionNumber })),
});
// The template is replaced wholesale, so the audit row carries both compositions rather
// than one row per coach — a reader wants "what does this route run now vs. before".
await this.auditService.log({
action: AUDIT_ACTIONS.ASSIGN,
entityType: AUDIT_ENTITIES.RouteCoachTemplate,
entityId: routeId,
oldData: { routeCode: route.code, coaches: previous },
newData: {
routeCode: route.code,
coaches: dto.coaches.map(c => ({ coachId: c.coachId, positionNumber: c.positionNumber })),
},
});
return this.getRouteCoachTemplate(routeId);
}
async removeRouteCoachTemplate(routeId: string) {
const route = await this.prisma.route.findUnique({ where: { id: routeId } });
if (!route) throw new NotFoundException('Route not found');
const previous = await this.prisma.routeCoachTemplate.findMany({
where: { routeId },
orderBy: { positionNumber: 'asc' },
select: { coachId: true, positionNumber: true },
});
await this.prisma.routeCoachTemplate.deleteMany({ where: { routeId } });
await this.auditService.log({
action: AUDIT_ACTIONS.UNASSIGN,
entityType: AUDIT_ENTITIES.RouteCoachTemplate,
entityId: routeId,
oldData: { routeCode: route.code, coaches: previous },
});
return { deleted: true, routeId };
}

View File

@@ -0,0 +1,199 @@
import { SchedulesService } from './schedules.service';
/**
* Master-data coverage, using schedules as the representative entity.
*
* `updateScheduleStatus` was a one-line Prisma update with no audit call at all, so "who
* cancelled this schedule" had no answer. Fare-rule edits were similar: the previous price was
* read and then discarded, leaving an UPDATE row that didn't say what changed.
*/
describe('SchedulesService — audit', () => {
const SCHEDULE_ID = 'sched-1';
let prisma: Record<string, any>;
let audit: { log: jest.Mock };
let service: SchedulesService;
const scheduleRow = (over: Record<string, any> = {}) => ({
id: SCHEDULE_ID,
trainId: 'train-1',
routeId: 'route-1',
originStationId: 'station-a',
destinationStationId: 'station-b',
departureAt: new Date('2026-09-01T06:00:00.000Z'),
arrivalAt: new Date('2026-09-01T18:00:00.000Z'),
durationMinutes: 720,
stopsCount: 3,
status: 'SCHEDULED',
...over,
});
const build = (over: Record<string, any> = {}) => {
const schedule = scheduleRow(over);
prisma = {
trainSchedule: {
findUnique: jest.fn().mockResolvedValue(schedule),
update: jest.fn(async ({ data }: any) => ({ ...schedule, ...data })),
},
fareRule: {
findUnique: jest.fn(),
create: jest.fn(),
update: jest.fn(),
delete: jest.fn().mockResolvedValue({}),
},
routeFareRule: {
findUnique: jest.fn(),
update: jest.fn(),
delete: jest.fn().mockResolvedValue({}),
},
segmentFareRule: { findUnique: jest.fn(), update: jest.fn(), delete: jest.fn() },
};
audit = { log: jest.fn().mockResolvedValue(undefined) };
service = new SchedulesService(
prisma as any,
{} as any, // routesService
{} as any, // fareEngine
audit as any,
{ updateLiveStatus: jest.fn() } as any, // liveService
);
return schedule;
};
const rows = () => audit.log.mock.calls.map((c) => c[0]);
describe('updateScheduleStatus', () => {
it('records one STATUS_CHANGE with the status it moved from and to', async () => {
build();
await service.updateScheduleStatus(SCHEDULE_ID, { status: 'BOARDING' } as any);
expect(rows()).toHaveLength(1);
expect(rows()[0]).toMatchObject({
action: 'STATUS_CHANGE',
entityType: 'Schedule',
entityId: SCHEDULE_ID,
oldData: { status: 'SCHEDULED' },
});
expect(rows()[0].newData.status).toBe('BOARDING');
});
it('uses CANCEL for a cancellation so it is not lost among ordinary updates', async () => {
build();
await service.updateScheduleStatus(SCHEDULE_ID, { status: 'CANCELLED' } as any);
expect(rows()[0]).toMatchObject({
action: 'CANCEL',
entityType: 'Schedule',
entityId: SCHEDULE_ID,
oldData: { status: 'SCHEDULED' },
});
});
it('records nothing when the schedule does not exist', async () => {
build();
prisma.trainSchedule.findUnique.mockResolvedValue(null);
await expect(
service.updateScheduleStatus(SCHEDULE_ID, { status: 'CANCELLED' } as any),
).rejects.toThrow();
expect(audit.log).not.toHaveBeenCalled();
});
it('records nothing when the write itself fails', async () => {
build();
prisma.trainSchedule.update.mockRejectedValue(new Error('db down'));
await expect(
service.updateScheduleStatus(SCHEDULE_ID, { status: 'CANCELLED' } as any),
).rejects.toThrow();
expect(audit.log).not.toHaveBeenCalled();
});
it('leaves the actor to AuditService', async () => {
build();
await service.updateScheduleStatus(SCHEDULE_ID, { status: 'DELAYED' } as any);
expect(rows()[0].userId).toBeUndefined();
});
});
describe('fare rules', () => {
it('records the previous price on an update, not just the new one', async () => {
build();
const before = {
id: 'fr-1',
tripId: SCHEDULE_ID,
seatClassId: 'sc-1',
baseFareMinor: 50000,
currency: 'ETB',
nationality: null,
validFrom: new Date('2026-01-01'),
validUntil: null,
};
prisma.fareRule.findUnique.mockResolvedValue(before);
prisma.fareRule.update.mockResolvedValue({ ...before, baseFareMinor: 65000 });
await service.updateFareRule('fr-1', { baseFareMinor: 65000 } as any);
const row = rows()[0];
expect(row).toMatchObject({ action: 'UPDATE', entityType: 'FareRule', entityId: 'fr-1' });
expect(row.oldData).toMatchObject({ baseFareMinor: 50000 });
expect(row.newData).toMatchObject({ baseFareMinor: 65000 });
});
it('records what a deleted fare rule was worth', async () => {
build();
prisma.fareRule.findUnique.mockResolvedValue({
id: 'fr-1',
tripId: SCHEDULE_ID,
seatClassId: 'sc-1',
baseFareMinor: 50000,
currency: 'ETB',
});
await service.deleteFareRule('fr-1');
expect(rows()[0]).toMatchObject({ action: 'DELETE', entityType: 'FareRule', entityId: 'fr-1' });
expect(rows()[0].oldData).toMatchObject({ baseFareMinor: 50000 });
});
it('records a route fare-rule price change that previously left no trail', async () => {
build();
const before = {
id: 'rfr-1',
routeId: 'route-1',
seatClassId: 'sc-1',
passengerCategory: 'ADULT',
baseFareMinor: 40000,
surchargeMinor: 0,
validFrom: new Date('2026-01-01'),
validUntil: null,
};
prisma.routeFareRule.findUnique.mockResolvedValue(before);
prisma.routeFareRule.update.mockResolvedValue({ ...before, baseFareMinor: 45000 });
await service.updateRouteFareRule('rfr-1', { baseFareMinor: 45000 });
expect(rows()[0]).toMatchObject({ action: 'UPDATE', entityType: 'RouteFareRule' });
expect(rows()[0].oldData).toMatchObject({ baseFareMinor: 40000 });
expect(rows()[0].newData).toMatchObject({ baseFareMinor: 45000 });
});
});
describe('reads', () => {
it('writes nothing when listing schedules', async () => {
build();
prisma.trainSchedule.findMany = jest.fn().mockResolvedValue([]);
await service.listSchedules({} as any);
expect(audit.log).not.toHaveBeenCalled();
});
it('writes nothing when listing fare rules', async () => {
build();
prisma.fareRule.findMany = jest.fn().mockResolvedValue([]);
await service.getFareRules(SCHEDULE_ID);
expect(audit.log).not.toHaveBeenCalled();
});
});
});

View File

@@ -6,9 +6,59 @@ import { CreateScheduleDto, UpdateScheduleDto, CreateFareRuleDto, UpdateSchedule
import { DeleteOperationException } from '../../common/exceptions/delete-operation.exception';
import { parseEthiopianTime, startOfDayEAT, startOfNextDayEAT } from '../../common/utils/timezone.utils';
import { AuditService } from '../../common/audit.service';
import { AUDIT_ACTIONS, AUDIT_ENTITIES } from '../../common/audit.actions';
import { snapshot } from '../../common/audit-snapshot';
import { LiveService } from '../live/live.service';
import { computePlannedStopTimes } from '../../common/utils/schedule-times.utils';
const SCHEDULE_AUDIT_FIELDS = [
'trainId',
'routeId',
'originStationId',
'destinationStationId',
'departureAt',
'arrivalAt',
'durationMinutes',
'stopsCount',
'status',
] as const;
const FARE_RULE_AUDIT_FIELDS = [
'tripId',
'seatClassId',
'baseFareMinor',
'currency',
'nationality',
'validFrom',
'validUntil',
] as const;
const SEGMENT_FARE_AUDIT_FIELDS = [
'routeId',
'seatClassId',
'originStopSequence',
'destinationStopSequence',
'baseFareMinor',
'currency',
'validFrom',
'validUntil',
] as const;
const ROUTE_FARE_AUDIT_FIELDS = [
'routeId',
'seatClassId',
'passengerCategory',
'baseFareMinor',
'surchargeMinor',
'validFrom',
'validUntil',
] as const;
const STOP_TIME_AUDIT_FIELDS = [
'scheduleId',
'stationId',
'sequence',
'plannedArrivalAt',
'plannedDepartureAt',
'status',
] as const;
@Injectable()
export class SchedulesService {
private readonly logger = new Logger(SchedulesService.name);
@@ -102,6 +152,24 @@ export class SchedulesService {
currentDate = new Date(currentDate.getTime() + dto.repeatEveryDays * 24 * 60 * 60 * 1000);
}
// One row for the whole sweep, not one per schedule — the operator performed a single
// action and the created ids are the interesting part.
await this.auditService.log({
action: AUDIT_ACTIONS.BULK_CREATE,
entityType: AUDIT_ENTITIES.Schedule,
entityId: dto.routeId,
newData: {
routeId: dto.routeId,
trainId: dto.trainId,
startDateTime: dto.startDateTime,
forNextDays: dto.forNextDays,
repeatEveryDays: dto.repeatEveryDays,
schedulesCreated: scheduleCount,
scheduleIds,
errorCount: errors.length,
},
});
return { schedulesCreated: scheduleCount, errors, scheduleIds };
}
@@ -234,7 +302,12 @@ export class SchedulesService {
}
const result = await this.getSchedule(schedule.id);
await this.auditService.log({ action: 'CREATE', entityType: 'Schedule', entityId: schedule.id, newData: { trainId: dto.trainId, routeId: dto.routeId, departureAt: dep } });
await this.auditService.log({
action: AUDIT_ACTIONS.CREATE,
entityType: AUDIT_ENTITIES.Schedule,
entityId: schedule.id,
newData: snapshot(schedule, SCHEDULE_AUDIT_FIELDS),
});
return result;
}
@@ -352,12 +425,37 @@ export class SchedulesService {
await this.routesService.applyRouteToSchedule(dto.routeId, id, plannedTimesMap);
const result = await this.getSchedule(id);
await this.auditService.log({ action: 'UPDATE', entityType: 'Schedule', entityId: id, newData: { trainId: dto.trainId, departureAt: dep } });
await this.auditService.log({
action: AUDIT_ACTIONS.UPDATE,
entityType: AUDIT_ENTITIES.Schedule,
entityId: id,
oldData: snapshot(schedule, SCHEDULE_AUDIT_FIELDS),
newData: snapshot(result as any, SCHEDULE_AUDIT_FIELDS),
});
return result;
}
async updateScheduleStatus(id: string, dto: UpdateScheduleStatusDto) {
return this.prisma.trainSchedule.update({ where: { id }, data: { status: dto.status } });
const schedule = await this.prisma.trainSchedule.findUnique({ where: { id } });
if (!schedule) throw new NotFoundException('Schedule not found');
const updated = await this.prisma.trainSchedule.update({
where: { id },
data: { status: dto.status },
});
// CANCELLED is the one transition an operator is asked to justify after the fact, so it
// gets its own verb; everything else is a plain status move.
await this.auditService.log({
action:
dto.status === 'CANCELLED' ? AUDIT_ACTIONS.CANCEL : AUDIT_ACTIONS.STATUS_CHANGE,
entityType: AUDIT_ENTITIES.Schedule,
entityId: id,
oldData: { status: schedule.status },
newData: { status: updated.status, departureAt: updated.departureAt.toISOString() },
});
return updated;
}
async deleteSchedule(id: string, cascade = false) {
@@ -438,7 +536,13 @@ export class SchedulesService {
await this.prisma.travelPackage.deleteMany({ where: { id: { in: packageIds } } });
}
await this.prisma.trainSchedule.delete({ where: { id } });
await this.auditService.log({ action: 'DELETE', entityType: 'Schedule', entityId: id });
await this.auditService.log({
action: AUDIT_ACTIONS.DELETE,
entityType: AUDIT_ENTITIES.Schedule,
entityId: id,
oldData: snapshot(schedule, SCHEDULE_AUDIT_FIELDS),
newData: { cascade, bookingsAffected: (schedule as any)._count?.bookings ?? 0 },
});
return { deleted: true, id };
}
@@ -456,7 +560,7 @@ export class SchedulesService {
});
if (!stop) throw new NotFoundException(`Stop at sequence ${sequence} not found on schedule`);
return this.prisma.tripStopTime.update({
const updated = await this.prisma.tripStopTime.update({
where: { scheduleId_sequence: { scheduleId, sequence } },
data: {
plannedArrivalAt: dto.plannedArrivalAt ? parseEthiopianTime(dto.plannedArrivalAt) : undefined,
@@ -465,6 +569,14 @@ export class SchedulesService {
},
include: { station: true },
});
await this.auditService.log({
action: AUDIT_ACTIONS.UPDATE,
entityType: AUDIT_ENTITIES.Schedule,
entityId: scheduleId,
oldData: snapshot(stop, STOP_TIME_AUDIT_FIELDS),
newData: snapshot(updated, STOP_TIME_AUDIT_FIELDS),
});
return updated;
}
/**
@@ -522,10 +634,19 @@ export class SchedulesService {
await this.liveService.updateLiveStatus(scheduleId, { delayMinutes: accumulatedDelayMinutes });
await this.auditService.log({
action: 'UPDATE',
entityType: 'Schedule',
action: AUDIT_ACTIONS.UPDATE,
entityType: AUDIT_ENTITIES.Schedule,
entityId: scheduleId,
newData: { delayMinutes: dto.delayMinutes, fromSequence: dto.fromSequence, accumulatedDelayMinutes },
oldData: {
delayMinutes: currentLive?.delayMinutes ?? 0,
departureAt: schedule.departureAt.toISOString(),
},
newData: {
delayMinutes: dto.delayMinutes,
fromSequence: dto.fromSequence,
accumulatedDelayMinutes,
stopsShifted: stopsToShift.length,
},
});
return this.getSchedule(scheduleId);
@@ -547,7 +668,12 @@ export class SchedulesService {
const validFrom = dto.validFrom ? parseEthiopianTime(dto.validFrom) : now;
const validUntil = dto.validUntil ? parseEthiopianTime(dto.validUntil) : null;
return this.prisma.$transaction(async (tx) => {
const superseded = await this.prisma.fareRule.findFirst({
where: { tripId: scheduleId, seatClassId, validUntil: null },
orderBy: { validFrom: 'desc' },
});
const created = await this.prisma.$transaction(async (tx) => {
await tx.fareRule.updateMany({
where: { tripId: scheduleId, seatClassId, validUntil: null },
data: { validUntil: now },
@@ -557,11 +683,27 @@ export class SchedulesService {
include: { seatClass: true },
});
});
// A schedule fare is versioned rather than edited, so the audit row pairs the rule that was
// closed off with the one that replaced it — otherwise the price change is invisible.
await this.auditService.log({
action: AUDIT_ACTIONS.UPDATE,
entityType: AUDIT_ENTITIES.ScheduleFare,
entityId: created.id,
oldData: snapshot(superseded, FARE_RULE_AUDIT_FIELDS),
newData: {
...snapshot(created, FARE_RULE_AUDIT_FIELDS),
scheduleId,
seatClassName: seatClass.name,
},
});
return created;
}
createFareRule(dto: CreateFareRuleDto) {
async createFareRule(dto: CreateFareRuleDto) {
const { validFrom, validUntil, scheduleId, nationality, passengerCategory, ...rest } = dto;
const result = this.prisma.fareRule.create({
const rule = await this.prisma.fareRule.create({
data: {
...rest,
tripId: scheduleId,
@@ -571,8 +713,15 @@ export class SchedulesService {
},
include: { seatClass: true },
});
result.then(r => this.auditService.log({ action: 'CREATE', entityType: 'FareRule', entityId: r.id, newData: { seatClassId: r.seatClassId, baseFareMinor: r.baseFareMinor } }));
return result;
// Awaited, not a floating .then(): an unhandled rejection there could outlive the response,
// and the row could land after the caller had already moved on.
await this.auditService.log({
action: AUDIT_ACTIONS.CREATE,
entityType: AUDIT_ENTITIES.FareRule,
entityId: rule.id,
newData: snapshot(rule, FARE_RULE_AUDIT_FIELDS),
});
return rule;
}
async updateFareRule(id: string, dto: Partial<CreateFareRuleDto>) {
@@ -580,7 +729,7 @@ export class SchedulesService {
if (!existing) throw new NotFoundException('Fare rule not found');
const { validFrom, validUntil, scheduleId, nationality, passengerCategory, ...rest } = dto;
return this.prisma.fareRule.update({
const updated = await this.prisma.fareRule.update({
where: { id },
data: {
...rest,
@@ -591,19 +740,32 @@ export class SchedulesService {
},
include: { seatClass: true },
});
await this.auditService.log({
action: AUDIT_ACTIONS.UPDATE,
entityType: AUDIT_ENTITIES.FareRule,
entityId: id,
oldData: snapshot(existing, FARE_RULE_AUDIT_FIELDS),
newData: snapshot(updated, FARE_RULE_AUDIT_FIELDS),
});
return updated;
}
async deleteFareRule(id: string) {
const existing = await this.prisma.fareRule.findUnique({ where: { id } });
if (!existing) throw new NotFoundException('Fare rule not found');
await this.prisma.fareRule.delete({ where: { id } });
await this.auditService.log({ action: 'DELETE', entityType: 'FareRule', entityId: id });
await this.auditService.log({
action: AUDIT_ACTIONS.DELETE,
entityType: AUDIT_ENTITIES.FareRule,
entityId: id,
oldData: snapshot(existing, FARE_RULE_AUDIT_FIELDS),
});
return { deleted: true, id };
}
createSegmentFareRule(dto: any) {
async createSegmentFareRule(dto: any) {
const { validFrom, validUntil, passengerCategory, ...rest } = dto;
return this.prisma.segmentFareRule.create({
const rule = await this.prisma.segmentFareRule.create({
data: {
...rest,
validFrom: parseEthiopianTime(validFrom),
@@ -611,6 +773,13 @@ export class SchedulesService {
},
include: { seatClass: true, route: true },
});
await this.auditService.log({
action: AUDIT_ACTIONS.CREATE,
entityType: AUDIT_ENTITIES.SegmentFareRule,
entityId: rule.id,
newData: snapshot(rule, SEGMENT_FARE_AUDIT_FIELDS),
});
return rule;
}
getSegmentFares(routeId: string) {
@@ -621,13 +790,25 @@ export class SchedulesService {
});
}
deleteSegmentFareRule(id: string) {
return this.prisma.segmentFareRule.delete({ where: { id } });
async deleteSegmentFareRule(id: string) {
const existing = await this.prisma.segmentFareRule.findUnique({ where: { id } });
if (!existing) throw new NotFoundException('Segment fare rule not found');
const deleted = await this.prisma.segmentFareRule.delete({ where: { id } });
await this.auditService.log({
action: AUDIT_ACTIONS.DELETE,
entityType: AUDIT_ENTITIES.SegmentFareRule,
entityId: id,
oldData: snapshot(existing, SEGMENT_FARE_AUDIT_FIELDS),
});
return deleted;
}
updateSegmentFareRule(id: string, dto: any) {
async updateSegmentFareRule(id: string, dto: any) {
const existing = await this.prisma.segmentFareRule.findUnique({ where: { id } });
if (!existing) throw new NotFoundException('Segment fare rule not found');
const { validFrom, validUntil, passengerCategory, ...rest } = dto;
return this.prisma.segmentFareRule.update({
const updated = await this.prisma.segmentFareRule.update({
where: { id },
data: {
...rest,
@@ -636,6 +817,14 @@ export class SchedulesService {
},
include: { seatClass: true, route: true },
});
await this.auditService.log({
action: AUDIT_ACTIONS.UPDATE,
entityType: AUDIT_ENTITIES.SegmentFareRule,
entityId: id,
oldData: snapshot(existing, SEGMENT_FARE_AUDIT_FIELDS),
newData: snapshot(updated, SEGMENT_FARE_AUDIT_FIELDS),
});
return updated;
}
async getFareRules(scheduleId?: string) {
@@ -700,6 +889,15 @@ export class SchedulesService {
}
}
// One row for the sweep: the operator pressed sync once, and every fare it rewrote is
// reconstructable from the FareRule versions it created.
await this.auditService.log({
action: AUDIT_ACTIONS.SYNC,
entityType: AUDIT_ENTITIES.ScheduleFare,
entityId: scheduleId,
newData: { scheduleId, synced, errorCount: errors.length },
});
return { synced, errors };
}
@@ -718,6 +916,16 @@ export class SchedulesService {
);
const plannedTimesMap = Object.fromEntries(plannedTimes.map(t => [t.sequence, t]));
await this.routesService.applyRouteToSchedule(schedule.routeId, scheduleId, plannedTimesMap);
await this.auditService.log({
action: AUDIT_ACTIONS.BULK_UPDATE,
entityType: AUDIT_ENTITIES.Schedule,
entityId: scheduleId,
newData: {
recalculatedStopTimes: true,
routeId: schedule.routeId,
stopCount: plannedTimes.length,
},
});
return { recalculated: true, scheduleId, stopCount: plannedTimes.length };
}
@@ -731,6 +939,12 @@ export class SchedulesService {
const inactiveCoach = existingCoaches.find(c => c.status !== 'ACTIVE');
if (inactiveCoach) throw new BadRequestException(`Coach ${inactiveCoach.number} is not active`);
const previous = await this.prisma.coachAssignment.findMany({
where: { scheduleId },
orderBy: { positionNumber: 'asc' },
select: { coachId: true, positionNumber: true },
});
await this.prisma.coachAssignment.deleteMany({ where: { scheduleId } });
const data = coaches.map((c) => ({
@@ -741,6 +955,20 @@ export class SchedulesService {
}));
await this.prisma.coachAssignment.createMany({ data });
// Assignment is a wholesale replacement, so both compositions go on one row rather than a
// delete row per coach followed by a create row per coach.
await this.auditService.log({
action: AUDIT_ACTIONS.ASSIGN,
entityType: AUDIT_ENTITIES.CoachAssignment,
entityId: scheduleId,
oldData: { scheduleId, coaches: previous },
newData: {
scheduleId,
coaches: coaches.map((c) => ({ coachId: c.coachId, positionNumber: c.positionNumber })),
},
});
return { message: 'Coaches assigned successfully', count: coaches.length };
}
@@ -795,19 +1023,55 @@ export class SchedulesService {
if (dto.coaches !== undefined) {
if (dto.coaches.length > 0) {
// Logs its own ASSIGN row; this method only audits the schedule's own fields, so the
// two rows describe two facts rather than double-reporting one.
await this.assignCoaches(id, dto.coaches);
} else {
const cleared = await this.prisma.coachAssignment.findMany({
where: { scheduleId: id },
select: { coachId: true, positionNumber: true },
});
await this.prisma.coachAssignment.deleteMany({ where: { scheduleId: id } });
await this.auditService.log({
action: AUDIT_ACTIONS.UNASSIGN,
entityType: AUDIT_ENTITIES.CoachAssignment,
entityId: id,
oldData: { scheduleId: id, coaches: cleared },
newData: { scheduleId: id, coaches: [] },
});
}
}
return this.getSchedule(id);
const result = await this.getSchedule(id);
if (Object.keys(updateData).length > 0) {
const statusChanged = dto.status !== undefined && dto.status !== schedule.status;
await this.auditService.log({
action: statusChanged
? dto.status === 'CANCELLED'
? AUDIT_ACTIONS.CANCEL
: AUDIT_ACTIONS.STATUS_CHANGE
: AUDIT_ACTIONS.UPDATE,
entityType: AUDIT_ENTITIES.Schedule,
entityId: id,
oldData: snapshot(schedule, SCHEDULE_AUDIT_FIELDS),
newData: snapshot(result as any, SCHEDULE_AUDIT_FIELDS),
});
}
return result;
}
async removeCoachAssignment(scheduleId: string, coachId: string) {
const assignment = await this.prisma.coachAssignment.findFirst({ where: { scheduleId, coachId } });
if (!assignment) throw new NotFoundException('Coach assignment not found');
await this.prisma.coachAssignment.delete({ where: { id: assignment.id } });
await this.auditService.log({
action: AUDIT_ACTIONS.UNASSIGN,
entityType: AUDIT_ENTITIES.CoachAssignment,
entityId: assignment.id,
oldData: { scheduleId, coachId, positionNumber: assignment.positionNumber },
});
return { message: 'Coach assignment removed' };
}
@@ -846,14 +1110,23 @@ export class SchedulesService {
},
include: { seatClass: true, route: true },
});
await this.auditService.log({ action: 'CREATE', entityType: 'RouteFareRule', entityId: rule.id, newData: { routeId: dto.routeId, seatClassId: dto.seatClassId, baseFareMinor: dto.baseFareMinor } });
await this.auditService.log({
action: AUDIT_ACTIONS.CREATE,
entityType: AUDIT_ENTITIES.RouteFareRule,
entityId: rule.id,
newData: {
...snapshot(rule, ROUTE_FARE_AUDIT_FIELDS),
routeCode: route.code,
seatClassName: seatClass.name,
},
});
return rule;
}
async updateRouteFareRule(id: string, dto: { baseFareMinor?: number; surchargeMinor?: number; validFrom?: string; validUntil?: string }) {
const rule = await this.prisma.routeFareRule.findUnique({ where: { id } });
if (!rule) throw new NotFoundException('Route fare rule not found');
return this.prisma.routeFareRule.update({
const updated = await this.prisma.routeFareRule.update({
where: { id },
data: {
...(dto.baseFareMinor !== undefined && { baseFareMinor: dto.baseFareMinor }),
@@ -863,13 +1136,26 @@ export class SchedulesService {
},
include: { seatClass: true, route: true },
});
await this.auditService.log({
action: AUDIT_ACTIONS.UPDATE,
entityType: AUDIT_ENTITIES.RouteFareRule,
entityId: id,
oldData: snapshot(rule, ROUTE_FARE_AUDIT_FIELDS),
newData: snapshot(updated, ROUTE_FARE_AUDIT_FIELDS),
});
return updated;
}
async deleteRouteFareRule(id: string) {
const rule = await this.prisma.routeFareRule.findUnique({ where: { id } });
if (!rule) throw new NotFoundException('Route fare rule not found');
await this.prisma.routeFareRule.delete({ where: { id } });
await this.auditService.log({ action: 'DELETE', entityType: 'RouteFareRule', entityId: id });
await this.auditService.log({
action: AUDIT_ACTIONS.DELETE,
entityType: AUDIT_ENTITIES.RouteFareRule,
entityId: id,
oldData: snapshot(rule, ROUTE_FARE_AUDIT_FIELDS),
});
return { deleted: true, id };
}
}

View File

@@ -2,6 +2,22 @@ import { Injectable, NotFoundException, ConflictException } from '@nestjs/common
import { PrismaService } from '../../common/prisma.service';
import { DeleteOperationException } from '../../common/exceptions/delete-operation.exception';
import { AuditService } from '../../common/audit.service';
import { AUDIT_ACTIONS, AUDIT_ENTITIES } from '../../common/audit.actions';
import { snapshot } from '../../common/audit-snapshot';
/**
* Seat classes carry the per-km tariff rate, so `baseFareMinor` is the field an auditor is
* actually chasing — logging only the name made a price change indistinguishable from a rename.
*/
const SEAT_CLASS_AUDIT_FIELDS = [
'coachTypeId',
'name',
'description',
'baseFareMinor',
'premiumMinor',
'insuranceFeeMinor',
'isActive',
] as const;
@Injectable()
export class SeatClassesService {
@@ -30,7 +46,13 @@ export class SeatClassesService {
...(basePrice !== undefined && { baseFareMinor: basePrice }),
};
const updated = await this.prisma.seatClass.update({ where: { id }, data });
await this.auditService.log({ action: 'UPDATE', entityType: 'SeatClass', entityId: id, newData: { name: updated.name } });
await this.auditService.log({
action: AUDIT_ACTIONS.UPDATE,
entityType: AUDIT_ENTITIES.SeatClass,
entityId: id,
oldData: snapshot(sc, SEAT_CLASS_AUDIT_FIELDS),
newData: snapshot(updated, SEAT_CLASS_AUDIT_FIELDS),
});
return updated;
}
@@ -42,7 +64,12 @@ export class SeatClassesService {
...(basePrice !== undefined && { baseFareMinor: basePrice }),
};
const sc = await this.prisma.seatClass.create({ data });
await this.auditService.log({ action: 'CREATE', entityType: 'SeatClass', entityId: sc.id, newData: { name: sc.name } });
await this.auditService.log({
action: AUDIT_ACTIONS.CREATE,
entityType: AUDIT_ENTITIES.SeatClass,
entityId: sc.id,
newData: snapshot(sc, SEAT_CLASS_AUDIT_FIELDS),
});
return sc;
} catch (e: any) {
if (e.code === 'P2002') throw new ConflictException(`Seat class "${dto.name}" already exists`);
@@ -76,7 +103,13 @@ export class SeatClassesService {
}
const deleted = await this.prisma.seatClass.delete({ where: { id } });
await this.auditService.log({ action: 'DELETE', entityType: 'SeatClass', entityId: id, oldData: { name: sc.name } });
await this.auditService.log({
action: AUDIT_ACTIONS.DELETE,
entityType: AUDIT_ENTITIES.SeatClass,
entityId: id,
oldData: snapshot(sc, SEAT_CLASS_AUDIT_FIELDS),
newData: { cascade, fareRulesDeleted: cascade ? totalFareRules : 0 },
});
return deleted;
}
}

View File

@@ -6,6 +6,7 @@ import { Cron, CronExpression } from '@nestjs/schedule';
import { SegmentsService } from '../segments/segments.service';
import { SystemConfigService, CONFIG_KEYS } from '../system-config/system-config.service';
import { AuditService } from '../../common/audit.service';
import { AUDIT_ACTIONS, AUDIT_ENTITIES } from '../../common/audit.actions';
import { SmsClientService } from '../notifications/sms-client.service';
import { computePaymentDeadline } from '../../common/utils/payment-deadline.utils';
import { checkDirectionConflict } from '../../common/utils/journey-direction.utils';
@@ -899,6 +900,13 @@ export class SeatsService {
}
}
await this.auditService.log({
action: AUDIT_ACTIONS.IMPORT,
entityType: AUDIT_ENTITIES.Seat,
entityId: scheduleId,
newData: { scheduleId, rows: lines.length, imported, errorCount: errors.length },
});
return { imported, errors: errors.slice(0, 10) };
}
@@ -932,10 +940,11 @@ export class SeatsService {
});
}
await this.auditService.log({
action: 'UPDATE',
entityType: 'Seat',
action: AUDIT_ACTIONS.BLOCK,
entityType: AUDIT_ENTITIES.Seat,
entityId: seatId,
newData: { status: 'BLOCKED', reason, reasonCategory, scheduleId, blockedBy },
oldData: { status: seat.status, seatNumber: seat.seatNumber, coachId: seat.coachId },
newData: { status: 'BLOCKED', reason, reasonCategory, scheduleId, blockedBy, blockedByName },
});
return { blocked: true, seatId, reason, reasonCategory, scheduleId, blockedBy, blockedByName };
}
@@ -950,7 +959,13 @@ export class SeatsService {
await this.prisma.seat.update({ where: { id: seatId }, data: { status: 'AVAILABLE' } });
await this.prisma.seatBlock.deleteMany({ where: { seatId, scheduleId: null } });
}
await this.auditService.log({ action: 'UPDATE', entityType: 'Seat', entityId: seatId, newData: { status: 'AVAILABLE', scheduleId } });
await this.auditService.log({
action: AUDIT_ACTIONS.UNBLOCK,
entityType: AUDIT_ENTITIES.Seat,
entityId: seatId,
oldData: { status: seat.status, seatNumber: seat.seatNumber, coachId: seat.coachId },
newData: { status: 'AVAILABLE', scheduleId },
});
return { unblocked: true, seatId, scheduleId };
}
@@ -968,6 +983,13 @@ export class SeatsService {
blockedByName: actor?.name ?? 'System',
},
});
await this.auditService.log({
action: AUDIT_ACTIONS.STATUS_CHANGE,
entityType: AUDIT_ENTITIES.Seat,
entityId: seatId,
oldData: { status: seat.status, seatNumber: seat.seatNumber, coachId: seat.coachId },
newData: { status: 'UNDER_MAINTENANCE', reason, blockedBy: actor?.id ?? 'SYSTEM' },
});
return { maintenance: true, seatId, reason };
}
@@ -976,6 +998,13 @@ export class SeatsService {
if (!seat) throw new NotFoundException('Seat not found');
await this.prisma.seat.update({ where: { id: seatId }, data: { status: 'AVAILABLE' as any } });
await this.prisma.seatBlock.deleteMany({ where: { seatId } });
await this.auditService.log({
action: AUDIT_ACTIONS.STATUS_CHANGE,
entityType: AUDIT_ENTITIES.Seat,
entityId: seatId,
oldData: { status: seat.status, seatNumber: seat.seatNumber, coachId: seat.coachId },
newData: { status: 'AVAILABLE' },
});
return { maintenance: false, seatId };
}
@@ -991,7 +1020,12 @@ export class SeatsService {
});
await this.renumberCoachSeats(seat.coachId);
await this.auditService.log({ action: 'DELETE', entityType: 'Seat', entityId: seatId, oldData: { seatNumber: seat.seatNumber, coachId: seat.coachId } });
await this.auditService.log({
action: AUDIT_ACTIONS.DELETE,
entityType: AUDIT_ENTITIES.Seat,
entityId: seatId,
oldData: { seatNumber: seat.seatNumber, coachId: seat.coachId, status: seat.status },
});
return { removed: true, seatId, originalSeatNumber: seat.seatNumber };
}
@@ -1007,6 +1041,13 @@ export class SeatsService {
await this.renumberCoachSeats(seat.coachId);
const restored = await this.prisma.seat.findUnique({ where: { id: seatId } });
await this.auditService.log({
action: AUDIT_ACTIONS.RESTORE,
entityType: AUDIT_ENTITIES.Seat,
entityId: seatId,
oldData: { seatNumber: seat.seatNumber, coachId: seat.coachId },
newData: { seatNumber: restored?.seatNumber, coachId: seat.coachId },
});
return { restored: true, seatId, seatNumber: restored?.seatNumber };
}
@@ -1582,6 +1623,24 @@ export class SeatsService {
});
}
// `results` carries contactPhone for the SMS step — the audit row keeps only the seat move
// itself, so a passenger's number never lands in a log retained for a year.
await this.auditService.log({
action: AUDIT_ACTIONS.BULK_UPDATE,
entityType: AUDIT_ENTITIES.Seat,
entityId: coachIds[0],
newData: {
coachIds,
resolved: results.length,
unresolved: unresolved.length,
moves: results.map((r) => ({
bookingRef: r.bookingRef,
oldSeatNumber: r.oldSeatNumber,
newSeatNumber: r.newSeatNumber,
})),
},
});
return {
resolved: results.length,
unresolved: unresolved.length,

View File

@@ -1,10 +1,23 @@
import { Injectable, NotFoundException, Inject, Optional, BadRequestException } from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
import { PrismaService } from '../../common/prisma.service';
import { AuditService } from '../../common/audit.service';
import { AUDIT_ACTIONS, AUDIT_ENTITIES } from '../../common/audit.actions';
import { snapshot } from '../../common/audit-snapshot';
import { CreateStationDto } from './stations.dto';
import { DeleteOperationException } from '../../common/exceptions/delete-operation.exception';
/** The station fields worth carrying into an audit row — identity, placement, and status. */
const STATION_AUDIT_FIELDS = [
'code',
'name',
'city',
'countryCode',
'lat',
'lng',
'sequence',
'isOperational',
] as const;
interface StationFilters {
search?: string;
country?: string;
@@ -16,7 +29,6 @@ export class StationsService {
constructor(
private prisma: PrismaService,
private auditService: AuditService,
@Optional() @Inject(REQUEST) private request?: any,
) {}
findAll(filters: StationFilters = {}) {
@@ -61,11 +73,10 @@ export class StationsService {
});
await this.auditService.log({
userId: this.request?.user?.id,
action: 'CREATE',
entityType: 'Station',
action: AUDIT_ACTIONS.CREATE,
entityType: AUDIT_ENTITIES.Station,
entityId: station.id,
newData: station,
newData: snapshot(station, STATION_AUDIT_FIELDS),
});
return station;
@@ -85,12 +96,11 @@ export class StationsService {
});
await this.auditService.log({
userId: this.request?.user?.id,
action: 'UPDATE',
entityType: 'Station',
action: AUDIT_ACTIONS.UPDATE,
entityType: AUDIT_ENTITIES.Station,
entityId: id,
oldData: oldStation,
newData: updatedStation,
oldData: snapshot(oldStation, STATION_AUDIT_FIELDS),
newData: snapshot(updatedStation, STATION_AUDIT_FIELDS),
});
return updatedStation;
@@ -139,11 +149,11 @@ export class StationsService {
const deleted = await this.prisma.station.delete({ where: { id } });
await this.auditService.log({
userId: this.request?.user?.id,
action: 'DELETE',
entityType: 'Station',
action: AUDIT_ACTIONS.DELETE,
entityType: AUDIT_ENTITIES.Station,
entityId: id,
oldData: station,
oldData: snapshot(station, STATION_AUDIT_FIELDS),
newData: { cascade },
});
return deleted;

View File

@@ -0,0 +1,231 @@
import { TicketsService } from './tickets.service';
/**
* "USER X boarded TICKET Y" has to be answerable from AuditLog alone.
*
* Before this, boarding wrote `action: 'VERIFY'` with no actor and no previous status, and the
* only name on the row was `validatorId` — a request-body field, so whoever scanned could put
* anyone's id in the trail. These pin: one row per boarding (not one per layer), the actor
* coming from the session, and the ticket's before/after status both being recorded.
*/
describe('TicketsService — boarding audit', () => {
const ACTOR = { id: 'iam-staff-1', name: 'Abebe Kebede', phone: '+251911223344' };
const TICKET_ID = 'ticket-1';
const BOOKING_ID = 'booking-1';
const BOOKING_REF = 'EDR-0001';
let prisma: Record<string, any>;
let audit: { log: jest.Mock };
let service: TicketsService;
const build = (
opts: {
bookingType?: string;
ticket?: Record<string, any>;
booking?: Record<string, any>;
approvedLegs?: string[];
} = {},
) => {
const ticket = {
id: TICKET_ID,
bookingId: BOOKING_ID,
bookingRef: BOOKING_REF,
seatId: 'seat-9',
leg: 1,
status: 'ACTIVE',
validatedAt: null,
boardedAt: null,
...opts.ticket,
};
// Departure an hour out, so scanAndBoard's boarding window is open.
const departureAt = new Date(Date.now() + 60 * 60 * 1000);
const booking = {
id: BOOKING_ID,
bookingRef: BOOKING_REF,
bookingType: opts.bookingType ?? 'ONE_WAY',
status: 'CONFIRMED',
originStationId: 'station-a',
destinationStationId: 'station-b',
outboundBoardedAt: null,
returnBoardedAt: null,
tickets: [ticket],
seats: [],
schedule: {
id: 'sched-1',
departureAt,
arrivalAt: new Date(departureAt.getTime() + 6 * 60 * 60 * 1000),
originStationId: 'station-a',
destinationStationId: 'station-b',
originStation: { id: 'station-a', name: 'Furi Labu' },
destinationStation: { id: 'station-b', name: 'Dire Dawa' },
train: { id: 'train-1', number: 'T1' },
stopTimes: [],
},
returnSchedule: null,
...opts.booking,
};
prisma = {
ticket: {
findUnique: jest.fn().mockResolvedValue(ticket),
findFirst: jest.fn().mockResolvedValue(ticket),
update: jest.fn().mockResolvedValue(ticket),
},
booking: {
findUnique: jest.fn().mockResolvedValue(booking),
update: jest.fn().mockResolvedValue(booking),
},
gateValidationLog: {
create: jest.fn().mockResolvedValue({}),
findMany: jest
.fn()
.mockResolvedValue((opts.approvedLegs ?? []).map((leg) => ({ leg, status: 'APPROVED' }))),
},
};
audit = { log: jest.fn().mockResolvedValue(undefined) };
// Constructor order: prisma, notifications, systemConfig, auditService, dataSource.
service = new TicketsService(
prisma as any,
{ sendSms: jest.fn(), sendEmail: jest.fn() } as any,
{ get: jest.fn().mockResolvedValue(null), getNumber: jest.fn().mockResolvedValue(4) } as any,
audit as any,
{} as any,
);
return { ticket, booking };
};
const rows = () => audit.log.mock.calls.map((c) => c[0]);
const boardRows = () => rows().filter((r) => r.action === 'BOARD');
describe('a successful one-way boarding', () => {
it('writes exactly one BOARD row', async () => {
build();
await service.validate(BOOKING_REF, 'GATE-1', 'MOBILE-GATE', undefined, ACTOR);
expect(boardRows()).toHaveLength(1);
});
it('identifies the ticket and the booking it belongs to', async () => {
build();
await service.validate(BOOKING_REF, 'GATE-1', 'MOBILE-GATE', undefined, ACTOR);
expect(boardRows()[0]).toMatchObject({
action: 'BOARD',
entityType: 'Ticket',
entityId: TICKET_ID,
});
expect(boardRows()[0].newData).toMatchObject({
bookingRef: BOOKING_REF,
bookingId: BOOKING_ID,
seatId: 'seat-9',
});
});
it('records the status the ticket moved from and to', async () => {
build();
await service.validate(BOOKING_REF, 'GATE-1', undefined, undefined, ACTOR);
const row = boardRows()[0];
expect(row.oldData).toMatchObject({ status: 'ACTIVE', validatedAt: null });
expect(row.newData.status).toBe('USED');
expect(row.newData.boardedAt).toEqual(expect.any(String));
});
it('leaves the actor to AuditService rather than passing a client-supplied id', async () => {
build();
await service.validate(BOOKING_REF, 'anyone-can-type-this', undefined, undefined, ACTOR);
// `userId` is never set at the call site — AuditService reads the guarded session, so the
// body value below can only ever appear as descriptive context.
expect(boardRows()[0].userId).toBeUndefined();
expect(boardRows()[0].newData.validatorId).toBe('anyone-can-type-this');
});
it('names the authenticated user on the gate log when no validatorId is sent', async () => {
build();
await service.validate(BOOKING_REF, '', undefined, undefined, ACTOR);
// Previously fell straight through to the anonymous 'BACKOFFICE' literal.
expect(prisma.gateValidationLog.create.mock.calls[0][0].data.validatorId).toBe(ACTOR.id);
});
});
describe('scanAndBoard', () => {
it('produces one row, not one per layer', async () => {
build();
// scanAndBoard delegates to validate(); logging in both would double every boarding.
await service.scanAndBoard(BOOKING_REF, 'GATE-1', 'MOBILE-GATE', ACTOR);
expect(boardRows()).toHaveLength(1);
});
});
describe('a refused boarding', () => {
it('records BOARD_DENIED with the reason on a round-trip leg already used', async () => {
build({ bookingType: 'ROUND_TRIP', booking: { outboundBoardedAt: new Date() } });
await expect(
service.validate(BOOKING_REF, 'GATE-1', undefined, 'OUTBOUND', ACTOR),
).rejects.toThrow();
const denied = rows().filter((r) => r.action === 'BOARD_DENIED');
expect(denied).toHaveLength(1);
expect(denied[0]).toMatchObject({ entityType: 'Ticket', entityId: TICKET_ID });
expect(denied[0].newData).toMatchObject({
result: 'REJECTED',
reason: 'OUTBOUND_ALREADY_USED',
bookingRef: BOOKING_REF,
});
});
it('writes no BOARD row when the boarding was refused', async () => {
build({ bookingType: 'TRANSIT', approvedLegs: ['LEG1'] });
await expect(
service.validate(BOOKING_REF, 'GATE-1', undefined, 'LEG1', ACTOR),
).rejects.toThrow();
expect(boardRows()).toHaveLength(0);
});
});
describe('reads', () => {
it('writes nothing when simply fetching a ticket', async () => {
build();
await service.getByRef(BOOKING_REF).catch(() => undefined);
expect(audit.log).not.toHaveBeenCalled();
});
});
describe('failed operations', () => {
it('records nothing when the booking does not exist', async () => {
build();
prisma.booking.findUnique.mockResolvedValue(null);
await expect(
service.validate(BOOKING_REF, 'GATE-1', undefined, undefined, ACTOR),
).rejects.toThrow();
expect(audit.log).not.toHaveBeenCalled();
});
it('records nothing when the ticket write itself fails', async () => {
build();
prisma.ticket.update.mockRejectedValue(new Error('db down'));
await expect(
service.validate(BOOKING_REF, 'GATE-1', undefined, undefined, ACTOR),
).rejects.toThrow();
expect(boardRows()).toHaveLength(0);
});
});
describe('sensitive data', () => {
it('keeps the QR payload and passenger name off the row', async () => {
build({ ticket: { qrPayload: 'QR-SECRET', passengerName: 'Abebe Kebede' } });
await service.validate(BOOKING_REF, 'GATE-1', undefined, undefined, ACTOR);
const serialized = JSON.stringify(boardRows()[0]);
expect(serialized).not.toContain('QR-SECRET');
expect(serialized).not.toContain('Abebe Kebede');
});
});
});

View File

@@ -1,8 +1,9 @@
import { Body, Controller, Get, Param, Post, Query, UseGuards, Delete, Patch, SetMetadata } from '@nestjs/common';
import { Body, Controller, Get, Param, Post, Query, Req, UseGuards, Delete, Patch, SetMetadata } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth, ApiBody, ApiQuery } from '@nestjs/swagger';
import { TicketsService } from './tickets.service';
import { PassengerStaff, PassengerAdmin } from '../../common/passenger-guards';
import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry';
import { resolveActingUser } from '../../common/acting-user';
@ApiTags('Tickets')
@Controller('tickets')
@@ -135,10 +136,13 @@ export class TicketsController {
})
scanAndBoard(
@Param('qrCodeOrRef') qrCodeOrRef: string,
@Req() req: any,
@Body('validatorId') validatorId: string,
@Body('gateId') gateId?: string,
) {
return this.service.scanAndBoard(qrCodeOrRef, validatorId, gateId);
// `validatorId` still labels the gate/agent on the gate log; who is accountable for the
// boarding comes from the JWT, which the body cannot influence.
return this.service.scanAndBoard(qrCodeOrRef, validatorId, gateId, resolveActingUser(req));
}
@Post(':bookingRef/validate')
@@ -165,11 +169,12 @@ export class TicketsController {
})
validate(
@Param('bookingRef') ref: string,
@Req() req: any,
@Body('validatorId') validatorId: string,
@Body('gateId') gateId?: string,
@Body('leg') leg?: string,
) {
return this.service.validate(ref, validatorId, gateId, leg);
return this.service.validate(ref, validatorId, gateId, leg, resolveActingUser(req));
}
@Get(':ticketId/validation-logs')
@@ -216,8 +221,8 @@ export class TicketsController {
},
},
})
validateOfflineBatch(@Body() body: { validations: any[] }) {
return this.service.validateOfflineBatch(body.validations);
validateOfflineBatch(@Body() body: { validations: any[] }, @Req() req: any) {
return this.service.validateOfflineBatch(body.validations, resolveActingUser(req));
}
@Delete(':id')

View File

@@ -5,6 +5,8 @@ import { PrismaService } from '../../common/prisma.service';
import { NotificationsService } from '../notifications/notifications.service';
import { SystemConfigService, CONFIG_KEYS } from '../system-config/system-config.service';
import { AuditService } from '../../common/audit.service';
import { AUDIT_ACTIONS, AUDIT_ENTITIES } from '../../common/audit.actions';
import { ActingUser } from '../../common/acting-user';
import { resolveBookingSegment } from '../../common/utils/segment-resolver.utils';
import * as QRCode from 'qrcode';
@@ -289,12 +291,16 @@ export class TicketsService {
reassigned.push({ seatNumber: bs.seat.seatNumber, newSeatNumber: candidate.seatNumber });
}
await this.auditService.log({
action: 'UPDATE',
entityType: 'Booking',
entityId: bookingId,
newData: { smartReassigned: true, changes: reassigned },
});
// Only when a seat actually moved: this runs on every reassignment sweep, including from
// generateMissing()'s batch loop, and an empty `changes` row says nothing happened.
if (reassigned.length > 0) {
await this.auditService.log({
action: AUDIT_ACTIONS.UPDATE,
entityType: AUDIT_ENTITIES.Booking,
entityId: bookingId,
newData: { smartReassigned: true, changes: reassigned },
});
}
return this.generate(bookingId);
}
@@ -506,7 +512,15 @@ export class TicketsService {
}).catch(() => null);
}
await this.auditService.log({ action: 'CREATE', entityType: 'Ticket', entityId: booking.id, newData: { bookingRef: booking.bookingRef, totalTickets: tickets.length } });
// NOTE: entityId is the booking's id, not a ticket id — pre-existing and left as-is so
// historical rows stay queryable the same way. Ticket generation is outside this change's
// scope; see the audit-trail report.
await this.auditService.log({
action: AUDIT_ACTIONS.CREATE,
entityType: AUDIT_ENTITIES.Ticket,
entityId: booking.id,
newData: { bookingRef: booking.bookingRef, totalTickets: tickets.length },
});
return { tickets, totalTickets: tickets.length };
}
@@ -587,7 +601,12 @@ export class TicketsService {
};
}
async scanAndBoard(qrCodeOrRef: string, validatorId: string, gateId?: string) {
async scanAndBoard(
qrCodeOrRef: string,
validatorId: string,
gateId?: string,
actor?: ActingUser | null,
) {
try {
// Extract booking reference from QR code if it's JSON
let bookingRef = qrCodeOrRef;
@@ -662,7 +681,7 @@ export class TicketsService {
}
// Use existing validation logic to handle round trips properly
const result = await this.validate(bookingRef, validatorId, gateId);
const result = await this.validate(bookingRef, validatorId, gateId, undefined, actor);
if ((result as any).alreadyValidated) {
return {
success: false,
@@ -745,7 +764,21 @@ export class TicketsService {
}
}
async validate(ticketIdOrRef: string, validatorId: string, gateId?: string, leg?: string) {
/**
* Boards a ticket at the gate.
*
* `validatorId` is the gate/agent label the client sends and keeps its existing meaning on
* `Ticket.validatorId` and `GateValidationLog`. `actor` is the authenticated staff member from
* the request — it is what the audit trail attributes the boarding to, so a caller cannot board
* a passenger under someone else's name by editing the request body.
*/
async validate(
ticketIdOrRef: string,
validatorId: string,
gateId?: string,
leg?: string,
actor?: ActingUser | null,
) {
// Accept either a ticket UUID or a bookingRef
let bookingRef = ticketIdOrRef;
const isUuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(ticketIdOrRef);
@@ -754,7 +787,9 @@ export class TicketsService {
if (!ticket) throw new NotFoundException('Ticket not found');
bookingRef = ticket.bookingRef;
}
const resolvedValidatorId = validatorId || 'BACKOFFICE';
// Falls back to the authenticated user before the anonymous 'BACKOFFICE' literal, so an
// omitted validatorId still names a real person on the gate log.
const resolvedValidatorId = validatorId || actor?.id || 'BACKOFFICE';
const booking = await this.prisma.booking.findUnique({ where: { bookingRef } });
if (!booking) throw new NotFoundException('Booking not found');
const ticket = await this.prisma.ticket.findFirst({ where: { bookingId: booking.id } });
@@ -763,6 +798,14 @@ export class TicketsService {
const type = booking.bookingType;
const now = new Date();
// Snapshotted before any branch mutates the rows, so every audit row below can report the
// status the ticket actually moved away from.
const previousTicketStatus = ticket.status;
const previousValidatedAt = ticket.validatedAt?.toISOString() ?? null;
const previousOutboundBoardedAt =
(booking as any).outboundBoardedAt?.toISOString() ?? null;
const previousReturnBoardedAt = (booking as any).returnBoardedAt?.toISOString() ?? null;
const markTicketUsed = async () => {
if (ticket.status !== 'USED') {
await this.prisma.ticket.update({ where: { id: ticket.id }, data: { status: 'USED' } });
@@ -782,7 +825,24 @@ export class TicketsService {
});
await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, status: 'APPROVED' } });
this.fireBoardingPassNotification(booking, ticket, null);
await this.auditService.log({ action: 'VERIFY', entityType: 'Ticket', entityId: ticket.id, newData: { bookingRef, validatorId: resolvedValidatorId, leg: 'ONE_WAY' } });
await this.auditService.log({
action: AUDIT_ACTIONS.BOARD,
entityType: AUDIT_ENTITIES.Ticket,
entityId: ticket.id,
oldData: { status: ticket.status, validatedAt: null, boardedAt: null },
newData: {
status: 'USED',
validatedAt: now.toISOString(),
boardedAt: now.toISOString(),
leg: 'ONE_WAY',
bookingRef,
bookingId: booking.id,
seatId: ticket.seatId,
ticketLeg: ticket.leg,
gateId,
validatorId: resolvedValidatorId,
},
});
return { validated: true, ticketId: ticket.id, validatedAt: now };
}
@@ -797,6 +857,23 @@ export class TicketsService {
if (alreadyValidated) {
await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, leg: resolvedLeg, status: 'REJECTED', reason: `${resolvedLeg}_ALREADY_USED` } as any });
await markTicketUsed();
// A refused boarding is exactly the attempt an investigator wants attributed; the gate
// log records it but carries no IAM actor, IP, or user-agent.
await this.auditService.log({
action: AUDIT_ACTIONS.BOARD_DENIED,
entityType: AUDIT_ENTITIES.Ticket,
entityId: ticket.id,
oldData: { status: ticket.status, validatedAt: ticket.validatedAt?.toISOString() ?? null },
newData: {
result: 'REJECTED',
reason: `${resolvedLeg}_ALREADY_USED`,
leg: resolvedLeg,
bookingRef,
bookingId: booking.id,
gateId,
validatorId: resolvedValidatorId,
},
});
throw new BadRequestException(`${resolvedLeg} already validated`);
}
const validatedAt = ticket.validatedAt ?? now;
@@ -810,7 +887,24 @@ export class TicketsService {
}
await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, leg: resolvedLeg, status: 'APPROVED' } as any });
this.fireBoardingPassNotification(booking, ticket, resolvedLeg);
await this.auditService.log({ action: 'VERIFY', entityType: 'Ticket', entityId: ticket.id, newData: { bookingRef, validatorId: resolvedValidatorId, leg: resolvedLeg } });
await this.auditService.log({
action: AUDIT_ACTIONS.BOARD,
entityType: AUDIT_ENTITIES.Ticket,
entityId: ticket.id,
oldData: { status: previousTicketStatus, validatedAt: previousValidatedAt },
newData: {
status: 'USED',
validatedAt: validatedAt.toISOString(),
boardedAt: now.toISOString(),
leg: resolvedLeg,
bookingRef,
bookingId: booking.id,
seatId: ticket.seatId,
ticketLeg: ticket.leg,
gateId,
validatorId: resolvedValidatorId,
},
});
return { validated: true, ticketId: ticket.id, leg: resolvedLeg, validatedAt };
}
@@ -826,6 +920,21 @@ export class TicketsService {
if ((booking as any).outboundBoardedAt) {
await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, leg: resolvedLeg, status: 'REJECTED', reason: 'OUTBOUND_ALREADY_USED' } as any });
await markTicketUsed();
await this.auditService.log({
action: AUDIT_ACTIONS.BOARD_DENIED,
entityType: AUDIT_ENTITIES.Ticket,
entityId: ticket.id,
oldData: { status: previousTicketStatus, validatedAt: previousValidatedAt },
newData: {
result: 'REJECTED',
reason: 'OUTBOUND_ALREADY_USED',
leg: resolvedLeg,
bookingRef,
bookingId: booking.id,
gateId,
validatorId: resolvedValidatorId,
},
});
throw new BadRequestException('Outbound leg already validated');
}
bookingData.outboundBoardedAt = now;
@@ -833,6 +942,21 @@ export class TicketsService {
if ((booking as any).returnBoardedAt) {
await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, leg: resolvedLeg, status: 'REJECTED', reason: 'RETURN_ALREADY_USED' } as any });
await markTicketUsed();
await this.auditService.log({
action: AUDIT_ACTIONS.BOARD_DENIED,
entityType: AUDIT_ENTITIES.Ticket,
entityId: ticket.id,
oldData: { status: previousTicketStatus, validatedAt: previousValidatedAt },
newData: {
result: 'REJECTED',
reason: 'RETURN_ALREADY_USED',
leg: resolvedLeg,
bookingRef,
bookingId: booking.id,
gateId,
validatorId: resolvedValidatorId,
},
});
throw new BadRequestException('Return leg already validated');
}
bookingData.returnBoardedAt = now;
@@ -852,7 +976,29 @@ export class TicketsService {
}
await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, leg: resolvedLeg, status: 'APPROVED' } as any });
this.fireBoardingPassNotification(booking, ticket, resolvedLeg);
await this.auditService.log({ action: 'VERIFY', entityType: 'Ticket', entityId: ticket.id, newData: { bookingRef, validatorId: resolvedValidatorId, leg: resolvedLeg } });
await this.auditService.log({
action: AUDIT_ACTIONS.BOARD,
entityType: AUDIT_ENTITIES.Ticket,
entityId: ticket.id,
oldData: {
status: previousTicketStatus,
validatedAt: previousValidatedAt,
outboundBoardedAt: previousOutboundBoardedAt,
returnBoardedAt: previousReturnBoardedAt,
},
newData: {
status: 'USED',
validatedAt: validatedAt.toISOString(),
boardedAt: now.toISOString(),
leg: resolvedLeg,
bookingRef,
bookingId: booking.id,
seatId: ticket.seatId,
ticketLeg: ticket.leg,
gateId,
validatorId: resolvedValidatorId,
},
});
return { validated: true, ticketId: ticket.id, leg: resolvedLeg, validatedAt };
}
@@ -894,7 +1040,7 @@ export class TicketsService {
}));
}
async validateOfflineBatch(validations: OfflineValidation[]) {
async validateOfflineBatch(validations: OfflineValidation[], actor?: ActingUser | null) {
const results = [];
for (const validation of validations) {
@@ -903,7 +1049,8 @@ export class TicketsService {
validation.bookingRef,
validation.validatorId,
validation.gateId,
validation.leg
validation.leg,
actor,
);
results.push({
bookingRef: validation.bookingRef,

View File

@@ -1,58 +1,110 @@
'use client';
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Eye, Download } from 'lucide-react';
import { Eye, Download, ChevronLeft, ChevronRight } from 'lucide-react';
import DataTable from '@/components/ui/DataTable';
import Badge from '@/components/ui/Badge';
import { auditApi } from '@/lib/api';
import { formatDateTime } from '@/lib/utils';
import Modal from '@/components/ui/Modal';
import ActionButton from '@/components/ui/ActionButton';
import { PermissionGuard } from '@/components/layout/PermissionGuard';
import { PERMS } from '@/lib/permissions';
import type { AuditLog } from '@/types/edr';
export default function AuditLogsPage() {
const [filters, setFilters] = useState({ search: '', action: '', entityType: '' });
const PAGE_SIZE = 50;
/** Fallbacks used until GET /audit/vocabulary answers, and if it ever fails. */
const FALLBACK_ACTIONS = ['CREATE', 'UPDATE', 'DELETE', 'STATUS_CHANGE', 'CANCEL', 'BOARD'];
const FALLBACK_ENTITY_TYPES = ['Station', 'Train', 'Coach', 'Seat', 'Route', 'Schedule', 'Ticket'];
/** Actions that read as destructive/irreversible, for colouring only. */
const DESTRUCTIVE_ACTIONS = new Set(['DELETE', 'CANCEL', 'BOARD_DENIED']);
const CREATIVE_ACTIONS = new Set(['CREATE', 'BULK_CREATE', 'RESTORE', 'BOARD', 'PAY']);
function actionVariant(action: string) {
if (CREATIVE_ACTIONS.has(action)) return 'success';
if (DESTRUCTIVE_ACTIONS.has(action)) return 'danger';
if (action === 'LOGIN' || action === 'LOGOUT') return 'info';
return 'primary';
}
function AuditLogsPageContent() {
const [filters, setFilters] = useState({
search: '',
action: '',
entityType: '',
from: '',
to: '',
});
const [page, setPage] = useState(0);
const [selectedLog, setSelectedLog] = useState<any>(null);
const [showDetailsModal, setShowDetailsModal] = useState(false);
const [exporting, setExporting] = useState(false);
// Any filter change invalidates the current offset.
useEffect(() => setPage(0), [filters]);
const query = { ...filters, limit: PAGE_SIZE, offset: page * PAGE_SIZE };
const { data, isLoading } = useQuery({
queryKey: ['audit-logs', filters],
queryFn: () => auditApi.getLogs(filters),
refetchInterval: 30000, // Refetch every 30 seconds
queryKey: ['audit-logs', query],
queryFn: () => auditApi.getLogs(query),
refetchInterval: 30000,
});
const getActionBadgeColor = (action: string) => {
switch (action) {
case 'CREATE':
return 'success';
case 'UPDATE':
return 'primary';
case 'DELETE':
return 'danger';
case 'LOGIN':
return 'info';
case 'LOGOUT':
return 'secondary';
default:
return 'secondary';
const { data: vocabulary } = useQuery({
queryKey: ['audit-vocabulary'],
queryFn: () => auditApi.getVocabulary(),
staleTime: Infinity,
});
// Per-action tallies come from the API's own `total` under the same filters, not from the
// rows on screen — counting the current page made "Total Logs" cap out at the page size.
// limit: 1 keeps these to a COUNT plus a single row.
const creates = useQuery({
queryKey: ['audit-count', 'CREATE', filters],
queryFn: () => auditApi.getLogs({ ...filters, action: 'CREATE', limit: 1, offset: 0 }),
refetchInterval: 60000,
});
const updates = useQuery({
queryKey: ['audit-count', 'UPDATE', filters],
queryFn: () => auditApi.getLogs({ ...filters, action: 'UPDATE', limit: 1, offset: 0 }),
refetchInterval: 60000,
});
const deletes = useQuery({
queryKey: ['audit-count', 'DELETE', filters],
queryFn: () => auditApi.getLogs({ ...filters, action: 'DELETE', limit: 1, offset: 0 }),
refetchInterval: 60000,
});
const logs: AuditLog[] = Array.isArray(data?.items) ? data.items : [];
const total: number = typeof data?.total === 'number' ? data.total : logs.length;
const pageCount = Math.max(1, Math.ceil(total / PAGE_SIZE));
const actionOptions: string[] = vocabulary?.actions ?? FALLBACK_ACTIONS;
const entityTypeOptions: string[] = vocabulary?.entityTypes ?? FALLBACK_ENTITY_TYPES;
const formatJsonData = (value: any) => {
if (!value) return 'N/A';
try {
return JSON.stringify(value, null, 2);
} catch {
return String(value);
}
};
const formatJsonData = (data: any) => {
if (!data) return 'N/A';
try {
return JSON.stringify(data, null, 2);
} catch {
return String(data);
}
};
/** Who acted, falling back through the identity the API actually returns. */
const actorName = (log: AuditLog) => log.userName || 'System';
const actorDetail = (log: AuditLog) => log.userPhone || log.iamUserId || 'N/A';
const columns = [
{
key: 'createdAt',
label: 'Timestamp',
sortable: true,
render: (log: any) => (
render: (log: AuditLog) => (
<div className="text-sm">
<div className="font-medium">{formatDateTime(log.createdAt)}</div>
<div className="text-xs text-muted-foreground">{new Date(log.createdAt).toLocaleTimeString()}</div>
@@ -63,17 +115,13 @@ export default function AuditLogsPage() {
key: 'action',
label: 'Action',
sortable: true,
render: (log: any) => (
<Badge className={getActionBadgeColor(log.action)}>
{log.action}
</Badge>
),
render: (log: AuditLog) => <Badge className={actionVariant(log.action)}>{log.action}</Badge>,
},
{
key: 'entityType',
label: 'Entity Type',
sortable: true,
render: (log: any) => (
render: (log: AuditLog) => (
<span className="px-2 py-1 bg-gray-100 dark:bg-gray-700 rounded text-xs font-medium">
{log.entityType}
</span>
@@ -82,29 +130,27 @@ export default function AuditLogsPage() {
{
key: 'entityId',
label: 'Entity ID',
render: (log: any) => (
render: (log: AuditLog) => (
<span className="font-mono text-xs text-muted-foreground">
{log.entityId ? log.entityId.substring(0, 12) : 'System'}
{log.entityId ? log.entityId.substring(0, 12) : ''}
</span>
),
},
{
key: 'user',
label: 'User',
render: (log: any) => (
render: (log: AuditLog) => (
<div>
<div className="font-medium text-sm">{log.user?.fullName || 'System'}</div>
<div className="text-xs text-muted-foreground">{log.user?.email || log.userId || 'N/A'}</div>
<div className="font-medium text-sm">{actorName(log)}</div>
<div className="text-xs text-muted-foreground font-mono">{actorDetail(log)}</div>
</div>
),
},
{
key: 'ipAddress',
label: 'IP Address',
render: (log: any) => (
<span className="text-xs text-muted-foreground font-mono">
{log.ipAddress || 'N/A'}
</span>
render: (log: AuditLog) => (
<span className="text-xs text-muted-foreground font-mono">{log.ipAddress || 'N/A'}</span>
),
},
];
@@ -112,7 +158,7 @@ export default function AuditLogsPage() {
const actions = [
{
label: 'View Details',
onClick: (log: any) => {
onClick: (log: AuditLog) => {
setSelectedLog(log);
setShowDetailsModal(true);
},
@@ -121,12 +167,60 @@ export default function AuditLogsPage() {
},
];
const logs: any[] = Array.isArray(data?.items) ? data.items : [];
const stats = {
total: logs.length,
creates: logs.filter((l: any) => l.action === 'CREATE').length,
updates: logs.filter((l: any) => l.action === 'UPDATE').length,
deletes: logs.filter((l: any) => l.action === 'DELETE').length,
/**
* Exports every row matching the current filters, not just the page on screen — pulled in
* API-sized batches so a year of logs doesn't arrive as one request.
*/
const exportCsv = async () => {
setExporting(true);
try {
const batch = 200;
const rows: AuditLog[] = [];
for (let offset = 0; offset < total; offset += batch) {
const chunk = await auditApi.getLogs({ ...filters, limit: batch, offset });
const items: AuditLog[] = Array.isArray(chunk?.items) ? chunk.items : [];
if (!items.length) break;
rows.push(...items);
}
if (!rows.length) return;
const headers = [
'Timestamp',
'Action',
'Entity Type',
'Entity ID',
'User Name',
'User Phone',
'User ID',
'IP Address',
'Old Data',
'New Data',
];
const body = rows.map((l) => [
formatDateTime(l.createdAt),
l.action,
l.entityType,
l.entityId || '',
l.userName || '',
l.userPhone || '',
l.iamUserId || '',
l.ipAddress || '',
l.oldData ? JSON.stringify(l.oldData) : '',
l.newData ? JSON.stringify(l.newData) : '',
]);
const csv = [headers, ...body]
.map((r) => r.map((v) => `"${String(v).replace(/"/g, '""')}"`).join(','))
.join('\n');
const blob = new Blob([csv], { type: 'text/csv' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `audit-logs-${new Date().toISOString().split('T')[0]}.csv`;
a.click();
URL.revokeObjectURL(url);
} finally {
setExporting(false);
}
};
return (
@@ -136,63 +230,39 @@ export default function AuditLogsPage() {
<h1 className="text-3xl font-bold text-foreground">Audit Logs</h1>
<p className="text-muted-foreground mt-1">Track all system activities and changes</p>
</div>
<ActionButton
icon={Download}
variant="secondary"
onClick={() => {
const items: any[] = Array.isArray(data?.items) ? data!.items : [];
if (!items.length) return;
const headers = ['Timestamp', 'Action', 'Entity Type', 'Entity ID', 'User ID', 'IP Address'];
const rows = items.map((l: any) => [
formatDateTime(l.createdAt),
l.action,
l.entityType,
l.entityId || '',
l.iamUserId || l.userId || '',
l.ipAddress || '',
]);
const csv = [headers, ...rows].map(r => r.map((v: string) => `"${String(v).replace(/"/g, '""')}"`).join(',')).join('\n');
const blob = new Blob([csv], { type: 'text/csv' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `audit-logs-${new Date().toISOString().split('T')[0]}.csv`;
a.click();
URL.revokeObjectURL(url);
}}
>
Export CSV
<ActionButton icon={Download} variant="secondary" onClick={exportCsv} disabled={exporting || !total}>
{exporting ? 'Exporting…' : 'Export CSV'}
</ActionButton>
</div>
{/* Stats Cards */}
{/* Stats — counts come from the API under the active filters, not the visible page */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="card">
<div className="text-muted-foreground text-sm font-medium">Total Logs</div>
<div className="text-2xl font-bold mt-2">{stats.total}</div>
<div className="text-2xl font-bold mt-2">{total}</div>
</div>
<div className="card">
<div className="text-green-600 text-sm font-medium">Created</div>
<div className="text-2xl font-bold mt-2">{stats.creates}</div>
<div className="text-2xl font-bold mt-2">{creates.data?.total ?? '—'}</div>
</div>
<div className="card">
<div className="text-blue-600 text-sm font-medium">Updated</div>
<div className="text-2xl font-bold mt-2">{stats.updates}</div>
<div className="text-2xl font-bold mt-2">{updates.data?.total ?? '—'}</div>
</div>
<div className="card">
<div className="text-red-600 text-sm font-medium">Deleted</div>
<div className="text-2xl font-bold mt-2">{stats.deletes}</div>
<div className="text-2xl font-bold mt-2">{deletes.data?.total ?? '—'}</div>
</div>
</div>
{/* Filters */}
<div className="card">
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div>
<label className="label">Search (User/Entity ID)</label>
<div className="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-6 gap-4">
<div className="lg:col-span-2">
<label className="label">Search (User / Entity ID)</label>
<input
type="text"
placeholder="Search logs..."
placeholder="Name, phone, user ID, entity ID…"
className="input"
value={filters.search}
onChange={(e) => setFilters({ ...filters, search: e.target.value })}
@@ -206,11 +276,11 @@ export default function AuditLogsPage() {
onChange={(e) => setFilters({ ...filters, action: e.target.value })}
>
<option value="">All Actions</option>
<option value="CREATE">Create</option>
<option value="UPDATE">Update</option>
<option value="DELETE">Delete</option>
<option value="LOGIN">Login</option>
<option value="LOGOUT">Logout</option>
{actionOptions.map((a) => (
<option key={a} value={a}>
{a}
</option>
))}
</select>
</div>
<div>
@@ -221,55 +291,42 @@ export default function AuditLogsPage() {
onChange={(e) => setFilters({ ...filters, entityType: e.target.value })}
>
<option value="">All Types</option>
<optgroup label="Master Data">
<option value="Station">Station</option>
<option value="Route">Route</option>
<option value="RouteStop">Route Stop</option>
<option value="Train">Train</option>
<option value="TrainSchedule">Train Schedule</option>
<option value="Coach">Coach</option>
<option value="CoachType">Coach Type</option>
<option value="SeatClass">Seat Class</option>
<option value="FareRule">Fare Rule</option>
<option value="RouteFareRule">Route Fare Rule</option>
<option value="SegmentFareRule">Segment Fare Rule</option>
<option value="BaggageAllowance">Baggage Allowance</option>
</optgroup>
<optgroup label="Operations">
<option value="Booking">Booking</option>
<option value="Payment">Payment</option>
<option value="Ticket">Ticket</option>
<option value="Seat">Seat</option>
<option value="SeatBlock">Seat Block</option>
</optgroup>
<optgroup label="Users & Access">
<option value="User">User</option>
<option value="Agent">Agent</option>
<option value="Passenger">Passenger</option>
</optgroup>
<optgroup label="System & Features">
<option value="Notification">Notification</option>
<option value="Promotion">Promotion</option>
<option value="Loyalty">Loyalty</option>
<option value="Wallet">Wallet</option>
<option value="FraudAlert">Fraud Alert</option>
<option value="FraudRule">Fraud Rule</option>
</optgroup>
{entityTypeOptions.map((t) => (
<option key={t} value={t}>
{t}
</option>
))}
</select>
</div>
<div className="flex items-end">
<ActionButton
variant="secondary"
onClick={() => setFilters({ search: '', action: '', entityType: '' })}
className="w-full"
>
Clear Filters
</ActionButton>
<div>
<label className="label">From</label>
<input
type="date"
className="input"
value={filters.from}
onChange={(e) => setFilters({ ...filters, from: e.target.value })}
/>
</div>
<div>
<label className="label">To</label>
<input
type="date"
className="input"
value={filters.to}
onChange={(e) => setFilters({ ...filters, to: e.target.value })}
/>
</div>
</div>
<div className="mt-4 flex justify-end">
<ActionButton
variant="secondary"
onClick={() => setFilters({ search: '', action: '', entityType: '', from: '', to: '' })}
>
Clear Filters
</ActionButton>
</div>
</div>
{/* Data Table */}
<DataTable
data={logs}
columns={columns}
@@ -278,136 +335,205 @@ export default function AuditLogsPage() {
emptyMessage="No audit logs found"
/>
{/* Pagination */}
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">
{total === 0
? 'No results'
: `Showing ${page * PAGE_SIZE + 1}${Math.min((page + 1) * PAGE_SIZE, total)} of ${total}`}
</p>
<div className="flex items-center gap-2">
<ActionButton
variant="secondary"
icon={ChevronLeft}
disabled={page === 0}
onClick={() => setPage((p) => Math.max(0, p - 1))}
>
Previous
</ActionButton>
<span className="text-sm text-muted-foreground px-2">
Page {page + 1} of {pageCount}
</span>
<ActionButton
variant="secondary"
icon={ChevronRight}
disabled={page + 1 >= pageCount}
onClick={() => setPage((p) => p + 1)}
>
Next
</ActionButton>
</div>
</div>
{/* Details Modal */}
<Modal
isOpen={showDetailsModal}
onClose={() => { setShowDetailsModal(false); setSelectedLog(null); }}
onClose={() => {
setShowDetailsModal(false);
setSelectedLog(null);
}}
title="Audit Log Details"
size="xl"
>
{selectedLog && (() => {
const l = selectedLog;
const actionColor: Record<string, string> = {
CREATE: 'from-emerald-600 to-emerald-700',
UPDATE: 'from-blue-600 to-blue-700',
DELETE: 'from-red-600 to-red-700',
LOGIN: 'from-violet-600 to-violet-700',
LOGOUT: 'from-gray-600 to-gray-700',
};
const gradient = actionColor[l.action] || 'from-gray-600 to-gray-700';
{selectedLog &&
(() => {
const l: AuditLog = selectedLog;
const gradient = CREATIVE_ACTIONS.has(l.action)
? 'from-emerald-600 to-emerald-700'
: DESTRUCTIVE_ACTIONS.has(l.action)
? 'from-red-600 to-red-700'
: 'from-blue-600 to-blue-700';
const Field = ({ label, value, mono = false, truncate = false }: { label: string; value: string; mono?: boolean; truncate?: boolean }) => (
<div className="bg-muted/40 rounded-lg p-3">
<p className="text-xs text-muted-foreground mb-1">{label}</p>
<p className={`text-sm font-semibold text-foreground${mono ? ' font-mono' : ''}${truncate ? ' truncate' : ''}`} title={value}>{value || '—'}</p>
</div>
);
const SectionHeader = ({ title }: { title: string }) => (
<h3 className="text-xs font-bold uppercase tracking-widest text-muted-foreground mb-3 flex items-center gap-2">
<span className="w-4 h-px bg-muted-foreground/40 inline-block" />{title}
</h3>
);
return (
<div>
<div className={`-mx-6 -mt-4 mb-6 px-6 py-5 bg-gradient-to-r ${gradient} rounded-t-lg`}>
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-white/70 text-xs font-semibold uppercase tracking-widest mb-1">Action</p>
<p className="text-white text-2xl font-bold">{l.action}</p>
</div>
<div className="text-right shrink-0">
<span className="inline-block bg-white/20 text-white text-xs font-mono px-3 py-1 rounded-full">{l.entityType}</span>
<p className="text-white/70 text-xs mt-2">{formatDateTime(l.createdAt)}</p>
</div>
</div>
<div className="mt-4 grid grid-cols-2 gap-3">
<div className="bg-white/10 rounded-lg px-3 py-2">
<p className="text-white/70 text-xs">User</p>
<p className="text-white text-sm font-bold truncate">{l.user?.fullName || 'System'}</p>
</div>
<div className="bg-white/10 rounded-lg px-3 py-2">
<p className="text-white/70 text-xs">IP Address</p>
<p className="text-white text-sm font-mono font-bold">{l.ipAddress || 'N/A'}</p>
</div>
</div>
const Field = ({
label,
value,
mono = false,
truncate = false,
}: {
label: string;
value?: string;
mono?: boolean;
truncate?: boolean;
}) => (
<div className="bg-muted/40 rounded-lg p-3">
<p className="text-xs text-muted-foreground mb-1">{label}</p>
<p
className={`text-sm font-semibold text-foreground${mono ? ' font-mono' : ''}${truncate ? ' truncate' : ''}`}
title={value}
>
{value || '—'}
</p>
</div>
);
<div className="space-y-6">
<section>
<SectionHeader title="Event Details" />
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<Field label="Action" value={l.action} />
<Field label="Entity Type" value={l.entityType} mono />
<Field label="Entity ID" value={l.entityId || 'System'} mono truncate />
<Field label="Timestamp" value={formatDateTime(l.createdAt)} />
const SectionHeader = ({ title }: { title: string }) => (
<h3 className="text-xs font-bold uppercase tracking-widest text-muted-foreground mb-3 flex items-center gap-2">
<span className="w-4 h-px bg-muted-foreground/40 inline-block" />
{title}
</h3>
);
return (
<div>
<div className={`-mx-6 -mt-4 mb-6 px-6 py-5 bg-gradient-to-r ${gradient} rounded-t-lg`}>
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-white/70 text-xs font-semibold uppercase tracking-widest mb-1">Action</p>
<p className="text-white text-2xl font-bold">{l.action}</p>
</div>
<div className="text-right shrink-0">
<span className="inline-block bg-white/20 text-white text-xs font-mono px-3 py-1 rounded-full">
{l.entityType}
</span>
<p className="text-white/70 text-xs mt-2">{formatDateTime(l.createdAt)}</p>
</div>
</div>
</section>
<div className="mt-4 grid grid-cols-2 gap-3">
<div className="bg-white/10 rounded-lg px-3 py-2">
<p className="text-white/70 text-xs">User</p>
<p className="text-white text-sm font-bold truncate">{actorName(l)}</p>
</div>
<div className="bg-white/10 rounded-lg px-3 py-2">
<p className="text-white/70 text-xs">IP Address</p>
<p className="text-white text-sm font-mono font-bold">{l.ipAddress || 'N/A'}</p>
</div>
</div>
</div>
<div className="space-y-6">
<section>
<SectionHeader title="Event Details" />
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<Field label="Action" value={l.action} />
<Field label="Entity Type" value={l.entityType} mono />
<Field label="Entity ID" value={l.entityId} mono truncate />
<Field label="Timestamp" value={formatDateTime(l.createdAt)} />
</div>
</section>
{l.user && (
<section>
<SectionHeader title="User Information" />
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
<Field label="Full Name" value={l.user.fullName} />
<Field label="Email" value={l.user.email} truncate />
<Field label="User ID" value={l.userId} mono truncate />
<Field label="Name" value={l.userName} />
<Field label="Phone" value={l.userPhone} mono />
<Field label="IAM User ID" value={l.iamUserId} mono truncate />
</div>
</section>
)}
{(l.ipAddress || l.userAgent) && (
<section>
<SectionHeader title="Network Information" />
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<Field label="IP Address" value={l.ipAddress} mono />
<div className="bg-muted/40 rounded-lg p-3">
<p className="text-xs text-muted-foreground mb-1">User Agent</p>
<p className="text-xs font-mono text-foreground break-all leading-relaxed">{l.userAgent || '—'}</p>
{(l.ipAddress || l.userAgent) && (
<section>
<SectionHeader title="Network Information" />
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<Field label="IP Address" value={l.ipAddress} mono />
<div className="bg-muted/40 rounded-lg p-3">
<p className="text-xs text-muted-foreground mb-1">User Agent</p>
<p className="text-xs font-mono text-foreground break-all leading-relaxed">
{l.userAgent || '—'}
</p>
</div>
</div>
</div>
</section>
)}
</section>
)}
{(l.oldData || l.newData) && (
<section>
<SectionHeader title="Data Changes" />
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{l.oldData && (
<div>
<p className="text-xs font-bold text-red-600 dark:text-red-400 mb-2 uppercase tracking-wide">
Before
</p>
<pre className="text-xs p-3 bg-red-50 dark:bg-red-950/20 rounded-lg border border-red-200 dark:border-red-900 overflow-auto max-h-52 text-muted-foreground leading-relaxed">
{formatJsonData(l.oldData)}
</pre>
</div>
)}
{l.newData && (
<div>
<p className="text-xs font-bold text-emerald-600 dark:text-emerald-400 mb-2 uppercase tracking-wide">
After
</p>
<pre className="text-xs p-3 bg-emerald-50 dark:bg-emerald-950/20 rounded-lg border border-emerald-200 dark:border-emerald-900 overflow-auto max-h-52 text-muted-foreground leading-relaxed">
{formatJsonData(l.newData)}
</pre>
</div>
)}
</div>
</section>
)}
{(l.oldData || l.newData) && (
<section>
<SectionHeader title="Data Changes" />
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{l.oldData && (
<div>
<p className="text-xs font-bold text-red-600 dark:text-red-400 mb-2 uppercase tracking-wide"> Before</p>
<pre className="text-xs p-3 bg-red-50 dark:bg-red-950/20 rounded-lg border border-red-200 dark:border-red-900 overflow-auto max-h-52 text-muted-foreground leading-relaxed">
{formatJsonData(l.oldData)}
</pre>
</div>
)}
{l.newData && (
<div>
<p className="text-xs font-bold text-emerald-600 dark:text-emerald-400 mb-2 uppercase tracking-wide"> After</p>
<pre className="text-xs p-3 bg-emerald-50 dark:bg-emerald-950/20 rounded-lg border border-emerald-200 dark:border-emerald-900 overflow-auto max-h-52 text-muted-foreground leading-relaxed">
{formatJsonData(l.newData)}
</pre>
</div>
)}
<SectionHeader title="System" />
<div className="grid grid-cols-1 gap-3">
<Field label="Log ID" value={l.id} mono truncate />
</div>
</section>
)}
</div>
<section>
<SectionHeader title="System" />
<div className="grid grid-cols-1 gap-3">
<Field label="Log ID" value={l.id} mono truncate />
</div>
</section>
<div className="flex justify-end gap-2 pt-6 mt-2 border-t border-muted">
<ActionButton
variant="secondary"
onClick={() => {
setShowDetailsModal(false);
setSelectedLog(null);
}}
>
Close
</ActionButton>
</div>
</div>
<div className="flex justify-end gap-2 pt-6 mt-2 border-t border-muted">
<ActionButton variant="secondary" onClick={() => { setShowDetailsModal(false); setSelectedLog(null); }}>Close</ActionButton>
</div>
</div>
);
})()}
);
})()}
</Modal>
</div>
);
}
export default function AuditLogsPage() {
return (
<PermissionGuard permission={PERMS.audit.view}>
<AuditLogsPageContent />
</PermissionGuard>
);
}

View File

@@ -380,7 +380,11 @@ export const verifaydaApi = {
// Audit API
export const auditApi = {
getLogs: async (params?: any) => {
const query = new URLSearchParams(params as Record<string, string>).toString();
// Drop empty filters so a blank search box doesn't send `search=` and match nothing.
const entries = Object.entries(params ?? {}).filter(
([, v]) => v !== undefined && v !== null && v !== '',
);
const query = new URLSearchParams(entries as [string, string][]).toString();
const response = await apiClient.get<any>(`/audit/logs${query ? `?${query}` : ''}`);
if (response?.data) {
return Array.isArray(response.data) ? { items: response.data } : response;
@@ -388,6 +392,7 @@ export const auditApi = {
return Array.isArray(response) ? { items: response } : response;
},
getLog: (id: string) => apiClient.get<any>(`/audit/logs/${id}`),
getVocabulary: () => apiClient.get<any>('/audit/vocabulary'),
};
// Live Tracking API

View File

@@ -301,7 +301,11 @@ export interface FraudRule {
// Audit Types
export interface AuditLog {
id: string;
userId?: string;
/** IAM id of the staff member who performed the action. The API field is `iamUserId`. */
iamUserId?: string;
/** Actor's name and phone, denormalized by the API at write time. */
userName?: string;
userPhone?: string;
action: string;
entityType: string;
entityId?: string;