diff --git a/.gitignore b/.gitignore index b9dc16c3c..18fdae9c2 100644 --- a/.gitignore +++ b/.gitignore @@ -49,3 +49,6 @@ test-results/ playwright-report/ blob-report/ RUNNING_LOCALLY.md + +# Generated per-shard compose file for the integration suite (it.mjs). +integration/.it-shards.yaml diff --git a/apps/edr-freight-api/.env.example b/apps/edr-freight-api/.env.example index 9338a958f..c579eb388 100644 --- a/apps/edr-freight-api/.env.example +++ b/apps/edr-freight-api/.env.example @@ -98,10 +98,10 @@ FAYDA_PRIVATE_KEY_BASE64= # OAuth redirect_uri for MOBILE clients (must be registered with eSignet) FAYDA_REDIRECT_URI=http://localhost:3001/api/fayda/verification/complete # OAuth redirect_uri for WEB clients. Defaults to FAYDA_REDIRECT_URI when unset. -FAYDA_WEB_REDIRECT_URI=http://localhost:3000/callback +FAYDA_WEB_REDIRECT_URI=http://localhost:3000/fayda/callback # OAuth redirect_uri for the customer portal (its own origin — must also be # registered with eSignet). Defaults to FAYDA_WEB_REDIRECT_URI when unset. -FAYDA_PORTAL_REDIRECT_URI=http://localhost:5173/callback +FAYDA_PORTAL_REDIRECT_URI=http://localhost:5173/fayda/callback CLIENT_ASSERTION_TYPE=urn:ietf:params:oauth:client-assertion-type:jwt-bearer FAYDA_SCOPE=openid profile email phone address FAYDA_ACR_VALUES=mosip:idp:acr:generated-code diff --git a/apps/edr-freight-api/docs/FREIGHT_MASTER_FLOW.md b/apps/edr-freight-api/docs/FREIGHT_MASTER_FLOW.md index ea68e6938..98657843c 100644 --- a/apps/edr-freight-api/docs/FREIGHT_MASTER_FLOW.md +++ b/apps/edr-freight-api/docs/FREIGHT_MASTER_FLOW.md @@ -21,7 +21,7 @@ flowchart TD S0(["Customer visits portal"]):::start S0 --> S1["Signup via IAM
GET /auth/check-availability @Public
POST /otp/send + /otp/verify (P)"]:::port S1 --> S2{"Identity proofing
(VeriFayda)?"}:::dec - S2 -->|"Yes"| S3["POST /fayda/verification/start →
/callback → /complete
upsert iam.users (verified_by=fayda) (P)"]:::port + S2 -->|"Yes"| S3["POST /fayda/verification/start →
/fayda/callback → /complete
upsert iam.users (verified_by=fayda) (P)"]:::port S2 -->|"No"| S4 S3 --> S4["POST /companies/onboarding/start
draft company (placeholder TIN, PENDING) (P)"]:::port S4 --> S4b["Wizard: PATCH /profile, /onboarding-step,
upload license + docs
GET /onboarding/requirements (P)"]:::port diff --git a/apps/edr-freight-api/docs/FREIGHT_SYSTEM_FLOW.md b/apps/edr-freight-api/docs/FREIGHT_SYSTEM_FLOW.md index a6fd8bbeb..b29763060 100644 --- a/apps/edr-freight-api/docs/FREIGHT_SYSTEM_FLOW.md +++ b/apps/edr-freight-api/docs/FREIGHT_SYSTEM_FLOW.md @@ -131,7 +131,7 @@ sequenceDiagram `HasActiveDelegationGuard` as **global `APP_GUARD`s** — *every* route is JWT-protected unless it carries `@Public()`. Fine-grained `FreightPermissionGuard([perm])` decorators add permission checks on staff routes. Explicitly **public** endpoints: `GET /api/files/:fileId`, `POST /api/otp/{send,verify}`, -`GET /api/auth/check-availability`, the `fayda/verification/*` + `/callback` endpoints, +`GET /api/auth/check-availability`, the `fayda/verification/*` + `/fayda/callback` endpoints, `GET /api/payments/{checkout,receipt/:orderId}`, and the service-to-service `POST /api/internal/payments/mark-paid`. Real login / JWT issuance lives in the **external IAM package**, not this repo. (Note: `@edr/api-common`'s `@Public` and `@tria-plc/api-common`'s `@IsPublic` both set the same `"isPublic"` metadata key the guard reads.) @@ -288,7 +288,7 @@ flowchart TD chk --> otp["POST /otp/send + /otp/verify (P) @Public"] otp --> fayda{"Identity proofing?"} fayda -->|"VeriFayda 2.0"| fstart["POST /fayda/verification/start
→ eSignet authorize URL"] - fstart --> fcb["Fayda redirect → GET /callback (ack)
→ GET /fayda/verification/complete
(PKCE code exchange → upsert iam.users)"] + fstart --> fcb["Fayda redirect → GET /fayda/callback (ack)
→ GET /fayda/verification/complete
(PKCE code exchange → upsert iam.users)"] fcb --> onb fayda -->|"skip"| onb @@ -313,7 +313,7 @@ drives the required document set. Booking guards elsewhere `403` if the acting p | POST | `/api/fayda/verification/start` | start eSignet session (PKCE) | `@Public` + OptionalJwt | (B) verifayda.service | | GET | `/api/fayda/verification/complete` | code→identity, upsert `iam.users` | `@Public` | (B) verifayda.service | | GET | `/api/fayda/verification/status` | current user's Fayda link | JwtGuard | — | -| GET | `/callback` | passive Fayda redirect ack (no `/api`) | `@Public` | popup postMessage | +| GET | `/fayda/callback` | passive Fayda redirect ack (no `/api`) | `@Public` | popup postMessage | | GET·PUT | `/api/me/signature` | reusable signature (MinIO, base64) | JwtGuard | (P)(B) signatures.service | | GET | `/api/test_user1` · `/api/test_user2` | permission-guard demo | `PermissionGuard` | (B) demo pages | | GET | `/api/companies/getInfo` · `/profile` · `/dashboard` | company info / KPIs | JwtGuard | (P) companies.service | diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index 0e7949d7e..73e19709e 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -46,6 +46,7 @@ import { NotificationInboxModule } from "./modules/notification-inbox/notificati import { SupportChatModule } from "./modules/support-chat/support-chat.module"; import { FileUploadSettingsModule } from "./modules/file-upload-settings/file-upload-settings.module"; import { DropdownSettingsModule } from "./modules/dropdown-settings/dropdown-settings.module"; +import { ExchangeSettingsModule } from "./modules/exchange-settings/exchange-settings.module"; import { ContractTemplatesModule } from "./modules/contract-templates/contract-templates.module"; import { OtpModule } from "./modules/otp/otp.module"; import { HealthModule } from "./modules/health/health.module"; @@ -192,6 +193,7 @@ import { LoginAudienceMiddleware } from "./modules/auth/login-audience.middlewar SupportChatModule, FileUploadSettingsModule, DropdownSettingsModule, + ExchangeSettingsModule, ContractTemplatesModule, OtpModule, HealthModule, diff --git a/apps/edr-freight-api/src/config/app.config.ts b/apps/edr-freight-api/src/config/app.config.ts index 050b07145..8e03ac3f0 100644 --- a/apps/edr-freight-api/src/config/app.config.ts +++ b/apps/edr-freight-api/src/config/app.config.ts @@ -24,12 +24,13 @@ export default registerAs("app", () => ({ }, // Consumed by @edr/api-common ExchangeModule.forRootAsync (see bookings.module.ts). cbeExchange: { - /** ethio.forex CBET page — scraped for USD buying/selling rates. */ + /** CBE daily-exchange-rates JSON — USD `transactionalSelling` is used. */ scrapeUrl: process.env.CBE_EXCHANGE_SCRAPE_URL ?? process.env.CBE_EXCHANGE_API_URL ?? - "https://ethio.forex/bank/CBET", - fallbackRate: numberFromEnv("CBE_EXCHANGE_FALLBACK_RATE", 130), + "https://combanketh.et/cbeapi/daily-exchange-rates/?_limit=1&_sort=Date%3ADESC", + // No fallback env var: the fallback lives in freight.exchange_settings, + // maintained by the backoffice and by write-back on every successful fetch. cacheTtlMs: numberFromEnv("CBE_EXCHANGE_CACHE_TTL_MS", 3_600_000), }, })); 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/main.ts b/apps/edr-freight-api/src/main.ts index f13c596d2..a4fbefdd2 100644 --- a/apps/edr-freight-api/src/main.ts +++ b/apps/edr-freight-api/src/main.ts @@ -19,7 +19,7 @@ import { AppModule } from "./app.module"; * ~13.4MB on the wire. Express defaults to 100kb, which rejected any real stamp * image with a 413 "request entity too large". */ -const JSON_BODY_LIMIT = '20mb'; +const JSON_BODY_LIMIT = "20mb"; /** * Static /etc/hosts-style overrides from `DNS_HOST_OVERRIDES`, formatted as @@ -44,7 +44,9 @@ function applyDnsHostOverrides(): void { } if (overrides.size === 0) return; - const dns = createRequire(__filename)("node:dns") as typeof import("node:dns"); + const dns = createRequire(__filename)( + "node:dns", + ) as typeof import("node:dns"); const originalLookup = dns.lookup.bind(dns); // `dns.lookup` is overloaded (options optional, all/family variants); the // cast keeps that surface intact while we intercept only mapped hostnames. @@ -63,7 +65,9 @@ function applyDnsHostOverrides(): void { ) => void; const family = ip.includes(":") ? 6 : 4; const wantsAll = - typeof options === "object" && options !== null && (options as { all?: boolean }).all; + typeof options === "object" && + options !== null && + (options as { all?: boolean }).all; process.nextTick(() => wantsAll ? done(null, [{ address: ip, family }]) : done(null, ip, family), @@ -77,7 +81,15 @@ function applyDnsHostOverrides(): void { applyDnsHostOverrides(); -async function bootstrap() { +/** + * Build the app with every global the production process applies, but do NOT + * listen. Exported so a test harness can boot the REAL app in its own process + * (integration/src/app.ts) and get the same prefix, pipe, filter, interceptor + * and body-parser configuration — replaying this list by hand is how an e2e + * harness silently drifts from production (routes 404 without the "api" + * prefix, responses lose the transform envelope). + */ +export async function createFreightApp(): Promise { const app = await NestFactory.create(AppModule); // Nest's own body-parser API, NOT `app.use(json(...))` from express: express @@ -86,14 +98,14 @@ async function bootstrap() { // pnpm's hoisted dev store and died as MODULE_NOT_FOUND in the production // image, where `pnpm deploy --prod` installs declared dependencies only. // This also RECONFIGURES the default parsers rather than racing them. - app.useBodyParser('json', { limit: JSON_BODY_LIMIT }); - app.useBodyParser('urlencoded', { limit: JSON_BODY_LIMIT, extended: true }); + app.useBodyParser("json", { limit: JSON_BODY_LIMIT }); + app.useBodyParser("urlencoded", { limit: JSON_BODY_LIMIT, extended: true }); // Dev CORS: reflect any localhost origin and allow credentials so the // freight portal (5173), passenger portal (5174), backoffices (5183/5184) // and any other dev port can call the API with cookies + Authorization. // For production, restrict `origin` to known FQDNs. - + app.enableCors({ origin: true, // reflect request origin credentials: true, @@ -130,8 +142,11 @@ async function bootstrap() { maxAge: 86400, // cache preflight for 24h to cut chatter in dev }); - // /callback stays un-prefixed: it's the Fayda OAuth redirect_uri ack endpoint. - app.setGlobalPrefix("api", { exclude: ["callback"] }); + // /fayda/callback stays un-prefixed: it's the Fayda OAuth redirect_uri ack + // endpoint. Exact path, not "fayda" — exclusion is an exact route match, so + // "fayda" would leave /fayda/callback prefixed (404 at the registered + // redirect_uri) while still reading as if it covered the whole subtree. + app.setGlobalPrefix("api", { exclude: ["fayda/callback"] }); // enableImplicitConversion is OFF: class-transformer's implicit boolean // coercion turns any non-empty multipart/form-data string (including the // literal "false") into `true`, silently corrupting flags like isHazardous @@ -154,13 +169,21 @@ async function bootstrap() { const document = SwaggerModule.createDocument(app, config); SwaggerModule.setup("api/docs", app, document); + return app; +} + +async function bootstrap() { + const app = await createFreightApp(); const port = parseInt(process.env.PORT ?? "3001", 10); // await app.listen(port, "0.0.0.0"); - await app.listen( - - port) + await app.listen(port); // eslint-disable-next-line no-console console.log(`[freight-api] listening on port ${port}`); } -bootstrap(); +// Only self-start when this file IS the entrypoint. The Dockerfile's +// `CMD ["node", "dist/main.js"]` still boots; importers get `createFreightApp` +// without the process binding a port behind their back. +if (require.main === module) { + bootstrap(); +} diff --git a/apps/edr-freight-api/src/migrations/3240000000000-CreateExchangeSettings.ts b/apps/edr-freight-api/src/migrations/3240000000000-CreateExchangeSettings.ts new file mode 100644 index 000000000..68ad4f49b --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3240000000000-CreateExchangeSettings.ts @@ -0,0 +1,45 @@ +import { MigrationInterface, QueryRunner, Table } from 'typeorm'; + +/** + * Single-row store for the USD→ETB fallback used when the CBE exchange-rate + * endpoint is unreachable. The live CBE rate always wins; every successful + * fetch overwrites this row, so it holds the last known good rate rather than + * a constant that drifts. Operators can also set it by hand during an outage. + * + * Seeded with the CBE USD transactional selling rate on 2026-08-04, so the + * fallback is usable before the first successful fetch. + */ +export class CreateExchangeSettings3240000000000 implements MigrationInterface { + name = 'CreateExchangeSettings3240000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.createTable( + new Table({ + schema: 'freight', + name: 'exchange_settings', + columns: [ + { name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'gen_random_uuid()' }, + { name: 'fallback_rate', type: 'numeric', precision: 18, scale: 6 }, + // AUTO when written by the CBE sync, MANUAL when set in the backoffice. + { name: 'fallback_source', type: 'varchar', length: '16', default: "'AUTO'" }, + { name: 'last_synced_at', type: 'timestamptz', isNullable: true }, + // IAM user id (iam.users) — no FK, iam schema is externally owned. + { name: 'updated_by_id', type: 'uuid', isNullable: true }, + { name: 'created_at', type: 'timestamptz', default: 'now()' }, + { name: 'updated_at', type: 'timestamptz', default: 'now()' }, + { name: 'deleted_at', type: 'timestamptz', isNullable: true }, + ], + }), + true, + ); + + await queryRunner.query(` + INSERT INTO freight.exchange_settings (fallback_rate, fallback_source) + VALUES (162.416500, 'AUTO') + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.dropTable('freight.exchange_settings', true); + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts index 2063bb01d..84354d860 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts @@ -1,8 +1,8 @@ import { Module, forwardRef } from "@nestjs/common"; import { UserTradeAccessModule } from "../user-trade-access/user-trade-access.module"; -import { ConfigService } from "@nestjs/config"; import { TypeOrmModule } from "@nestjs/typeorm"; -import { ExchangeModule, ExchangeOptions } from "@edr/api-common"; + +import { registerExchangeModule } from "../exchange-settings/exchange-module-options"; // import { CustomersModule } from '../customers/customers.module'; import { CompaniesModule } from '../companies/companies.module'; @@ -86,11 +86,7 @@ import { VehiclesModule } from "../vehicles/vehicles.module"; RuleEngineModule, FileUploadSettingsModule, SignaturesModule, - ExchangeModule.forRootAsync({ - inject: [ConfigService], - useFactory: (config: ConfigService): ExchangeOptions => - config.get("app.cbeExchange") ?? {}, - }), + registerExchangeModule(), ], controllers: [BookingsController], providers: [ diff --git a/apps/edr-freight-api/src/modules/companies/companies.controller.ts b/apps/edr-freight-api/src/modules/companies/companies.controller.ts index 7665d82eb..1c9d75487 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.controller.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.controller.ts @@ -408,6 +408,30 @@ export class CompaniesController { return this.companiesService.completeIdentityVerification(user.id, dto); } + @Post("identity/gm/same-as-owner") + @ApiOperation({ + summary: + "Declare the General Manager is the company's owner, copying the owner's verified identity across. " + + "Refused until the owner is Fayda-verified — there would be nothing proven to copy.", + }) + async setGmSameAsOwner( + @CurrentUser() user: CurrentIamUser, + ): Promise { + return this.companiesService.setGmSameAsOwner(user.id); + } + + @Delete("identity/gm") + @ApiOperation({ + summary: + "Clear the General Manager's identity — the \"same as owner\" declaration or a verification, and the details either wrote. " + + "Leaves the GM open to be verified in their own right, or typed where Fayda is optional.", + }) + async clearGmIdentity( + @CurrentUser() user: CurrentIamUser, + ): Promise { + return this.companiesService.clearGmIdentity(user.id); + } + @Delete("identity/fayda/poa") @ApiOperation({ summary: diff --git a/apps/edr-freight-api/src/modules/companies/companies.fayda-identity.spec.ts b/apps/edr-freight-api/src/modules/companies/companies.fayda-identity.spec.ts index 511c3aa25..32513df4f 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.fayda-identity.spec.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.fayda-identity.spec.ts @@ -21,9 +21,11 @@ import { POA_DELEGATION_FILE_KEY } from "../file-upload-settings/poa-delegation. * is named, both nationalities must verify them, and their details come from * the verified payload rather than the form. * - * The owner is NOT the general manager — GM is a separate, plain typed role - * the portal offers a "same as owner" copy for, but it is never itself - * Fayda-verified or gated on. + * The owner is NOT the general manager. The GM is proved the same way, by one + * of two routes — verifying in their own right, or being declared the owner, + * which reuses that verification rather than making one human prove themselves + * twice. It stays out of the trading gate either way: the GM names who to talk + * to, not what the company may do. */ interface Ctx { @@ -418,10 +420,12 @@ describe("Ethiopian companies verify with Fayda; foreign companies verify identi ).resolves.toBeDefined(); }); - it("still requires a Fayda-verified PoA from a foreign company", async () => { - // The owner's credential is nationality-specific; the representative's is - // not. A PoA acts for the company inside Ethiopia whoever owns it, so a - // typed foreign name is not a representative the platform can accept. + it("accepts a typed PoA from a foreign company, whose representative may hold no Fayda ID", async () => { + // Fayda is an Ethiopian national ID, so only an Ethiopian company's + // representative can be held to it. A foreign company is offered the + // verification and uses it where its representative holds one, but a typed + // name stays sufficient — holding it to Fayda would leave a foreign + // company whose representative has no Fayda ID unable to trade at all. const { service } = makeService({ profileTypes: [ProfileType.importer], nationality: CompanyNationality.Foreign, @@ -434,6 +438,48 @@ describe("Ethiopian companies verify with Fayda; foreign companies verify identi files: [paper()], }); + await expect( + service.createCompanyProfileForUser( + "user-1", + ProfileType.freightForwarder, + ), + ).resolves.toBeDefined(); + }); + + it("still refuses a foreign company that named no PoA at all", async () => { + // The typed fallback is a different credential, not a waiver: a freight + // forwarder acts on other companies' behalf and needs a representative + // whatever its nationality. + const { service } = makeService({ + profileTypes: [ProfileType.importer], + nationality: CompanyNationality.Foreign, + attributes: { ownerPassportNumber: "P1234567" }, + files: [paper()], + }); + + await expect( + service.createCompanyProfileForUser( + "user-1", + ProfileType.freightForwarder, + ), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it("holds an Ethiopian company to a Fayda-verified PoA, typed details notwithstanding", async () => { + // The relaxation above is scoped to foreign companies only — an Ethiopian + // representative holds a Fayda ID, so typing a name must not substitute. + const { service } = makeService({ + profileTypes: [ProfileType.importer], + nationality: CompanyNationality.Ethiopian, + attributes: { + ...OWNER_VERIFIED, + poaName: "Abebe Bekele", + poaEmail: "abebe@example.com", + poaPhone: "+251911000000", + }, + files: [paper()], + }); + await expect( service.createCompanyProfileForUser( "user-1", @@ -463,4 +509,119 @@ describe("Ethiopian companies verify with Fayda; foreign companies verify identi ), ).rejects.toBeInstanceOf(BadRequestException); }); + + // ------------------------------------------------------------------------- + // General manager + // ------------------------------------------------------------------------- + + it("reuses the owner's verified identity when the GM is declared the same person", async () => { + // The GM is very often the owner. Copying the proven identity is the whole + // point — asking one human to complete two verifications proves nothing + // extra, and typing the details instead would forge a verified badge. + const { service, ctx } = makeService({ + attributes: { + ...OWNER_VERIFIED, + ownerEmail: "abebe@example.com", + ownerPhone: "+251911222333", + }, + }); + + const state = await service.setGmSameAsOwner("user-1"); + + expect(state.gm.verified).toBe(true); + expect(state.gmSameAsOwner).toBe(true); + expect(state.gm.name).toBe("Abebe Bikila"); + expect(ctx.attributes.gmFaydaSub).toBe("owner-sub"); + // The notifiers mail the flat column, so a linked GM has to land there too. + expect(ctx.attributes.generalManagerEmail).toBe("abebe@example.com"); + }); + + it("refuses to declare the GM is the owner while the owner is unverified", async () => { + // Without a verification there is no proven identity to copy — only typed + // text, which would arrive wearing a badge it had not earned. + const { service } = makeService({ attributes: {} }); + + await expect(service.setGmSameAsOwner("user-1")).rejects.toBeInstanceOf( + BadRequestException, + ); + }); + + it("lets the GM verify as the same human as the owner", async () => { + // The owner/PoA collision check exists because self-delegation is not + // delegation. It must not fire here: the GM being the owner is a supported + // answer, so verifying with the owner's own Fayda sub has to succeed. + const { service, ctx } = makeService({ + attributes: { ...OWNER_VERIFIED }, + verification: { + purpose: "VERIFY", + verified: true, + sub: "owner-sub", + fullName: "Abebe Bikila", + email: "abebe@example.com", + phoneNumber: "+251911222333", + }, + }); + + const state = await service.completeIdentityVerification("user-1", { + subject: "gm", + code: "c", + state: "s", + }); + + expect(state.gm.verified).toBe(true); + expect(ctx.attributes.gmFaydaSub).toBe("owner-sub"); + expect(ctx.attributes.generalManagerName).toBe("Abebe Bikila"); + }); + + it("still refuses a PoA who is the owner", async () => { + // The GM exemption above must not have widened into the PoA. + const { service } = makeService({ + attributes: { ...OWNER_VERIFIED }, + verification: { + purpose: "VERIFY", + verified: true, + sub: "owner-sub", + fullName: "Abebe Bikila", + }, + }); + + await expect( + service.completeIdentityVerification("user-1", { + subject: "poa", + code: "c", + state: "s", + }), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it("reports a pre-existing typed GM as unverified rather than blank", async () => { + // Companies onboarded before the GM was verifiable have typed details and + // no gm* attributes. Those details are still what the notifiers mail, so + // they must survive — flagged unverified so the portal offers the upgrade. + const { service, company } = makeService({ + attributes: { + ...OWNER_VERIFIED, + generalManagerName: "Legacy Manager", + generalManagerEmail: "legacy@example.com", + }, + }); + + const state = service.getCompanyIdentityState(company() as never); + + expect(state.gm.verified).toBe(false); + expect(state.gm.name).toBe("Legacy Manager"); + expect(state.gm.email).toBe("legacy@example.com"); + }); + + it("does not let an unproven GM block the company from trading", async () => { + // The GM names who to talk to, not what the company may do. Capturing it + // through Fayda changed how it is collected, not whether it gates. + const { service } = makeService({ + attributes: { ...OWNER_VERIFIED }, + }); + + await expect( + service.createCompanyProfileForUser("user-1", ProfileType.importer), + ).resolves.toBeDefined(); + }); }); diff --git a/apps/edr-freight-api/src/modules/companies/companies.service.ts b/apps/edr-freight-api/src/modules/companies/companies.service.ts index e0af07e0b..f21263c7f 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.service.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.service.ts @@ -33,6 +33,7 @@ import { buildCompanyIdentityState, CompanyIdentityStateDto, CompleteIdentityVerificationDto, + IDENTITY_SUBJECTS, IdentitySubject, } from "./dto/complete-identity-verification.dto"; import { ETradeService } from "./services/etrade.service"; @@ -122,31 +123,47 @@ const REQUIRED_POA_FIELDS: { key: string; label: string }[] = [ /** * `attributes` key prefix per verifiable person. The owner is NOT the general - * manager — GM is a plain typed role (the portal offers a "same as owner" copy - * once the owner is verified), while the owner is who this verification - * actually proves. They're very often the same human; that's what the copy is - * for. + * manager: the owner is who the verification proves the company through, the + * GM is personnel it names. They're very often the same human, which is what + * the portal's "same as owner" copy is for. */ -const IDENTITY_PREFIX: Record = { +const IDENTITY_PREFIX: Record = { owner: "owner", poa: "poa", + gm: "gm", }; const IDENTITY_LABEL: Record = { owner: "owner", poa: "Power of Attorney", + gm: "General Manager", }; +/** + * Typed GM columns a GM verification also writes. Three notifier services mail + * `company.generalManagerEmail` directly, so leaving these behind would mean a + * verified GM whose address the system never actually uses. + */ +const GM_TYPED_FIELDS = [ + "generalManagerName", + "generalManagerEmail", + "generalManagerPhone", +] as const; + /** * Identity fields a Fayda verification owns outright, per person. Once verified * these can no longer be typed — the government IdP is the source, so an edit * that disagrees with it is either a mistake or an attempt to launder the - * guarantee away. The GM fields are deliberately absent: GM is never itself - * Fayda-verified, so it stays freely editable regardless of the owner's state. + * guarantee away. + * + * The GM's entries are its typed columns: a verified GM is locked the same way + * the others are, while an unverified one (a foreign company's, or a record + * that predates this) stays freely editable. */ const IDENTITY_OWNED_FIELDS: Record = { owner: ["ownerName", "ownerEmail", "ownerPhone", "ownerAddress"], poa: ["poaName", "poaEmail", "poaPhone", "poaAddress"], + gm: [...GM_TYPED_FIELDS], }; /** @@ -834,7 +851,7 @@ export class CompaniesService { // Renaming a Fayda-verified person by hand would launder the guarantee // away, so the fields the verification owns are refused once it exists. - for (const subject of ["owner", "poa"] as IdentitySubject[]) { + for (const subject of IDENTITY_SUBJECTS) { if (!attrUpdates[`${IDENTITY_PREFIX[subject]}FaydaSub`]) continue; for (const field of IDENTITY_OWNED_FIELDS[subject]) { const incoming = (dto as Record)[field]; @@ -2693,12 +2710,17 @@ export class CompaniesService { // The owner delegating power of attorney to themselves is not a // delegation — it would let one identity satisfy both halves of the check. - const other: IdentitySubject = dto.subject === "poa" ? "owner" : "poa"; - const otherSub = company.attributes?.[`${IDENTITY_PREFIX[other]}FaydaSub`]; - if (otherSub && otherSub === result.sub) { - throw new BadRequestException( - `This identity is already registered as the company's ${IDENTITY_LABEL[other]}. The Power of Attorney must be a different person from the owner.`, - ); + // Only owner/PoA collide this way: the GM is very often the owner, and + // saying so is a supported answer rather than a conflict, so it is left out + // of this check entirely. + if (dto.subject === "owner" || dto.subject === "poa") { + const other: IdentitySubject = dto.subject === "poa" ? "owner" : "poa"; + const otherSub = company.attributes?.[`${IDENTITY_PREFIX[other]}FaydaSub`]; + if (otherSub && otherSub === result.sub) { + throw new BadRequestException( + `This identity is already registered as the company's ${IDENTITY_LABEL[other]}. The Power of Attorney must be a different person from the owner.`, + ); + } } const now = new Date().toISOString(); @@ -2714,13 +2736,26 @@ export class CompaniesService { ...(result.address ? { [`${prefix}Address`]: result.address } : {}), }; + // A GM verification also lands on the typed columns the rest of the system + // already reads (the booking, train-scheduling and contract notifiers all + // mail `generalManagerEmail`), and clears any earlier "same as owner" + // declaration — verifying in their own right is the GM answering for + // themselves. + if (dto.subject === "gm") { + identity.gmSameAsOwner = false; + if (result.fullName) identity.generalManagerName = result.fullName; + if (result.email) identity.generalManagerEmail = result.email; + if (result.phoneNumber) + identity.generalManagerPhone = normalizeE164(result.phoneNumber); + } + // An approved company's *owner* is its identity proof, so re-verifying one // is staged for backoffice review rather than quietly rewriting a live - // record. The PoA is personnel — the company names its own representative, - // and the delegation letter backing them is what the reviewer sees — so a - // PoA verification lands live, matching the typed PoA fields in - // `SELF_SERVICE_ATTRIBUTES`. - if (company.status === CompanyStatus.Active && dto.subject !== "poa") { + // record. The PoA and GM are personnel — the company names its own + // representative and manager, and the delegation letter backing the PoA is + // what the reviewer sees — so those land live, matching their typed + // counterparts in `SELF_SERVICE_ATTRIBUTES`. + if (company.status === CompanyStatus.Active && dto.subject === "owner") { await this.stageIdentityChange(company, userId, identity); return this.getCompanyIdentityState(company); } @@ -2734,6 +2769,85 @@ export class CompaniesService { return this.getCompanyIdentityState(updated); } + /** + * Declare that the General Manager is the company's owner. + * + * The GM is very often the owner, and making that human verify twice buys + * nothing — the owner's verification already proves them. So this copies the + * owner's verified identity across rather than starting a second flow, and + * records `gmSameAsOwner` so the portal can show it as a declaration rather + * than as a verification the GM passed in their own right. + * + * Refused until the owner is actually verified: without that there is no + * proven identity to copy, only typed text that would arrive wearing a + * verified badge. + */ + async setGmSameAsOwner(userId: string): Promise { + const { company } = await this.getCompanyInfoByUserId(userId); + const attrs = company.attributes ?? {}; + const ownerSub = attrs.ownerFaydaSub as string | undefined; + if (!ownerSub) { + throw new BadRequestException( + "Verify the company owner with Fayda first — there is no proven identity to reuse yet.", + ); + } + + const copied: Record = { + gmSameAsOwner: true, + gmFaydaSub: ownerSub, + gmFaydaVerifiedAt: attrs.ownerFaydaVerifiedAt ?? new Date().toISOString(), + gmName: attrs.ownerName ?? null, + gmEmail: attrs.ownerEmail ?? null, + gmPhone: attrs.ownerPhone ?? null, + gmAddress: attrs.ownerAddress ?? null, + gmBirthdate: attrs.ownerBirthdate ?? null, + gmGender: attrs.ownerGender ?? null, + // Kept in step for the notifiers, same as a GM verification does. + generalManagerName: attrs.ownerName ?? null, + generalManagerEmail: attrs.ownerEmail ?? null, + generalManagerPhone: attrs.ownerPhone ?? null, + }; + + const updated = await this.companiesRepo.update(company.id, { + attributes: { ...attrs, ...copied }, + }); + if (!updated) + throw new NotFoundException(`Company ${company.id} not found`); + updated.companyProfiles = company.companyProfiles; + return this.getCompanyIdentityState(updated); + } + + /** + * Undo the "same as owner" declaration, clearing the copied identity so the + * GM can be verified in their own right (or typed, where Fayda is optional). + */ + async clearGmIdentity(userId: string): Promise { + const { company } = await this.getCompanyInfoByUserId(userId); + const attrs = { ...(company.attributes ?? {}) }; + for (const key of [ + "gmSameAsOwner", + "gmFaydaSub", + "gmFaydaVerifiedAt", + "gmName", + "gmEmail", + "gmPhone", + "gmAddress", + "gmBirthdate", + "gmGender", + ...GM_TYPED_FIELDS, + ]) { + attrs[key] = null; + } + + const updated = await this.companiesRepo.update(company.id, { + attributes: attrs, + }); + if (!updated) + throw new NotFoundException(`Company ${company.id} not found`); + updated.companyProfiles = company.companyProfiles; + return this.getCompanyIdentityState(updated); + } + /** * Drop the Power of Attorney entirely — the verified identity, the details it * wrote and the delegation paper together. @@ -2864,14 +2978,28 @@ export class CompaniesService { ); } - // The representative is not. A PoA acts for the company inside Ethiopia - // whoever owns it, so they are always an Ethiopian holding a Fayda ID — - // a foreign company nominates one rather than typing a name. const poaNamed = POA_ATTRIBUTES.some((k) => (company.attributes?.[k] as string | undefined)?.trim(), ); if (!opts.requirePoa && !poaNamed) return; + // Fayda is an Ethiopian national ID, so only an Ethiopian company's + // representative can be held to it. A foreign company is offered the + // verification and nominates a Fayda-holding representative where it can, + // but a typed name has to remain sufficient — otherwise a foreign company + // whose representative holds no Fayda ID could never trade at all. Mirrors + // `poaProven` in buildCompanyIdentityState; the two must agree. + if (state.passportRequired) { + if (!state.poa.verified && !state.poa.name?.trim()) { + throw new BadRequestException( + opts.requirePoa + ? "Name your Power of Attorney — a freight forwarder cannot operate without one." + : "Complete the Power of Attorney you named, or remove the representative.", + ); + } + return; + } + if (!state.poa.verified) { throw new BadRequestException( opts.requirePoa diff --git a/apps/edr-freight-api/src/modules/companies/dto/complete-identity-verification.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/complete-identity-verification.dto.ts index a9988cd28..d41e61e8e 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/complete-identity-verification.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/complete-identity-verification.dto.ts @@ -5,13 +5,15 @@ import { Company, CompanyNationality } from "../entities/company.entity"; import { ProfileType } from "../entities/company-profile.entity"; /** - * The two people a company is verified through — its owner and its Power of - * Attorney. "Owner" is not the same as the General Manager: a company's GM is - * a plain typed role (with a "same as owner" copy the portal offers), while - * the owner is the person this verification proves. They're very often the - * same human, which is exactly what the copy is for. + * The three people a company is verified through — its owner, its Power of + * Attorney and its General Manager. The owner is the person the company's + * existence is proven by; the other two are personnel it names. + * + * The GM is very often the owner, which is what the portal's "same as owner" + * copy is for: that path reuses the owner's verified identity outright rather + * than asking the same human to verify twice. */ -export const IDENTITY_SUBJECTS = ["owner", "poa"] as const; +export const IDENTITY_SUBJECTS = ["owner", "poa", "gm"] as const; export type IdentitySubject = (typeof IDENTITY_SUBJECTS)[number]; export class CompleteIdentityVerificationDto { @@ -73,6 +75,19 @@ export class CompanyIdentityStateDto { @ApiProperty({ type: IdentityVerificationStateDto }) poa!: IdentityVerificationStateDto; + @ApiProperty({ + type: IdentityVerificationStateDto, + description: + "General manager. `verified` is true both when the GM verified with Fayda in their own right and when the company declared the GM is the owner — in the latter case the owner's Fayda sub backs it.", + }) + gm!: IdentityVerificationStateDto; + + @ApiProperty({ + description: + "True when the GM's identity is the owner's, declared through the portal's \"same as owner\" copy rather than a separate verification.", + }) + gmSameAsOwner!: boolean; + @ApiProperty({ description: "False while a mandatory requirement (Fayda for Ethiopian, passport for foreign) is still outstanding.", @@ -81,11 +96,28 @@ export class CompanyIdentityStateDto { } /** `attributes` key prefix per person. */ -const PREFIX: Record = { +const PREFIX: Record = { owner: "owner", poa: "poa", + gm: "gm", }; +/** + * Typed GM fields, kept in step with the Fayda-written ones. + * + * The GM predates this verification: its details are plain company columns + * that three notifier services mail (booking-lifecycle, train-scheduling and + * contract notifiers all read `company.generalManagerEmail`). A verification + * therefore writes BOTH — the `gm*` attributes carry the proof, these carry + * the value everything else already reads — and an unverified company keeps + * showing whatever was typed before this existed. + */ +const GM_TYPED_KEYS = { + name: "generalManagerName", + email: "generalManagerEmail", + phone: "generalManagerPhone", +} as const; + /** company.attributes keys that together mean "a PoA was entered". */ const POA_KEYS = [ "poaName", @@ -101,7 +133,7 @@ function stateFor( ): IdentityVerificationStateDto { const p = PREFIX[subject]; const read = (key: string) => (attrs[key] as string | undefined) ?? null; - return { + const state: IdentityVerificationStateDto = { verified: Boolean(read(`${p}FaydaSub`)), name: read(`${p}Name`), phone: read(`${p}Phone`), @@ -111,6 +143,18 @@ function stateFor( birthdate: read(`${p}Birthdate`), gender: read(`${p}Gender`), }; + if (subject !== "gm") return state; + + // Companies onboarded before the GM was verifiable have typed details and no + // `gm*` attributes at all. Report those rather than a blank card — they are + // still what the notifiers mail — leaving `verified` false so the portal + // offers the upgrade instead of pretending the identity is proven. + return { + ...state, + name: state.name ?? read(GM_TYPED_KEYS.name), + email: state.email ?? read(GM_TYPED_KEYS.email), + phone: state.phone ?? read(GM_TYPED_KEYS.phone), + }; } /** @@ -144,14 +188,34 @@ export function buildCompanyIdentityState( (p) => p.type === ProfileType.freightForwarder, ) || POA_KEYS.some((k) => (attrs[k] as string | undefined)?.trim()); - // Only the *owner's* credential is nationality-specific. A Power of Attorney - // acts for the company inside Ethiopia whoever owns it, so the PoA is always - // proven with Fayda — a foreign company nominates a representative who holds - // one rather than typing a name nothing backs. + const gm = stateFor(attrs, "gm"); + const gmSameAsOwner = Boolean(attrs.gmSameAsOwner); + const ownerProven = faydaRequired ? owner.verified : !passportRequired || Boolean(owner.passportNumber); - const complete = ownerProven && (!poaDue || poa.verified); - return { faydaRequired, passportRequired, owner, poa, complete }; + // Fayda is an Ethiopian national ID, so only an Ethiopian company's + // personnel can be held to it. A foreign company may nominate a + // representative who holds one — and is offered the verification — but a + // typed name has to remain sufficient, or a foreign company whose PoA has no + // Fayda ID could never finish onboarding. + const poaProven = faydaRequired + ? poa.verified + : poa.verified || Boolean(poa.name?.trim()); + + // The GM is deliberately absent from this verdict: it names who to talk to, + // not what the company may do, and it has never gated trading. Capturing it + // through Fayda changes how it is collected, not whether it is required. + const complete = ownerProven && (!poaDue || poaProven); + + return { + faydaRequired, + passportRequired, + owner, + poa, + gm, + gmSameAsOwner, + complete, + }; } 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/contracts/contracts.module.ts b/apps/edr-freight-api/src/modules/contracts/contracts.module.ts index 0e60462a4..2a07b02e5 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.module.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.module.ts @@ -1,9 +1,8 @@ import { Module, forwardRef } from '@nestjs/common'; import { UserTradeAccessModule } from '../user-trade-access/user-trade-access.module'; -import { ConfigService } from '@nestjs/config'; import { TypeOrmModule } from '@nestjs/typeorm'; -import { ExchangeModule, ExchangeOptions } from '@edr/api-common'; +import { registerExchangeModule } from '../exchange-settings/exchange-module-options'; import { BillingModule } from '../billing/billing.module'; import { CompaniesModule } from '../companies/companies.module'; import { FilesModule } from '../files/files.module'; @@ -102,11 +101,7 @@ import { ContractDocumentViewModelBuilder } from '../../contracts/contract-docum // by ContractBookingService.createUnderContract. forwardRef because // TrainSchedulingModule already imports ContractsModule. forwardRef(() => TrainSchedulingModule), - ExchangeModule.forRootAsync({ - inject: [ConfigService], - useFactory: (config: ConfigService): ExchangeOptions => - config.get('app.cbeExchange') ?? {}, - }), + registerExchangeModule(), ], controllers: [ContractsController, GlExchangeController], providers: [ diff --git a/apps/edr-freight-api/src/modules/exchange-settings/dto/update-exchange-setting.dto.ts b/apps/edr-freight-api/src/modules/exchange-settings/dto/update-exchange-setting.dto.ts new file mode 100644 index 000000000..98e87007c --- /dev/null +++ b/apps/edr-freight-api/src/modules/exchange-settings/dto/update-exchange-setting.dto.ts @@ -0,0 +1,13 @@ +import { IsNumber, Max, Min } from "class-validator"; + +/** + * Operator-set USD→ETB fallback. Bounded well outside any plausible published + * rate but far short of a fat-fingered magnitude error — this value multiplies + * real invoice amounts whenever CBE is unreachable. + */ +export class UpdateExchangeSettingDto { + @IsNumber({ maxDecimalPlaces: 6 }) + @Min(1) + @Max(10_000) + fallbackRate!: number; +} diff --git a/apps/edr-freight-api/src/modules/exchange-settings/entities/exchange-setting.entity.ts b/apps/edr-freight-api/src/modules/exchange-settings/entities/exchange-setting.entity.ts new file mode 100644 index 000000000..1e1f4ad66 --- /dev/null +++ b/apps/edr-freight-api/src/modules/exchange-settings/entities/exchange-setting.entity.ts @@ -0,0 +1,47 @@ +import { BaseEntity } from "@edr/api-common"; +import { Column, Entity } from "typeorm"; + +/** + * Whether the stored fallback rate was written by the automatic sync (after a + * successful CBE fetch) or typed in by an operator in the backoffice. + */ +export type ExchangeFallbackSource = "AUTO" | "MANUAL"; + +/** + * Single-row table holding the USD→ETB fallback used when the CBE endpoint is + * unreachable. The live CBE rate always wins; this is only consulted on + * failure, and is overwritten by every successful fetch so it tracks the last + * known good rate. + */ +@Entity({ schema: "freight", name: "exchange_settings" }) +export class ExchangeSetting extends BaseEntity { + /** USD→ETB rate served while the CBE endpoint is failing. */ + @Column({ + name: "fallback_rate", + type: "numeric", + precision: 18, + scale: 6, + transformer: { + to: (value: number) => value, + from: (value: string | null) => (value === null ? null : Number(value)), + }, + }) + fallbackRate!: number; + + /** `AUTO` when written by the sync, `MANUAL` when set in the backoffice. */ + @Column({ + name: "fallback_source", + type: "varchar", + length: 16, + default: "AUTO", + }) + fallbackSource!: ExchangeFallbackSource; + + /** When the fallback last changed — i.e. the last successful CBE fetch. */ + @Column({ name: "last_synced_at", type: "timestamptz", nullable: true }) + lastSyncedAt?: Date | null; + + /** IAM user id of the last operator to set the rate manually. */ + @Column({ name: "updated_by_id", type: "uuid", nullable: true }) + updatedById?: string | null; +} diff --git a/apps/edr-freight-api/src/modules/exchange-settings/exchange-module-options.ts b/apps/edr-freight-api/src/modules/exchange-settings/exchange-module-options.ts new file mode 100644 index 000000000..fb126f969 --- /dev/null +++ b/apps/edr-freight-api/src/modules/exchange-settings/exchange-module-options.ts @@ -0,0 +1,27 @@ +import { ExchangeModule, ExchangeOptions } from "@edr/api-common"; +import { ConfigService } from "@nestjs/config"; +import { DynamicModule } from "@nestjs/common"; + +import { ExchangeSettingsService } from "./exchange-settings.service"; + +/** + * The app's single `ExchangeModule` registration shape: CBE endpoint config + * from `app.cbeExchange`, with the DB-backed fallback wired in. + * + * `ExchangeModule` is registered per-feature-module (bookings, contracts, + * warehouses), so this keeps the three call sites identical rather than + * letting their options drift apart. + */ +export function registerExchangeModule(): DynamicModule { + return ExchangeModule.forRootAsync({ + inject: [ConfigService, ExchangeSettingsService], + useFactory: ( + config: ConfigService, + settings: ExchangeSettingsService, + ): ExchangeOptions => ({ + ...(config.get("app.cbeExchange") ?? {}), + loadFallbackRate: () => settings.loadFallbackRate(), + saveFallbackRate: (rate: number) => settings.saveFallbackRate(rate), + }), + }); +} 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 new file mode 100644 index 000000000..a6c08a317 --- /dev/null +++ b/apps/edr-freight-api/src/modules/exchange-settings/exchange-settings.controller.ts @@ -0,0 +1,56 @@ +import { Body, Controller, Get, Patch } from "@nestjs/common"; +import { ApiBearerAuth, 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 { FreightAdmin } from "../../common/booking-guards"; +import { UpdateExchangeSettingDto } from "./dto/update-exchange-setting.dto"; +import { ExchangeSettingsService } from "./exchange-settings.service"; + +@ApiTags("exchange-settings") +@ApiBearerAuth() +@Controller("exchange-settings") +export class ExchangeSettingsController { + constructor(private readonly service: ExchangeSettingsService) {} + + @Get() + @FreightAdmin() + @ApiOperation({ + summary: "Current USD→ETB fallback rate and CBE feed health", + }) + async get() { + 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: status, + }; + } + + @Patch() + @FreightAdmin() + @ApiOperation({ + summary: + "Set the USD→ETB fallback by hand (used only while CBE is unreachable)", + }) + async update( + @Body() dto: UpdateExchangeSettingDto, + @CurrentUser() user: TCurrentUser, + ) { + const updated = await this.service.setManualRate( + dto.fallbackRate, + user?.id ?? null, + ); + + return { + fallbackRate: updated.fallbackRate, + fallbackSource: updated.fallbackSource, + lastSyncedAt: updated.lastSyncedAt, + updatedById: updated.updatedById, + }; + } +} diff --git a/apps/edr-freight-api/src/modules/exchange-settings/exchange-settings.module.ts b/apps/edr-freight-api/src/modules/exchange-settings/exchange-settings.module.ts new file mode 100644 index 000000000..df0f08c62 --- /dev/null +++ b/apps/edr-freight-api/src/modules/exchange-settings/exchange-settings.module.ts @@ -0,0 +1,25 @@ +import { Global, Module } from "@nestjs/common"; +import { TypeOrmModule } from "@nestjs/typeorm"; + +import { ExchangeSetting } from "./entities/exchange-setting.entity"; +import { ExchangeSettingsController } from "./exchange-settings.controller"; +import { registerExchangeModule } from "./exchange-module-options"; +import { ExchangeSettingsService } from "./exchange-settings.service"; + +/** + * Global so the several `ExchangeModule.forRootAsync` registrations (bookings, + * contracts, warehouses) can inject {@link ExchangeSettingsService} into their + * options factory without each importing this module. + * + * Also registers its own `ExchangeModule` so `ExchangeSettingsController` can + * report the live CBE feed status (`ExchangeService.getProviderStatus()`) + * alongside the DB-backed fallback rate. + */ +@Global() +@Module({ + imports: [TypeOrmModule.forFeature([ExchangeSetting]), registerExchangeModule()], + controllers: [ExchangeSettingsController], + providers: [ExchangeSettingsService], + exports: [ExchangeSettingsService], +}) +export class ExchangeSettingsModule {} 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 new file mode 100644 index 000000000..e0b670292 --- /dev/null +++ b/apps/edr-freight-api/src/modules/exchange-settings/exchange-settings.service.ts @@ -0,0 +1,141 @@ +import { Injectable, Logger } from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { Repository } from "typeorm"; + +import { ExchangeSetting } from "./entities/exchange-setting.entity"; + +/** + * Rate used before the row exists and before the first successful CBE fetch — + * the CBE USD transactional selling rate on 2026-08-04. + */ +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. + * + * The live CBE rate is always preferred. This value is only read on failure, + * and every successful fetch overwrites it, so it tracks the last known good + * rate rather than drifting into a stale constant. + */ +@Injectable() +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: {} }); + if (existing) return existing; + + return this.repository.save( + this.repository.create({ + fallbackRate: SEED_FALLBACK_RATE, + fallbackSource: "AUTO", + lastSyncedAt: null, + }), + ); + } + + /** + * Reads the stored fallback for the exchange provider. Returns `null` on any + * failure so the provider falls through to its own static default rather + * 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(); + 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) { + 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; + } + } + + /** + * Records a freshly fetched live rate as the new fallback. Marked `AUTO`, + * overwriting a manual entry — a manual rate is a stopgap for while CBE is + * 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, + fallbackSource: "AUTO", + lastSyncedAt: new Date(), + updatedById: null, + }); + this.logger.log(`Exchange fallback synced from CBE: ${rate} ETB/USD`); + } + + /** Operator sets the fallback by hand, e.g. during a prolonged CBE outage. */ + async setManualRate( + rate: number, + updatedById?: string | null, + ): Promise { + const current = await this.get(); + await this.repository.update(current.id, { + fallbackRate: rate, + fallbackSource: "MANUAL", + updatedById: updatedById ?? null, + }); + this.logger.warn( + `Exchange fallback set manually to ${rate} ETB/USD by ${updatedById ?? "unknown user"}`, + ); + return this.get(); + } +} 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/notifications/strategies/notification.sms.strategy.ts b/apps/edr-freight-api/src/modules/notifications/strategies/notification.sms.strategy.ts index cddc67b2d..ac021c322 100644 --- a/apps/edr-freight-api/src/modules/notifications/strategies/notification.sms.strategy.ts +++ b/apps/edr-freight-api/src/modules/notifications/strategies/notification.sms.strategy.ts @@ -1,62 +1,22 @@ import { Injectable, Logger } from "@nestjs/common"; -import { ConfigService } from "@nestjs/config"; -import axios, { isAxiosError } from "axios"; import { NotificationStrategy } from "./notification.strategy"; +import { SmsClientService } from "../sms-client.service"; @Injectable() export class SmsNotificationStrategy implements NotificationStrategy { private readonly logger = new Logger(SmsNotificationStrategy.name); - constructor(private readonly configService: ConfigService) {} + constructor(private readonly smsClient: SmsClientService) {} async send(recipient: string, message: string): Promise { - const url = - this.configService.get("OZIKING_SMS_URL") ?? - "https://notification-dev.license.aafda.gov.et/api/sms-services/ozeking/sms"; - - const appKey = this.configService.get("OZIKING_APP_KEY") ?? ""; - if (!appKey) { - this.logger.warn("OZIKING_APP_KEY is not set — SMS may be rejected by the API"); - } - - this.logger.debug(`Sending SMS to ${recipient} via ${url}`); - - // axios defaults to no timeout — a hanging gateway would block the caller - // (and any transaction it sits in) indefinitely. Always bound the wait. - const timeout = Number(this.configService.get("SMS_TIMEOUT_MS") ?? 8000); - - try { - const response = await axios.post( - url, - { - to: recipient, - sourceId: this.configService.get("OZIKING_SOURCE_ID") ?? "EDR", - sourceName: this.configService.get("OZIKING_SOURCE_NAME") ?? "EDR Freight", - appKey, - text: message, - callbackUrl: "", - }, - { - timeout, - headers: { - accept: "*/*", - "Content-Type": "application/json", - }, - }, - ); - - this.logger.debug(`SMS API response: ${response.status} ${JSON.stringify(response.data)}`); - return true; - } catch (err) { - if (isAxiosError(err)) { - this.logger.error( - `SMS API error: ${err.message} | status=${err.response?.status} | body=${JSON.stringify(err.response?.data)}`, - ); - } else { - this.logger.error(`SMS send failed: ${String(err)}`); - } - throw err; + const { queued } = await this.smsClient.sendSms({ + to: recipient, + message, + }); + if (!queued) { + this.logger.error(`SMS to ${recipient} was not queued to RabbitMQ`); } + return queued; } } diff --git a/apps/edr-freight-api/src/modules/otp/otp.service.ts b/apps/edr-freight-api/src/modules/otp/otp.service.ts index d5688e1c2..a7361fbdd 100644 --- a/apps/edr-freight-api/src/modules/otp/otp.service.ts +++ b/apps/edr-freight-api/src/modules/otp/otp.service.ts @@ -265,10 +265,10 @@ export class OtpService { /** * SMS half of {@link dispatchEmail}; same swallow-and-report contract. Sent - * via NotificationsService's direct-HTTP Ozeking strategy — the same - * transport the notification system uses — rather than the RabbitMQ - * `SMS_SERVICE` queue, so `queued: true` here means the gateway accepted the - * request, not just that a broker took ownership of the message. + * via NotificationsService's `directSend`, which now routes through the + * same RabbitMQ `SMS_SERVICE` queue as every other SMS in freight-api, so + * `queued: true` here means the broker confirmed ownership of the message, + * not that the carrier delivered it. */ private async dispatchSms( phone: string, 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/train-scheduling/booking-window.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts index 6cdef8c05..c60dd14a6 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts @@ -91,7 +91,16 @@ export class BookingWindowService implements OnModuleInit { // 10-second cadence: every transition is derived from persisted timestamps // and applied idempotently, so a finer tick only shrinks the lag between a // deadline passing and the phase actually moving (was a full minute). - @Cron('*/10 * * * * *', { name: 'booking-window-tick', timeZone: BATCH_TIMEZONE }) + // + // Overridable because that lag is the integration suite's pacing floor: every + // window phase, wagon allocation and expiry it waits on lands on this tick, so + // a 10s cadence costs ~5s of pure latency per wait across a few hundred waits. + // The suite runs it at `*/1 * * * * *`. Read at class-definition time, so the + // env var must be set before the module is imported. + @Cron(process.env.BOOKING_WINDOW_TICK_CRON ?? '*/10 * * * * *', { + name: 'booking-window-tick', + timeZone: BATCH_TIMEZONE, + }) async tick(): Promise { if (this.ticking) return; this.ticking = true; diff --git a/apps/edr-freight-api/src/modules/verifayda/fayda-callback.controller.ts b/apps/edr-freight-api/src/modules/verifayda/fayda-callback.controller.ts index 1c5569750..7f19eb313 100644 --- a/apps/edr-freight-api/src/modules/verifayda/fayda-callback.controller.ts +++ b/apps/edr-freight-api/src/modules/verifayda/fayda-callback.controller.ts @@ -6,12 +6,14 @@ import { VerifaydaCallbackDto } from './verifayda.dto'; /** * Plain acknowledgement endpoint for the Fayda redirect_uri when it points at * the API instead of the web app (e.g. MOBILE clients or connectivity checks). - * Registered at /callback (excluded from the global /api prefix in main.ts). + * Registered at /fayda/callback (excluded by exact path from the global /api + * prefix in main.ts — the exclusion must NOT be widened to "fayda", or + * /api/fayda/verification/* loses its prefix too). * It does NOT consume the verification session — the client must still call * GET /api/fayda/verification/complete with the echoed code+state. */ @ApiTags('Fayda Verification') -@Controller('callback') +@Controller('fayda/callback') export class FaydaCallbackController { @Get() @IsPublic() 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/modules/warehouses/warehouses.module.ts b/apps/edr-freight-api/src/modules/warehouses/warehouses.module.ts index 4011bc14f..d91ac2baf 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouses.module.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouses.module.ts @@ -1,8 +1,7 @@ import { Module, forwardRef } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; -import { ExchangeModule, ExchangeOptions } from '@edr/api-common'; import { TypeOrmModule } from '@nestjs/typeorm'; +import { registerExchangeModule } from '../exchange-settings/exchange-module-options'; import { BillingModule } from '../billing/billing.module'; import { DocumentsModule } from '../billing/documents/documents.module'; import { FilesModule } from '../files/files.module'; @@ -78,11 +77,7 @@ import { WarehousesService } from './warehouses.service'; NotificationsModule, NotificationInboxModule, SignaturesModule, - ExchangeModule.forRootAsync({ - inject: [ConfigService], - useFactory: (config: ConfigService): ExchangeOptions => - config.get('app.cbeExchange') ?? {}, - }), + registerExchangeModule(), ], controllers: [ WarehousesController, diff --git a/apps/edr-freight-api/src/scripts/seed-warehouse-demo.ts b/apps/edr-freight-api/src/scripts/seed-warehouse-demo.ts index 24118d882..593abb305 100644 --- a/apps/edr-freight-api/src/scripts/seed-warehouse-demo.ts +++ b/apps/edr-freight-api/src/scripts/seed-warehouse-demo.ts @@ -5,6 +5,7 @@ import { resolve } from 'path'; config({ path: resolve(__dirname, '../../.env') }); import { NestFactory } from '@nestjs/core'; +import { DataSource } from 'typeorm'; import { AppModule } from '../app.module'; import { Batch14TestDataSeeder } from '../seed/batch1-4-test-data.seeder'; import { Batch5TestDataSeeder } from '../seed/batch5-test-data.seeder'; @@ -14,19 +15,33 @@ import { IndodeFacilitySeeder } from '../seed/indode-facility.seeder'; import { PricingDataSeeder } from '../seed/pricing-data.seeder'; import { WarehouseDemoSeeder } from '../seed/warehouse-demo.seeder'; +/** Demo data only — refuse to run against anything but a local dev database. */ +function assertLocalhost() { + const host = process.env.DB_HOST ?? 'localhost'; + if (host !== 'localhost' && host !== '127.0.0.1') { + console.error(`Refusing to seed demo data: DB_HOST is "${host}", not localhost.`); + process.exit(1); + } +} + async function main() { + assertLocalhost(); + const app = await NestFactory.createApplicationContext(AppModule, { logger: ['error', 'warn', 'log'], }); try { - await app.get(PricingDataSeeder).run(); - await app.get(IndodeFacilitySeeder).run(); - await app.get(Batch14TestDataSeeder).run(); - await app.get(Batch5TestDataSeeder).run(); - await app.get(Batch7TestDataSeeder).run(); - await app.get(Batch8TestDataSeeder).run(); - await app.get(WarehouseDemoSeeder).run(); + // Demo seeders are intentionally not AppModule providers (they'd run on every + // boot), so construct them against the app's DataSource instead of via DI. + const dataSource = app.get(DataSource); + await new PricingDataSeeder(dataSource).run(); + await new IndodeFacilitySeeder(dataSource).run(); + await new Batch14TestDataSeeder(dataSource).run(); + await new Batch5TestDataSeeder(dataSource).run(); + await new Batch7TestDataSeeder(dataSource).run(); + await new Batch8TestDataSeeder(dataSource).run(); + await new WarehouseDemoSeeder(dataSource).run(); console.log('Warehouse demo data seeded.'); } finally { 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-api/src/seed/warehouse-demo.seeder.ts b/apps/edr-freight-api/src/seed/warehouse-demo.seeder.ts index d8e585a35..d669e0335 100644 --- a/apps/edr-freight-api/src/seed/warehouse-demo.seeder.ts +++ b/apps/edr-freight-api/src/seed/warehouse-demo.seeder.ts @@ -2,8 +2,10 @@ import { Injectable, Logger } from '@nestjs/common'; import { DataSource } from 'typeorm'; import { Booking } from '../modules/bookings/entities/booking.entity'; +import { CustomerTruckAssignment } from '../modules/bookings/entities/customer-truck-assignment.entity'; import { CargoType } from '../modules/rule-engine/entities/cargo-type.entity'; import { CompanyProfile } from '../modules/companies/entities/company-profile.entity'; +import { EmptyContainerReturn } from '../modules/import-operations/entities/empty-container-return.entity'; import { Locomotive } from '../modules/locomotives/entities/locomotive.entity'; import { ServiceType } from '../modules/rule-engine/entities/service-type.entity'; import { Yard } from '../modules/rule-engine/entities/yard.entity'; @@ -23,6 +25,8 @@ import { WarehouseZone } from '../modules/warehouses/entities/warehouse-zone.ent * Import → Arrive Queue : an ARRIVED import train with IN_TRANSIT bookings (no inventory) * Import → Unloaded Queue : UNLOADED import inventory * Import → Dispatch Queue : READY_FOR_PICKUP import inventory (PASSED) + * Import → Import Trucks : a customer self-haul truck assigned to an unloaded booking + * Import → Container Returns : empty container returns at two different statuses * * Idempotent: guarded on a sentinel booking reference. Uses dedicated WH-DEMO-* references so it * never collides with other seeders. To repopulate after items are walked through their lifecycle, @@ -166,16 +170,19 @@ export class WarehouseDemoSeeder { } // 4) Import Unloaded Queue — UNLOADED import inventory (not inspected, not stored). + let firstUnloadedBooking: Booking | null = null; for (let i = 1; i <= 3; i++) { const b = await makeBooking(`WH-DEMO-UNL-${i}`, 'IMPORT', 'IN_TRANSIT', 5000 + i * 500, i); await makeInventory(b, 'UNLOADED', 5000 + i * 500, { arrivedAt: ago(90), unloadedAt: ago(45), }); + firstUnloadedBooking ??= b; created++; } // 5) Import Dispatch Queue — READY_FOR_PICKUP import inventory (inspection PASSED). + let firstPickupBooking: Booking | null = null; for (let i = 1; i <= 3; i++) { const b = await makeBooking(`WH-DEMO-PKR-${i}`, 'IMPORT', 'IN_TRANSIT', 5500 + i * 500, i); await makeInventory(b, 'READY_FOR_PICKUP', 5500 + i * 500, { @@ -185,6 +192,50 @@ export class WarehouseDemoSeeder { inspectedAt: ago(120), readyForPickupAt: ago(60), }); + firstPickupBooking ??= b; + created++; + } + + // 6) Import Trucks / booking Trucks tab — a customer self-haul truck on the unloaded booking. + if (firstUnloadedBooking) { + await this.dataSource.getRepository(CustomerTruckAssignment).save( + this.dataSource.getRepository(CustomerTruckAssignment).create({ + bookingId: firstUnloadedBooking.id, + plateNumber: 'WH-DEMO-3210', + driverName: 'Demo Driver', + truckType: 'FLATBED', + assignedAt: ago(80), + arrivedAt: ago(50), + }), + ); + created++; + } + + // 7) Container Returns — two empty returns at different stages of the return workflow. + if (firstPickupBooking) { + const returnRepo = this.dataSource.getRepository(EmptyContainerReturn); + await returnRepo.save( + returnRepo.create({ + containerNumber: 'WHDU1234561', + bookingId: firstPickupBooking.id, + returnDate: ago(20), + facility: 'Indode', + status: 'RETURNED', + returnedBy: 'CUSTOMER', + statusHistory: [], + }), + ); + await returnRepo.save( + returnRepo.create({ + containerNumber: 'WHDU1234562', + bookingId: firstPickupBooking.id, + returnDate: ago(90), + facility: 'Indode', + status: 'DOCUMENTATION_CLEARED', + returnedBy: 'CUSTOMER', + statusHistory: [], + }), + ); created++; } diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index 4dc1fb9e9..51fcd7e42 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -292,7 +292,10 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ label: "Locomotives", href: "/dashboard/locomotives", icon: , - permission: [FREIGHT_PERMS.locomotives.view, FREIGHT_PERMS.fleet.view], + permission: [ + FREIGHT_PERMS.locomotives.view, + FREIGHT_PERMS.fleet.view, + ], }, { label: "Train Builder", @@ -818,7 +821,7 @@ const App = () => { {UserManagementRoutes()} {/* } /> */} } /> - } /> + } /> } /> { } /> } /> } /> - } /> + } + /> } /> } /> } /> @@ -1173,7 +1179,9 @@ const App = () => { + } @@ -1181,7 +1189,12 @@ const App = () => { + } @@ -1189,7 +1202,9 @@ const App = () => { + } @@ -1197,7 +1212,9 @@ const App = () => { + } @@ -1205,7 +1222,9 @@ const App = () => { + } @@ -1213,7 +1232,9 @@ const App = () => { + } @@ -1221,7 +1242,9 @@ const App = () => { + } @@ -1242,7 +1265,12 @@ const App = () => { + } @@ -1250,7 +1278,12 @@ const App = () => { + } @@ -1336,7 +1369,9 @@ const App = () => { + } @@ -1424,7 +1459,12 @@ const App = () => { + } @@ -1432,7 +1472,9 @@ const App = () => { + } @@ -1440,7 +1482,9 @@ const App = () => { + } @@ -1448,7 +1492,9 @@ const App = () => { + } @@ -1456,7 +1502,9 @@ const App = () => { + } @@ -1464,7 +1512,9 @@ const App = () => { + } @@ -1485,7 +1535,12 @@ const App = () => { + } @@ -1493,7 +1548,12 @@ const App = () => { + } diff --git a/apps/edr-freight-web/backoffice/src/complaints/utils/complaintVerificationStorage.ts b/apps/edr-freight-web/backoffice/src/complaints/utils/complaintVerificationStorage.ts index f7a500cfc..44441a26f 100644 --- a/apps/edr-freight-web/backoffice/src/complaints/utils/complaintVerificationStorage.ts +++ b/apps/edr-freight-web/backoffice/src/complaints/utils/complaintVerificationStorage.ts @@ -47,7 +47,7 @@ export function isComplaintAuthContext(pathname = ""): boolean { pathname.startsWith("/complaints") || pathname === "/complaint-form" || pathname === "/follow-complaint" || - pathname === "/callback" + pathname === "/fayda/callback" ); } 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/errors/ApiErrorModal.tsx b/apps/edr-freight-web/backoffice/src/components/errors/ApiErrorModal.tsx index 07c9a39d1..4f8b0186a 100644 --- a/apps/edr-freight-web/backoffice/src/components/errors/ApiErrorModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/errors/ApiErrorModal.tsx @@ -28,7 +28,7 @@ let listener: Listener | null = null; /** Current-page path patterns where the global modal must stay silent. */ const EXCLUDED_PATH_PATTERNS = [ /^\/auth/, - /^\/callback/, + /^\/fayda/, /warehouse/i, /first-mile/i, /last-mile/i, 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/FleetFormDialog.tsx b/apps/edr-freight-web/backoffice/src/components/fleet/FleetFormDialog.tsx index f0ccf0be0..b43c03f18 100644 --- a/apps/edr-freight-web/backoffice/src/components/fleet/FleetFormDialog.tsx +++ b/apps/edr-freight-web/backoffice/src/components/fleet/FleetFormDialog.tsx @@ -148,7 +148,7 @@ const FleetFormDialog = ({ }); }, [open, fields]); - // Receive the ?code&state relayed by the /callback popup, exchange it for + // Receive the ?code&state relayed by the /fayda/callback popup, exchange it for // the verified identity, and prefill the matching form fields. useEffect(() => { if (!open || !verifyWithFayda) return; 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/components/ruleEngine/RuleEngineFormDialog.tsx b/apps/edr-freight-web/backoffice/src/components/ruleEngine/RuleEngineFormDialog.tsx index 01bb5ceea..49aeb17cd 100644 --- a/apps/edr-freight-web/backoffice/src/components/ruleEngine/RuleEngineFormDialog.tsx +++ b/apps/edr-freight-web/backoffice/src/components/ruleEngine/RuleEngineFormDialog.tsx @@ -153,11 +153,13 @@ const RuleEngineFormDialog = ({ buildInitialValues(fields, initialRecord), ); const [position, setPosition] = useState(RULE_ENGINE_POSITION_END); + const [fieldErrors, setFieldErrors] = useState>({}); useEffect(() => { if (open) { setValues(buildInitialValues(fields, initialRecord)); setPosition(RULE_ENGINE_POSITION_END); + setFieldErrors({}); } }, [open, fields, initialRecord]); @@ -185,6 +187,9 @@ const RuleEngineFormDialog = ({ const formRows = useMemo(() => buildFormRows(visibleFields), [visibleFields]); const setField = (name: string, value: unknown) => { + setFieldErrors((current) => + current[name] ? { ...current, [name]: "" } : current, + ); setValues((current) => { const next = { ...current, [name]: value }; // Changing what a rate applies to (or its surcharge trigger) can invalidate @@ -223,6 +228,10 @@ const RuleEngineFormDialog = ({ const handleSubmit = (event: React.FormEvent) => { event.preventDefault(); const payload: Record = {}; + // Required selects that are empty block the submit and mark themselves, + // rather than posting an incomplete payload for the API to reject. + setFieldErrors({}); + let blocked = false; for (const field of visibleFields) { // Derived fields always submit their computed value — never stale state. @@ -241,7 +250,16 @@ const RuleEngineFormDialog = ({ field.type === "select" && (raw === "" || raw === RULE_ENGINE_SELECT_NONE) ) { + // A required select left empty must not silently submit nothing — the + // API rejects the payload with a message that reads as if the admin + // skipped a field they never saw cleared (e.g. yards reset by a trade + // direction change). Surface it on the field instead. if (!field.required) continue; + setFieldErrors((current) => ({ + ...current, + [field.name]: `${field.label} is required.`, + })); + blocked = true; } else if (raw === "" || raw === undefined) { if (!field.required) continue; payload[field.name] = raw; @@ -254,6 +272,8 @@ const RuleEngineFormDialog = ({ payload.code = String(payload.code).toUpperCase(); } + if (blocked) return; + if (!initialRecord && positionOptions && position !== RULE_ENGINE_POSITION_END) { payload.insertAfterId = position; } @@ -340,10 +360,10 @@ const RuleEngineFormDialog = ({ value={resolveSelectValue(field, values)} onChange={(v) => setField(field.name, v === RULE_ENGINE_SELECT_NONE ? "" : v)} disabled={selectOptionsLoading} - // Native required blocks submit while a mandatory select is empty — - // without it the form posts and the API 400s (e.g. a container - // customs/lashing rate with no container type picked). + // Mantine's Select is not a native input, so `required` only marks it + // visually — handleSubmit is what actually blocks an empty one. required={field.required} + error={fieldErrors[field.name] || undefined} data={options .filter((opt) => opt.value !== "") .map((opt) => ({ diff --git a/apps/edr-freight-web/backoffice/src/constants/URLS.ts b/apps/edr-freight-web/backoffice/src/constants/URLS.ts index 0e3facead..d8993df04 100644 --- a/apps/edr-freight-web/backoffice/src/constants/URLS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/URLS.ts @@ -53,6 +53,10 @@ export const URL_CONSTANTS = { NOTIFICATIONS: "/settings/notifications", }, + EXCHANGE_SETTINGS: { + BASE: "/exchange-settings", + }, + DROPDOWN_SETTINGS: { BASE: "/dropdown-settings", BY_ID: (id: string) => `/api/dropdown-settings/${id}`, diff --git a/apps/edr-freight-web/backoffice/src/hooks/useExchangeSettings.ts b/apps/edr-freight-web/backoffice/src/hooks/useExchangeSettings.ts new file mode 100644 index 000000000..d5fca3c6e --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/hooks/useExchangeSettings.ts @@ -0,0 +1,34 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useTranslation } from "react-i18next"; +import { toast } from "sonner"; + +import { exchangeSettingsService } from "@/services/exchangeSettings.service"; +import { useErrorHandler } from "@/shared/hooks/useErrorHandler"; + +const QUERY_KEY = ["exchangeSettings"]; + +export const useExchangeSettingsQuery = () => + useQuery({ + queryKey: QUERY_KEY, + queryFn: () => exchangeSettingsService.get(), + // Feed health is only interesting while it is being looked at. + staleTime: 30_000, + refetchOnWindowFocus: true, + }); + +export const useSetExchangeFallbackRate = () => { + const queryClient = useQueryClient(); + const { t } = useTranslation(); + const { handleError } = useErrorHandler(t); + + return useMutation({ + mutationFn: (rate: number) => exchangeSettingsService.setFallbackRate(rate), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: QUERY_KEY }); + toast.success( + t("exchangeSettings.updated", "Fallback exchange rate updated"), + ); + }, + onError: handleError, + }); +}; 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/FaydaCallbackPage.tsx b/apps/edr-freight-web/backoffice/src/pages/FaydaCallbackPage.tsx index 5febb664a..48b9d32ce 100644 --- a/apps/edr-freight-web/backoffice/src/pages/FaydaCallbackPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/FaydaCallbackPage.tsx @@ -5,7 +5,7 @@ import type { FaydaCallbackMessage } from "@/services/verifayda.service"; /** * Landing page for the eSignet redirect_uri (FAYDA_WEB_REDIRECT_URI → - * http://localhost:5183/callback). Runs inside the verification popup: + * http://localhost:5183/fayda/callback). Runs inside the verification popup: * relays ?code&state (or ?error) to the window that opened it via * postMessage, then closes itself. The opener performs the /complete call * so the single-use session is only consumed once, in one place. diff --git a/apps/edr-freight-web/backoffice/src/pages/SettingsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/SettingsPage.tsx index 8f4281c14..d005064b0 100644 --- a/apps/edr-freight-web/backoffice/src/pages/SettingsPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/SettingsPage.tsx @@ -33,6 +33,7 @@ import { AlertDialogTitle, } from "@/shared/common/ui/alert-dialog"; import { toast } from "sonner"; +import ExchangeRateSettingsCard from "./settings/ExchangeRateSettingsCard"; export default function SettingsPage() { const [createDialogOpen, setCreateDialogOpen] = useState(false); @@ -77,6 +78,8 @@ export default function SettingsPage() { return (
+ + 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")}> + + -