mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 08:20:58 +00:00
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:
61
apps/edr-freight-api/src/modules/routes/purge-guard.spec.ts
Normal file
61
apps/edr-freight-api/src/modules/routes/purge-guard.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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' })
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user