mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 17:08:18 +00:00
feat: ( audit ) resolve the actor from the session and audit all backoffice mutations
This commit is contained in:
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user