feat: ( audit ) resolve the actor from the session and audit all backoffice mutations

This commit is contained in:
Abubeker Yasin
2026-08-18 15:13:22 +03:00
parent 33100a31ae
commit 0eb64ad9a1
32 changed files with 3068 additions and 420 deletions

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