diff --git a/apps/edr-freight-api/src/contracts/contract-article.util.ts b/apps/edr-freight-api/src/contracts/contract-article.util.ts index 92bef9dd3..ee7978e08 100644 --- a/apps/edr-freight-api/src/contracts/contract-article.util.ts +++ b/apps/edr-freight-api/src/contracts/contract-article.util.ts @@ -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 { 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([ diff --git a/apps/edr-freight-api/src/modules/locomotives/locomotives.controller.ts b/apps/edr-freight-api/src/modules/locomotives/locomotives.controller.ts index 3883f8d99..3b0dc6ec9 100644 --- a/apps/edr-freight-api/src/modules/locomotives/locomotives.controller.ts +++ b/apps/edr-freight-api/src/modules/locomotives/locomotives.controller.ts @@ -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); + } } diff --git a/apps/edr-freight-api/src/modules/locomotives/locomotives.service.ts b/apps/edr-freight-api/src/modules/locomotives/locomotives.service.ts index 1da03072e..1d45cbeff 100644 --- a/apps/edr-freight-api/src/modules/locomotives/locomotives.service.ts +++ b/apps/edr-freight-api/src/modules/locomotives/locomotives.service.ts @@ -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 { + 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>( + `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>( + `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>( + `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 }); + } } diff --git a/apps/edr-freight-api/src/modules/routes/purge-guard.spec.ts b/apps/edr-freight-api/src/modules/routes/purge-guard.spec.ts new file mode 100644 index 000000000..c737f18be --- /dev/null +++ b/apps/edr-freight-api/src/modules/routes/purge-guard.spec.ts @@ -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) => 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 }).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(); + }); +}); diff --git a/apps/edr-freight-api/src/modules/routes/routes.controller.ts b/apps/edr-freight-api/src/modules/routes/routes.controller.ts index 259dd4c9f..ed61f08b0 100644 --- a/apps/edr-freight-api/src/modules/routes/routes.controller.ts +++ b/apps/edr-freight-api/src/modules/routes/routes.controller.ts @@ -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' }) diff --git a/apps/edr-freight-api/src/modules/routes/routes.service.ts b/apps/edr-freight-api/src/modules/routes/routes.service.ts index 8c2989b87..017c23551 100644 --- a/apps/edr-freight-api/src/modules/routes/routes.service.ts +++ b/apps/edr-freight-api/src/modules/routes/routes.service.ts @@ -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 { + const route = await this.findById(id); + + const [schedules] = await this.dataSource.query>( + `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 diff --git a/apps/edr-freight-api/src/modules/wagons/purge-guard.spec.ts b/apps/edr-freight-api/src/modules/wagons/purge-guard.spec.ts new file mode 100644 index 000000000..ab86cea51 --- /dev/null +++ b/apps/edr-freight-api/src/modules/wagons/purge-guard.spec.ts @@ -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(); + }); +}); diff --git a/apps/edr-freight-api/src/modules/wagons/wagons.controller.ts b/apps/edr-freight-api/src/modules/wagons/wagons.controller.ts index c792057d8..f90e9ca32 100644 --- a/apps/edr-freight-api/src/modules/wagons/wagons.controller.ts +++ b/apps/edr-freight-api/src/modules/wagons/wagons.controller.ts @@ -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' }) diff --git a/apps/edr-freight-api/src/modules/wagons/wagons.service.ts b/apps/edr-freight-api/src/modules/wagons/wagons.service.ts index f51846e2d..038ff83b3 100644 --- a/apps/edr-freight-api/src/modules/wagons/wagons.service.ts +++ b/apps/edr-freight-api/src/modules/wagons/wagons.service.ts @@ -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 { + 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 diff --git a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts index 9190316cd..c30a2ebda 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts @@ -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', diff --git a/apps/edr-freight-web/backoffice/src/components/fleet/FleetCardGrid.tsx b/apps/edr-freight-web/backoffice/src/components/fleet/FleetCardGrid.tsx index 1f8ab3f9f..09544062f 100644 --- a/apps/edr-freight-web/backoffice/src/components/fleet/FleetCardGrid.tsx +++ b/apps/edr-freight-web/backoffice/src/components/fleet/FleetCardGrid.tsx @@ -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} /> diff --git a/apps/edr-freight-web/backoffice/src/components/fleet/FleetRecordActions.tsx b/apps/edr-freight-web/backoffice/src/components/fleet/FleetRecordActions.tsx index 6d9978f2c..4ab2c42f9 100644 --- a/apps/edr-freight-web/backoffice/src/components/fleet/FleetRecordActions.tsx +++ b/apps/edr-freight-web/backoffice/src/components/fleet/FleetRecordActions.tsx @@ -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} ) : null} + {onPurge ? ( + onPurge(record)} + leftSection={} + > + Delete permanently + + ) : null} ); diff --git a/apps/edr-freight-web/backoffice/src/lib/permissions.ts b/apps/edr-freight-web/backoffice/src/lib/permissions.ts index e50f93c2c..bc70c95a2 100644 --- a/apps/edr-freight-web/backoffice/src/lib/permissions.ts +++ b/apps/edr-freight-web/backoffice/src/lib/permissions.ts @@ -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); } diff --git a/apps/edr-freight-web/backoffice/src/pages/contract_templates/ContractTemplateEditorPage.tsx b/apps/edr-freight-web/backoffice/src/pages/contract_templates/ContractTemplateEditorPage.tsx index 027bdae74..42942b3e7 100644 --- a/apps/edr-freight-web/backoffice/src/pages/contract_templates/ContractTemplateEditorPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/contract_templates/ContractTemplateEditorPage.tsx @@ -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: [], }); diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/FleetResourcePage.tsx b/apps/edr-freight-web/backoffice/src/pages/fleet/FleetResourcePage.tsx index 213501d74..98da6b50f 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/FleetResourcePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/FleetResourcePage.tsx @@ -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(null); const [removeTarget, setRemoveTarget] = useState(null); + const [purgeTarget, setPurgeTarget] = useState(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(null); const [historyTarget, setHistoryTarget] = useState(null); const [selectedDriver, setSelectedDriver] = useState(""); @@ -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) + : 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} /> )} @@ -718,6 +768,48 @@ const FleetResourcePage = () => { + Delete permanently} + radius="lg" + centered + > + + + This permanently removes{" "} + + {purgeLabel || `this ${config.entityLabel.toLowerCase()}`} + {" "} + from the database. It cannot be undone. + + + 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. + + setPurgeConfirmText(e.currentTarget.value)} + /> + + + + + + + { diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/RoutesPage.tsx b/apps/edr-freight-web/backoffice/src/pages/fleet/RoutesPage.tsx index 4823be33d..6a70596aa 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/RoutesPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/RoutesPage.tsx @@ -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(null); + const [purgeTarget, setPurgeTarget] = useState(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)} > ) : null} + {canPurge ? ( + + setPurgeTarget(row.original)} + > + + + + ) : null} ), }, ]; - }, [deactivateMutation.isPending, canUpdate, canDelete]); + }, [deactivateMutation.isPending, purgeMutation.isPending, canUpdate, canDelete, canPurge]); return ( @@ -696,6 +744,78 @@ export default function RoutesPage() { + setDeactivateTarget(null)} + title={Mark stop working} + radius="lg" + centered + > + + + Stop new operations on{" "} + + {deactivateTarget ? formatRouteLabel(deactivateTarget) : ""} + + ? The route keeps its history and can no longer be booked. + + + + + + + + + Delete permanently} + radius="lg" + centered + > + + + This permanently removes{" "} + + {purgeLabel} + {" "} + and its stops from the database. It cannot be undone. + + + 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. + + setPurgeConfirmText(e.currentTarget.value)} + /> + + + + + + + setViewing(null)} diff --git a/apps/edr-freight-web/backoffice/src/services/api.ts b/apps/edr-freight-web/backoffice/src/services/api.ts index b21ec07a0..a2d5f1b76 100644 --- a/apps/edr-freight-web/backoffice/src/services/api.ts +++ b/apps/edr-freight-web/backoffice/src/services/api.ts @@ -1605,6 +1605,15 @@ export const api = { undefined, () => [["routes"]], ), + + /** Irreversible purge — refused while any train schedule uses the route. */ + purge: endpoint( + "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: { diff --git a/apps/edr-freight-web/backoffice/src/services/fleet/fleet.service.ts b/apps/edr-freight-web/backoffice/src/services/fleet/fleet.service.ts index e47160176..87dda642b 100644 --- a/apps/edr-freight-web/backoffice/src/services/fleet/fleet.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/fleet/fleet.service.ts @@ -77,6 +77,21 @@ const removeHandlers: Record Promise 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 Promise> +> = { + 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) => 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); + }, }; diff --git a/apps/edr-freight-web/backoffice/src/services/locomotives.service.ts b/apps/edr-freight-web/backoffice/src/services/locomotives.service.ts index fa80ec86c..f3a617e51 100644 --- a/apps/edr-freight-web/backoffice/src/services/locomotives.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/locomotives.service.ts @@ -85,4 +85,7 @@ export const locomotivesService = { update: (id: string, data: Partial) => 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`), }; diff --git a/apps/edr-freight-web/backoffice/src/services/routes.service.ts b/apps/edr-freight-web/backoffice/src/services/routes.service.ts index d3795d83f..eb14a4aef 100644 --- a/apps/edr-freight-web/backoffice/src/services/routes.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/routes.service.ts @@ -101,6 +101,9 @@ export const routesService = { update: (id: string, data: Partial) => 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 => { const rows = await ruleEngineService.listAll("yards", { isActive: true }); diff --git a/apps/edr-freight-web/backoffice/src/services/wagon.service.ts b/apps/edr-freight-web/backoffice/src/services/wagon.service.ts index 9a5127f2b..8d379ce44 100644 --- a/apps/edr-freight-web/backoffice/src/services/wagon.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/wagon.service.ts @@ -123,6 +123,8 @@ export const wagonService = { create: (data: Partial) => apiClient.post('/wagons', data), update: (id: string, data: Partial) => 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 }),