add permanent purge functionality for wagons and routes

- Implemented a  method in  to permanently delete wagons without history.
- Added corresponding permissions for hard delete actions in .
- Updated the UI components to include purge actions, ensuring they are only available to users with the appropriate permissions.
- Created modals for confirming permanent deletions in  and .
- Enhanced API services to handle purge requests for locomotives, wagons, and routes.
- Added tests for the purge functionality in both  and  services to ensure proper behavior and error handling.
This commit is contained in:
Marshal
2026-08-04 14:07:16 +00:00
parent 8cf49aa1cc
commit 5d68a3f7b7
22 changed files with 806 additions and 20 deletions

View File

@@ -0,0 +1,61 @@
import { ConflictException, NotFoundException } from '@nestjs/common';
import { RoutesService } from './routes.service';
const ROUTE = {
id: 'r1',
originYard: { code: 'ADD', label: 'Addis' },
destinationYard: { code: 'DIR', label: 'Dire Dawa' },
milestones: [],
};
const makeService = (scheduleCount: number, route: unknown = ROUTE) => {
const deletes: string[] = [];
const manager = {
getRepository: (entity: { name?: string }) => ({
delete: async () => {
deletes.push(entity?.name ?? 'unknown');
},
}),
};
const dataSource = {
query: jest.fn(async () => [{ count: scheduleCount }]),
transaction: jest.fn(async (cb: (m: unknown) => Promise<void>) => cb(manager)),
};
const routesRepository = {};
const svc = new RoutesService(dataSource as never, routesRepository as never);
// findById is the service's own loader; stub it to isolate the purge guard.
(svc as unknown as { findById: (id: string) => Promise<unknown> }).findById =
async () => {
if (!route) throw new NotFoundException('Route not found');
return route;
};
return { svc, dataSource, deletes };
};
describe('RoutesService.purge', () => {
it('purges a route no schedule references', async () => {
const { svc, dataSource, deletes } = makeService(0);
await svc.purge('r1');
expect(dataSource.transaction).toHaveBeenCalled();
// Milestones then the route itself, inside one transaction.
expect(deletes).toHaveLength(2);
});
it('refuses while train schedules reference it', async () => {
const { svc, dataSource } = makeService(4);
await expect(svc.purge('r1')).rejects.toThrow(ConflictException);
await expect(svc.purge('r1')).rejects.toThrow(/4 train schedule\(s\)/);
expect(dataSource.transaction).not.toHaveBeenCalled();
});
it('names the route in the refusal so the message is actionable', async () => {
const { svc } = makeService(1);
await expect(svc.purge('r1')).rejects.toThrow(/ADD|Addis/);
});
it('propagates a not-found route', async () => {
const { svc, dataSource } = makeService(0, null);
await expect(svc.purge('nope')).rejects.toThrow(NotFoundException);
expect(dataSource.transaction).not.toHaveBeenCalled();
});
});

View File

@@ -1,7 +1,23 @@
import { Body, Controller, Delete, Get, Param, ParseUUIDPipe, Patch, Post, Query } from '@nestjs/common';
import {
Body,
Controller,
Delete,
Get,
HttpCode,
HttpStatus,
Param,
ParseUUIDPipe,
Patch,
Post,
Query,
} from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { FleetManage, FleetView } from '../../common/booking-guards';
import {
BookingStaff,
FleetManage,
FleetView,
} from '../../common/booking-guards';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
import { CreateRouteDto } from './dto/create-route.dto';
import { FilterRoutesDto } from './dto/filter-routes.dto';
@@ -48,6 +64,21 @@ export class RoutesController {
return this.routesService.update(id, dto);
}
// Declared before @Delete(':id') so "permanent" is never captured as an id.
// BookingStaff, not FleetManage: the latter also accepts the coarse
// fleet:manage key, which would hand an irreversible purge to everyone who
// can edit the fleet. This action requires its own grant, nothing else.
@Delete(':id/permanent')
@BookingStaff(FREIGHT_PERMS.routes.hardDelete)
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({
summary:
'Permanently delete a route (irreversible; refused while any train schedule references it)',
})
purge(@Param('id', ParseUUIDPipe) id: string) {
return this.routesService.purge(id);
}
@Delete(':id')
@FleetManage(FREIGHT_PERMS.routes.delete)
@ApiOperation({ summary: 'Deactivate route' })

View File

@@ -206,6 +206,41 @@ export class RoutesService {
return this.findById(id);
}
/**
* Permanently purge a route — irreversible, and only for corridors nothing
* has run on: a mistyped or duplicated definition.
*
* `train_schedules.route_id` is NO ACTION, so Postgres would reject the
* delete with a raw constraint error; the schedules are counted up front
* instead so the refusal says what is blocking. The route's own milestones
* cascade with it, which is correct — they are the route's definition, not
* history that outlives it. Deactivating (`deactivate`) stays the answer for
* a corridor that has actually been used.
*/
async purge(id: string): Promise<void> {
const route = await this.findById(id);
const [schedules] = await this.dataSource.query<Array<{ count: number }>>(
`SELECT count(*)::int AS count
FROM freight.train_schedules
WHERE route_id = $1`,
[id],
);
if (schedules?.count > 0) {
throw new ConflictException(
`Route ${formatRouteLabel(route)} cannot be permanently deleted — ${schedules.count} train schedule(s) still reference it. Deactivate it instead, which keeps the history intact.`,
);
}
await this.dataSource.transaction(async (manager) => {
// Milestones are FK-cascaded, but delete them explicitly so the intent is
// visible here rather than depending on the constraint alone.
await manager.getRepository(RouteMilestone).delete({ routeId: id });
await manager.getRepository(Route).delete({ id });
});
}
/**
* A route IS its ordered stop list — "Addis → Adama → Dire Dawa" and
* "Addis → Dire Dawa" share endpoints but are different corridors. So the