mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 17:10:56 +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/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