mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 21:20:57 +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:
@@ -1,7 +1,23 @@
|
||||
import { Body, Controller, 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, StaffReference } from '../../common/booking-guards';
|
||||
import {
|
||||
BookingStaff,
|
||||
FleetManage,
|
||||
StaffReference,
|
||||
} from '../../common/booking-guards';
|
||||
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||
import { CreateLocomotiveDto } from './dto/create-locomotive.dto';
|
||||
import { FilterLocomotivesDto } from './dto/filter-locomotives.dto';
|
||||
@@ -59,4 +75,18 @@ export class LocomotivesController {
|
||||
decommission(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.locomotivesService.decommission(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.locomotives.hardDelete)
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'Permanently delete a locomotive (irreversible; refused if any train references it)',
|
||||
})
|
||||
purge(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.locomotivesService.purge(id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -222,4 +222,61 @@ export class LocomotivesService {
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Permanently purge a locomotive — irreversible, and only for rows nothing
|
||||
* references: a mistyped or duplicated entry.
|
||||
*
|
||||
* Every FK onto `locomotives` is NO ACTION, so Postgres would reject the
|
||||
* delete with a raw constraint error. The references are resolved up front
|
||||
* instead, naming the trains involved so the message says what to detach.
|
||||
* Decommissioning (`decommission`) stays the answer for a real locomotive
|
||||
* leaving service.
|
||||
*/
|
||||
async purge(id: string): Promise<void> {
|
||||
const locomotive = await this.locomotivesRepository.findById(id);
|
||||
if (!locomotive) {
|
||||
throw new NotFoundException(`Locomotive ${id} not found`);
|
||||
}
|
||||
|
||||
const [builtTrains, setsViaJoin, setsDirect] = await Promise.all([
|
||||
this.dataSource.query<Array<{ code: string | null }>>(
|
||||
`SELECT t.code
|
||||
FROM freight.train_locomotives tl
|
||||
JOIN freight.trains t ON t.id = tl.train_id
|
||||
WHERE tl.locomotive_id = $1`,
|
||||
[id],
|
||||
),
|
||||
this.dataSource.query<Array<{ code: string | null }>>(
|
||||
`SELECT t.code
|
||||
FROM freight.train_set_locomotives tsl
|
||||
JOIN freight.train_sets ts ON ts.id = tsl.train_set_id
|
||||
LEFT JOIN freight.trains t ON t.id = ts.train_id
|
||||
WHERE tsl.locomotive_id = $1`,
|
||||
[id],
|
||||
),
|
||||
this.dataSource.query<Array<{ code: string | null }>>(
|
||||
`SELECT t.code
|
||||
FROM freight.train_sets ts
|
||||
LEFT JOIN freight.trains t ON t.id = ts.train_id
|
||||
WHERE ts.locomotive_id = $1`,
|
||||
[id],
|
||||
),
|
||||
]);
|
||||
|
||||
const referencing = [...builtTrains, ...setsViaJoin, ...setsDirect];
|
||||
if (referencing.length > 0) {
|
||||
const codes = [
|
||||
...new Set(referencing.map((r) => r.code).filter(Boolean)),
|
||||
];
|
||||
const named = codes.length > 0 ? ` (${codes.join(', ')})` : '';
|
||||
throw new ConflictException(
|
||||
`Locomotive ${locomotive.code} is used by ${referencing.length} train record(s)${named}; detach it before deleting it permanently. Decommission it instead to take it out of service.`,
|
||||
);
|
||||
}
|
||||
|
||||
await this.dataSource
|
||||
.getRepository(Locomotive)
|
||||
.delete({ id });
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
61
apps/edr-freight-api/src/modules/wagons/purge-guard.spec.ts
Normal file
61
apps/edr-freight-api/src/modules/wagons/purge-guard.spec.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { ConflictException, NotFoundException } from '@nestjs/common';
|
||||
import { WagonsService } from './wagons.service';
|
||||
|
||||
// Minimal stubs: only what purge() touches.
|
||||
const makeService = (wagon: any, counts: [number, number, number], pinned = false) => {
|
||||
const wagonRepo = { findOne: jest.fn().mockResolvedValue(wagon), remove: jest.fn().mockResolvedValue(undefined) };
|
||||
// Keyed off the SQL so the stub survives repeated purge() calls in one test.
|
||||
const dataSource = {
|
||||
query: jest.fn(async (sql: string) => {
|
||||
if (sql.includes('train_schedule')) return pinned ? [{ x: 1 }] : [];
|
||||
if (sql.includes('wagon_movements')) return [{ count: counts[0] }];
|
||||
if (sql.includes('containers')) return [{ count: counts[1] }];
|
||||
if (sql.includes('train_set_wagons')) return [{ count: counts[2] }];
|
||||
return [];
|
||||
}),
|
||||
};
|
||||
const svc = new WagonsService(wagonRepo as any, {} as any, dataSource as any);
|
||||
return { svc, wagonRepo };
|
||||
};
|
||||
|
||||
describe('WagonsService.purge', () => {
|
||||
const clean = { id: 'w1', wagonNumber: 'W-0001', trainId: null };
|
||||
|
||||
it('purges a wagon with no references', async () => {
|
||||
const { svc, wagonRepo } = makeService(clean, [0, 0, 0]);
|
||||
await svc.purge('w1');
|
||||
expect(wagonRepo.remove).toHaveBeenCalledWith(clean);
|
||||
});
|
||||
|
||||
it('refuses when the wagon has movement history', async () => {
|
||||
const { svc, wagonRepo } = makeService(clean, [12, 0, 0]);
|
||||
await expect(svc.purge('w1')).rejects.toThrow(ConflictException);
|
||||
await expect(svc.purge('w1')).rejects.toThrow(/12 movement record/);
|
||||
expect(wagonRepo.remove).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('refuses when containers or train-set slots reference it', async () => {
|
||||
const { svc, wagonRepo } = makeService(clean, [0, 3, 2]);
|
||||
await expect(svc.purge('w1')).rejects.toThrow(/3 container\(s\), 2 train-set slot/);
|
||||
expect(wagonRepo.remove).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('refuses a coupled wagon before any count query runs', async () => {
|
||||
const { svc, wagonRepo } = makeService({ ...clean, trainId: 't1' }, [0, 0, 0]);
|
||||
await expect(svc.purge('w1')).rejects.toThrow(/coupled to a train/);
|
||||
expect(wagonRepo.remove).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('refuses a wagon pinned to a live schedule', async () => {
|
||||
const { svc, wagonRepo } = makeService(clean, [0, 0, 0], true);
|
||||
await expect(svc.purge('w1')).rejects.toThrow(/pinned to an active schedule/);
|
||||
expect(wagonRepo.remove).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('404s an unknown wagon', async () => {
|
||||
const wagonRepo = { findOne: jest.fn().mockResolvedValue(null), remove: jest.fn() };
|
||||
const svc = new WagonsService(wagonRepo as any, {} as any, { query: jest.fn() } as any);
|
||||
await expect(svc.purge('nope')).rejects.toThrow(NotFoundException);
|
||||
expect(wagonRepo.remove).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,8 @@ import {
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Patch,
|
||||
@@ -12,7 +14,11 @@ import {
|
||||
import { ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { CurrentUser } from '@edr/api-common';
|
||||
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
|
||||
import { FleetManage, StaffReference } from '../../common/booking-guards';
|
||||
import {
|
||||
BookingStaff,
|
||||
FleetManage,
|
||||
StaffReference,
|
||||
} from '../../common/booking-guards';
|
||||
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||
import { CreateWagonDto } from './dto/create-wagon.dto';
|
||||
import { ListWagonsQueryDto } from './dto/list-wagons-query.dto';
|
||||
@@ -69,6 +75,21 @@ export class WagonsController {
|
||||
return this.wagonsService.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.wagons.hardDelete)
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'Permanently delete a wagon (irreversible; refused if it has movements, containers or train-set slots)',
|
||||
})
|
||||
purge(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.wagonsService.purge(id);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@FleetManage(FREIGHT_PERMS.wagons.delete)
|
||||
@ApiOperation({ summary: 'Delete a wagon' })
|
||||
|
||||
@@ -212,6 +212,75 @@ export class WagonsService {
|
||||
await this.wagonRepo.softRemove(wagon);
|
||||
}
|
||||
|
||||
/**
|
||||
* Permanently purge a wagon — irreversible, and only for rows that carry no
|
||||
* history: a mistyped or duplicated entry someone wants gone for good.
|
||||
*
|
||||
* `wagon_movements` cascades on delete, so a wagon with movements would take
|
||||
* its ledger history down with it. Rather than allow that, every reference is
|
||||
* checked first and the purge is refused if any exist — soft delete (`remove`)
|
||||
* stays the answer for a wagon that has actually been used.
|
||||
*
|
||||
* Soft-deleted wagons are purgeable, so `withDeleted` is used to find them.
|
||||
*/
|
||||
async purge(id: string): Promise<void> {
|
||||
const wagon = await this.wagonRepo.findOne({
|
||||
where: { id },
|
||||
withDeleted: true,
|
||||
});
|
||||
if (!wagon) {
|
||||
throw new NotFoundException(`Wagon ${id} not found`);
|
||||
}
|
||||
|
||||
if (wagon.trainId != null) {
|
||||
throw new ConflictException(
|
||||
`Wagon ${wagon.wagonNumber} is coupled to a train; detach it via train-builder before deleting it permanently`,
|
||||
);
|
||||
}
|
||||
if (await this.isWagonPinnedToLiveSchedule(id)) {
|
||||
throw new ConflictException(
|
||||
`Wagon ${wagon.wagonNumber} is pinned to an active schedule and cannot be deleted permanently`,
|
||||
);
|
||||
}
|
||||
|
||||
// Each of these would either lose history (movements cascade) or silently
|
||||
// blank a live reference (containers / train-set slots are SET NULL).
|
||||
const blockers: string[] = [];
|
||||
const [movements, containers, trainSetSlots] = await Promise.all([
|
||||
this.dataSource.query(
|
||||
`SELECT count(*)::int AS count FROM freight.wagon_movements WHERE wagon_id = $1`,
|
||||
[id],
|
||||
),
|
||||
this.dataSource.query(
|
||||
`SELECT count(*)::int AS count FROM freight.containers WHERE wagon_id = $1`,
|
||||
[id],
|
||||
),
|
||||
this.dataSource.query(
|
||||
`SELECT count(*)::int AS count FROM freight.train_set_wagons WHERE physical_wagon_id = $1`,
|
||||
[id],
|
||||
),
|
||||
]);
|
||||
if (movements[0]?.count > 0) {
|
||||
blockers.push(`${movements[0].count} movement record(s)`);
|
||||
}
|
||||
if (containers[0]?.count > 0) {
|
||||
blockers.push(`${containers[0].count} container(s)`);
|
||||
}
|
||||
if (trainSetSlots[0]?.count > 0) {
|
||||
blockers.push(`${trainSetSlots[0].count} train-set slot(s)`);
|
||||
}
|
||||
|
||||
if (blockers.length > 0) {
|
||||
throw new ConflictException(
|
||||
`Wagon ${wagon.wagonNumber} cannot be permanently deleted — it still has ${blockers.join(
|
||||
', ',
|
||||
)}. Delete it normally instead, which keeps the history intact.`,
|
||||
);
|
||||
}
|
||||
|
||||
await this.wagonRepo.remove(wagon);
|
||||
}
|
||||
|
||||
/**
|
||||
* A wagon is busy when any live (DRAFT/SCHEDULED/DISPATCHED) schedule pins it
|
||||
* to one of its slots — schedule occupancy lives on TrainSetWagon rows, not
|
||||
|
||||
Reference in New Issue
Block a user