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

@@ -3,7 +3,10 @@ import Handlebars from 'handlebars';
/** One numbered clause of a dynamic article, with optional nested bullets. */
export interface RenderedClause {
text: string;
/** Computed outline number, e.g. "3" or "2.1.4". */
/**
* Computed outline marker for this clause at its own level: "3" at depth 1,
* "b" at depth 2, "iv" at depth 3, cycling back to arabic at depth 4.
*/
number: string;
/** Nesting level: 1 = clause, 2 = sub-clause (x.y), 3 = x.y.z, … */
depth: number;
@@ -35,6 +38,51 @@ const CLAUSE_NUMBER_RE = /^(?:(\d+(?:\.\d+)+)[.)]?|(\d+)[.)])(?:\s+|$)/;
/** Deepest supported sub-clause level (1.1.1.1.1.1). */
const MAX_CLAUSE_DEPTH = 6;
/** 1 → "a", 2 → "b", … 27 → "aa". */
function toAlpha(n: number): string {
let out = '';
let value = n;
while (value > 0) {
const rem = (value - 1) % 26;
out = String.fromCharCode(97 + rem) + out;
value = Math.floor((value - 1) / 26);
}
return out || 'a';
}
const ROMAN: Array<[number, string]> = [
[1000, 'm'], [900, 'cm'], [500, 'd'], [400, 'cd'],
[100, 'c'], [90, 'xc'], [50, 'l'], [40, 'xl'],
[10, 'x'], [9, 'ix'], [5, 'v'], [4, 'iv'], [1, 'i'],
];
/** 1 → "i", 4 → "iv", 9 → "ix". */
function toRoman(n: number): string {
let value = n;
let out = '';
for (const [amount, numeral] of ROMAN) {
while (value >= amount) {
out += numeral;
value -= amount;
}
}
return out || 'i';
}
/**
* Word-processor outline markers, cycling by depth the way Quill's own list
* rendering does: 1. → a. → i. → 1. … Depth 1 keeps plain arabic numerals so
* top-level clauses read as "1.", "2." in the contract; the marker is the
* clause's own counter at its level, NOT a dotted path — "a" under clause 2 is
* "a", not "2.a".
*/
export function clauseMarker(counter: number, depth: number): string {
const style = (depth - 1) % 3;
if (style === 1) return toAlpha(counter);
if (style === 2) return toRoman(counter);
return String(counter);
}
/**
* Parse a template article body into clauses. Format: one clause per line.
* A leading outline number ("2. ", "2.1 ", "2.1.3 ") nests the line as a
@@ -82,7 +130,7 @@ export function parseArticleBody(body: string): Pick<RenderedArticle, 'paragraph
clauses.push({
text: match ? line.slice(match[0].length).trim() : line,
number: counters.slice(0, depth).join('.'),
number: clauseMarker(counters[depth - 1], depth),
depth,
bullets: [],
});

View File

@@ -27,18 +27,32 @@ describe('parseArticleBody', () => {
expect(parsed.clauses).toEqual([]);
});
it('nests numbered sub-clauses by their outline token and renumbers sequentially', () => {
it('nests sub-clauses by outline token and marks each level 1. → a. → i.', () => {
const parsed = parseArticleBody(
'1. Scope\n5.1 Rail transport\n1.1.1 Wagon supply\n2. Payment',
);
expect(parsed.clauses.map((c) => [c.number, c.depth, c.text])).toEqual([
['1', 1, 'Scope'],
['1.1', 2, 'Rail transport'],
['1.1.1', 3, 'Wagon supply'],
['a', 2, 'Rail transport'],
['i', 3, 'Wagon supply'],
['2', 1, 'Payment'],
]);
});
it('cycles markers back to arabic at depth 4 and counts each level on its own', () => {
const parsed = parseArticleBody(
'1. One\n1.1 Alpha\n1.2 Beta\n1.2.1 Roman one\n1.2.2 Roman two\n1.2.2.1 Deep',
);
expect(parsed.clauses.map((c) => [c.number, c.depth])).toEqual([
['1', 1],
['a', 2],
['b', 2],
['i', 3],
['ii', 3],
['1', 4],
]);
});
it('clamps a sub-clause with no open parent to the next available level', () => {
const parsed = parseArticleBody('1.1.1 Orphan sub-clause\nSecond clause.');
expect(parsed.clauses.map((c) => [c.number, c.depth])).toEqual([

View File

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

View File

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

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

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

View File

@@ -235,6 +235,7 @@ export const FLEET_RAIL_PERMISSIONS: FreightPermissionSeed[] = [
perm('e1a00001-0001-4000-8000-000000000002', 'edr_freight_app:locomotives:create', 'Create locomotive'),
perm('e1a00001-0001-4000-8000-000000000003', 'edr_freight_app:locomotives:update', 'Update locomotive'),
perm('e1a00001-0001-4000-8000-000000000004', 'edr_freight_app:locomotives:delete', 'Delete locomotive'),
perm('e1a00001-0001-4000-8000-000000000005', 'edr_freight_app:locomotives:hard_delete', 'Permanently delete locomotive'),
perm('e1b00001-0001-4000-8000-000000000001', 'edr_freight_app:wagons:view', 'View wagons'),
perm('e1b00001-0001-4000-8000-000000000002', 'edr_freight_app:wagons:create', 'Create wagon'),
perm('e1b00001-0001-4000-8000-000000000003', 'edr_freight_app:wagons:update', 'Update wagon'),
@@ -248,6 +249,7 @@ export const FLEET_RAIL_PERMISSIONS: FreightPermissionSeed[] = [
perm('e1b00001-0001-4000-8000-000000000008', 'edr_freight_app:wagons:transfer_view', 'View wagon transfer requests'),
perm('e1b00001-0001-4000-8000-000000000009', 'edr_freight_app:wagons:transfer_cancel', 'Withdraw a wagon transfer request'),
perm('e1b00001-0001-4000-8000-00000000000a', 'edr_freight_app:wagons:transfer_close_short', 'Close a transfer request short of the requested count'),
perm('e1b00001-0001-4000-8000-00000000000b', 'edr_freight_app:wagons:hard_delete', 'Permanently delete wagon'),
perm('e1c00001-0001-4000-8000-000000000001', 'edr_freight_app:trains:view', 'View trains'),
perm('e1c00001-0001-4000-8000-000000000002', 'edr_freight_app:trains:create', 'Create train'),
perm('e1c00001-0001-4000-8000-000000000003', 'edr_freight_app:trains:update', 'Update train'),
@@ -257,6 +259,7 @@ export const FLEET_RAIL_PERMISSIONS: FreightPermissionSeed[] = [
perm('e1d00001-0001-4000-8000-000000000002', 'edr_freight_app:routes:create', 'Create route'),
perm('e1d00001-0001-4000-8000-000000000003', 'edr_freight_app:routes:update', 'Update route'),
perm('e1d00001-0001-4000-8000-000000000004', 'edr_freight_app:routes:delete', 'Delete route'),
perm('e1d00001-0001-4000-8000-000000000005', 'edr_freight_app:routes:hard_delete', 'Permanently delete route'),
perm('e1e00001-0001-4000-8000-000000000001', 'edr_freight_app:containers:view', 'View containers'),
perm('e1e00001-0001-4000-8000-000000000002', 'edr_freight_app:containers:create', 'Create container'),
perm('e1e00001-0001-4000-8000-000000000003', 'edr_freight_app:containers:update', 'Update container'),
@@ -530,12 +533,19 @@ export const FREIGHT_PERMS = {
create: 'edr_freight_app:locomotives:create',
update: 'edr_freight_app:locomotives:update',
delete: 'edr_freight_app:locomotives:delete',
/**
* Permanently purge the row — irreversible, and separate from `delete`
* (which only decommissions) so it can be granted to far fewer people.
*/
hardDelete: 'edr_freight_app:locomotives:hard_delete',
},
wagons: {
view: 'edr_freight_app:wagons:view',
create: 'edr_freight_app:wagons:create',
update: 'edr_freight_app:wagons:update',
delete: 'edr_freight_app:wagons:delete',
/** Permanently purge the row — irreversible; see locomotives.hardDelete. */
hardDelete: 'edr_freight_app:wagons:hard_delete',
// Requester creates a transfer request; OCC fulfils it (picks the wagons and
// executes the move). Distinct keys so OCC can hold fulfil without request.
transferRequest: 'edr_freight_app:wagons:transfer_request',
@@ -562,6 +572,8 @@ export const FREIGHT_PERMS = {
create: 'edr_freight_app:routes:create',
update: 'edr_freight_app:routes:update',
delete: 'edr_freight_app:routes:delete',
/** Permanently purge the row — irreversible; see locomotives.hardDelete. */
hardDelete: 'edr_freight_app:routes:hard_delete',
},
containers: {
view: 'edr_freight_app:containers:view',

View File

@@ -22,6 +22,8 @@ export interface FleetCardGridProps {
/** Omit to hide the action (caller lacks the update/delete permission). */
onEdit?: (record: FleetRecord) => void;
onRemove?: (record: FleetRecord) => void;
/** Irreversible purge — omitted unless the caller holds the hard-delete grant. */
onPurge?: (record: FleetRecord) => void;
}
const FleetCardGrid = ({
@@ -35,6 +37,7 @@ const FleetCardGrid = ({
onPaginationChange,
onEdit,
onRemove,
onPurge,
}: FleetCardGridProps) => {
const presentation = resolveFleetCardPresentation(config);
@@ -183,6 +186,7 @@ const FleetCardGrid = ({
layout="compact"
onEdit={onEdit}
onRemove={onRemove}
onPurge={onPurge}
/>
</Stack>
</Card>

View File

@@ -1,4 +1,12 @@
import { Edit2, Trash2, Eye, Users, MoreVertical, History } from "lucide-react";
import {
Edit2,
Trash2,
Eye,
Users,
MoreVertical,
History,
ShieldAlert,
} from "lucide-react";
import { ActionIcon, Menu, MenuItem, Tooltip } from "@mantine/core";
import { useNavigate } from "react-router-dom";
@@ -11,6 +19,8 @@ export interface FleetRecordActionsProps {
/** Omit to hide the action (caller lacks the update/delete permission). */
onEdit?: (record: FleetRecord) => void;
onRemove?: (record: FleetRecord) => void;
/** Irreversible purge — omitted unless the caller holds the hard-delete grant. */
onPurge?: (record: FleetRecord) => void;
onAssignDriver?: (record: FleetRecord) => void;
onHistory?: (record: FleetRecord) => void;
onViewDetail?: (record: FleetRecord) => void;
@@ -22,6 +32,7 @@ const FleetRecordActions = ({
config,
onEdit,
onRemove,
onPurge,
onAssignDriver,
onHistory,
onViewDetail,
@@ -46,6 +57,7 @@ const FleetRecordActions = ({
if (
!onEdit &&
!onRemove &&
!onPurge &&
!showDetail &&
!showViewDetail &&
!showHistory &&
@@ -170,6 +182,15 @@ const FleetRecordActions = ({
{removeLabel}
</MenuItem>
) : null}
{onPurge ? (
<MenuItem
color="red"
onClick={() => onPurge(record)}
leftSection={<ShieldAlert size={14} strokeWidth={2} />}
>
Delete permanently
</MenuItem>
) : null}
</Menu.Dropdown>
</Menu>
);

View File

@@ -122,12 +122,16 @@ export const FREIGHT_PERMS = {
create: "edr_freight_app:locomotives:create",
update: "edr_freight_app:locomotives:update",
delete: "edr_freight_app:locomotives:delete",
/** Permanent purge — irreversible, granted separately from `delete`. */
hardDelete: "edr_freight_app:locomotives:hard_delete",
},
wagons: {
view: "edr_freight_app:wagons:view",
create: "edr_freight_app:wagons:create",
update: "edr_freight_app:wagons:update",
delete: "edr_freight_app:wagons:delete",
/** Permanent purge — irreversible, granted separately from `delete`. */
hardDelete: "edr_freight_app:wagons:hard_delete",
transferRequest: "edr_freight_app:wagons:transfer_request",
transferFulfill: "edr_freight_app:wagons:transfer_fulfill",
transferHistoryAll: "edr_freight_app:wagons:transfer_history_all",
@@ -148,6 +152,8 @@ export const FREIGHT_PERMS = {
create: "edr_freight_app:routes:create",
update: "edr_freight_app:routes:update",
delete: "edr_freight_app:routes:delete",
/** Permanent purge — irreversible, granted separately from `delete`. */
hardDelete: "edr_freight_app:routes:hard_delete",
},
containers: {
view: "edr_freight_app:containers:view",
@@ -598,6 +604,19 @@ export function canFleetAction(
);
}
/**
* Permanent-purge check for locomotives and wagons. Unlike
* {@link canFleetAction} this does NOT fall back to the coarse fleet:manage
* key — an irreversible delete needs its own grant, and the API guards these
* endpoints the same way.
*/
export function canFleetHardDelete(
user: AuthUser | null | undefined,
resource: "locomotives" | "wagons" | "routes",
): boolean {
return hasPermission(user, FREIGHT_PERMS[resource].hardDelete);
}
export function isFreightAdmin(user: AuthUser | null | undefined): boolean {
return hasPermission(user, FREIGHT_PERMS.admin);
}

View File

@@ -64,7 +64,7 @@ import type { ContractTemplateArticle } from "@/services/contract-templates.serv
import { bodyToHtml, htmlToBody } from "./article-html";
const BODY_HINT =
"Each paragraph becomes a numbered clause (1., 2., …) — use Indent to nest it as a sub-clause (1.1, 1.1.1). The bullet list makes • points under the clause above. Numbering is assigned when the document is generated, so it always comes out sequential. Placeholders are filled from the contract.";
"Enter starts a new line. Use the numbered list for clauses and Tab (or Indent) to nest — levels number 1. → a. → i. like a word processor. Numbering is assigned when the document is generated, so it always comes out sequential. Placeholders are filled from the contract.";
interface ArticleDraft {
id?: string;
@@ -350,6 +350,49 @@ function matchDepth(match: RegExpExecArray | null): number | null {
/** Deepest supported sub-clause level. */
const MAX_CLAUSE_DEPTH = 6;
/** 1 → "a", 2 → "b", … 27 → "aa". Mirrors the API's `toAlpha`. */
function toAlpha(n: number): string {
let out = "";
let value = n;
while (value > 0) {
const rem = (value - 1) % 26;
out = String.fromCharCode(97 + rem) + out;
value = Math.floor((value - 1) / 26);
}
return out || "a";
}
const ROMAN: Array<[number, string]> = [
[1000, "m"], [900, "cm"], [500, "d"], [400, "cd"],
[100, "c"], [90, "xc"], [50, "l"], [40, "xl"],
[10, "x"], [9, "ix"], [5, "v"], [4, "iv"], [1, "i"],
];
/** 1 → "i", 4 → "iv". Mirrors the API's `toRoman`. */
function toRoman(n: number): string {
let value = n;
let out = "";
for (const [amount, numeral] of ROMAN) {
while (value >= amount) {
out += numeral;
value -= amount;
}
}
return out || "i";
}
/**
* Outline marker for a clause at its own level, cycling 1. → a. → i. by depth.
* Mirrors `clauseMarker` in the API's contract-article.util.ts — the preview
* must match the generated document exactly.
*/
function clauseMarker(counter: number, depth: number): string {
const style = (depth - 1) % 3;
if (style === 1) return toAlpha(counter);
if (style === 2) return toRoman(counter);
return String(counter);
}
/**
* Mirror of the API renderer's rules (contract-article.util.ts): one clause per
* line; a leading outline number ("2. ", "2.1 ") nests the line as a sub-clause
@@ -379,7 +422,7 @@ function parseArticleBody(body: string): ParsedBody {
counters[depth - 1] += 1;
clauses.push({
text: match ? cleaned.slice(match[0].length).trim() : cleaned,
number: counters.slice(0, depth).join("."),
number: clauseMarker(counters[depth - 1], depth),
depth,
bullets: [],
});

View File

@@ -1,11 +1,16 @@
import type { ColumnDef } from "@edr/ui-common";
import { Box, Button, Card, Container, Group, Modal, Select, Stack, Text, Title } from "@mantine/core";
import { Box, Button, Card, Container, Group, Modal, Select, Stack, Text, TextInput, Title } from "@mantine/core";
import { DatePickerInput } from "@mantine/dates";
import { keepPreviousData, useMutation, useQuery } from "@tanstack/react-query";
import { api } from "@/services/api";
import { useAuth } from "@/auth/useAuth";
import { canFleetAction, hasPermission, FREIGHT_PERMS } from "@/lib/permissions";
import {
canFleetAction,
canFleetHardDelete,
hasPermission,
FREIGHT_PERMS,
} from "@/lib/permissions";
import Breadcrumbs from "@/components/ui/Breadcrumbs";
import { Inbox, Plus, Warehouse } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
@@ -31,6 +36,7 @@ import {
type FleetResourceSlug,
} from "@/pages/fleet/config/resources";
import {
isFleetPurgeable,
isFleetServerPaginated,
type FleetListFilters,
type FleetRecord,
@@ -49,6 +55,12 @@ const FleetResourcePage = () => {
const canCreate = canFleetAction(user, slug, "create");
const canUpdate = canFleetAction(user, slug, "update");
const canDelete = canFleetAction(user, slug, "delete");
// Irreversible purge: only locomotives/wagons expose it, and it needs its own
// grant — the coarse fleet:manage key deliberately does not unlock it.
const canPurge =
isFleetPurgeable(slug) &&
(slug === "locomotives" || slug === "wagons") &&
canFleetHardDelete(user, slug);
// Wagon transfer workspace: shown only to holders of a transfer capability
// (raise a request, fulfill one, or see the cross-yard history).
const canTransfer =
@@ -71,6 +83,10 @@ const FleetResourcePage = () => {
const [formOpen, setFormOpen] = useState(false);
const [editing, setEditing] = useState<FleetRecord | null>(null);
const [removeTarget, setRemoveTarget] = useState<FleetRecord | null>(null);
const [purgeTarget, setPurgeTarget] = useState<FleetRecord | null>(null);
// Typing the record's own code is the confirmation — a purge cannot be undone,
// so a single misplaced click must not be enough to trigger it.
const [purgeConfirmText, setPurgeConfirmText] = useState("");
const [assigningDriver, setAssigningDriver] = useState<FleetRecord | null>(null);
const [historyTarget, setHistoryTarget] = useState<FleetRecord | null>(null);
const [selectedDriver, setSelectedDriver] = useState<string>("");
@@ -142,6 +158,7 @@ const FleetResourcePage = () => {
const create = useMutation(api.fleet.create.mutationOptions());
const update = useMutation(api.fleet.update.mutationOptions());
const remove = useMutation(api.fleet.remove.mutationOptions());
const purge = useMutation(api.fleet.purge.mutationOptions());
const { data: wagonTypes = [], isLoading: wagonTypesLoading } = useQuery(
api.wagonTypes.list.queryOptions(),
@@ -385,6 +402,7 @@ const FleetResourcePage = () => {
: undefined
}
onRemove={canDelete ? setRemoveTarget : undefined}
onPurge={canPurge ? setPurgeTarget : undefined}
onAssignDriver={canUpdate ? setAssigningDriver : undefined}
onHistory={setHistoryTarget}
/>
@@ -446,6 +464,37 @@ const FleetResourcePage = () => {
}
};
/** The code the operator must retype to confirm a purge. */
const purgeFields = purgeTarget
? (purgeTarget as unknown as Record<string, unknown>)
: null;
const purgeLabel = purgeFields
? String(purgeFields.wagonNumber ?? purgeFields.code ?? "")
: "";
const closePurge = () => {
setPurgeTarget(null);
setPurgeConfirmText("");
};
const handlePurge = async () => {
if (!purgeTarget || !("id" in purgeTarget)) return;
try {
await purge.mutateAsync({ slug, id: String(purgeTarget.id) });
toast({ title: `${config.entityLabel} permanently deleted` });
closePurge();
} catch (err: unknown) {
const message =
(err as { response?: { data?: { message?: string } } })?.response?.data?.message ??
"Permanent delete failed";
toast({
title: "Permanent delete failed",
description: String(message),
variant: "destructive",
});
}
};
const handleAssignDriver = async () => {
if (!assigningDriver || !("id" in assigningDriver) || !selectedDriver) return;
try {
@@ -673,6 +722,7 @@ const FleetResourcePage = () => {
: undefined
}
onRemove={canDelete ? setRemoveTarget : undefined}
onPurge={canPurge ? setPurgeTarget : undefined}
/>
)}
</Stack>
@@ -718,6 +768,48 @@ const FleetResourcePage = () => {
</Stack>
</Modal>
<Modal
opened={Boolean(purgeTarget)}
onClose={closePurge}
title={<Text fw={600}>Delete permanently</Text>}
radius="lg"
centered
>
<Stack gap="md">
<Text size="sm">
This permanently removes{" "}
<Text span fw={700}>
{purgeLabel || `this ${config.entityLabel.toLowerCase()}`}
</Text>{" "}
from the database. It cannot be undone.
</Text>
<Text size="sm" c="dimmed">
Only unused records can be purged if it has any history or is still
referenced, the request is refused and you should use{" "}
{(config.removeActionLabel ?? "Delete").toLowerCase()} instead.
</Text>
<TextInput
label={`Type ${purgeLabel} to confirm`}
placeholder={purgeLabel}
value={purgeConfirmText}
onChange={(e) => setPurgeConfirmText(e.currentTarget.value)}
/>
<Group justify="flex-end">
<Button variant="default" onClick={closePurge}>
Cancel
</Button>
<Button
color="red"
loading={purge.isPending}
disabled={purgeConfirmText.trim() !== purgeLabel || !purgeLabel}
onClick={handlePurge}
>
Delete permanently
</Button>
</Group>
</Stack>
</Modal>
<Modal
opened={Boolean(assigningDriver)}
onClose={() => {

View File

@@ -8,6 +8,7 @@ import {
Plus,
Route as RouteIcon,
Trash2,
ShieldAlert,
} from "lucide-react";
import type { ColumnDef } from "@edr/ui-common";
import {
@@ -23,6 +24,7 @@ import {
SimpleGrid,
Stack,
Text,
TextInput,
ThemeIcon,
Tooltip,
} from "@mantine/core";
@@ -38,7 +40,7 @@ import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
import { api } from "@/services/api";
import { ruleEngineService } from "@/services/ruleEngine/ruleEngine.service";
import { useAuth } from "@/auth/useAuth";
import { canFleetAction } from "@/lib/permissions";
import { canFleetAction, canFleetHardDelete } from "@/lib/permissions";
import { useToast } from "@/hooks/use-toast";
import {
formatRouteLabel,
@@ -168,6 +170,14 @@ export default function RoutesPage() {
const canCreate = canFleetAction(user, "routes", "create");
const canUpdate = canFleetAction(user, "routes", "update");
const canDelete = canFleetAction(user, "routes", "delete");
// Irreversible purge needs its own grant — the coarse fleet:manage key that
// canFleetAction accepts deliberately does not unlock it.
const canPurge = canFleetHardDelete(user, "routes");
// Both destructive actions confirm first: deactivate is recoverable but still
// changes what operations can book, and a purge cannot be undone at all.
const [deactivateTarget, setDeactivateTarget] = useState<RouteRecord | null>(null);
const [purgeTarget, setPurgeTarget] = useState<RouteRecord | null>(null);
const [purgeConfirmText, setPurgeConfirmText] = useState("");
const routesQuery = useQuery({
...api.routes.listPaged.queryOptions({
@@ -202,6 +212,7 @@ export default function RoutesPage() {
const createMutation = useMutation(api.routes.create.mutationOptions());
const updateMutation = useMutation(api.routes.update.mutationOptions());
const deactivateMutation = useMutation(api.routes.deactivate.mutationOptions());
const purgeMutation = useMutation(api.routes.purge.mutationOptions());
// Narrowing the result set can strand the user on a page that no longer
// exists (search down to 3 rows while on page 5 → empty table).
@@ -353,15 +364,40 @@ export default function RoutesPage() {
}
};
const handleDeactivate = async (route: RouteRecord) => {
const handleDeactivate = async () => {
if (!deactivateTarget) return;
try {
await deactivateMutation.mutateAsync(route.id);
await deactivateMutation.mutateAsync(deactivateTarget.id);
toast({ title: "Route marked stop working" });
setDeactivateTarget(null);
} catch {
toast({ title: "Update failed", description: "Could not update route status", variant: "destructive" });
}
};
const closePurge = () => {
setPurgeTarget(null);
setPurgeConfirmText("");
};
/** The label the operator must retype to confirm an irreversible purge. */
const purgeLabel = purgeTarget ? formatRouteLabel(purgeTarget) : "";
const handlePurge = async () => {
if (!purgeTarget) return;
try {
await purgeMutation.mutateAsync(purgeTarget.id);
toast({ title: "Route permanently deleted" });
closePurge();
} catch (error) {
toast({
title: "Permanent delete failed",
description: normalizeRouteError(error),
variant: "destructive",
});
}
};
const handleStatusChange = async (route: RouteRecord, status: RouteStatus) => {
try {
await updateMutation.mutateAsync({ id: route.id, data: { status } });
@@ -465,17 +501,29 @@ export default function RoutesPage() {
variant="subtle"
color="red"
disabled={row.original.status === "STOP_WORKING" || deactivateMutation.isPending}
onClick={() => handleDeactivate(row.original)}
onClick={() => setDeactivateTarget(row.original)}
>
<Trash2 size={16} />
</ActionIcon>
</Tooltip>
) : null}
{canPurge ? (
<Tooltip label="Delete permanently">
<ActionIcon
variant="subtle"
color="red"
disabled={purgeMutation.isPending}
onClick={() => setPurgeTarget(row.original)}
>
<ShieldAlert size={16} />
</ActionIcon>
</Tooltip>
) : null}
</Group>
),
},
];
}, [deactivateMutation.isPending, canUpdate, canDelete]);
}, [deactivateMutation.isPending, purgeMutation.isPending, canUpdate, canDelete, canPurge]);
return (
<PageContainer>
@@ -696,6 +744,78 @@ export default function RoutesPage() {
</form>
</Modal>
<Modal
opened={Boolean(deactivateTarget)}
onClose={() => setDeactivateTarget(null)}
title={<Text fw={600}>Mark stop working</Text>}
radius="lg"
centered
>
<Stack gap="md">
<Text size="sm">
Stop new operations on{" "}
<Text span fw={700}>
{deactivateTarget ? formatRouteLabel(deactivateTarget) : ""}
</Text>
? The route keeps its history and can no longer be booked.
</Text>
<Group justify="flex-end">
<Button variant="default" onClick={() => setDeactivateTarget(null)}>
Cancel
</Button>
<Button
color="red"
loading={deactivateMutation.isPending}
onClick={handleDeactivate}
>
Mark stop working
</Button>
</Group>
</Stack>
</Modal>
<Modal
opened={Boolean(purgeTarget)}
onClose={closePurge}
title={<Text fw={600}>Delete permanently</Text>}
radius="lg"
centered
>
<Stack gap="md">
<Text size="sm">
This permanently removes{" "}
<Text span fw={700}>
{purgeLabel}
</Text>{" "}
and its stops from the database. It cannot be undone.
</Text>
<Text size="sm" c="dimmed">
Only unused routes can be purged if any train schedule still
references it, the request is refused and you should mark it stop
working instead.
</Text>
<TextInput
label="Type the route to confirm"
placeholder={purgeLabel}
value={purgeConfirmText}
onChange={(e) => setPurgeConfirmText(e.currentTarget.value)}
/>
<Group justify="flex-end">
<Button variant="default" onClick={closePurge}>
Cancel
</Button>
<Button
color="red"
loading={purgeMutation.isPending}
disabled={purgeConfirmText.trim() !== purgeLabel || !purgeLabel}
onClick={handlePurge}
>
Delete permanently
</Button>
</Group>
</Stack>
</Modal>
<Modal
opened={Boolean(viewing)}
onClose={() => setViewing(null)}

View File

@@ -1605,6 +1605,15 @@ export const api = {
undefined,
() => [["routes"]],
),
/** Irreversible purge — refused while any train schedule uses the route. */
purge: endpoint<string, void>(
"routes",
"purge",
(id) => routesService.purge(id).then(() => undefined),
undefined,
() => [["routes"]],
),
},
stations: {
@@ -2194,6 +2203,15 @@ export const api = {
undefined,
({ slug }) => [QUERY_KEYS.FLEET.list(slug)],
),
/** Irreversible purge — locomotives and wagons only. */
purge: endpoint<{ slug: FleetResourceSlug; id: string }, unknown>(
"fleet",
"purge",
({ slug, id }) => fleetService.purge(slug, id),
undefined,
({ slug }) => [QUERY_KEYS.FLEET.list(slug)],
),
},
truckTypes: {

View File

@@ -77,6 +77,21 @@ const removeHandlers: Record<FleetResourceSlug, (id: string) => Promise<unknown>
drivers: (id) => driversService.delete(id),
};
/**
* Permanent purge, only for the two slugs that expose it. Everything else stays
* soft-delete/decommission only, so there is deliberately no entry here.
*/
const purgeHandlers: Partial<
Record<FleetResourceSlug, (id: string) => Promise<unknown>>
> = {
locomotives: (id) => locomotivesService.purge(id),
wagons: (id) => wagonService.purge(id),
};
/** True when the slug supports an irreversible purge. */
export const isFleetPurgeable = (slug: FleetResourceSlug) =>
slug in purgeHandlers;
export const fleetService = {
list: (slug: FleetResourceSlug, filters?: FleetListFilters) => listHandlers[slug](filters),
/** Only for slugs in `pagedHandlers` — guard with `isFleetServerPaginated`. */
@@ -89,4 +104,11 @@ export const fleetService = {
update: (slug: FleetResourceSlug, id: string, data: Record<string, unknown>) =>
updateHandlers[slug](id, data),
remove: (slug: FleetResourceSlug, id: string) => removeHandlers[slug](id),
purge: (slug: FleetResourceSlug, id: string) => {
const handler = purgeHandlers[slug];
if (!handler) {
throw new Error(`Fleet resource "${slug}" cannot be permanently deleted`);
}
return handler(id);
},
};

View File

@@ -85,4 +85,7 @@ export const locomotivesService = {
update: (id: string, data: Partial<SaveLocomotivePayload>) =>
apiClient.patch(URL_CONSTANTS.LOCOMOTIVES.BY_ID(id), data),
decommission: (id: string) => apiClient.post(URL_CONSTANTS.LOCOMOTIVES.DECOMMISSION(id), {}),
/** Irreversible purge — the API refuses it while any train references the loco. */
purge: (id: string) =>
apiClient.delete(`${URL_CONSTANTS.LOCOMOTIVES.BY_ID(id)}/permanent`),
};

View File

@@ -101,6 +101,9 @@ export const routesService = {
update: (id: string, data: Partial<SaveRoutePayload>) =>
apiClient.patch(URL_CONSTANTS.ROUTES.BY_ID(id), data),
deactivate: (id: string) => apiClient.delete(URL_CONSTANTS.ROUTES.BY_ID(id)),
/** Irreversible purge — the API refuses it while any train schedule uses the route. */
purge: (id: string) =>
apiClient.delete(`${URL_CONSTANTS.ROUTES.BY_ID(id)}/permanent`),
/** All active yards (page-walked — the yards list API caps pageSize at 100). */
getYards: async (): Promise<YardRef[]> => {
const rows = await ruleEngineService.listAll("yards", { isActive: true });

View File

@@ -123,6 +123,8 @@ export const wagonService = {
create: (data: Partial<Wagon>) => apiClient.post('/wagons', data),
update: (id: string, data: Partial<Wagon>) => apiClient.patch(`/wagons/${id}`, data),
delete: (id: string) => apiClient.delete(`/wagons/${id}`),
/** Irreversible purge — the API refuses it when the wagon has any history. */
purge: (id: string) => apiClient.delete(`/wagons/${id}/permanent`),
/** Relocate many wagons to one yard in a single call (writes movement ledger). */
bulkTransfer: (wagonIds: string[], toYardId: string) =>
apiClient.post<{ moved: number }>('/wagons/bulk-transfer', { wagonIds, toYardId }),