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 s.containerSize ?? '').filter(Boolean)), + ].join(', '); + const cargoTypeName = [ + ...new Set( + scope + .map((s) => s.cargoType?.cargoTypeName ?? s.cargoFreeText ?? '') + .filter(Boolean), + ), + ].join(', '); + const cargoSummary = scope + .map((s) => { + const name = s.cargoType?.cargoTypeName ?? s.cargoFreeText ?? null; + const size = s.containerSize ? `(${s.containerSize})` : null; + const cap = s.quantityCap ? `× ${Number(s.quantityCap)}` : null; + return [name, size, cap].filter(Boolean).join(' '); + }) + .filter(Boolean) + .join('; '); + return { originLabel: this.yardLabel(firstRoute?.originYard), destinationLabel: this.yardLabel(firstRoute?.destinationYard), @@ -302,6 +327,9 @@ export class ContractDocumentViewModelBuilder { scheduledDate: this.formatDate(null), contractType: this.valueOrDash(contract.contractType), cargoDescription: this.valueOrDash(cargoName), + cargoTypeName: this.valueOrDash(cargoTypeName), + containerType: this.valueOrDash(containerType), + cargoSummary: this.valueOrDash(cargoSummary), totalWeightVgm: '—', equipmentReturn: this.valueOrDash(contract.equipmentReturn), // A hazardous contract names the declared class + UN number on the diff --git a/apps/edr-freight-api/src/contracts/contract-dynamic-template.spec.ts b/apps/edr-freight-api/src/contracts/contract-dynamic-template.spec.ts index 765c41142..6cefe6423 100644 --- a/apps/edr-freight-api/src/contracts/contract-dynamic-template.spec.ts +++ b/apps/edr-freight-api/src/contracts/contract-dynamic-template.spec.ts @@ -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([ @@ -90,6 +104,8 @@ describe('dynamic template rendering (edr-dynamic.hbs)', () => { template: { ...meta, title: 'Bulk Import Contract', templateFile: 'edr-dynamic.hbs' }, contractDate: '1 January 2026', contractYear: 2026, + contractStartDate: '1 January 2026', + contractEndDate: '31 December 2026', client: { companyName: 'Abyssinia Trading PLC', companyAddress: 'Bole Sub-city, Addis Ababa', @@ -117,6 +133,9 @@ describe('dynamic template rendering (edr-dynamic.hbs)', () => { scheduledDate: '—', contractType: 'GENERAL', cargoDescription: 'Steel billets', + cargoTypeName: 'Steel billets', + containerType: '—', + cargoSummary: 'Steel billets × 2,800', totalWeightVgm: '—', equipmentReturn: '—', hazardousLabel: 'No', @@ -192,6 +211,42 @@ describe('dynamic template rendering (edr-dynamic.hbs)', () => { expect(html).toContain('#1b9e7a'); }); + it('shows the contract validity window in the commercial schedule annex', () => { + const html = renderer.render(dynamicView()); + expect(html).toContain('Valid from'); + expect(html).toContain('Valid until'); + expect(html).toContain('1 January 2026'); + expect(html).toContain('31 December 2026'); + }); + + it('interpolates the start/end date placeholders inside article text', () => { + const view = dynamicView(); + expect( + interpolateTemplateText( + 'In force {{contractStartDate}} to {{contractEndDate}}.', + view, + ), + ).toBe('In force 1 January 2026 to 31 December 2026.'); + }); + + it('shows cargo type and container type in the commercial schedule annex', () => { + const html = renderer.render(dynamicView()); + expect(html).toContain('Cargo type'); + expect(html).toContain('Container type'); + expect(html).toContain('Cargo scope'); + expect(html).toContain('Steel billets × 2,800'); + }); + + it('interpolates the cargo/container placeholders inside article text', () => { + const view = dynamicView(); + const body = + 'Cargo: {{schedule.cargoTypeName}} in {{schedule.containerType}} ' + + '({{schedule.freightType}}). Scope: {{schedule.cargoSummary}}.'; + expect(interpolateTemplateText(body, view)).toBe( + 'Cargo: Steel billets in — (BULK). Scope: Steel billets × 2,800.', + ); + }); + it('renders the live rate schedule lane under the pricing article', () => { const html = renderer.render(dynamicView()); expect(html).toContain('Rate Schedule'); diff --git a/apps/edr-freight-api/src/contracts/contract-renderer.service.spec.ts b/apps/edr-freight-api/src/contracts/contract-renderer.service.spec.ts index b0fc4c8ef..fc7e66e5b 100644 --- a/apps/edr-freight-api/src/contracts/contract-renderer.service.spec.ts +++ b/apps/edr-freight-api/src/contracts/contract-renderer.service.spec.ts @@ -16,6 +16,8 @@ describe('ContractRendererService', () => { template, contractDate: '1 January 2026', contractYear: 2026, + contractStartDate: '1 January 2026', + contractEndDate: '31 December 2026', client: { companyName: 'Test Co', companyAddress: 'Addis Ababa', @@ -43,6 +45,9 @@ describe('ContractRendererService', () => { scheduledDate: '1 January 2026', contractType: 'NEW', cargoDescription: 'Container cargo', + cargoTypeName: 'Coffee', + containerType: '40ft', + cargoSummary: 'Coffee (40ft) × 12', totalWeightVgm: '24 tons', equipmentReturn: 'RETURN', hazardousLabel: 'No', diff --git a/apps/edr-freight-api/src/contracts/contract-view-model.builder.ts b/apps/edr-freight-api/src/contracts/contract-view-model.builder.ts index 07f4d25d3..d1158d035 100644 --- a/apps/edr-freight-api/src/contracts/contract-view-model.builder.ts +++ b/apps/edr-freight-api/src/contracts/contract-view-model.builder.ts @@ -41,6 +41,13 @@ export interface ContractViewModel { template: ContractTemplateMeta; contractDate: string; contractYear: number; + /** + * The contract's validity window (`contract_valid_from` / `_until`). Distinct + * from `contractDate`, which is the day the document is generated — these are + * the dates the contract is actually in force between. "—" when unset. + */ + contractStartDate: string; + contractEndDate: string; client: { companyName: string; companyAddress: string; @@ -68,6 +75,16 @@ export interface ContractViewModel { scheduledDate: string; contractType: string; cargoDescription: string; + /** + * The named cargo type on its own (e.g. "Coffee"), separate from + * `cargoDescription` which folds in free text and a container fallback. + * Lets a clause name the commodity without the surrounding prose. + */ + cargoTypeName: string; + /** Container size alone, e.g. "20ft" / "40ft"; "—" for bulk. */ + containerType: string; + /** Every cargo line on the contract, e.g. "Coffee (40ft) × 12". */ + cargoSummary: string; totalWeightVgm: string; equipmentReturn: string; hazardousLabel: string; @@ -133,6 +150,8 @@ export class ContractViewModelBuilder { year: 'numeric', }), contractYear: new Date().getFullYear(), + contractStartDate: this.formatDate(booking.contractValidFrom), + contractEndDate: this.formatDate(booking.contractValidUntil), client: { companyName: booking.company?.name ?? 'Client', companyAddress: this.valueOrDash(booking.company?.address), @@ -195,6 +214,21 @@ export class ContractViewModelBuilder { 'Bulk commodity' : booking.cargoType?.cargoTypeName || 'Container cargo'; const totalWeight = Number(booking.cargoTotalWeightVgm || 0); + // A booking may carry both sizes; name each one once, in the order booked. + const containerType = [ + ...new Set( + (booking.bookingContainers ?? []) + .map( + (line) => + line.containerType?.label ?? + (line.containerType?.sizeFt + ? `${line.containerType.sizeFt}ft` + : line.containerSize) ?? + '', + ) + .filter(Boolean), + ), + ].join(', '); return { originLabel: this.yardLabel(booking.originYard), @@ -207,6 +241,13 @@ export class ContractViewModelBuilder { scheduledDate: this.formatDate(booking.scheduledDate), contractType: this.valueOrDash(booking.contractType), cargoDescription: this.valueOrDash(cargoName), + cargoTypeName: this.valueOrDash(booking.cargoType?.cargoTypeName), + containerType: this.valueOrDash(containerType), + cargoSummary: this.valueOrDash( + [cargoName, containerType ? `(${containerType})` : null] + .filter(Boolean) + .join(' '), + ), totalWeightVgm: totalWeight > 0 ? `${totalWeight.toLocaleString()} tons` : '—', equipmentReturn: this.valueOrDash(booking.equipmentReturn), diff --git a/apps/edr-freight-api/src/contracts/templates/edr-dynamic.hbs b/apps/edr-freight-api/src/contracts/templates/edr-dynamic.hbs index 9c4957b2b..6ba7c1610 100644 --- a/apps/edr-freight-api/src/contracts/templates/edr-dynamic.hbs +++ b/apps/edr-freight-api/src/contracts/templates/edr-dynamic.hbs @@ -125,12 +125,30 @@ Hazardous cargo {{schedule.hazardousLabel}} + + Cargo type + {{schedule.cargoTypeName}} + Container type + {{schedule.containerType}} + + + Cargo scope + {{schedule.cargoSummary}} + Freight type + {{schedule.freightType}} + Equipment return {{schedule.equipmentReturn}} Payment currency {{paymentArticle}} + + Valid from + {{contractStartDate}} + Valid until + {{contractEndDate}} + diff --git a/apps/edr-freight-api/src/modules/contract-templates/contract-templates.service.ts b/apps/edr-freight-api/src/modules/contract-templates/contract-templates.service.ts index cb956878d..ea41b57a2 100644 --- a/apps/edr-freight-api/src/modules/contract-templates/contract-templates.service.ts +++ b/apps/edr-freight-api/src/modules/contract-templates/contract-templates.service.ts @@ -208,6 +208,9 @@ export class ContractTemplatesService { year: "numeric", }), contractYear: now.getFullYear(), + // Representative validity window for the admin preview only. + contractStartDate: `1 January ${now.getFullYear()}`, + contractEndDate: `31 December ${now.getFullYear()}`, client: { companyName: "Abyssinia Trading PLC", companyAddress: "Bole Sub-city, Woreda 03, H.No 1234, Addis Ababa", @@ -239,6 +242,11 @@ export class ContractTemplatesService { scheduledDate: "—", contractType: "GENERAL", cargoDescription: isBulk ? "Steel billets — 2,800 MT" : "40ft containers — FMCG cargo", + cargoTypeName: isBulk ? "Steel billets" : "Coffee", + containerType: isBulk ? "—" : "40ft", + cargoSummary: isBulk + ? "Steel billets × 2,800" + : "Coffee (40ft) × 12; Sesame (20ft) × 6", totalWeightVgm: "—", equipmentReturn: isBulk ? "—" : "With empty return", hazardousLabel: "No", diff --git a/apps/edr-freight-api/src/modules/exchange-settings/exchange-settings.controller.ts b/apps/edr-freight-api/src/modules/exchange-settings/exchange-settings.controller.ts index 782a5b6a0..a6c08a317 100644 --- a/apps/edr-freight-api/src/modules/exchange-settings/exchange-settings.controller.ts +++ b/apps/edr-freight-api/src/modules/exchange-settings/exchange-settings.controller.ts @@ -1,6 +1,6 @@ import { Body, Controller, Get, Patch } from "@nestjs/common"; import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger"; -import { CurrentUser, ExchangeService } from "@edr/api-common"; +import { CurrentUser } from "@edr/api-common"; import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/current-user.type"; import { FreightAdmin } from "../../common/booking-guards"; @@ -11,10 +11,7 @@ import { ExchangeSettingsService } from "./exchange-settings.service"; @ApiBearerAuth() @Controller("exchange-settings") export class ExchangeSettingsController { - constructor( - private readonly service: ExchangeSettingsService, - private readonly exchangeService: ExchangeService, - ) {} + constructor(private readonly service: ExchangeSettingsService) {} @Get() @FreightAdmin() @@ -22,24 +19,15 @@ export class ExchangeSettingsController { summary: "Current USD→ETB fallback rate and CBE feed health", }) async get() { - const [setting, status] = [ - await this.service.get(), - this.exchangeService.getProviderStatus(), - ]; + const setting = await this.service.get(); + const status = this.service.getFeedStatus(); return { fallbackRate: setting.fallbackRate, fallbackSource: setting.fallbackSource, lastSyncedAt: setting.lastSyncedAt, updatedById: setting.updatedById, - feed: { - rate: status.rate, - source: status.source, - lastSuccessAt: status.lastSuccessAt - ? new Date(status.lastSuccessAt).toISOString() - : null, - lastError: status.lastError, - }, + feed: status, }; } diff --git a/apps/edr-freight-api/src/modules/exchange-settings/exchange-settings.service.ts b/apps/edr-freight-api/src/modules/exchange-settings/exchange-settings.service.ts index 4cccdf8ea..e0b670292 100644 --- a/apps/edr-freight-api/src/modules/exchange-settings/exchange-settings.service.ts +++ b/apps/edr-freight-api/src/modules/exchange-settings/exchange-settings.service.ts @@ -10,6 +10,18 @@ import { ExchangeSetting } from "./entities/exchange-setting.entity"; */ const SEED_FALLBACK_RATE = 162.4165; +/** Health of the CBE feed, as surfaced to the backoffice. */ +export interface ExchangeFeedStatus { + /** Rate most recently observed, whatever its source. */ + rate: number | null; + /** `live` means CBE answered; `stored`/`default` mean it is failing. */ + source: "live" | "stored" | null; + /** ISO timestamp of the last successful fetch. */ + lastSuccessAt: string | null; + /** Message from the most recent failure, cleared on success. */ + lastError: string | null; +} + /** * Owns the single `exchange_settings` row: the USD→ETB fallback used when the * CBE endpoint is unreachable. @@ -22,11 +34,30 @@ const SEED_FALLBACK_RATE = 162.4165; export class ExchangeSettingsService { private readonly logger = new Logger(ExchangeSettingsService.name); + /** + * Feed health, recorded from the exchange provider's callbacks rather than + * read off an injected `ExchangeService`. The provider is registered several + * times (bookings, contracts, warehouses), so no single instance sees every + * fetch — and injecting one here would be circular, since those + * registrations inject *this* service. + */ + private feed: ExchangeFeedStatus = { + rate: null, + source: null, + lastSuccessAt: null, + lastError: null, + }; + constructor( @InjectRepository(ExchangeSetting) private readonly repository: Repository, ) {} + /** Health of the CBE feed as last observed by any provider instance. */ + getFeedStatus(): ExchangeFeedStatus { + return { ...this.feed }; + } + /** The settings row, created at the seed rate on first access. */ async get(): Promise { const existing = await this.repository.findOne({ where: {} }); @@ -47,15 +78,22 @@ export class ExchangeSettingsService { * than propagating a database error into a pricing call. */ async loadFallbackRate(): Promise { + // Only reached when the live fetch failed, so this call is itself the + // signal that the feed is down. try { const { fallbackRate } = await this.get(); - return Number.isFinite(fallbackRate) && fallbackRate > 0 - ? fallbackRate - : null; + const usable = Number.isFinite(fallbackRate) && fallbackRate > 0; + this.feed = { + ...this.feed, + rate: usable ? fallbackRate : this.feed.rate, + source: "stored", + lastError: this.feed.lastError ?? "CBE endpoint unreachable", + }; + return usable ? fallbackRate : null; } catch (err) { - this.logger.warn( - `Could not read stored exchange fallback: ${(err as Error).message}`, - ); + const message = (err as Error).message; + this.feed = { ...this.feed, source: "stored", lastError: message }; + this.logger.warn(`Could not read stored exchange fallback: ${message}`); return null; } } @@ -66,6 +104,14 @@ export class ExchangeSettingsService { * down, so a working CBE feed takes precedence again. */ async saveFallbackRate(rate: number): Promise { + // Only called after a successful fetch, so the feed is confirmed healthy. + this.feed = { + rate, + source: "live", + lastSuccessAt: new Date().toISOString(), + lastError: null, + }; + const current = await this.get(); await this.repository.update(current.id, { fallbackRate: rate, 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/contracts/GlCreateBookingForm.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx index 4a1cb402f..52f7ee40d 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx @@ -20,7 +20,6 @@ import { Loader, Modal, Paper, - SegmentedControl, Select, Stack, Switch, @@ -49,7 +48,11 @@ import { X, } from "lucide-react"; import type { Freight } from "@edr/types"; -import { ExportTrainPicker, OperationDatePicker } from "@edr/ui-common"; +import { + CurrencySelector, + ExportTrainPicker, + OperationDatePicker, +} from "@edr/ui-common"; import { api } from "@/services/api"; import { PageContainer } from "@/components/page"; @@ -1713,47 +1716,42 @@ export default function GlCreateBookingForm() { title="Schedule" description="Pick the binding shipment day. Only days with an open train that has enough matching wagons for the cargo can be selected." /> - {cargoQuery === null ? ( - } - > - Enter your cargo details first — available shipment days depend - on the wagons your cargo needs. - - ) : ( - - Shipment day * - - { - setScheduledDate(d); - // A new day invalidates the old train pick. - setTrainScheduleId(""); - }} - /> - - {showErrors && dateError && ( - - {dateError} - - )} - {isExportPick && scheduledDate ? ( - - ) : null} + + Shipment day * + + {/* Calendar stays visible before cargo is entered — all days + disabled with a hint, since availability depends on cargo. */} + { + setScheduledDate(d); + // A new day invalidates the old train pick. + setTrainScheduleId(""); + }} + /> - )} + {showErrors && dateError && ( + + {dateError} + + )} + {isExportPick && scheduledDate ? ( + + ) : null} + )} @@ -1769,16 +1767,10 @@ export default function GlCreateBookingForm() { ? "Requested by the customer on their shipment request." : "The contract is quoted in USD — pick the currency this shipment is invoiced in."} - setPaymentCurrency(v as "USD" | "ETB")} + onChange={setPaymentCurrency} disabled={isIntercity} - data={[ - { label: "USD", value: "USD" }, - { label: "ETB", value: "ETB" }, - ]} - color="edr-green" - radius={10} /> 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 17977b743..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 @@ -21,20 +21,23 @@ import { Title, Tooltip, } from "@mantine/core"; +import ReactQuill from "react-quill-new"; +import "react-quill-new/dist/quill.snow.css"; + import { AlertTriangle, ArrowDown, + ArrowLeftRight, ArrowUp, + Boxes, Building2, + Container, CalendarClock, CalendarDays, CalendarRange, ChevronDown, Coins, Hash, - ListOrdered, - ListPlus, - ListTree, Mail, MapPin, Package, @@ -58,9 +61,10 @@ import { useUpdateContractTemplate, } from "@/hooks/contract-templates/useContractTemplates"; import type { ContractTemplateArticle } from "@/services/contract-templates.service"; +import { bodyToHtml, htmlToBody } from "./article-html"; const BODY_HINT = - 'One clause per line. Use "New clause" for the next number (1., 2., …), "Sub-clause" for a nested number (1.1, then 1.1.1), and "Bullet" for a • point — the number or bullet is typed for you, just add the text. Placeholders are filled from the contract when the document is generated.'; + "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; @@ -105,6 +109,18 @@ const QUICK_PLACEHOLDERS: PlaceholderDef[] = [ icon: CalendarRange, hint: "Year the contract is signed", }, + { + token: "{{contractStartDate}}", + label: "Start date", + icon: CalendarClock, + hint: "Date the contract's validity begins", + }, + { + token: "{{contractEndDate}}", + label: "End date", + icon: CalendarClock, + hint: "Date the contract's validity ends", + }, ]; const MORE_PLACEHOLDER_GROUPS: { label: string; items: PlaceholderDef[] }[] = [ @@ -170,6 +186,42 @@ const MORE_PLACEHOLDER_GROUPS: { label: string; items: PlaceholderDef[] }[] = [ icon: Package, hint: "Description of the cargo", }, + { + token: "{{schedule.cargoTypeName}}", + label: "Cargo type", + icon: Package, + hint: "Named commodity on its own, e.g. Coffee", + }, + { + token: "{{schedule.containerType}}", + label: "Container type", + icon: Container, + hint: "Container size, e.g. 20ft / 40ft — dash for bulk", + }, + { + token: "{{schedule.cargoSummary}}", + label: "Cargo summary", + icon: Boxes, + hint: "Every cargo line, e.g. Coffee (40ft) × 12", + }, + { + token: "{{schedule.tradeDirection}}", + label: "Trade direction", + icon: ArrowLeftRight, + hint: "IMPORT / EXPORT / DOMESTIC", + }, + { + token: "{{schedule.freightType}}", + label: "Freight type", + icon: Boxes, + hint: "CONTAINER or BULK", + }, + { + token: "{{schedule.hazardousLabel}}", + label: "Hazardous", + icon: AlertTriangle, + hint: "Declared hazard class + UN number, or No", + }, { token: "{{schedule.totalWeightVgm}}", label: "Total weight", @@ -237,6 +289,22 @@ const ALL_PLACEHOLDERS: PlaceholderDef[] = [ ...MORE_PLACEHOLDER_GROUPS.flatMap((g) => g.items), ]; +/** + * Deliberately narrow toolbar: the stored body carries STRUCTURE only (clause + * depth + bullets), which is what the contract renderer numbers and lays out. + * Bold/colour/font would be dropped on save, so they are not offered — + * an author never loses formatting they were allowed to apply. + */ +const QUILL_MODULES = { + toolbar: [ + [{ list: "ordered" }, { list: "bullet" }], + [{ indent: "-1" }, { indent: "+1" }], + ["clean"], + ], +}; + +const QUILL_FORMATS = ["list", "indent"]; + const KNOWN_TOKENS = new Set([ ...ALL_PLACEHOLDERS.map((p) => p.token), // Still filled by the renderer, just no longer offered as an insert button. @@ -282,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 @@ -311,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: [], }); @@ -326,31 +437,6 @@ function parseArticleBody(body: string): ParsedBody { return { clauses }; } -/** - * Rewrite the leading outline tokens in a body so every numbered clause line - * carries its computed sequential number (stale numbers self-heal). Lines - * without a number token and bullet lines pass through untouched. - */ -function renumberBody(body: string): string { - const counters: number[] = []; - return body - .split("\n") - .map((raw) => { - const line = raw.trim(); - if (!line || line.startsWith("- ")) return raw; - const match = CLAUSE_NUMBER_RE.exec(line); - let depth = matchDepth(match) ?? 1; - depth = Math.min(depth, counters.length + 1); - counters.splice(depth); - while (counters.length < depth) counters.push(0); - counters[depth - 1] += 1; - if (!match) return raw; - const number = counters.slice(0, depth).join("."); - return `${number}. ${line.slice(match[0].length).trim()}`; - }) - .join("\n"); -} - /** Render clause text with {{placeholders}} highlighted as green chips. */ function HighlightedText({ text }: { text: string }) { const parts = text.split(/(\{\{[^{}]+\}\})/g); @@ -674,88 +760,46 @@ function ArticleEditorModal({ }: ArticleEditorModalProps) { const [title, setTitle] = useState(initial.title); const [body, setBody] = useState(initial.body); + // Quill is uncontrolled-ish: it owns its own DOM, so seed it once from the + // stored body and let onChange convert edits back rather than re-deriving + // HTML from `body` on every keystroke (which would fight the caret). + const [html, setHtml] = useState(() => bodyToHtml(initial.body)); const titleRef = useRef(null); - const bodyRef = useRef(null); + const quillRef = useRef(null); // Placeholders drop into whichever field held the cursor last (body default). const lastFocused = useRef<"title" | "body">("body"); - const insertAtCursor = (snippet: string) => { - const isTitle = lastFocused.current === "title"; - const el = isTitle ? titleRef.current : bodyRef.current; - const value = isTitle ? title : body; - const start = el?.selectionStart ?? value.length; - const end = el?.selectionEnd ?? start; - const next = value.slice(0, start) + snippet + value.slice(end); - if (isTitle) setTitle(next); - else setBody(next); - // Refocus and place the caret right after the inserted snippet once the - // controlled re-render has flushed. - requestAnimationFrame(() => { - if (!el) return; - el.focus(); - const caret = start + snippet.length; - el.setSelectionRange(caret, caret); - }); + /** Body is the source of truth for saving/preview; HTML is the editor view. */ + const applyHtml = (nextHtml: string) => { + setHtml(nextHtml); + setBody(htmlToBody(nextHtml)); }; - /** - * Insert a structured line (clause / sub-clause / bullet) on a fresh line - * below the one the caret is on. Clause lines get their outline number typed - * in automatically ("3. ", "3.1. ", …) and every numbered line in the body is - * renumbered so the text always matches the preview. - */ - const insertStructuredLine = (kind: "clause" | "sub" | "bullet") => { - const el = bodyRef.current; - lastFocused.current = "body"; - const caret = el?.selectionStart ?? body.length; - // Structured lines never split a sentence — insert after the caret's line. - const lineEnd = body.indexOf("\n", caret); - const insertAt = lineEnd === -1 ? body.length : lineEnd; - const before = body.slice(0, insertAt); - const after = body.slice(insertAt); // "" or starts with "\n" - - let prefix: string; - if (kind === "bullet") { - prefix = "- "; - } else { - // New clause always starts a fresh top-level number. Sub-clause nests - // one level under a clause (1 → 1.1) but adds a SIBLING when the caret - // is already on a sub-clause (1.1 → 1.2 → 1.3, not ever-deeper) — a - // third level is reached by typing its number (e.g. "1.1.1 ") directly. - const above = parseArticleBody(before); - const lastDepth = above.paragraph - ? 1 - : (above.clauses[above.clauses.length - 1]?.depth ?? 0); - const depth = - kind === "sub" - ? lastDepth <= 1 - ? Math.min(lastDepth + 1, MAX_CLAUSE_DEPTH) - : lastDepth - : 1; - // Digits are placeholders — renumberBody assigns the real value. - prefix = `${Array.from({ length: depth }, () => "1").join(".")}. `; + const insertAtCursor = (snippet: string) => { + if (lastFocused.current === "title") { + const el = titleRef.current; + const start = el?.selectionStart ?? title.length; + const end = el?.selectionEnd ?? start; + setTitle(title.slice(0, start) + snippet + title.slice(end)); + requestAnimationFrame(() => { + if (!el) return; + el.focus(); + const caret = start + snippet.length; + el.setSelectionRange(caret, caret); + }); + return; } - - const beforeLines = before.length > 0 ? before.split("\n") : []; - const afterLines = - after.length > 0 ? after.slice(1).split("\n") : []; - const insertedIdx = beforeLines.length; - const joined = [...beforeLines, prefix, ...afterLines].join("\n"); - const next = kind === "bullet" ? joined : renumberBody(joined); - setBody(next); - - // Caret lands at the end of the inserted line, ready for typing. - const caretTarget = next - .split("\n") - .slice(0, insertedIdx + 1) - .join("\n").length; - requestAnimationFrame(() => { - const field = bodyRef.current; - if (!field) return; - field.focus(); - field.setSelectionRange(caretTarget, caretTarget); - }); + // Quill tracks its own selection; insert there so the token lands where the + // author was typing instead of at the end of the document. + const editor = quillRef.current?.getEditor(); + if (!editor) return; + const range = editor.getSelection(true); + const at = range?.index ?? editor.getLength(); + editor.deleteText(at, range?.length ?? 0); + editor.insertText(at, snippet, "user"); + editor.setSelection(at + snippet.length, 0); + applyHtml(editor.root.innerHTML); }; const parsed = useMemo(() => parseArticleBody(body), [body]); @@ -846,78 +890,24 @@ function ArticleEditorModal({ - Add structure + Article body - - - - - - - - - - - + + {BODY_HINT} + + (lastFocused.current = "body")}> + + -