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

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