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 { 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();
});
});

View File

@@ -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' })

View File

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