diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index f8a1eb68e..b1c797dc6 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -110,6 +110,7 @@ jobs: DEPLOY_USER: tria DOCKER_BUILDKIT: "1" COMPOSE_DOCKER_CLI_BUILD: "1" + ENV_MANAGER_TOKEN: ${{ secrets.ENV_MANAGER_TOKEN }} steps: - name: Checkout @@ -135,10 +136,10 @@ jobs: ;; esac - - name: Sync environment from server + - name: Sync environment from Env manager app run: | chmod +x scripts/deploy/*.sh - ./scripts/deploy/sync-env-from-server.sh "${{ matrix.service }}" + ./scripts/deploy/sync-env-from-env-manager.sh "${{ matrix.service }}" - name: Set compose project name run: | diff --git a/.github/workflows/malware-scan.yml b/.github/workflows/malware-scan.yml index 751118839..15ca4faeb 100644 --- a/.github/workflows/malware-scan.yml +++ b/.github/workflows/malware-scan.yml @@ -35,7 +35,7 @@ jobs: # Plain `self-hosted` — GitHub applies this label to every self-hosted # runner automatically. The scan is host-agnostic, unlike the deploy jobs # which pin to a branch-specific runner. - runs-on: self-hosted + runs-on: [self-hosted, dev] outputs: infected: ${{ steps.scan.outputs.infected }} steps: diff --git a/.gitignore b/.gitignore index 63784b865..8fa8bca90 100644 --- a/.gitignore +++ b/.gitignore @@ -53,3 +53,12 @@ RUNNING_LOCALLY.md # Generated per-shard compose file for the integration suite (it.mjs). integration/.it-shards.yaml +# private keys / certificates (EIMS INSA credentials and anything like them) — never commit +*.key +*.pem +*.pem.txt +*.p12 +*.pfx +*.crt +secrets/ +certs/ diff --git a/apps/edr-freight-api/.env.example b/apps/edr-freight-api/.env.example index 96af26034..da0b1ceb9 100644 --- a/apps/edr-freight-api/.env.example +++ b/apps/edr-freight-api/.env.example @@ -126,3 +126,65 @@ EMAIL_QUEUE=email_queue # Shared secret for service-to-service calls (payment microservice <-> freight). # Required at boot; set ALLOW_UNAUTH_INTERNAL=true instead ONLY for local dev. SERVICE_AUTH_TOKEN=change-me + +# ── MoR EIMS e-invoicing (core.mor.gov.et) ───────────────────────────────── +# Disabled by default; every EIMS call fails fast with EIMS_NOT_CONFIGURED until enabled. +EIMS_ENABLED=false +EIMS_BASE_URL=https://core.mor.gov.et +EIMS_CLIENT_ID= +EIMS_CLIENT_SECRET= +EIMS_API_KEY= +EIMS_TIN= +# Source-system identity comes from the access token's systemNumber/systemType claims. +# Setting these turns them into expected-value checks: a mismatch against the token fails +# fast rather than one side silently winning. Leave empty to take the gateway's word. +EIMS_SYSTEM_NUMBER= +EIMS_SYSTEM_TYPE= +# Absolute paths to the INSA-issued credentials. Keep them OUTSIDE the repo; the file +# patterns are gitignored, but a path outside the working tree is safer still. +# The certificate is transmitted as base64 of this file's exact bytes — do not convert it. +EIMS_PRIVATE_KEY_PATH= +EIMS_CERTIFICATE_PATH= +# Optional tuning +EIMS_HTTP_TIMEOUT_MS=30000 +EIMS_TOKEN_SKEW_SECONDS=45 + +# ── EIMS invoice registration (required only to register invoices) ───────── +# Seller identity: EDR's own legal details are not modelled anywhere in the DB. +# Region and Wereda are MoR *codes* (e.g. 13 / 574), not names. +EIMS_SELLER_LEGAL_NAME= +EIMS_SELLER_VAT_NUMBER= +EIMS_SELLER_PHONE= +EIMS_SELLER_EMAIL= +EIMS_SELLER_REGION= +EIMS_SELLER_WEREDA= +# Optional seller address parts; sent as null when unset. +EIMS_SELLER_CITY= +EIMS_SELLER_SUBCITY= +EIMS_SELLER_HOUSE_NUMBER= +EIMS_SELLER_LOCALITY= +# Tax treatment — REQUIRES FINANCE SIGN-OFF. The application models no tax at all +# (invoice.taxAmount is always 0), so nothing here is defaulted: registration fails +# locally, naming the missing variables, until these are set. +EIMS_TAX_CODE=0 +EIMS_TAX_RATE_PERCENT=0 +EIMS_EXCISE_TAX_VALUE=0 +EIMS_INCOME_WITHHOLD_VALUE=0 +EIMS_TRANSACTION_WITHHOLD_VALUE=0 +# Document classification and payment presentation. +EIMS_TRANSACTION_TYPE=B2B +EIMS_NATURE_OF_SUPPLIES=Service +EIMS_PAYMENT_MODE=CASH +EIMS_PAYMENT_TERM=IMMIDIATE +EIMS_UNIT_DEFAULT=PCS +# MoR numeric country code for the buyer; our companies store the country name. +EIMS_BUYER_COUNTRY_CODE= +EIMS_CASHIER_NAME= +EIMS_SALESPERSON_NAME= +# Automatic filing of issued invoices (@Cron sweep, one invoice per tick). +# Independent of EIMS_ENABLED on purpose: authentication can be live long before +# filing is. Both must be true before anything is submitted automatically. +EIMS_AUTO_SUBMIT=false +EIMS_AUTO_SUBMIT_CRON=0 */5 * * * * +# MoR rejects documents older than 3 days; the sweep will not attempt those. +EIMS_AUTO_SUBMIT_MAX_AGE_DAYS=3 diff --git a/apps/edr-freight-api/package.json b/apps/edr-freight-api/package.json index c4837c8a7..fb6a0bd31 100644 --- a/apps/edr-freight-api/package.json +++ b/apps/edr-freight-api/package.json @@ -37,7 +37,8 @@ "iam:migration:revert": "pnpm run iam:typeorm:cli migration:revert", "iam:migration:show": "pnpm run iam:typeorm:cli migration:show", "migration:run": "nest build && node dist/scripts/migrate.js", - "script": "ts-node -r tsconfig-paths/register src/scripts/main.ts" + "script": "ts-node -r tsconfig-paths/register src/scripts/main.ts", + "eims:login": "ts-node -r tsconfig-paths/register src/scripts/eims-login.ts" }, "dependencies": { "@edr/api-common": "workspace:*", diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index c98990d25..50dde1034 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -23,6 +23,7 @@ import databaseConfig from "./config/database.config"; import telebirrConfig from "./config/telebirr.config"; import rabbitmqConfig from "./config/rabbitmq.config"; import faydaConfig from "./config/fayda.config"; +import eimsConfig from "./config/eims.config"; import { BookingsModule } from "./modules/bookings/bookings.module"; import { ContractsModule } from "./modules/contracts/contracts.module"; @@ -76,6 +77,7 @@ import { FileUploadSettingsSeeder } from "./seed/file-upload-settings.seeder"; // import { ExportDjiboutiInterchangeDemoSeeder } from "./seed/export-djibouti-interchange-demo.seeder"; // import { MarshallingDemoTrainsSeeder } from "./seed/marshalling-demo-trains.seeder"; import { FreightPermissionKeyMigrationSeeder } from "./seed/freight-permission-key-migration.seeder"; +import { FreightNotificationPermissionsSeeder } from "./seed/freight-notification-permissions.seeder"; // import { DemoFreightDataSeeder } from "./seed/demo-freight-data.seeder"; // import { GovCompaniesSeeder } from "./seed/gov-companies.seeder"; import { ApprovedFirstLastMileDemoBookingsSeeder } from "./seed/approved-first-lastmile-demo-bookings.seeder"; @@ -83,6 +85,7 @@ import { PaidImportExportMileDemoSeeder } from "./seed/paid-import-export-mile-d //New Trains, Wagons, Container and Cargo management modules import { TrainsModule } from "./modules/trains/trains.module"; import { VerifaydaModule } from "./modules/verifayda/verifayda.module"; +import { EimsModule } from "./modules/eims/eims.module"; import { FleetHistoryModule } from "./modules/fleet-history/fleet-history.module"; import { WagonsModule } from "./modules/wagons/wagons.module"; import { ContainersModule } from "./modules/container-management/containers.module"; @@ -126,6 +129,7 @@ if (!process.env.APPLICATION_NAME) { telebirrConfig, rabbitmqConfig, faydaConfig, + eimsConfig, ], }), ScheduleModule.forRoot(), @@ -247,6 +251,7 @@ if (!process.env.APPLICATION_NAME) { InterchangeDocumentsModule, ImportOperationsModule, VerifaydaModule, + EimsModule, FleetHistoryModule, AiModule, AuditModule, @@ -257,6 +262,7 @@ if (!process.env.APPLICATION_NAME) { FileUploadSettingsSeeder, // YardFacilitiesSeeder, FreightPermissionKeyMigrationSeeder, + FreightNotificationPermissionsSeeder, // Disabled seeds — providers commented out (imports/injection/run too): // DemoUsersSeeder, // FreightStaffUsersSeeder, @@ -287,6 +293,7 @@ export class AppModule implements OnApplicationBootstrap { private readonly fileUploadSettingsSeeder: FileUploadSettingsSeeder, // private readonly yardFacilitiesSeeder: YardFacilitiesSeeder, private readonly freightPermissionKeyMigrationSeeder: FreightPermissionKeyMigrationSeeder, + private readonly freightNotificationPermissionsSeeder: FreightNotificationPermissionsSeeder, // Disabled seeds — injections commented out (imports/provider/run too): // private readonly demoUsersSeeder: DemoUsersSeeder, // private readonly freightStaffUsersSeeder: FreightStaffUsersSeeder, @@ -328,6 +335,16 @@ export class AppModule implements OnApplicationBootstrap { await this.edrOrgSeeder.run(); await this.iamBaselineSeeder.run(); await this.freightPositionsSeeder.run(); + // freightNotificationPermissions → seeds the :get_notification + // keys and backfills them onto whoever + // already holds each desk's anchor + // permission. Runs LAST in this block so + // it sees a freshly-seeded catalog and + // freshly-seeded positions. Unlike the + // seeders above it is NOT gated behind + // SEED_EDR_ORG — without it every staff + // notification resolves to no one. + await this.freightNotificationPermissionsSeeder.run(); // File upload settings — keep enabled. await this.fileUploadSettingsSeeder.run(); diff --git a/apps/edr-freight-api/src/common/booking-guards.ts b/apps/edr-freight-api/src/common/booking-guards.ts index 49bd31c61..d1b5364c3 100644 --- a/apps/edr-freight-api/src/common/booking-guards.ts +++ b/apps/edr-freight-api/src/common/booking-guards.ts @@ -95,9 +95,14 @@ export const TrainSchedulingRulesManage = () => * wagons:delete, …). The legacy coarse fleet:view / fleet:manage keys remain * valid as a one-of fallback so existing role grants keep working. */ -export const FleetView = (granular?: string) => +export const FleetView = (granular?: string | string[]) => BookingStaff( - granular ? [granular, FREIGHT_PERMS.fleet.view] : FREIGHT_PERMS.fleet.view, + granular + ? [ + ...(Array.isArray(granular) ? granular : [granular]), + FREIGHT_PERMS.fleet.view, + ] + : FREIGHT_PERMS.fleet.view, ); export const FleetManage = (granular?: string) => diff --git a/apps/edr-freight-api/src/common/freight-permission.guard.ts b/apps/edr-freight-api/src/common/freight-permission.guard.ts index db6275c07..9cb002891 100644 --- a/apps/edr-freight-api/src/common/freight-permission.guard.ts +++ b/apps/edr-freight-api/src/common/freight-permission.guard.ts @@ -9,6 +9,7 @@ import { import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type'; import { hasFreightPermission, isSuperAdmin } from './freight-permission.util'; +import { readTwinOf } from '../seed/freight-permissions.registry'; // String literals on purpose (same reasoning as login-audience.middleware.ts): // the values are wire-format constants from iam.users.user_type, and importing @@ -22,13 +23,43 @@ const userTypeOf = (user: TCurrentUser): string | undefined => const isEmployee = (user: TCurrentUser): boolean => userTypeOf(user) === 'employee' || isSuperAdmin(user); +const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']); + +/** + * Does the caller satisfy a required permission? + * + * Holding the key outright always passes. A required `:view` is ALSO + * satisfied by the weaker `:read` — the key that buys API reads + * without putting the module in the backoffice sidebar — but only on a safe + * HTTP method. + * + * The method restriction is load-bearing, not caution. Nest runs class AND + * method guards, so controllers list every route key on the class gate, + * `:view` among the write keys. Without this check a `:read` holder would + * clear that class gate and then reach any write route that has no method + * gate of its own. Keying on the HTTP verb closes that by construction rather + * than by an audit that goes stale the next time a route is added. + */ +const satisfiedBy = ( + user: TCurrentUser, + required: string, + method: string, +): boolean => { + if (hasFreightPermission(user, required)) return true; + if (!SAFE_METHODS.has(method)) return false; + const readTwin = readTwinOf(required); + return Boolean(readTwin && hasFreightPermission(user, readTwin)); +}; + export function FreightPermissionGuard( permissions: string[], ): Type { @Injectable() class FreightPermissionsGuard implements CanActivate { canActivate(context: ExecutionContext): boolean { - const request = context.switchToHttp().getRequest<{ user?: TCurrentUser }>(); + const request = context + .switchToHttp() + .getRequest<{ user?: TCurrentUser; method: string }>(); const user = request.user; if (!user) { @@ -39,7 +70,7 @@ export function FreightPermissionGuard( } if (!permissions?.length) return true; - if (permissions.some((p) => hasFreightPermission(user, p))) { + if (permissions.some((p) => satisfiedBy(user, p, request.method))) { return true; } @@ -79,7 +110,9 @@ export function MixedAudienceGuard(permissions: string[]): Type { @Injectable() class MixedAudiencesGuard implements CanActivate { canActivate(context: ExecutionContext): boolean { - const request = context.switchToHttp().getRequest<{ user?: TCurrentUser }>(); + const request = context + .switchToHttp() + .getRequest<{ user?: TCurrentUser; method: string }>(); const user = request.user; if (!user) { @@ -93,7 +126,7 @@ export function MixedAudienceGuard(permissions: string[]): Type { } if ( !permissions?.length || - permissions.some((p) => hasFreightPermission(user, p)) + permissions.some((p) => satisfiedBy(user, p, request.method)) ) { return true; } diff --git a/apps/edr-freight-api/src/config/eims.config.ts b/apps/edr-freight-api/src/config/eims.config.ts new file mode 100644 index 000000000..6eaaf8007 --- /dev/null +++ b/apps/edr-freight-api/src/config/eims.config.ts @@ -0,0 +1,185 @@ +import { registerAs } from "@nestjs/config"; + +/** + * Ethiopian MoR EIMS e-invoicing gateway. + * + * Disabled by default: with `EIMS_ENABLED=false` the config resolves to a stub and every EIMS + * service throws a clear error on use, so a deployment without credentials still boots. + * + * Secrets (client secret, API key) and the credential file paths live only here and are never + * logged — validation reports missing variable *names*, never their values. + */ +export interface EimsConfig { + enabled: boolean; + baseUrl: string; + clientId: string; + clientSecret: string; + apiKey: string; + tin: string; + /** + * Optional *expectations* for the source-system identity, not inputs. + * + * The access token MoR issues carries `systemNumber` and `systemType` claims for the credentials + * that authenticated, and those are what registration uses. When these are set they are compared + * against the token and a mismatch fails fast — neither side silently wins. Leave them empty to + * take whatever the gateway says. + */ + systemNumber: string; + systemType: string; + /** Filesystem path to the INSA-issued RSA private key (PEM). Never leaves the server. */ + privateKeyPath: string; + /** Filesystem path to the INSA-issued certificate bundle; sent as base64 of its exact bytes. */ + certificatePath: string; + httpTimeoutMs: number; + /** Re-authenticate this many ms before the access token actually expires. */ + tokenSkewMs: number; + /** + * Automatic submission of issued invoices, off by default. + * + * Invoices are produced by the workflow, so the production path is a sweep rather than a human + * action — but enabling it starts filing real documents with the tax authority, which is + * irreversible from our side. It therefore needs its own deliberate switch, separate from + * `EIMS_ENABLED`, so that authentication can be live long before filing is. + */ + autoSubmit: boolean; + autoSubmitCron: string; + /** MoR rejects a document whose date is more than 3 days old; the sweep will not attempt those. */ + autoSubmitMaxAgeDays: number; + /** + * Seller identity and tax/business treatment for the invoice document. + * + * None of this is derivable from the database: EDR's own legal identity exists nowhere in the + * codebase, and the app models no tax at all. Values are required at registration time and are + * validated there rather than at boot, so a deployment can run with EIMS enabled for + * authentication before finance has signed off on the tax treatment. + */ + invoice: EimsInvoiceConfig; +} + +export interface EimsInvoiceConfig { + sellerLegalName: string; + sellerVatNumber: string; + sellerPhone: string; + sellerEmail: string; + /** MoR *codes*, not names (e.g. "13" for Addis Ababa, "574"). */ + sellerRegion: string; + sellerWereda: string; + sellerCity: string | null; + sellerSubCity: string | null; + sellerHouseNumber: string | null; + sellerLocality: string | null; + /** REQUIRES_BUSINESS_CONFIRMATION — no tax model exists in this application. */ + taxCode: string; + taxRatePercent: number | null; + exciseTaxValue: number | null; + incomeWithholdValue: number | null; + transactionWithholdValue: number | null; + /** B2B / B2C — a tax classification, so it is configured, not inferred. */ + transactionType: string; + natureOfSupplies: string; + paymentMode: string; + paymentTerm: string; + unitDefault: string; + buyerCountryCode: string | null; + cashierName: string | null; + salesPersonName: string | null; +} + +const REQUIRED_VARS = [ + "EIMS_CLIENT_ID", + "EIMS_CLIENT_SECRET", + "EIMS_API_KEY", + "EIMS_TIN", + "EIMS_PRIVATE_KEY_PATH", + "EIMS_CERTIFICATE_PATH", +] as const; + +const positiveInt = (raw: string | undefined, fallback: number, name: string): number => { + if (raw === undefined || raw === "") return fallback; + const value = Number.parseInt(raw, 10); + if (Number.isNaN(value) || value <= 0) { + throw new Error(`${name} must be a positive integer`); + } + return value; +}; + +/** Unset stays null so the registration-time check can name it; a set-but-bogus value throws. */ +const optionalNumber = (raw: string | undefined, name: string): number | null => { + if (raw === undefined || raw === "") return null; + const value = Number(raw); + if (!Number.isFinite(value)) throw new Error(`${name} must be a number`); + return value; +}; + +export default registerAs("eims", (): EimsConfig => { + const enabled = (process.env.EIMS_ENABLED ?? "false").toLowerCase() === "true"; + const baseUrl = (process.env.EIMS_BASE_URL ?? "https://core.mor.gov.et").replace(/\/+$/, ""); + const httpTimeoutMs = positiveInt(process.env.EIMS_HTTP_TIMEOUT_MS, 30_000, "EIMS_HTTP_TIMEOUT_MS"); + const tokenSkewMs = + positiveInt(process.env.EIMS_TOKEN_SKEW_SECONDS, 45, "EIMS_TOKEN_SKEW_SECONDS") * 1000; + + const base: EimsConfig = { + enabled, + baseUrl, + clientId: process.env.EIMS_CLIENT_ID ?? "", + clientSecret: process.env.EIMS_CLIENT_SECRET ?? "", + apiKey: process.env.EIMS_API_KEY ?? "", + tin: process.env.EIMS_TIN ?? "", + systemNumber: process.env.EIMS_SYSTEM_NUMBER ?? "", + systemType: process.env.EIMS_SYSTEM_TYPE ?? "", + privateKeyPath: process.env.EIMS_PRIVATE_KEY_PATH ?? "", + certificatePath: process.env.EIMS_CERTIFICATE_PATH ?? "", + httpTimeoutMs, + tokenSkewMs, + autoSubmit: (process.env.EIMS_AUTO_SUBMIT ?? "false").toLowerCase() === "true", + // Every 5 minutes by default: filing is not latency-sensitive, and a slow cadence keeps a + // misconfiguration from filing a burst of bad documents before anyone notices. + autoSubmitCron: process.env.EIMS_AUTO_SUBMIT_CRON || "0 */5 * * * *", + autoSubmitMaxAgeDays: positiveInt( + process.env.EIMS_AUTO_SUBMIT_MAX_AGE_DAYS, + 3, + "EIMS_AUTO_SUBMIT_MAX_AGE_DAYS", + ), + invoice: { + sellerLegalName: process.env.EIMS_SELLER_LEGAL_NAME ?? "", + sellerVatNumber: process.env.EIMS_SELLER_VAT_NUMBER ?? "", + sellerPhone: process.env.EIMS_SELLER_PHONE ?? "", + sellerEmail: process.env.EIMS_SELLER_EMAIL ?? "", + sellerRegion: process.env.EIMS_SELLER_REGION ?? "", + sellerWereda: process.env.EIMS_SELLER_WEREDA ?? "", + sellerCity: process.env.EIMS_SELLER_CITY || null, + sellerSubCity: process.env.EIMS_SELLER_SUBCITY || null, + sellerHouseNumber: process.env.EIMS_SELLER_HOUSE_NUMBER || null, + sellerLocality: process.env.EIMS_SELLER_LOCALITY || null, + taxCode: process.env.EIMS_TAX_CODE ?? "", + taxRatePercent: optionalNumber(process.env.EIMS_TAX_RATE_PERCENT, "EIMS_TAX_RATE_PERCENT"), + exciseTaxValue: optionalNumber(process.env.EIMS_EXCISE_TAX_VALUE, "EIMS_EXCISE_TAX_VALUE"), + incomeWithholdValue: optionalNumber( + process.env.EIMS_INCOME_WITHHOLD_VALUE, + "EIMS_INCOME_WITHHOLD_VALUE", + ), + transactionWithholdValue: optionalNumber( + process.env.EIMS_TRANSACTION_WITHHOLD_VALUE, + "EIMS_TRANSACTION_WITHHOLD_VALUE", + ), + transactionType: process.env.EIMS_TRANSACTION_TYPE ?? "", + natureOfSupplies: process.env.EIMS_NATURE_OF_SUPPLIES ?? "", + paymentMode: process.env.EIMS_PAYMENT_MODE ?? "", + paymentTerm: process.env.EIMS_PAYMENT_TERM ?? "", + unitDefault: process.env.EIMS_UNIT_DEFAULT ?? "", + buyerCountryCode: process.env.EIMS_BUYER_COUNTRY_CODE || null, + cashierName: process.env.EIMS_CASHIER_NAME || null, + salesPersonName: process.env.EIMS_SALESPERSON_NAME || null, + }, + }; + + if (!enabled) return base; + + const missing = REQUIRED_VARS.filter((name) => !process.env[name]); + if (missing.length > 0) { + throw new Error( + `EIMS integration is enabled (EIMS_ENABLED=true) but the following env vars are missing: ${missing.join(", ")}`, + ); + } + return base; +}); diff --git a/apps/edr-freight-api/src/migrations/3300000000000-EimsInvoiceRegistration.ts b/apps/edr-freight-api/src/migrations/3300000000000-EimsInvoiceRegistration.ts new file mode 100644 index 000000000..c80dfcd1e --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3300000000000-EimsInvoiceRegistration.ts @@ -0,0 +1,73 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * EIMS registration state. + * + * `freight.invoices` gains the per-invoice registration outcome: which EIMS counter the invoice + * consumed, the returned IRN, and the last failure. The partial unique index on `eims_irn` is the + * database-level guarantee that one IRN can never be recorded against two invoices, independent of + * application logic. + * + * `freight.eims_system_state` is a single row per MoR system number holding the sequence the + * gateway expects: the next `SourceSystem.InvoiceCounter` and the `ReferenceDetails.PreviousIrn` + * of the last successful registration. Registration takes `FOR UPDATE` on this row, so the counter + * and the IRN chain stay consistent under concurrent submissions. + * + * The `in_flight_*` columns make a submission a *durable reservation*: the counter is consumed and + * the holder recorded in a committed transaction before the HTTP call, so a crash mid-flight leaves + * evidence instead of silently freeing the slot for a blind resubmission. `blocked_reason` is set + * when a submission ends ambiguously (timeout, network, 5xx) — the IRN is unknown, so every later + * document for this system number would chain to a stale `PreviousIrn` and registration stops until + * a human resolves it. + * + * `eims_ack_date` is varchar, not timestamptz: EIMS returns a Java ZonedDateTime string + * ("2025-03-21T08:33:32.707753413Z[Etc/UTC]") that no JS date parser accepts. It is stored + * verbatim so a compliance value is never mangled by a parse. + */ +export class EimsInvoiceRegistration3300000000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.invoices + ADD COLUMN IF NOT EXISTS eims_status varchar(20) NOT NULL DEFAULT 'NOT_SUBMITTED', + ADD COLUMN IF NOT EXISTS eims_irn varchar(64), + ADD COLUMN IF NOT EXISTS eims_invoice_counter bigint, + ADD COLUMN IF NOT EXISTS eims_submitted_at timestamptz, + ADD COLUMN IF NOT EXISTS eims_ack_date varchar(64), + ADD COLUMN IF NOT EXISTS eims_last_error jsonb + `); + + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS ux_invoices_eims_irn + ON freight.invoices (eims_irn) WHERE eims_irn IS NOT NULL + `); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.eims_system_state ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + system_number varchar(32) NOT NULL UNIQUE, + next_invoice_counter bigint NOT NULL DEFAULT 1, + previous_irn varchar(64), + in_flight_invoice_id uuid, + in_flight_counter bigint, + blocked_reason text, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz + ) + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS freight.eims_system_state`); + await queryRunner.query(`DROP INDEX IF EXISTS freight.ux_invoices_eims_irn`); + await queryRunner.query(` + ALTER TABLE freight.invoices + DROP COLUMN IF EXISTS eims_status, + DROP COLUMN IF EXISTS eims_irn, + DROP COLUMN IF EXISTS eims_invoice_counter, + DROP COLUMN IF EXISTS eims_submitted_at, + DROP COLUMN IF EXISTS eims_ack_date, + DROP COLUMN IF EXISTS eims_last_error + `); + } +} diff --git a/apps/edr-freight-api/src/modules/backoffice/backoffice.service.ts b/apps/edr-freight-api/src/modules/backoffice/backoffice.service.ts index 49384922e..f47b5e8b1 100644 --- a/apps/edr-freight-api/src/modules/backoffice/backoffice.service.ts +++ b/apps/edr-freight-api/src/modules/backoffice/backoffice.service.ts @@ -46,7 +46,8 @@ export class BackofficeService { /** * IAM user ids of every current employee across all organizations — used by - * the notification recipients resolver's `allBackoffice` selector. + * support chat for staff room membership. Notifications deliberately do NOT + * use this: they target a desk via `getEmployeeUserIdsByPermission`. */ async getAllCurrentEmployeeUserIds(): Promise { const employees = await this.employeeRepository.find({ @@ -63,24 +64,67 @@ export class BackofficeService { * IAM user ids of current employees (any org) holding ANY of the given * permission keys — used by the notification recipients resolver's * `permissionKeys` selector for department/role-scoped targeting. + * + * This MUST agree with the request-time guard (`hasFreightPermission`, + * common/freight-permission.util.ts), which counts four grant carriers plus + * the super_admin bypass. Counting fewer silently drops legitimate + * recipients: an earlier version joined only direct position permissions, on + * which `bookings:view` resolved to 2 users — against 21 through position + * TYPES, which is where admin-created positions actually keep their grants. + * + * Raw SQL rather than QueryBuilder because `Position.positionTypePermissions` + * declares its inverse side against PositionType, so a relation join emits + * `ptp.position_type_id = position.id` and silently matches nothing. Same + * approach as FreightMeService's position-type lookups. */ async getEmployeeUserIdsByPermission( permissionKeys: string[], ): Promise { if (!permissionKeys.length) return []; - const rows: { userId: string | null }[] = await this.employeeRepository - .createQueryBuilder("employee") - .innerJoin("employee.employeePositions", "employeePosition") - .innerJoin("employeePosition.position", "position") - .innerJoin("position.positionPermission", "positionPermission") - .innerJoin("positionPermission.permission", "permission") - .where("employee.isCurrent = :isCurrent", { isCurrent: true }) - .andWhere("permission.key IN (:...permissionKeys)", { permissionKeys }) - .select("DISTINCT employee.user_id", "userId") - .getRawMany(); - return rows - .map((r) => r.userId) - .filter((id): id is string => Boolean(id)); + const rows: { userId: string }[] = await this.dataSource.query( + `WITH target AS (SELECT id FROM iam.permissions WHERE key = ANY($1)) + -- 1. IAM role grants (user_roles -> role_permissions). + SELECT e.user_id AS "userId" + FROM iam.employees e + JOIN iam.user_roles ur ON ur.user_id = e.user_id + JOIN iam.role_permissions rp ON rp.role_id = ur.role_id + WHERE e.is_current AND e.user_id IS NOT NULL + AND rp.permission_id IN (SELECT id FROM target) + UNION + -- 2. Direct position grants. A delegate keeps their own position AND + -- gains the one they stand in for, so both columns count. + SELECT e.user_id + FROM iam.employees e + JOIN iam.employee_positions ep + ON ep.employee_id = e.id AND ep.is_current + JOIN iam.position_permissions pp + ON pp.position_id IN (ep.position_id, ep.delegatee_position_id) + WHERE e.is_current AND e.user_id IS NOT NULL + AND pp.permission_id IN (SELECT id FROM target) + UNION + -- 3. Position TYPE grants — where admin-created positions keep theirs. + SELECT e.user_id + FROM iam.employees e + JOIN iam.employee_positions ep + ON ep.employee_id = e.id AND ep.is_current + JOIN iam.positions p + ON p.id IN (ep.position_id, ep.delegatee_position_id) + JOIN iam.position_type_permissions ptp + ON ptp.position_type_id = p.position_type_id + WHERE e.is_current AND e.user_id IS NOT NULL + AND ptp.permission_id IN (SELECT id FROM target) + UNION + -- 4. super_admin passes every freight permission check, so mirror that + -- here or admins go blind on desks nobody else has been granted yet. + SELECT e.user_id + FROM iam.employees e + JOIN iam.user_roles ur ON ur.user_id = e.user_id + JOIN iam.roles r ON r.id = ur.role_id + WHERE e.is_current AND e.user_id IS NOT NULL + AND r.key = 'super_admin'`, + [permissionKeys], + ); + return rows.map((r) => r.userId); } async createOrganizationUser( diff --git a/apps/edr-freight-api/src/modules/billing/billing.controller.ts b/apps/edr-freight-api/src/modules/billing/billing.controller.ts index d72528310..ea43c9c74 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.controller.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.controller.ts @@ -20,7 +20,12 @@ import { FilterInvoiceDto } from "./dto/filter-invoice.dto"; @ApiTags("billing") @Controller("billing") -@BookingStaff(FREIGHT_PERMS.invoices.view) +// Class gate lists every key its routes use: Nest runs class AND method +// guards, so a key missing here would deny before the route's own key runs. +@BookingStaff([ + FREIGHT_PERMS.invoices.view, + FREIGHT_PERMS.invoices.export, +]) @ApiBearerAuth() export class BillingController { constructor( diff --git a/apps/edr-freight-api/src/modules/billing/eims-invoice.mapper.spec.ts b/apps/edr-freight-api/src/modules/billing/eims-invoice.mapper.spec.ts new file mode 100644 index 000000000..aef4ac163 --- /dev/null +++ b/apps/edr-freight-api/src/modules/billing/eims-invoice.mapper.spec.ts @@ -0,0 +1,214 @@ +import { + EimsMapperContext, + EimsMapperInvoice, + EimsSellerDetails, + formatEimsDate, + toEimsInvoice, +} from "./eims-invoice.mapper"; + +const seller: EimsSellerDetails = { + City: null, + Email: "finance@edr.et", + HouseNumber: null, + LegalName: "Ethio-Djibouti Railway S.C.", + Locality: null, + Phone: "0911223344", + Region: "13", + SubCity: null, + Tin: "0016324478", + VatNumber: "3215840010", + Wereda: "574", +}; + +const invoice = (over: Partial = {}): EimsMapperInvoice => ({ + invoiceNumber: "INV-20260807-00042", + currency: "ETB", + issuedAt: new Date(2026, 7, 7, 9, 5, 3), + totalAmount: "11000.00", + company: { + name: "ABC Trading PLC", + tin: "0999930000", + vatNumber: "123475885858", + phone: "0912345678", + email: "buyer@abc.et", + region: "13", + zone: "SHA", + woreda: "574", + kebele: "03", + houseNo: "NEW", + country: "Ethiopia", + }, + lines: [ + { chargeType: "RAIL_FREIGHT", description: "Addis → Djibouti", quantity: "1.00", unitRate: "10000.00", amount: "10000.00" }, + { chargeType: "HAZARD_SURCHARGE", description: null, quantity: "2.00", unitRate: "500.00", amount: "1000.00", metadata: { unit: "CTR" } }, + ], + ...over, +}); + +const context = (over: Partial = {}): EimsMapperContext => ({ + systemNumber: "B0360154BA", + systemType: "SYS", + documentNumber: "24", + invoiceCounter: 7, + previousIrn: "", + cashierName: null, + salesPersonName: null, + transactionType: "B2B", + payment: { mode: "CASH", term: "IMMIDIATE" }, + taxForLine: () => ({ code: "VAT15", ratePercent: 15, exciseTaxValue: 0 }), + natureOfSupplies: "Service", + unitDefault: "PCS", + incomeWithholdValue: 0, + transactionWithholdValue: 0, + ...over, +}); + +describe("toEimsInvoice", () => { + it("emits the ten EIMS sections with the collection's field names", () => { + const doc = toEimsInvoice(invoice(), seller, context()); + + expect(Object.keys(doc)).toEqual([ + "BuyerDetails", + "DocumentDetails", + "ItemList", + "PaymentDetails", + "ReferenceDetails", + "SellerDetails", + "SourceSystem", + "TransactionType", + "ValueDetails", + "Version", + ]); + expect(doc.Version).toBe("1"); + expect(doc.DocumentDetails).toEqual({ DocumentNumber: "24", Date: "07-08-2026T09:05:03", Type: "INV" }); + expect(doc.SourceSystem.InvoiceCounter).toBe(7); + expect(doc.SellerDetails).toBe(seller); + }); + + it("maps the buyer from the company row and leaves unmodelled fields null", () => { + const doc = toEimsInvoice(invoice(), seller, context()); + + expect(doc.BuyerDetails).toEqual({ + City: null, + Email: "buyer@abc.et", + HouseNumber: "NEW", + IdNumber: null, + IdType: null, + Tin: "0999930000", + LegalName: "ABC Trading PLC", + Phone: "0912345678", + Region: "13", + Country: null, + Zone: "SHA", + Kebele: "03", + VatNumber: "123475885858", + Wereda: "574", + }); + }); + + it("applies per-line tax and totals it into ValueDetails", () => { + const doc = toEimsInvoice( + invoice(), + seller, + context({ + taxForLine: (line) => + line.chargeType === "RAIL_FREIGHT" + ? { code: "VAT15", ratePercent: 15, exciseTaxValue: 0 } + : { code: "EXEMPT", ratePercent: 0, exciseTaxValue: 50 }, + }), + ); + + expect(doc.ItemList[0]).toMatchObject({ + LineNumber: 1, + ItemCode: "RAIL_FREIGHT", + ProductDescription: "Addis → Djibouti", + Quantity: 1, + UnitPrice: 10000, + PreTaxValue: 10000, + TaxCode: "VAT15", + TaxAmount: 1500, + ExciseTaxValue: 0, + TotalLineAmount: 11500, + Unit: "PCS", + NatureOfSupplies: "Service", + HarmonizationCode: null, + }); + expect(doc.ItemList[1]).toMatchObject({ + LineNumber: 2, + ProductDescription: "HAZARD_SURCHARGE", + TaxCode: "EXEMPT", + TaxAmount: 0, + ExciseTaxValue: 50, + TotalLineAmount: 1050, + Unit: "CTR", + }); + expect(doc.ValueDetails).toEqual({ + Discount: null, + ExciseValue: 50, + IncomeWithholdValue: 0, + TaxValue: 1500, + TotalValue: 12550, + TransactionWithholdValue: 0, + InvoiceCurrency: "ETB", + }); + }); + + it("passes PreviousIrn through verbatim and defaults RelatedDocument to null", () => { + expect(toEimsInvoice(invoice(), seller, context()).ReferenceDetails).toEqual({ + PreviousIrn: "", + RelatedDocument: null, + }); + expect( + toEimsInvoice(invoice(), seller, context({ previousIrn: null, relatedDocument: "CN-9" })) + .ReferenceDetails, + ).toEqual({ PreviousIrn: null, RelatedDocument: "CN-9" }); + }); + + it("emits ExchangeRate only when supplied", () => { + expect(toEimsInvoice(invoice(), seller, context()).ValueDetails.ExchangeRate).toBeUndefined(); + + const usd = toEimsInvoice( + invoice({ currency: "USD" }), + seller, + context({ exchangeRate: 132.5 }), + ); + expect(usd.ValueDetails).toMatchObject({ InvoiceCurrency: "USD", ExchangeRate: 132.5 }); + }); + + it("honours a caller-supplied date formatter", () => { + const doc = toEimsInvoice(invoice(), seller, context({ formatDate: () => "2026-08-07T09:05:03Z" })); + expect(doc.DocumentDetails.Date).toBe("2026-08-07T09:05:03Z"); + }); + + it("throws when tax treatment cannot be resolved for a line", () => { + expect(() => + toEimsInvoice( + invoice(), + seller, + context({ taxForLine: () => ({ code: "", ratePercent: 15, exciseTaxValue: 0 }) }), + ), + ).toThrow(/unresolved tax treatment for line 1/); + }); + + it("throws on a missing buyer TIN, no lines, or an unissued invoice", () => { + expect(() => toEimsInvoice(invoice({ company: null }), seller, context())).toThrow(/buyer company TIN/); + expect(() => toEimsInvoice(invoice({ lines: [] }), seller, context())).toThrow(/has no lines/); + expect(() => toEimsInvoice(invoice({ issuedAt: null }), seller, context())).toThrow(/not issued/); + }); + + it("throws when the lines do not sum to the invoice total", () => { + expect(() => toEimsInvoice(invoice({ totalAmount: "9000.00" }), seller, context())).toThrow( + /lines sum to 11000 but the invoice total is 9000/, + ); + }); + + it("throws on a non-ETB invoice with no exchange rate", () => { + expect(() => toEimsInvoice(invoice({ currency: "USD" }), seller, context())).toThrow(/needs an exchangeRate/); + }); +}); + +describe("formatEimsDate", () => { + it("renders the observed dd-MM-yyyyTHH:mm:ss shape with zero padding", () => { + expect(formatEimsDate(new Date(2025, 2, 21, 0, 0, 0))).toBe("21-03-2025T00:00:00"); + }); +}); diff --git a/apps/edr-freight-api/src/modules/billing/eims-invoice.mapper.ts b/apps/edr-freight-api/src/modules/billing/eims-invoice.mapper.ts new file mode 100644 index 000000000..864d9f829 --- /dev/null +++ b/apps/edr-freight-api/src/modules/billing/eims-invoice.mapper.ts @@ -0,0 +1,362 @@ +/** + * Pure mapper from an EDR invoice onto the Ethiopian MoR EIMS registration document + * (`POST https://core.mor.gov.et/v1/register`). + * + * Field names, casing and section layout are taken verbatim from the supplied + * `EimsCoreApiMockCollection2.postman_collection.json`. Note the payload spells the district + * `Wereda` even though the collection *variable* is named `sellerWoreda`. + * + * Scope: mapping only — no HTTP, no signing, no persistence, no counter allocation. Everything + * that does not live on the invoice (document number, counters, previous IRN, seller identity, + * tax treatment) is supplied by the caller and is never guessed here. + * + * Values that the collection only *demonstrates* by example — the date format, the meaning of an + * empty `PreviousIrn`, the `SystemType` enum, `PaymentTerm` values — are treated as observed, not + * authoritative: they are passed through or overridable rather than validated against a fixed set. + */ + +import { round2 } from "./invoice-settlement.util"; + +/** Only proven-required constant: the 400 SCHEMA ERROR sample rejects a payload without it. */ +const EIMS_VERSION = "1"; + +/** The only `DocumentDetails.Type` observed in the supplied material. */ +const EIMS_DOCUMENT_TYPE = "INV"; + +export interface EimsBuyerDetails { + City: string | null; + Email: string | null; + HouseNumber: string | null; + IdNumber: string | null; + IdType: string | null; + Tin: string; + LegalName: string; + Phone: string | null; + Region: string | null; + Country: string | null; + Zone: string | null; + Kebele: string | null; + VatNumber: string | null; + Wereda: string | null; +} + +export interface EimsSellerDetails { + City: string | null; + Email: string | null; + HouseNumber: string | null; + LegalName: string; + Locality: string | null; + Phone: string | null; + /** MoR region *code* (e.g. "13"), not a region name. */ + Region: string | null; + SubCity: string | null; + Tin: string; + VatNumber: string | null; + /** MoR wereda *code* (e.g. "574"). */ + Wereda: string | null; +} + +export interface EimsDocumentDetails { + DocumentNumber: string; + /** Observed format `dd-MM-yyyyTHH:mm:ss`. Rule seen in the collection: within 3 days of now. */ + Date: string; + Type: string; +} + +export interface EimsInvoiceItem { + Discount: number; + ExciseTaxValue: number; + HarmonizationCode: string | null; + NatureOfSupplies: string; + ItemCode: string; + ProductDescription: string; + PreTaxValue: number; + Quantity: number; + LineNumber: number; + TaxAmount: number; + TaxCode: string; + TotalLineAmount: number; + Unit: string; + UnitPrice: number; +} + +export interface EimsPaymentDetails { + Mode: string; + PaymentTerm: string; +} + +export interface EimsReferenceDetails { + PreviousIrn: string | null; + RelatedDocument: string | null; +} + +export interface EimsSourceSystem { + CashierName: string | null; + InvoiceCounter: number; + SalesPersonName: string | null; + SystemNumber: string; + SystemType: string; +} + +export interface EimsValueDetails { + Discount: number | null; + ExciseValue: number; + IncomeWithholdValue: number; + TaxValue: number; + TotalValue: number; + TransactionWithholdValue: number; + InvoiceCurrency: string; + /** Absent from the register sample, present on the verify response. Emitted only when supplied. */ + ExchangeRate?: number; +} + +export interface EimsInvoiceRequest { + BuyerDetails: EimsBuyerDetails; + DocumentDetails: EimsDocumentDetails; + ItemList: EimsInvoiceItem[]; + PaymentDetails: EimsPaymentDetails; + ReferenceDetails: EimsReferenceDetails; + SellerDetails: EimsSellerDetails; + SourceSystem: EimsSourceSystem; + TransactionType: string; + ValueDetails: EimsValueDetails; + Version: string; +} + +/** `body` of a successful `POST /v1/register`, as observed in the collection. */ +export interface EimsRegisterResponseBody { + irn: string; + ackDate: string; + signedQR: string; + signedInvoice: string; + status: string; + documentNumber: string; + errorMessage: string | null; +} + +/** Numeric columns arrive from pg as strings; every money field is normalised through `num`. */ +export interface EimsMapperLine { + chargeType: string; + description?: string | null; + quantity: number | string; + unitRate: number | string; + amount: number | string; + metadata?: Record | null; +} + +export interface EimsMapperCompany { + name: string; + tin: string; + vatNumber?: string | null; + phone?: string | null; + email?: string | null; + region?: string | null; + zone?: string | null; + woreda?: string | null; + kebele?: string | null; + houseNo?: string | null; + country?: string | null; +} + +/** + * Structurally what `BillingService.findById` returns — the only read path that loads the header, + * the buyer company and the lines together. + */ +export interface EimsMapperInvoice { + invoiceNumber: string; + currency: string; + issuedAt?: Date | string | null; + totalAmount: number | string; + company?: EimsMapperCompany | null; + lines: EimsMapperLine[]; +} + +/** + * Tax treatment for a single line. EIMS models `TaxCode`/`TaxAmount`/`ExciseTaxValue` per item, and + * different charge types may eventually be treated differently, so this is resolved per line. + * + * Nothing in this repo can supply it: `Invoice.taxAmount` is hardcoded to 0 with no caller ever + * setting it, `invoice_lines` has no tax column, and the rate catalogue has no fiscal field. That + * is the absence of a tax model, not evidence of zero-rating — hence no default here. + */ +export interface EimsLineTax { + code: string; + ratePercent: number; + exciseTaxValue: number; +} + +export interface EimsMapperContext { + systemNumber: string; + /** Observed values: POS, MAN, CRM, EFD, SYS (the collection prose also mentions ERP). */ + systemType: string; + /** Caller decides the source — our own `invoiceNumber` or a dedicated EIMS sequence. */ + documentNumber: string; + invoiceCounter: number; + /** Passed through verbatim; the collection shows `""` used for an unchained document. */ + previousIrn: string | null; + cashierName: string | null; + salesPersonName: string | null; + /** B2B / B2C — a tax classification, so the caller states it. */ + transactionType: string; + payment: { mode: string; term: string }; + /** Must return a treatment for every line, or throw. */ + taxForLine: (line: EimsMapperLine, lineNumber: number) => EimsLineTax; + natureOfSupplies: string; + /** Used when a line carries no `metadata.unit`. */ + unitDefault: string; + incomeWithholdValue: number; + transactionWithholdValue: number; + /** Null for an ordinary invoice; set only for a real related-document case. */ + relatedDocument?: string | null; + /** MoR numeric country code for the buyer; our DB stores the country name. */ + buyerCountryCode?: string | null; + buyerIdType?: string | null; + buyerIdNumber?: string | null; + buyerCity?: string | null; + /** Required when the invoice currency is not ETB. */ + exchangeRate?: number | null; + invoiceDiscount?: number | null; + /** Override while the observed `dd-MM-yyyyTHH:mm:ss` format is unconfirmed by MoR. */ + formatDate?: (issuedAt: Date) => string; +} + +const num = (v: number | string): number => { + const n = Number(v); + if (!Number.isFinite(n)) throw new Error(`EIMS mapping: expected a numeric value, got ${String(v)}`); + return n; +}; + +const pad = (n: number, width = 2): string => String(n).padStart(width, "0"); + +/** Observed EIMS document-date format: `dd-MM-yyyyTHH:mm:ss`, no timezone marker. */ +export const formatEimsDate = (issuedAt: Date): string => + `${pad(issuedAt.getDate())}-${pad(issuedAt.getMonth() + 1)}-${issuedAt.getFullYear()}` + + `T${pad(issuedAt.getHours())}:${pad(issuedAt.getMinutes())}:${pad(issuedAt.getSeconds())}`; + +/** + * Map one loaded invoice onto an EIMS registration document. + * + * Throws rather than emitting a payload EIMS would reject opaquely: missing buyer TIN, no lines, + * an unissued invoice, unresolved line tax, a line/total mismatch, or a non-ETB invoice with no + * exchange rate. + */ +export function toEimsInvoice( + invoice: EimsMapperInvoice, + seller: EimsSellerDetails, + context: EimsMapperContext, +): EimsInvoiceRequest { + const company = invoice.company; + if (!company || !company.tin?.trim()) { + throw new Error(`EIMS mapping: invoice ${invoice.invoiceNumber} has no buyer company TIN`); + } + if (!invoice.lines?.length) { + throw new Error(`EIMS mapping: invoice ${invoice.invoiceNumber} has no lines`); + } + if (!invoice.issuedAt) { + throw new Error(`EIMS mapping: invoice ${invoice.invoiceNumber} is not issued (issuedAt is null)`); + } + if (invoice.currency !== "ETB" && context.exchangeRate == null) { + throw new Error( + `EIMS mapping: invoice ${invoice.invoiceNumber} is in ${invoice.currency} and needs an exchangeRate`, + ); + } + + const issuedAt = invoice.issuedAt instanceof Date ? invoice.issuedAt : new Date(invoice.issuedAt); + if (Number.isNaN(issuedAt.getTime())) { + throw new Error(`EIMS mapping: invoice ${invoice.invoiceNumber} has an unparseable issuedAt`); + } + + const ItemList: EimsInvoiceItem[] = invoice.lines.map((line, index) => { + const lineNumber = index + 1; + const tax = context.taxForLine(line, lineNumber); + if (!tax || !tax.code || !Number.isFinite(tax.ratePercent) || !Number.isFinite(tax.exciseTaxValue)) { + throw new Error( + `EIMS mapping: unresolved tax treatment for line ${lineNumber} (${line.chargeType}) ` + + `on invoice ${invoice.invoiceNumber}`, + ); + } + + const PreTaxValue = round2(num(line.amount)); + const TaxAmount = round2((PreTaxValue * tax.ratePercent) / 100); + const ExciseTaxValue = round2(tax.exciseTaxValue); + const unit = typeof line.metadata?.unit === "string" ? line.metadata.unit : context.unitDefault; + + return { + Discount: 0, + ExciseTaxValue, + HarmonizationCode: null, + NatureOfSupplies: context.natureOfSupplies, + ItemCode: line.chargeType, + ProductDescription: line.description?.trim() || line.chargeType, + PreTaxValue, + Quantity: round2(num(line.quantity)), + LineNumber: lineNumber, + TaxAmount, + TaxCode: tax.code, + TotalLineAmount: round2(PreTaxValue + TaxAmount + ExciseTaxValue), + Unit: unit, + UnitPrice: round2(num(line.unitRate)), + }; + }); + + const preTaxTotal = round2(ItemList.reduce((sum, item) => sum + item.PreTaxValue, 0)); + const invoiceTotal = round2(num(invoice.totalAmount)); + if (Math.abs(preTaxTotal - invoiceTotal) > 0.01) { + throw new Error( + `EIMS mapping: invoice ${invoice.invoiceNumber} lines sum to ${preTaxTotal} ` + + `but the invoice total is ${invoiceTotal}`, + ); + } + + const ValueDetails: EimsValueDetails = { + Discount: context.invoiceDiscount ?? null, + ExciseValue: round2(ItemList.reduce((sum, item) => sum + item.ExciseTaxValue, 0)), + IncomeWithholdValue: context.incomeWithholdValue, + TaxValue: round2(ItemList.reduce((sum, item) => sum + item.TaxAmount, 0)), + TotalValue: round2(ItemList.reduce((sum, item) => sum + item.TotalLineAmount, 0)), + TransactionWithholdValue: context.transactionWithholdValue, + InvoiceCurrency: invoice.currency, + }; + if (context.exchangeRate != null) ValueDetails.ExchangeRate = context.exchangeRate; + + return { + BuyerDetails: { + City: context.buyerCity ?? null, + Email: company.email ?? null, + HouseNumber: company.houseNo ?? null, + IdNumber: context.buyerIdNumber ?? null, + IdType: context.buyerIdType ?? null, + Tin: company.tin, + LegalName: company.name, + Phone: company.phone ?? null, + Region: company.region ?? null, + Country: context.buyerCountryCode ?? null, + Zone: company.zone ?? null, + Kebele: company.kebele ?? null, + VatNumber: company.vatNumber ?? null, + Wereda: company.woreda ?? null, + }, + DocumentDetails: { + DocumentNumber: context.documentNumber, + Date: (context.formatDate ?? formatEimsDate)(issuedAt), + Type: EIMS_DOCUMENT_TYPE, + }, + ItemList, + PaymentDetails: { Mode: context.payment.mode, PaymentTerm: context.payment.term }, + ReferenceDetails: { + PreviousIrn: context.previousIrn, + RelatedDocument: context.relatedDocument ?? null, + }, + SellerDetails: seller, + SourceSystem: { + CashierName: context.cashierName, + InvoiceCounter: context.invoiceCounter, + SalesPersonName: context.salesPersonName, + SystemNumber: context.systemNumber, + SystemType: context.systemType, + }, + TransactionType: context.transactionType, + ValueDetails, + Version: EIMS_VERSION, + }; +} diff --git a/apps/edr-freight-api/src/modules/billing/entities/invoice.entity.ts b/apps/edr-freight-api/src/modules/billing/entities/invoice.entity.ts index 23c332f80..c5c000943 100644 --- a/apps/edr-freight-api/src/modules/billing/entities/invoice.entity.ts +++ b/apps/edr-freight-api/src/modules/billing/entities/invoice.entity.ts @@ -1,6 +1,7 @@ import { BaseEntity } from "@edr/api-common"; import { Freight } from "@edr/types"; import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm"; +import type { EimsInvoiceError, EimsInvoiceStatus } from "../../eims/eims-registration.types"; import { PaymentEntity } from "../../payment/entities/payment.entity"; import { Company } from "../../companies/entities/company.entity"; import { CompanyProfile } from "../../companies/entities/company-profile.entity"; @@ -105,4 +106,27 @@ export class Invoice extends BaseEntity { @Column({ name: "due_at", type: "timestamptz" }) dueAt!: Date; + + /** MoR EIMS registration state. Set only by the EIMS module; billing never writes these. */ + @Column({ name: "eims_status", type: "varchar", length: 20, default: "NOT_SUBMITTED" }) + eimsStatus!: EimsInvoiceStatus; + + /** Invoice Reference Number returned by EIMS. Unique across invoices (partial index). */ + @Column({ name: "eims_irn", type: "varchar", length: 64, nullable: true }) + eimsIrn?: string | null; + + /** The `SourceSystem.InvoiceCounter` this invoice consumed. */ + @Column({ name: "eims_invoice_counter", type: "bigint", nullable: true }) + eimsInvoiceCounter?: number | null; + + @Column({ name: "eims_submitted_at", type: "timestamptz", nullable: true }) + eimsSubmittedAt?: Date | null; + + /** EIMS acknowledgement timestamp, stored verbatim — it is a Java ZonedDateTime string. */ + @Column({ name: "eims_ack_date", type: "varchar", length: 64, nullable: true }) + eimsAckDate?: string | null; + + /** Sanitized last failure: the gateway's own error fields only, never our signed envelope. */ + @Column({ name: "eims_last_error", type: "jsonb", nullable: true }) + eimsLastError?: EimsInvoiceError | null; } diff --git a/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.spec.ts b/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.spec.ts index 2c21d804c..9a5960295 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.spec.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.spec.ts @@ -1,5 +1,6 @@ import { BookingLifecycleNotifierService } from './booking-lifecycle-notifier.service'; import type { Booking } from './entities/booking.entity'; +import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; /** * Who hears "Operations wants changes" depends on who owns the booking. A @@ -74,3 +75,39 @@ describe('BookingLifecycleNotifierService — operation changes requested', () = expect(inbox.notify.mock.calls[0][0].recipients).toEqual({ companyId: 'co-1' }); }); }); + +/** + * Staff notifications used to go to every employee in every organization. They + * now target a desk — and the two desks are disjoint: the GL presets hold no + * bookings:view and no intake keys, so intake pings would be noise they cannot + * act on. Both branches run through the same `inAppStaff` helper, which is the + * easy place to lose the distinction again. + */ +describe('BookingLifecycleNotifierService — staff desk targeting', () => { + const booking = () => + ({ id: 'b-1', reference: 'BKG-0001', companyId: 'co-1' }) as Booking; + + let inbox: { notify: jest.Mock }; + let service: BookingLifecycleNotifierService; + + beforeEach(() => { + inbox = { notify: jest.fn().mockResolvedValue(undefined) }; + service = new BookingLifecycleNotifierService( + { directSend: jest.fn().mockResolvedValue(undefined) } as never, + inbox as never, + { query: jest.fn().mockResolvedValue([]) } as never, + ); + }); + + it('routes intake items to the booking desk and clearance items to the clearance desk', () => { + service.submittedToStaff(booking()); + service.clearanceDocsUploadedToStaff(booking()); + + expect(inbox.notify.mock.calls[0][0].recipients).toEqual({ + permissionKeys: [FREIGHT_PERMS.bookings.getNotification], + }); + expect(inbox.notify.mock.calls[1][0].recipients).toEqual({ + permissionKeys: [FREIGHT_PERMS.bookings.clearanceGetNotification], + }); + }); +}); diff --git a/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.ts index 6245195eb..90b020472 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.ts @@ -11,6 +11,16 @@ import { Booking } from './entities/booking.entity'; import { NotificationsService } from '../notifications/notifications.service'; import { NotificationInboxService } from '../notification-inbox/notification-inbox.service'; import { resolveCompanyNotifyPhone } from '../notifications/resolve-company-phone.util'; +import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; + +/** + * Clearance items are worked by the GL desks, which hold no bookings:view and + * no intake keys — so they take their own selector rather than the booking + * desk's. Every override using this deep-links to a clearance page. + */ +const CLEARANCE_DESK = { + permissionKeys: [FREIGHT_PERMS.bookings.clearanceGetNotification], +}; /** * Customer + staff notifications for the booking lifecycle: review, clearance @@ -89,7 +99,11 @@ export class BookingLifecycleNotifierService { }); } - /** Persist + push an in-app item to every backoffice staff user. */ + /** + * Persist + push an in-app item to the booking desk — staff holding + * `bookings:get_notification`. Callers whose item belongs to a different desk + * override `recipients` (see {@link CLEARANCE_DESK}). + */ private inAppStaff( b: Booking, title: string, @@ -97,7 +111,7 @@ export class BookingLifecycleNotifierService { overrides: Partial = {}, ): void { void this.inbox.notify({ - recipients: { allBackoffice: true }, + recipients: { permissionKeys: [FREIGHT_PERMS.bookings.getNotification] }, audience: NotificationAudience.BACKOFFICE, type: NotificationType.REQUEST_SUBMITTED, title, @@ -274,6 +288,7 @@ export class BookingLifecycleNotifierService { `the customs declaration can be filed.${note ? ` Note: "${note}"` : ''}`; this.logger.log(`TRANSIT ASSIGNEE REQUESTED — ${this.ref(b)}`); this.inAppStaff(b, `Transit assignee needed — ${b.reference}`, msg, { + recipients: CLEARANCE_DESK, type: NotificationType.CLEARANCE_REVIEW, link: `/dashboard/gl-djibouti/clearance/${b.id}`, }); @@ -288,6 +303,7 @@ export class BookingLifecycleNotifierService { `The customs declaration can now be filed.`; this.logger.log(`TRANSIT ASSIGNEE ASSIGNED — ${this.ref(b)}`); this.inAppStaff(b, `Transit assignee set — ${b.reference}`, msg, { + recipients: CLEARANCE_DESK, type: NotificationType.CLEARANCE_REVIEW, link: `/dashboard/bookings/${b.id}/clearance`, }); @@ -395,6 +411,7 @@ export class BookingLifecycleNotifierService { 'Clearance documents uploaded', `Customer uploaded clearance documents for booking ${this.ref(b)} — review them in the clearance queue.`, { + recipients: CLEARANCE_DESK, type: NotificationType.CLEARANCE_REVIEW, link: `/dashboard/bookings/${b.id}/clearance`, }, @@ -422,6 +439,7 @@ export class BookingLifecycleNotifierService { `The customer requested a change to the draft declaration on booking ${this.ref(b)}: ` + `"${note}". Send a corrected draft from the clearance page.`; this.inAppStaff(b, `Draft declaration change requested — ${this.ref(b)}`, msg, { + recipients: CLEARANCE_DESK, type: NotificationType.CLEARANCE_REVIEW, link: `/dashboard/bookings/${b.id}/clearance`, }); @@ -440,6 +458,7 @@ export class BookingLifecycleNotifierService { 'Payment slip uploaded', `Customer uploaded the ${label} payment slip for booking ${this.ref(b)}.`, { + recipients: CLEARANCE_DESK, type: NotificationType.PAYMENT_RECEIVED, link: `/dashboard/bookings/${b.id}/clearance`, }, diff --git a/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.ts index c576f5a43..198342cc8 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.ts @@ -1112,12 +1112,14 @@ export class BookingWagonCancellationService { private notifyStaff(booking: Booking, title: string, body: string): void { void this.inbox.notify({ - recipients: { allBackoffice: true }, + recipients: { permissionKeys: [FREIGHT_PERMS.bookings.getNotification] }, audience: NotificationAudience.BACKOFFICE, type: NotificationType.BOOKING_STATUS, title, body, - link: `/bookings/${booking.id}`, + // The portal path `/bookings/:id` used to be sent here, which 404s in the + // dashboard. The staff view of these lives on the queue page. + link: '/dashboard/wagon-cancellations', data: { bookingId: booking.id, reference: booking.reference }, }); } diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts index e4b902fe1..15799f000 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -424,7 +424,10 @@ export class BookingsService { const departureStation = booking.originYard?.label ?? booking.originYard?.code ?? '-'; const arrivalStation = booking.destinationYard?.label ?? booking.destinationYard?.code ?? '-'; - const cargoName = booking.cargoType?.cargoTypeName ?? booking.cargoFreeText ?? '-'; + // Container bookings carry no cargo type or free text — name the freight type + // rather than printing a dash in the Cargo Name column. + const cargoName = + booking.cargoType?.cargoTypeName ?? booking.cargoFreeText ?? booking.freightType ?? '-'; const currency = booking.paymentCurrency ?? 'ETB'; const totalAmount = Number(booking.adjustedTotalAmount ?? booking.totalAmount) || 0; const prices = this.splitAmountAcrossWagons( @@ -467,6 +470,28 @@ export class BookingsService { ) .join(''); + // The totals belong in , not : the Chromium-less fallback + // renderer only parses tbody rows, so a silently drops every footer + // figure from the printed sheet. + const totalsRow = ` + TOT + ${wagons.length} ${pendingWagons ? 'received lines' : 'wagons'} + ${ + pendingWagons + ? 'pending marshalling' + : `full ${fullWagons} / empty ${wagons.length - fullWagons}` + } + ${num(totals.tare, 2)} + ${num(totals.length)} + ${num(totals.capacity)} + + Gross ${num(totals.tare + totals.load)} T + + + + ${money(totalAmount)} + `; + return ` @@ -490,7 +515,7 @@ export class BookingsService { th { background: #f8fafc; color: #475569; text-align: left; } th, td { border: 1px solid #cbd5e1; padding: 5px 6px; font-size: 9.5px; vertical-align: top; } .num { text-align: right; } - tfoot td { background: #f8fafc; font-weight: 700; } + tr.totals td { background: #f8fafc; font-weight: 700; } .notice { margin-top: 10px; border-left: 4px solid #0f766e; background: #f0fdfa; padding: 8px 10px; font-size: 10px; color: #134e4a; } .signatures { display: grid; grid-template-columns: repeat(3, 1fr); gap: 18px; margin-top: 34px; } .line { border-top: 1px solid #334155; padding-top: 7px; font-size: 9px; color: #475569; min-height: 34px; } @@ -538,21 +563,8 @@ export class BookingsService { ${rows} + ${totalsRow} - - - ${ - pendingWagons - ? `Received lines: ${wagons.length} — wagons pending marshalling` - : `Total wagons: ${wagons.length} (full ${fullWagons} / empty ${wagons.length - fullWagons})` - } - ${num(totals.tare, 2)} - ${num(totals.length)} - ${num(totals.capacity)} - Gross weight (tare + load): ${num(totals.tare + totals.load)} T - ${money(totalAmount)} - -
diff --git a/apps/edr-freight-api/src/modules/cargoes/cargoes.controller.ts b/apps/edr-freight-api/src/modules/cargoes/cargoes.controller.ts index ac3adb7e8..09c9b677a 100644 --- a/apps/edr-freight-api/src/modules/cargoes/cargoes.controller.ts +++ b/apps/edr-freight-api/src/modules/cargoes/cargoes.controller.ts @@ -20,7 +20,14 @@ import { CargoesService } from './cargoes.service'; @ApiTags('cargoes') @Controller('cargoes') -@FleetView(FREIGHT_PERMS.cargoes.view) +// Class gate lists every key its routes use: Nest runs class AND method +// guards, so a key missing here would deny before the route's own key runs. +@FleetView([ + FREIGHT_PERMS.cargoes.view, + FREIGHT_PERMS.cargoes.create, + FREIGHT_PERMS.cargoes.update, + FREIGHT_PERMS.cargoes.delete, +]) export class CargoesController { constructor(private readonly cargoesService: CargoesService) {} diff --git a/apps/edr-freight-api/src/modules/companies/company-notifier.service.ts b/apps/edr-freight-api/src/modules/companies/company-notifier.service.ts index 66f88e9f8..178ace492 100644 --- a/apps/edr-freight-api/src/modules/companies/company-notifier.service.ts +++ b/apps/edr-freight-api/src/modules/companies/company-notifier.service.ts @@ -11,6 +11,7 @@ import { Company, CompanyStatus } from "./entities/company.entity"; import { NotificationsService } from "../notifications/notifications.service"; import { NotificationInboxService } from "../notification-inbox/notification-inbox.service"; import { resolveCompanyNotifyPhone } from "../notifications/resolve-company-phone.util"; +import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry"; /** Account statuses that lock the customer out and therefore must be told to them. */ const PUNITIVE_STATUSES: readonly CompanyStatus[] = [ @@ -175,12 +176,9 @@ export class CompanyNotifierService { // ── Backoffice-facing: work has arrived back in the review queue ──────────── /** - * Persist + push an in-app item to every backoffice staff user, deep-linked to - * the customer's detail page. - * - * The recipient resolver has no role/permission targeting (see - * `notification-recipients.service.ts`) — `allBackoffice` is the narrowest - * selector available, so marketing is reached by notifying all staff. + * Persist + push an in-app item to the customer desk — staff holding + * `customers:get_notification` — deep-linked to the customer's detail page, + * which is itself gated on `customers:view`. */ private notifyStaff( company: Company, @@ -189,7 +187,7 @@ export class CompanyNotifierService { data: Record = {}, ): void { void this.inbox.notify({ - recipients: { allBackoffice: true }, + recipients: { permissionKeys: [FREIGHT_PERMS.customers.getNotification] }, audience: NotificationAudience.BACKOFFICE, type: NotificationType.REQUEST_SUBMITTED, title, diff --git a/apps/edr-freight-api/src/modules/compliance/compliance.controller.ts b/apps/edr-freight-api/src/modules/compliance/compliance.controller.ts index 0e5949300..25d010253 100644 --- a/apps/edr-freight-api/src/modules/compliance/compliance.controller.ts +++ b/apps/edr-freight-api/src/modules/compliance/compliance.controller.ts @@ -11,7 +11,12 @@ import { ComplianceType } from './entities/compliance-record.entity'; @ApiTags('Vehicle Compliance') @Controller('compliance') -@BookingStaff(FREIGHT_PERMS.compliance.view) +// Class gate lists every key its routes use: Nest runs class AND method +// guards, so a key missing here would deny before the route's own key runs. +@BookingStaff([ + FREIGHT_PERMS.compliance.view, + FREIGHT_PERMS.compliance.manage, +]) export class ComplianceController { constructor(private readonly complianceService: ComplianceService) {} diff --git a/apps/edr-freight-api/src/modules/consignments/consignments.controller.ts b/apps/edr-freight-api/src/modules/consignments/consignments.controller.ts index 579b9ee26..a9aaa73e3 100644 --- a/apps/edr-freight-api/src/modules/consignments/consignments.controller.ts +++ b/apps/edr-freight-api/src/modules/consignments/consignments.controller.ts @@ -17,7 +17,12 @@ import { FilterConsignmentDto } from "./dto/filter-consignment.dto"; @ApiTags("consignments") @Controller("consignments") -@FleetView(FREIGHT_PERMS.consignments.view) +// Class gate lists every key its routes use: Nest runs class AND method +// guards, so a key missing here would deny before the route's own key runs. +@FleetView([ + FREIGHT_PERMS.consignments.view, + FREIGHT_PERMS.consignments.create, +]) export class ConsignmentsController { constructor(private readonly consignmentsService: ConsignmentsService) {} diff --git a/apps/edr-freight-api/src/modules/container-management/containers.controller.ts b/apps/edr-freight-api/src/modules/container-management/containers.controller.ts index 0a5e6bb0f..a209e2609 100644 --- a/apps/edr-freight-api/src/modules/container-management/containers.controller.ts +++ b/apps/edr-freight-api/src/modules/container-management/containers.controller.ts @@ -19,7 +19,14 @@ import { ContainersService } from './containers.service'; @ApiTags('containers') @Controller('containers') -@FleetView(FREIGHT_PERMS.containers.view) +// Class gate lists every key its routes use: Nest runs class AND method +// guards, so a key missing here would deny before the route's own key runs. +@FleetView([ + FREIGHT_PERMS.containers.view, + FREIGHT_PERMS.containers.create, + FREIGHT_PERMS.containers.update, + FREIGHT_PERMS.containers.delete, +]) export class ContainersController { constructor(private readonly containersService: ContainersService) {} diff --git a/apps/edr-freight-api/src/modules/contracts/contract-expiry.service.spec.ts b/apps/edr-freight-api/src/modules/contracts/contract-expiry.service.spec.ts index 0551cfc7a..27cfa5fa1 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-expiry.service.spec.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-expiry.service.spec.ts @@ -1,5 +1,6 @@ import { ContractExpiryService } from './contract-expiry.service'; import type { Contract } from './entities/contract.entity'; +import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; /** * The reminder must warn each customer once, ten days out, and must never let a @@ -60,4 +61,18 @@ describe('ContractExpiryService — expiry reminder', () => { inbox.notify.mockRejectedValue(new Error('inbox down')); await expect(service.remindExpiringContracts()).resolves.toBeUndefined(); }); + + // The sweep-failure alert is staff-facing. It used to go to every employee; + // it belongs to the people who would notice expired contracts still listed + // as active, i.e. the contract desk. + it('alerts the contract desk when the sweep itself fails', async () => { + repo.expireLapsedContracts.mockRejectedValue(new Error('deadlock')); + + await service.expireLapsedContracts(); + + expect(inbox.notify).toHaveBeenCalledTimes(1); + expect(inbox.notify.mock.calls[0][0].recipients).toEqual({ + permissionKeys: [FREIGHT_PERMS.contracts.getNotification], + }); + }); }); diff --git a/apps/edr-freight-api/src/modules/contracts/contract-expiry.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-expiry.service.ts index 1ef84c141..c570fbc0a 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-expiry.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-expiry.service.ts @@ -4,6 +4,7 @@ import { NotificationAudience, NotificationType } from '@edr/types'; import { NotificationInboxService } from '../notification-inbox/notification-inbox.service'; import { ContractsRepository } from './contracts.repository'; +import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; /** * How many days before a contract lapses the customer is reminded. Mirrored by @@ -79,7 +80,11 @@ export class ContractExpiryService { ); try { await this.inbox.notify({ - recipients: { allBackoffice: true }, + // The people who would notice expired contracts still listed as + // active are the ones working the contract desk. + recipients: { + permissionKeys: [FREIGHT_PERMS.contracts.getNotification], + }, audience: NotificationAudience.BACKOFFICE, type: NotificationType.GENERIC, title: 'Contract expiry sweep failed', diff --git a/apps/edr-freight-api/src/modules/contracts/contract-notifier.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-notifier.service.ts index 92a313569..e3c79a742 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-notifier.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-notifier.service.ts @@ -11,6 +11,16 @@ import { Contract } from './entities/contract.entity'; import { NotificationsService } from '../notifications/notifications.service'; import { NotificationInboxService } from '../notification-inbox/notification-inbox.service'; import { resolveCompanyNotifyPhone } from '../notifications/resolve-company-phone.util'; +import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; + +/** + * Clearance items are worked by the GL desks, which hold no intake keys — so + * they take their own selector rather than the contract desk's. Every override + * using this deep-links to a clearance or shipment-request page. + */ +const CLEARANCE_DESK = { + permissionKeys: [FREIGHT_PERMS.contracts.clearanceGetNotification], +}; /** * Customer + staff notifications for the contract lifecycle. Every customer @@ -86,7 +96,11 @@ export class ContractNotifierService { }); } - /** Persist + push an in-app item to every backoffice staff user. */ + /** + * Persist + push an in-app item to the contract desk — staff holding + * `contracts:get_notification`. Callers whose item belongs to a different + * desk override `recipients` (see {@link CLEARANCE_DESK}). + */ private inAppStaff( c: Contract, title: string, @@ -94,7 +108,7 @@ export class ContractNotifierService { overrides: Partial = {}, ): void { void this.inbox.notify({ - recipients: { allBackoffice: true }, + recipients: { permissionKeys: [FREIGHT_PERMS.contracts.getNotification] }, audience: NotificationAudience.BACKOFFICE, type: NotificationType.REQUEST_SUBMITTED, title, @@ -230,6 +244,7 @@ export class ContractNotifierService { `the customs declaration can be filed.${note ? ` Note: "${note}"` : ''}`; this.logger.log(`TRANSIT ASSIGNEE REQUESTED — ${c.reference}`); this.inAppStaff(c, `Transit assignee needed — ${c.reference}`, msg, { + recipients: CLEARANCE_DESK, type: NotificationType.CLEARANCE_REVIEW, link: `/dashboard/gl-djibouti/clearance/${c.id}`, }); @@ -248,6 +263,7 @@ export class ContractNotifierService { `The customs declaration can now be filed.`; this.logger.log(`TRANSIT ASSIGNEE ASSIGNED — ${c.reference}`); this.inAppStaff(c, `Transit assignee set — ${c.reference}`, msg, { + recipients: CLEARANCE_DESK, type: NotificationType.CLEARANCE_REVIEW, link: `/dashboard/contracts/clearance/${c.id}`, }); @@ -264,6 +280,7 @@ export class ContractNotifierService { `"${note}". Review and re-advise the amount on the clearance page.`; this.logger.log(`DUTY DISPUTED — ${c.reference}`); this.inAppStaff(c, `Duty disputed on ${c.reference}`, msg, { + recipients: CLEARANCE_DESK, type: NotificationType.CLEARANCE_REVIEW, link: `/dashboard/contracts/clearance/${c.id}`, }); @@ -321,6 +338,7 @@ export class ContractNotifierService { 'Clearance documents uploaded', `Customer uploaded clearance documents for contract ${this.ref(c)} — review them in the clearance queue.`, { + recipients: CLEARANCE_DESK, type: NotificationType.CLEARANCE_REVIEW, link: `/dashboard/contracts/clearance/${c.id}`, }, @@ -334,6 +352,7 @@ export class ContractNotifierService { 'Duty slip uploaded', `Customer uploaded the duty & tax payment slip for contract ${this.ref(c)}.`, { + recipients: CLEARANCE_DESK, type: NotificationType.PAYMENT_RECEIVED, link: `/dashboard/contracts/clearance/${c.id}`, }, @@ -347,6 +366,9 @@ export class ContractNotifierService { 'New shipment request', `Shipment request ${requestRef} was filed under contract ${this.ref(c)} and awaits GL review.`, { + // GL reviews these, and the shipment-requests page is gated on + // contracts:create_booking — a key only the GL Ethiopia preset holds. + recipients: CLEARANCE_DESK, link: `/dashboard/shipment-requests/${requestId}`, data: { contractId: c.id, requestId, reference: requestRef }, }, diff --git a/apps/edr-freight-api/src/modules/drivers/drivers.controller.ts b/apps/edr-freight-api/src/modules/drivers/drivers.controller.ts index e5b6ae146..8d2be55df 100644 --- a/apps/edr-freight-api/src/modules/drivers/drivers.controller.ts +++ b/apps/edr-freight-api/src/modules/drivers/drivers.controller.ts @@ -24,7 +24,14 @@ import { FleetHistoryService } from '../fleet-history/fleet-history.service'; @ApiTags('drivers') @ApiBearerAuth() @Controller('drivers') -@BookingStaff(FREIGHT_PERMS.drivers.view) +// Class gate lists every key its routes use: Nest runs class AND method +// guards, so a key missing here would deny before the route's own key runs. +@BookingStaff([ + FREIGHT_PERMS.drivers.view, + FREIGHT_PERMS.drivers.create, + FREIGHT_PERMS.drivers.update, + FREIGHT_PERMS.drivers.delete, +]) export class DriversController { constructor( private readonly driversService: DriversService, diff --git a/apps/edr-freight-api/src/modules/eims/dto/resolve-eims-registration.dto.ts b/apps/edr-freight-api/src/modules/eims/dto/resolve-eims-registration.dto.ts new file mode 100644 index 000000000..66896fd18 --- /dev/null +++ b/apps/edr-freight-api/src/modules/eims/dto/resolve-eims-registration.dto.ts @@ -0,0 +1,25 @@ +import { ApiPropertyOptional } from "@nestjs/swagger"; +import { IsBoolean, IsOptional, IsString, Length } from "class-validator"; + +/** + * Manual reconciliation of a submission that was never acknowledged. Exactly one of the two is + * meaningful: supply the IRN confirmed with MoR, or discard the attempt. + */ +export class ResolveEimsRegistrationDto { + @ApiPropertyOptional({ + description: "IRN confirmed in the MoR portal. Records the registration and resumes the chain.", + example: "9fe9bbbece6ab76c112b617534e6aac7aa8b819d5be79f4d3d088ed2e887b2e0", + }) + @IsOptional() + @IsString() + @Length(1, 64) + irn?: string; + + @ApiPropertyOptional({ + description: "Abandon the submission: the invoice is marked FAILED and the chain is unchanged.", + example: true, + }) + @IsOptional() + @IsBoolean() + discard?: boolean; +} diff --git a/apps/edr-freight-api/src/modules/eims/eims-auth.service.spec.ts b/apps/edr-freight-api/src/modules/eims/eims-auth.service.spec.ts new file mode 100644 index 000000000..1bed1fe29 --- /dev/null +++ b/apps/edr-freight-api/src/modules/eims/eims-auth.service.spec.ts @@ -0,0 +1,256 @@ +import { HttpService } from "@nestjs/axios"; +import { ConfigService } from "@nestjs/config"; +import { AxiosError, AxiosHeaders } from "axios"; +import { of, throwError } from "rxjs"; +import { EimsConfig } from "../../config/eims.config"; +import { eimsConfig, eimsToken } from "./eims-test-fixtures"; +import { EimsAuthService } from "./eims-auth.service"; +import { EimsSignerService } from "./eims-signer.service"; + +const CLIENT_SECRET = "super-secret-value"; +const API_KEY = "super-secret-apikey"; + +const cfg = (over: Partial = {}): EimsConfig => eimsConfig(over); + +const TOKEN_1 = eimsToken({ jti: "one" }); +const TOKEN_2 = eimsToken({ jti: "two" }); + +const loginBody = (accessToken: string, expiresIn = 3600) => ({ + data: { accessToken, refreshToken: "refresh-1", encryptionKey: null, expiresIn }, + status: "SUCCESS", +}); + +/** Stub signer: the real signing path has its own spec and needs no key material here. */ +const signer = { + signRequest: (request: T) => ({ request, signature: "SIGNATURE", certificate: "CERTIFICATE" }), +} as unknown as EimsSignerService; + +const build = (post: jest.Mock, config: EimsConfig = cfg()) => + new EimsAuthService( + { post } as unknown as HttpService, + { get: () => config } as unknown as ConfigService, + signer, + ); + +const axiosErr = (status: number, data: unknown) => + new AxiosError("Request failed", undefined, undefined, undefined, { + status, + statusText: "", + data, + headers: new AxiosHeaders(), + config: { headers: new AxiosHeaders() }, + }); + +describe("EimsAuthService.getValidAccessToken", () => { + it("posts the signed login envelope to /auth/login with no Authorization header", async () => { + const post = jest.fn().mockReturnValue(of({ data: loginBody(TOKEN_1) })); + + await build(post).getValidAccessToken(); + + expect(post).toHaveBeenCalledTimes(1); + const [url, body, options] = post.mock.calls[0]; + expect(url).toBe("https://core.mor.gov.et/auth/login"); + expect(options.headers).toEqual({ "Content-Type": "application/json" }); + expect(options.headers.Authorization).toBeUndefined(); + + expect(typeof body).toBe("string"); + expect(JSON.parse(body)).toEqual({ + request: { clientId: "cid", clientSecret: CLIENT_SECRET, apikey: API_KEY, tin: "0000034558" }, + signature: "SIGNATURE", + certificate: "CERTIFICATE", + }); + }); + + it("returns the access token from data.accessToken", async () => { + const post = jest.fn().mockReturnValue(of({ data: loginBody(TOKEN_1) })); + await expect(build(post).getValidAccessToken()).resolves.toBe(TOKEN_1); + }); + + it("reuses a cached token instead of logging in again", async () => { + const post = jest.fn().mockReturnValue(of({ data: loginBody(TOKEN_1) })); + const auth = build(post); + + await auth.getValidAccessToken(); + await expect(auth.getValidAccessToken()).resolves.toBe(TOKEN_1); + expect(post).toHaveBeenCalledTimes(1); + }); + + it("re-authenticates a skew-window before the token actually expires", async () => { + const post = jest + .fn() + .mockReturnValueOnce(of({ data: loginBody(TOKEN_1, 100) })) // 100s ttl, 45s skew ⇒ usable 55s + .mockReturnValueOnce(of({ data: loginBody(TOKEN_2) })); + const auth = build(post); + const start = Date.now(); + const clock = jest.spyOn(Date, "now"); + + try { + clock.mockReturnValue(start); + await expect(auth.getValidAccessToken()).resolves.toBe(TOKEN_1); + + clock.mockReturnValue(start + 50_000); // inside the window: still cached + await expect(auth.getValidAccessToken()).resolves.toBe(TOKEN_1); + expect(post).toHaveBeenCalledTimes(1); + + clock.mockReturnValue(start + 56_000); // past ttl-minus-skew, before the real 100s expiry + await expect(auth.getValidAccessToken()).resolves.toBe(TOKEN_2); + expect(post).toHaveBeenCalledTimes(2); + } finally { + clock.mockRestore(); + } + }); + + it("logs in again after invalidate()", async () => { + const post = jest + .fn() + .mockReturnValueOnce(of({ data: loginBody(TOKEN_1) })) + .mockReturnValueOnce(of({ data: loginBody(TOKEN_2) })); + const auth = build(post); + + await auth.getValidAccessToken(); + auth.invalidate(); + await expect(auth.getValidAccessToken()).resolves.toBe(TOKEN_2); + expect(post).toHaveBeenCalledTimes(2); + }); + + it("performs exactly one login for many concurrent callers", async () => { + const post = jest.fn().mockReturnValue(of({ data: loginBody(TOKEN_1) })); + const auth = build(post); + + const tokens = await Promise.all(Array.from({ length: 20 }, () => auth.getValidAccessToken())); + + expect(post).toHaveBeenCalledTimes(1); + expect(new Set(tokens)).toEqual(new Set([TOKEN_1])); + }); + + it("does not put the access token in its own log line", async () => { + const post = jest.fn().mockReturnValue(of({ data: loginBody(TOKEN_1) })); + const logged: string[] = []; + const auth = build(post); + jest + .spyOn(auth["logger"], "log") + .mockImplementation((message: unknown) => void logged.push(String(message))); + + await auth.getValidAccessToken(); + + expect(logged.join("\n")).not.toContain(TOKEN_1); + expect(logged.join("\n")).toContain("B0360154BA"); + }); + + it("refuses to call the gateway when EIMS is disabled", async () => { + const post = jest.fn(); + await expect(build(post, cfg({ enabled: false })).getValidAccessToken()).rejects.toThrow( + /EIMS integration is disabled/, + ); + expect(post).not.toHaveBeenCalled(); + }); + + it("rejects a 200 response that carries no access token", async () => { + const post = jest.fn().mockReturnValue(of({ data: { data: {}, status: "SUCCESS" } })); + await expect(build(post).getValidAccessToken()).rejects.toThrow(/returned no accessToken/); + }); + + it("surfaces gateway errors without leaking credentials or the envelope", async () => { + const post = jest.fn().mockReturnValue( + throwError(() => + axiosErr(401, { + message: "GATEWAY ERROR", + statusCode: 401, + code: "4400", + details: [{ errorMessage: "Invalid Credentials" }], + // Fields the gateway must never echo back into our logs or exceptions: + signature: "SIGNATURE", + certificate: "CERTIFICATE", + accessToken: "leaked-token", + }), + ), + ); + + const error = (await build(post) + .getValidAccessToken() + .catch((e: Error) => e)) as Error & { response?: unknown }; + const serialized = JSON.stringify({ message: error.message, response: error.response }); + + expect(error.message).toContain("EIMS login failed (401)"); + expect(error.message).toContain("Invalid Credentials"); + for (const secret of [CLIENT_SECRET, API_KEY, "SIGNATURE", "CERTIFICATE", "leaked-token"]) { + expect(serialized).not.toContain(secret); + } + }); + + it("maps a timeout to a TIMEOUT failure without a status", async () => { + const timeout = new AxiosError("timeout of 30000ms exceeded", "ECONNABORTED"); + const post = jest.fn().mockReturnValue(throwError(() => timeout)); + + await expect(build(post).getValidAccessToken()).rejects.toThrow(/EIMS login timed out/); + }); + + it("maps an unreachable gateway to a NETWORK failure", async () => { + const refused = new AxiosError("connect ECONNREFUSED", "ECONNREFUSED"); + const post = jest.fn().mockReturnValue(throwError(() => refused)); + + await expect(build(post).getValidAccessToken()).rejects.toThrow(/could not reach the gateway/); + }); +}); + +describe("EimsAuthService.getSessionContext", () => { + it("takes the source system from the token's claims", async () => { + const post = jest + .fn() + .mockReturnValue( + of({ data: loginBody(eimsToken({ systemNumber: "FROM-TOKEN", systemType: "POS" })) }), + ); + + // Env deliberately left empty: with nothing to check against, the token is simply believed. + await expect( + build(post, cfg({ systemNumber: "", systemType: "" })).getSessionContext(), + ).resolves.toEqual({ systemNumber: "FROM-TOKEN", systemType: "POS" }); + }); + + it("serves the session from the cached login rather than re-authenticating", async () => { + const post = jest.fn().mockReturnValue(of({ data: loginBody(TOKEN_1) })); + const auth = build(post); + + await auth.getSessionContext(); + await expect(auth.getSessionContext()).resolves.toEqual({ + systemNumber: "B0360154BA", + systemType: "SYS", + }); + expect(post).toHaveBeenCalledTimes(1); + }); + + it.each(["systemNumber", "systemType"])("rejects a token with no %s claim", async (claim) => { + const post = jest + .fn() + .mockReturnValue(of({ data: loginBody(eimsToken({ [claim]: undefined })) })); + + await expect( + build(post, cfg({ systemNumber: "", systemType: "" })).getSessionContext(), + ).rejects.toThrow(new RegExp(`no ${claim} claim`)); + }); + + it("rejects an access token that is not a decodable JWT", async () => { + const post = jest.fn().mockReturnValue(of({ data: loginBody("not-a-jwt") })); + + await expect(build(post).getSessionContext()).rejects.toThrow(/not a JWT/); + }); + + it.each([ + ["systemNumber", { systemNumber: "SOMETHING-ELSE" }, /EIMS_SYSTEM_NUMBER=B0360154BA/], + ["systemType", { systemType: "POS" }, /EIMS_SYSTEM_TYPE=SYS/], + ])("fails fast when the configured %s disagrees with the token", async (_name, over, pattern) => { + const post = jest.fn().mockReturnValue(of({ data: loginBody(eimsToken(over)) })); + + // cfg() sets EIMS_SYSTEM_NUMBER=B0360154BA and EIMS_SYSTEM_TYPE=SYS as expectations. + await expect(build(post).getSessionContext()).rejects.toThrow(pattern); + }); + + it("accepts a configured value that matches the token", async () => { + const post = jest.fn().mockReturnValue(of({ data: loginBody(TOKEN_1) })); + + await expect(build(post).getSessionContext()).resolves.toEqual({ + systemNumber: "B0360154BA", + systemType: "SYS", + }); + }); +}); diff --git a/apps/edr-freight-api/src/modules/eims/eims-auth.service.ts b/apps/edr-freight-api/src/modules/eims/eims-auth.service.ts new file mode 100644 index 000000000..9e53723d0 --- /dev/null +++ b/apps/edr-freight-api/src/modules/eims/eims-auth.service.ts @@ -0,0 +1,212 @@ +import { HttpService } from "@nestjs/axios"; +import { Injectable, Logger } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { firstValueFrom } from "rxjs"; +import { EimsConfig } from "../../config/eims.config"; +import { EimsSignerService, toSignedBody } from "./eims-signer.service"; +import { EimsApiException, EimsConfigException, toEimsApiException } from "./eims.errors"; +import { EimsLoginRequest, EimsLoginResponse } from "./eims.types"; + +interface TokenCache { + accessToken: string; + /** Epoch ms, already reduced by the configured skew. */ + expiresAt: number; + session: EimsSessionContext; +} + +/** + * Source-system identity, taken from the access token MoR issues us. + * + * The gateway stamps `systemNumber` and `systemType` into the token for the credentials that + * authenticated, which makes the token the authority on them — not our environment file. Anything + * we configured locally can only ever disagree with what MoR believes. + */ +export interface EimsSessionContext { + systemNumber: string; + systemType: string; +} + +/** Decode a JWT payload without verifying it: this is MoR's token, signed with MoR's key. */ +function decodeTokenClaims(accessToken: string): Record { + const payload = accessToken.split(".")[1]; + if (!payload) { + throw new EimsApiException("UNKNOWN", "EIMS access token is not a JWT (no payload segment)"); + } + try { + return JSON.parse(Buffer.from(payload, "base64url").toString("utf8")) as Record; + } catch (err) { + // The token itself is never included — only that its payload would not parse. + throw new EimsApiException( + "UNKNOWN", + `EIMS access token payload could not be decoded: ${(err as Error).message}`, + ); + } +} + +const claimString = (claims: Record, name: string): string => { + const value = claims[name]; + return typeof value === "string" ? value.trim() : ""; +}; + +/** Used when the gateway omits `expiresIn`; the observed value is 3600. */ +const FALLBACK_EXPIRES_IN_SECONDS = 3600; + +/** + * EIMS authentication: signed `POST /auth/login`, plus an in-memory access-token cache. + * + * Login is the one EIMS call that carries no bearer token, which is why it lives here rather than + * in the generic client. Tokens are held in memory only — never persisted, never logged, never + * returned to a frontend. + */ +@Injectable() +export class EimsAuthService { + private readonly logger = new Logger(EimsAuthService.name); + private cache: TokenCache | null = null; + private loginInFlight: Promise | null = null; + + constructor( + private readonly http: HttpService, + private readonly config: ConfigService, + private readonly signer: EimsSignerService, + ) {} + + private get cfg(): EimsConfig { + return this.config.get("eims")!; + } + + /** + * A non-expired access token, logging in if needed. Concurrent callers share one login: the + * first caller stores the in-flight promise and everyone else awaits it. + */ + async getValidAccessToken(): Promise { + if (this.cache && Date.now() < this.cache.expiresAt) { + return this.cache.accessToken; + } + if (this.loginInFlight) return this.loginInFlight; + + this.loginInFlight = this.login(); + try { + return await this.loginInFlight; + } finally { + this.loginInFlight = null; + } + } + + /** + * The source-system identity MoR issued this session, refreshing the login if needed. + * + * This is the authority for `SourceSystem.SystemNumber` / `SystemType`: the gateway stamps both + * into the access token for the authenticating credentials, so a local env value could only ever + * disagree with it. + */ + async getSessionContext(): Promise { + await this.getValidAccessToken(); + return this.cache!.session; + } + + /** Drop the cached token — called after a 401 so the next request re-authenticates. */ + invalidate(): void { + this.cache = null; + } + + /** + * Read the source-system claims out of the token, and cross-check anything configured locally. + * + * `EIMS_SYSTEM_NUMBER` / `EIMS_SYSTEM_TYPE` are optional expectations, not inputs: when set they + * are compared and a mismatch fails immediately rather than one silently winning. Registering + * under the wrong source system is not something to discover from a rejected invoice. + */ + private readSessionContext(accessToken: string, cfg: EimsConfig): EimsSessionContext { + const claims = decodeTokenClaims(accessToken); + const systemNumber = claimString(claims, "systemNumber"); + const systemType = claimString(claims, "systemType"); + + const missing = [ + !systemNumber && "systemNumber", + !systemType && "systemType", + ].filter(Boolean); + if (missing.length > 0) { + throw new EimsApiException( + "UNKNOWN", + `EIMS access token carries no ${missing.join(" or ")} claim; cannot identify the source system`, + ); + } + + const mismatches = [ + cfg.systemNumber && cfg.systemNumber !== systemNumber + ? `EIMS_SYSTEM_NUMBER=${cfg.systemNumber} but the token says ${systemNumber}` + : null, + cfg.systemType && cfg.systemType !== systemType + ? `EIMS_SYSTEM_TYPE=${cfg.systemType} but the token says ${systemType}` + : null, + ].filter(Boolean); + if (mismatches.length > 0) { + throw new EimsConfigException( + `EIMS source-system configuration disagrees with the issued token: ${mismatches.join("; ")}. ` + + "Correct the environment or the credentials — neither value is assumed to win.", + ); + } + + return { systemNumber, systemType }; + } + + private async login(): Promise { + const cfg = this.cfg; + if (!cfg.enabled) { + throw new EimsConfigException("EIMS integration is disabled; set EIMS_ENABLED=true to use it"); + } + + const request: EimsLoginRequest = { + clientId: cfg.clientId, + clientSecret: cfg.clientSecret, + apikey: cfg.apiKey, + tin: cfg.tin, + }; + const body = toSignedBody(this.signer.signRequest(request)); + + let response: EimsLoginResponse; + try { + const res = await firstValueFrom( + this.http.post(`${cfg.baseUrl}/auth/login`, body, { + headers: { "Content-Type": "application/json" }, + timeout: cfg.httpTimeoutMs, + }), + ); + response = res.data; + } catch (err) { + const mapped = toEimsApiException(err, "login"); + this.logger.error(mapped.message); + throw mapped; + } + + const accessToken = response?.data?.accessToken; + if (!accessToken) { + throw new EimsApiException("UNKNOWN", "EIMS login returned no accessToken"); + } + + const expiresIn = + Number.isFinite(response.data.expiresIn) && response.data.expiresIn > 0 + ? response.data.expiresIn + : FALLBACK_EXPIRES_IN_SECONDS; + + // TODO: implement `POST /auth/refresh-token` and hold `response.data.refreshToken`. The + // collection shows a bare `{refreshToken}` body with no envelope, but it also carries unsigned + // examples of calls that do require signing, so whether refresh must be signed is unconfirmed. + // Until MoR confirms it, an expired token just triggers a fresh login — `expiresIn` is 3600s, + // so that is one extra call an hour. + // Reject the session before caching it: a token we cannot identify a source system from is + // useless for registration, and a configured expectation that disagrees is a deployment fault. + const session = this.readSessionContext(accessToken, cfg); + + this.cache = { + accessToken, + expiresAt: Date.now() + Math.max(expiresIn * 1000 - cfg.tokenSkewMs, 1000), + session, + }; + this.logger.log( + `EIMS login succeeded; token cached for ~${expiresIn}s ` + + `(system ${session.systemNumber}, type ${session.systemType})`, + ); + return accessToken; + } +} diff --git a/apps/edr-freight-api/src/modules/eims/eims-auto-submit.service.spec.ts b/apps/edr-freight-api/src/modules/eims/eims-auto-submit.service.spec.ts new file mode 100644 index 000000000..6246d5a89 --- /dev/null +++ b/apps/edr-freight-api/src/modules/eims/eims-auto-submit.service.spec.ts @@ -0,0 +1,139 @@ +import { ConfigService } from "@nestjs/config"; +import { DataSource } from "typeorm"; + +import { EimsConfig } from "../../config/eims.config"; +import { EimsAutoSubmitService } from "./eims-auto-submit.service"; +import { EimsInvoiceRegistrationService } from "./eims-invoice-registration.service"; +import { EimsInvoiceStatus } from "./eims-registration.types"; +import { eimsConfig } from "./eims-test-fixtures"; + +const INVOICE_ID = "11111111-1111-4111-8111-111111111111"; + +/** + * `query` is answered by shape: the first call is the system-state guard, the second is the + * candidate lookup. Keeps the fake honest about the order the service actually asks in. + */ +const build = ( + opts: { + cfg?: Partial; + state?: { in_flight_invoice_id?: string | null; blocked_reason?: string | null }; + candidate?: { id: string; invoiceNumber: string } | null; + register?: jest.Mock; + } = {}, +) => { + const register = + opts.register ?? + jest.fn().mockResolvedValue({ eimsStatus: EimsInvoiceStatus.Registered, eimsIrn: "IRN-1" }); + + const query = jest.fn().mockImplementation((sql: string) => { + if (sql.includes("eims_system_state")) { + return Promise.resolve( + opts.state ? [{ in_flight_invoice_id: null, blocked_reason: null, ...opts.state }] : [], + ); + } + return Promise.resolve(opts.candidate === undefined ? [] : opts.candidate ? [opts.candidate] : []); + }); + + const service = new EimsAutoSubmitService( + { query } as unknown as DataSource, + { get: () => eimsConfig({ autoSubmit: true, ...opts.cfg }) } as unknown as ConfigService, + { registerInvoiceWithEims: register } as unknown as EimsInvoiceRegistrationService, + ); + return { service, register, query }; +}; + +const candidate = { id: INVOICE_ID, invoiceNumber: "INV-20260807-00006" }; + +describe("EimsAutoSubmitService.tick", () => { + it("files the oldest eligible invoice through the registration service", async () => { + const { service, register } = build({ candidate }); + + await service.tick(); + + expect(register).toHaveBeenCalledTimes(1); + expect(register).toHaveBeenCalledWith(INVOICE_ID); + }); + + it("files nothing when EIMS_AUTO_SUBMIT is off", async () => { + const { service, register, query } = build({ cfg: { autoSubmit: false }, candidate }); + + await service.tick(); + + expect(register).not.toHaveBeenCalled(); + expect(query).not.toHaveBeenCalled(); + }); + + it("files nothing when EIMS itself is disabled, even with auto-submit on", async () => { + const { service, register, query } = build({ cfg: { enabled: false }, candidate }); + + await service.tick(); + + expect(register).not.toHaveBeenCalled(); + expect(query).not.toHaveBeenCalled(); + }); + + it("does not submit while another submission is in flight", async () => { + const { service, register } = build({ + state: { in_flight_invoice_id: "22222222-2222-4222-8222-222222222222" }, + candidate, + }); + + await service.tick(); + + expect(register).not.toHaveBeenCalled(); + }); + + it("does not submit while the system number is blocked", async () => { + const { service, register } = build({ + state: { blocked_reason: "never acknowledged" }, + candidate, + }); + + await service.tick(); + + expect(register).not.toHaveBeenCalled(); + }); + + it("does nothing when no invoice is eligible", async () => { + const { service, register } = build({ candidate: null }); + + await service.tick(); + + expect(register).not.toHaveBeenCalled(); + }); + + it("asks only for NOT_SUBMITTED invoices, so UNKNOWN and FAILED are never retried", async () => { + const { service, query } = build({ candidate }); + + await service.tick(); + + const [sql, params] = query.mock.calls.find(([s]: [string]) => s.includes("freight.invoices"))!; + expect(sql).toContain("i.eims_status = $1"); + expect(params[0]).toBe(EimsInvoiceStatus.NotSubmitted); + expect(sql).toContain("i.issued_at IS NOT NULL"); + }); + + it("survives a filing failure so the job keeps running", async () => { + const register = jest.fn().mockRejectedValue(new Error("EIMS register failed (406)")); + const { service } = build({ candidate, register }); + + await expect(service.tick()).resolves.toBeUndefined(); + expect(register).toHaveBeenCalledTimes(1); + }); + + it("does not start a second tick while one is still filing", async () => { + let release: () => void = () => {}; + const register = jest.fn().mockImplementation( + () => new Promise((resolve) => (release = () => resolve({ eimsStatus: "REGISTERED" }))), + ); + const { service } = build({ candidate, register }); + + const first = service.tick(); + await new Promise((r) => setImmediate(r)); + await service.tick(); // overlapping tick, must be a no-op + + expect(register).toHaveBeenCalledTimes(1); + release(); + await first; + }); +}); diff --git a/apps/edr-freight-api/src/modules/eims/eims-auto-submit.service.ts b/apps/edr-freight-api/src/modules/eims/eims-auto-submit.service.ts new file mode 100644 index 000000000..fb5a0d1f6 --- /dev/null +++ b/apps/edr-freight-api/src/modules/eims/eims-auto-submit.service.ts @@ -0,0 +1,126 @@ +import { Injectable, Logger } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { Cron } from "@nestjs/schedule"; +import { InjectDataSource } from "@nestjs/typeorm"; +import { DataSource } from "typeorm"; + +import { EimsConfig } from "../../config/eims.config"; +import { EimsInvoiceRegistrationService } from "./eims-invoice-registration.service"; +import { EimsInvoiceStatus } from "./eims-registration.types"; + +/** + * Files issued invoices with MoR EIMS on a timer. + * + * Invoices are produced by the freight workflow rather than by a person, so this — not the manual + * endpoint — is the production path. It is a sweep rather than a hook on the eleven places an + * invoice can be created or issued, which buys three things: the workflow is untouched, the HTTP + * call is by construction outside the invoice's transaction, and an invoice missed through a crash + * or a restart is picked up on the next tick. + * + * `invoices.eims_status` is the queue — nothing new is persisted. Only `NOT_SUBMITTED` is eligible: + * `UNKNOWN` must never be retried automatically (the document may already be filed), and `FAILED` + * waits for an explicit retry policy rather than a timer's guess. + * + * Off unless **both** `EIMS_ENABLED` and `EIMS_AUTO_SUBMIT` are true. Enabling it starts filing + * real documents with the tax authority, and a registration cannot be undone from this side. + */ +@Injectable() +export class EimsAutoSubmitService { + private readonly logger = new Logger(EimsAutoSubmitService.name); + /** Guards against a tick starting while the previous one is still filing. */ + private running = false; + + constructor( + @InjectDataSource() private readonly dataSource: DataSource, + private readonly config: ConfigService, + private readonly registration: EimsInvoiceRegistrationService, + ) {} + + private get cfg(): EimsConfig { + return this.config.get("eims")!; + } + + /** + * One invoice per tick. + * + * Deliberately not a batch: each filing consumes a counter and advances the IRN chain, an + * ambiguous result blocks the system number until a human resolves it, and a misconfiguration + * should cost one rejected document rather than a burst of them. + */ + @Cron(process.env.EIMS_AUTO_SUBMIT_CRON ?? "0 */5 * * * *", { name: "eims-auto-submit" }) + async tick(): Promise { + const cfg = this.cfg; + if (!cfg.enabled || !cfg.autoSubmit) return; + if (this.running) return; + + this.running = true; + try { + // Rule of the chain: nothing may be filed while a submission is in flight or the system is + // blocked. The reservation would refuse anyway — checking first keeps the log quiet and + // avoids burning a tick on a guaranteed conflict. + const blocked = await this.systemBlockReason(); + if (blocked) { + this.logger.warn(`EIMS auto-submit paused: ${blocked}`); + return; + } + + const candidate = await this.nextCandidate(); + if (!candidate) return; + + const view = await this.registration.registerInvoiceWithEims(candidate.id); + this.logger.log( + `EIMS auto-submit: invoice ${candidate.invoiceNumber} -> ${view.eimsStatus}` + + (view.eimsIrn ? ` (IRN ${view.eimsIrn})` : ""), + ); + } catch (err) { + // Never let a filing failure kill the job. The outcome is already persisted on the invoice + // (FAILED or UNKNOWN with the gateway's own message), and a blocked system number stops the + // next tick at the guard above. + this.logger.error(`EIMS auto-submit tick failed: ${(err as Error).message}`); + } finally { + this.running = false; + } + } + + /** Why filing is currently impossible for this system number, or null when it is free. */ + private async systemBlockReason(): Promise { + const rows: { in_flight_invoice_id: string | null; blocked_reason: string | null }[] = + await this.dataSource.query( + `SELECT in_flight_invoice_id, blocked_reason + FROM freight.eims_system_state + WHERE system_number = $1 AND deleted_at IS NULL + LIMIT 1`, + [this.cfg.systemNumber], + ); + const state = rows[0]; + if (!state) return null; + if (state.blocked_reason) return state.blocked_reason; + if (state.in_flight_invoice_id) { + return `a submission for invoice ${state.in_flight_invoice_id} is still in flight`; + } + return null; + } + + /** + * Oldest never-submitted invoice that is issued, still inside MoR's document-age window, and + * carries at least one line. + */ + private async nextCandidate(): Promise<{ id: string; invoiceNumber: string } | null> { + const rows: { id: string; invoiceNumber: string }[] = await this.dataSource.query( + `SELECT i.id, i.invoice_number AS "invoiceNumber" + FROM freight.invoices i + WHERE i.eims_status = $1 + AND i.issued_at IS NOT NULL + AND i.deleted_at IS NULL + AND i.issued_at > now() - ($2 || ' days')::interval + AND EXISTS ( + SELECT 1 FROM freight.invoice_lines l + WHERE l.invoice_id = i.id AND l.deleted_at IS NULL + ) + ORDER BY i.issued_at ASC + LIMIT 1`, + [EimsInvoiceStatus.NotSubmitted, this.cfg.autoSubmitMaxAgeDays], + ); + return rows[0] ?? null; + } +} diff --git a/apps/edr-freight-api/src/modules/eims/eims-client.service.ts b/apps/edr-freight-api/src/modules/eims/eims-client.service.ts new file mode 100644 index 000000000..610647b1a --- /dev/null +++ b/apps/edr-freight-api/src/modules/eims/eims-client.service.ts @@ -0,0 +1,80 @@ +import { HttpService } from "@nestjs/axios"; +import { Injectable, Logger } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { firstValueFrom } from "rxjs"; +import { EimsConfig } from "../../config/eims.config"; +import { EimsAuthService } from "./eims-auth.service"; +import { EimsSignerService, toSignedBody } from "./eims-signer.service"; +import { toEimsApiException } from "./eims.errors"; + +/** + * Foundation for EIMS's bearer-authenticated endpoints (`/v1/register`, `/v1/verify`, …). + * + * Login is not routed through here: `/auth/login` carries no bearer token and lives in + * `EimsAuthService`. Nothing calls `postSigned` yet — invoice registration is a later phase. + */ +@Injectable() +export class EimsClientService { + private readonly logger = new Logger(EimsClientService.name); + + constructor( + private readonly http: HttpService, + private readonly config: ConfigService, + private readonly auth: EimsAuthService, + private readonly signer: EimsSignerService, + ) {} + + private get cfg(): EimsConfig { + return this.config.get("eims")!; + } + + /** + * Sign `request`, POST it to `path` with a valid bearer token, and return the parsed response. + * A 401 invalidates the cached token and retries exactly once. + */ + async postSigned(path: string, request: TRequest): Promise { + return this.send(path, request, false, true); + } + + /** + * POST `request` verbatim — bearer-authenticated but **not** wrapped in a signed envelope. + * + * `/v1/verify` is the only endpoint observed to work this way: the supplied collection sends a + * raw `{"irn":"…"}` body with no `signature`/`certificate` siblings. Kept as its own entry point + * so that if the live gateway turns out to require signing after all, exactly one call site + * changes — `postSigned` is already the alternative. + */ + async postBearer(path: string, request: TRequest): Promise { + return this.send(path, request, false, false); + } + + private async send( + path: string, + request: TRequest, + isRetry: boolean, + signed: boolean, + ): Promise { + const cfg = this.cfg; + const token = await this.auth.getValidAccessToken(); + const body = signed ? toSignedBody(this.signer.signRequest(request)) : request; + + try { + const res = await firstValueFrom( + this.http.post(`${cfg.baseUrl}${path}`, body, { + headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` }, + timeout: cfg.httpTimeoutMs, + }), + ); + return res.data; + } catch (err) { + const mapped = toEimsApiException(err, `POST ${path}`); + if (mapped.kind === "AUTH" && !isRetry) { + this.logger.warn(`EIMS rejected the token on ${path}; re-authenticating once`); + this.auth.invalidate(); + return this.send(path, request, true, signed); + } + this.logger.error(mapped.message); + throw mapped; + } + } +} diff --git a/apps/edr-freight-api/src/modules/eims/eims-credentials.provider.ts b/apps/edr-freight-api/src/modules/eims/eims-credentials.provider.ts new file mode 100644 index 000000000..b68a4a32f --- /dev/null +++ b/apps/edr-freight-api/src/modules/eims/eims-credentials.provider.ts @@ -0,0 +1,77 @@ +import { readFileSync } from "node:fs"; +import { KeyObject, createPrivateKey } from "node:crypto"; +import { Injectable, Logger } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { EimsConfig } from "../../config/eims.config"; +import { EimsConfigException } from "./eims.errors"; + +/** + * Loads the INSA-issued EIMS credentials from disk, once, and keeps them in memory. + * + * The certificate is sent as base64 of the **exact bytes of the issued file** — it is deliberately + * never parsed, re-encoded or re-exported, because that is what produced a working live login. + * The private key never leaves this process: it is only ever used to produce a signature. + */ +@Injectable() +export class EimsCredentialsProvider { + private readonly logger = new Logger(EimsCredentialsProvider.name); + private privateKey: KeyObject | null = null; + private certificateBase64: string | null = null; + + constructor(private readonly config: ConfigService) {} + + private get cfg(): EimsConfig { + return this.config.get("eims")!; + } + + /** RSA private key, parsed once. Throws a config error if the path is missing or unusable. */ + getPrivateKey(): KeyObject { + if (this.privateKey) return this.privateKey; + + const path = this.cfg.privateKeyPath; + if (!path) throw new EimsConfigException("EIMS_PRIVATE_KEY_PATH is not set"); + + let key: KeyObject; + try { + key = createPrivateKey(readFileSync(path)); + } catch (err) { + // The path is operational information, not a secret; the key material never appears. + throw new EimsConfigException( + `EIMS private key at ${path} could not be read or parsed: ${(err as Error).message}`, + ); + } + if (key.asymmetricKeyType !== "rsa") { + throw new EimsConfigException( + `EIMS private key at ${path} is ${key.asymmetricKeyType ?? "of unknown type"}; EIMS requires RSA`, + ); + } + + this.privateKey = key; + this.logger.log(`EIMS private key loaded (RSA-${key.asymmetricKeyDetails?.modulusLength ?? "?"})`); + return key; + } + + /** Base64 of the certificate file's exact bytes. No parsing, no re-encoding. */ + getCertificateBase64(): string { + if (this.certificateBase64) return this.certificateBase64; + + const path = this.cfg.certificatePath; + if (!path) throw new EimsConfigException("EIMS_CERTIFICATE_PATH is not set"); + + let bytes: Buffer; + try { + bytes = readFileSync(path); + } catch (err) { + throw new EimsConfigException( + `EIMS certificate at ${path} could not be read: ${(err as Error).message}`, + ); + } + if (bytes.length === 0) { + throw new EimsConfigException(`EIMS certificate at ${path} is empty`); + } + + this.certificateBase64 = bytes.toString("base64"); + this.logger.log(`EIMS certificate bundle loaded (${bytes.length} bytes)`); + return this.certificateBase64; + } +} diff --git a/apps/edr-freight-api/src/modules/eims/eims-invoice-context.ts b/apps/edr-freight-api/src/modules/eims/eims-invoice-context.ts new file mode 100644 index 000000000..20ccfdfba --- /dev/null +++ b/apps/edr-freight-api/src/modules/eims/eims-invoice-context.ts @@ -0,0 +1,117 @@ +import { BadRequestException } from "@nestjs/common"; +import { EimsConfig } from "../../config/eims.config"; +import { EimsSessionContext } from "./eims-auth.service"; +import { + EimsMapperContext, + EimsMapperLine, + EimsSellerDetails, +} from "../billing/eims-invoice.mapper"; + +/** + * Turns configuration into the seller identity and mapper context that `toEimsInvoice` requires. + * + * Everything here is unavailable from the database by construction: EDR's own legal identity is not + * modelled anywhere, and the application has no tax model at all (`invoice.taxAmount` is always 0, + * `invoice_lines` and the rate catalogue carry no fiscal columns). Rather than defaulting any of it, + * a missing value fails **here** — locally, before a single byte reaches the gateway — naming the + * exact environment variables to set. + */ + +interface RequiredSpec { + env: string; + value: string | number | null | undefined; +} + +// `systemNumber` / `systemType` are absent by design: they come from the access token, which is +// MoR's own statement of who we are. See EimsAuthService.getSessionContext. +const REQUIRED = (invoice: EimsConfig["invoice"], tin: string): RequiredSpec[] => [ + { env: "EIMS_TIN", value: tin }, + { env: "EIMS_SELLER_LEGAL_NAME", value: invoice.sellerLegalName }, + { env: "EIMS_SELLER_VAT_NUMBER", value: invoice.sellerVatNumber }, + { env: "EIMS_SELLER_PHONE", value: invoice.sellerPhone }, + { env: "EIMS_SELLER_EMAIL", value: invoice.sellerEmail }, + { env: "EIMS_SELLER_REGION", value: invoice.sellerRegion }, + { env: "EIMS_SELLER_WEREDA", value: invoice.sellerWereda }, + { env: "EIMS_TAX_CODE", value: invoice.taxCode }, + { env: "EIMS_TAX_RATE_PERCENT", value: invoice.taxRatePercent }, + { env: "EIMS_INCOME_WITHHOLD_VALUE", value: invoice.incomeWithholdValue }, + { env: "EIMS_TRANSACTION_WITHHOLD_VALUE", value: invoice.transactionWithholdValue }, + { env: "EIMS_TRANSACTION_TYPE", value: invoice.transactionType }, + { env: "EIMS_NATURE_OF_SUPPLIES", value: invoice.natureOfSupplies }, + { env: "EIMS_PAYMENT_MODE", value: invoice.paymentMode }, + { env: "EIMS_PAYMENT_TERM", value: invoice.paymentTerm }, + { env: "EIMS_UNIT_DEFAULT", value: invoice.unitDefault }, +]; + +/** Throws naming every unset variable at once, so one round trip fixes the whole configuration. */ +export function assertEimsInvoiceConfig(config: EimsConfig): void { + const missing = REQUIRED(config.invoice, config.tin) + .filter(({ value }) => value === null || value === undefined || value === "") + .map(({ env }) => env); + + if (missing.length > 0) { + throw new BadRequestException({ + code: "EIMS_INVOICE_CONFIG_INCOMPLETE", + message: + "EIMS invoice registration is not configured. Set these environment variables " + + `(tax values need finance sign-off — they are deliberately not defaulted): ${missing.join(", ")}`, + }); + } +} + +export function buildEimsSeller(config: EimsConfig): EimsSellerDetails { + const { invoice } = config; + return { + City: invoice.sellerCity, + Email: invoice.sellerEmail, + HouseNumber: invoice.sellerHouseNumber, + LegalName: invoice.sellerLegalName, + Locality: invoice.sellerLocality, + Phone: invoice.sellerPhone, + Region: invoice.sellerRegion, + SubCity: invoice.sellerSubCity, + Tin: config.tin, + VatNumber: invoice.sellerVatNumber, + Wereda: invoice.sellerWereda, + }; +} + +export interface EimsContextInput { + /** `DocumentDetails.DocumentNumber`. The caller decides its source. */ + documentNumber: string; + invoiceCounter: number; + previousIrn: string | null; + /** Source-system identity from the access token, never from configuration. */ + session: EimsSessionContext; + /** Required when the invoice currency is not ETB. */ + exchangeRate?: number | null; +} + +export function buildEimsContext(config: EimsConfig, input: EimsContextInput): EimsMapperContext { + const { invoice } = config; + // Validated by assertEimsInvoiceConfig; the non-null assertions below are safe after that call. + const taxCode = invoice.taxCode; + const ratePercent = invoice.taxRatePercent!; + const exciseTaxValue = invoice.exciseTaxValue ?? 0; + + return { + systemNumber: input.session.systemNumber, + systemType: input.session.systemType, + documentNumber: input.documentNumber, + invoiceCounter: input.invoiceCounter, + previousIrn: input.previousIrn, + cashierName: invoice.cashierName, + salesPersonName: invoice.salesPersonName, + transactionType: invoice.transactionType, + payment: { mode: invoice.paymentMode, term: invoice.paymentTerm }, + // One treatment for every line today. The mapper resolves tax per line, so a future + // charge-type-specific rule slots in here without touching the mapper. + taxForLine: (_line: EimsMapperLine) => ({ code: taxCode, ratePercent, exciseTaxValue }), + natureOfSupplies: invoice.natureOfSupplies, + unitDefault: invoice.unitDefault, + incomeWithholdValue: invoice.incomeWithholdValue!, + transactionWithholdValue: invoice.transactionWithholdValue!, + buyerCountryCode: invoice.buyerCountryCode, + exchangeRate: input.exchangeRate ?? null, + }; +} diff --git a/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.spec.ts b/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.spec.ts new file mode 100644 index 000000000..1035c2b33 --- /dev/null +++ b/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.spec.ts @@ -0,0 +1,565 @@ +import { BadRequestException, ConflictException } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { DataSource } from "typeorm"; + +import { EimsConfig } from "../../config/eims.config"; +import { Invoice } from "../billing/entities/invoice.entity"; +import { EimsInvoiceRequest } from "../billing/eims-invoice.mapper"; +import { eimsInvoiceConfig } from "./eims-test-fixtures"; +import { EimsAuthService } from "./eims-auth.service"; +import { EimsClientService } from "./eims-client.service"; +import { EimsApiException } from "./eims.errors"; +import { EimsInvoiceRegistrationService } from "./eims-invoice-registration.service"; +import { EimsSystemState } from "./entities/eims-system-state.entity"; +import { EimsInvoiceStatus } from "./eims-registration.types"; + +const SYSTEM_NUMBER = "B0360154BA"; +const INVOICE_ID = "11111111-1111-4111-8111-111111111111"; +const OTHER_INVOICE_ID = "22222222-2222-4222-8222-222222222222"; +const IRN = "9fe9bbbece6ab76c112b617534e6aac7aa8b819d5be79f4d3d088ed2e887b2e0"; + +const config = (over: Partial = {}): EimsConfig => + ({ + enabled: true, + baseUrl: "https://core.mor.gov.et", + clientId: "cid", + clientSecret: "secret", + apiKey: "key", + tin: "0000034558", + systemNumber: SYSTEM_NUMBER, + systemType: "SYS", + privateKeyPath: "/dev/null", + certificatePath: "/dev/null", + httpTimeoutMs: 30_000, + tokenSkewMs: 45_000, + invoice: eimsInvoiceConfig(over), + }) as EimsConfig; + +const invoiceRow = (over: Partial = {}): Invoice => + ({ + id: INVOICE_ID, + invoiceNumber: "INV-20260807-00042", + currency: "ETB", + issuedAt: new Date(2026, 7, 7, 9, 5, 3), + totalAmount: "10000.00", + eimsStatus: EimsInvoiceStatus.NotSubmitted, + eimsIrn: null, + eimsInvoiceCounter: null, + eimsSubmittedAt: null, + eimsAckDate: null, + eimsLastError: null, + company: { + name: "ABC Trading PLC", + tin: "0999930000", + vatNumber: "123475885858", + phone: "0912345678", + email: "buyer@abc.et", + region: "13", + zone: "SHA", + woreda: "574", + kebele: "03", + houseNo: "NEW", + country: "Ethiopia", + }, + ...over, + }) as unknown as Invoice; + +const LINES = [ + { + chargeType: "RAIL_FREIGHT", + description: "Addis to Djibouti", + quantity: "1.00", + unitRate: "10000.00", + amount: "10000.00", + }, +]; + +/** + * In-memory stand-in for the two locked rows. `update` merges, `createQueryBuilder(...).getOne()` + * returns the live object — enough to assert ordering, values and the reservation lifecycle without + * a database. + */ +class FakeDb { + invoices = new Map(); + state: EimsSystemState | null = null; + /** Runs before every transaction body, to simulate a concurrent writer. */ + onTransaction: (() => void) | null = null; + + constructor(invoices: Invoice[], state?: Partial) { + for (const inv of invoices) this.invoices.set(inv.id, inv); + this.state = { + id: "state-1", + systemNumber: SYSTEM_NUMBER, + nextInvoiceCounter: 7, + previousIrn: null, + inFlightInvoiceId: null, + inFlightCounter: null, + blockedReason: null, + ...state, + } as EimsSystemState; + } + + private manager = { + createQueryBuilder: (entity: unknown) => { + const isInvoice = entity === Invoice; + let id: string | undefined; + const builder = { + setLock: () => builder, + where: (_clause: string, params: Record) => { + id = params.invoiceId ?? params.systemNumber; + return builder; + }, + getOne: async () => (isInvoice ? (this.invoices.get(id!) ?? null) : this.state), + }; + return builder; + }, + findOne: async (_entity: unknown, options: { where: { id: string } }) => + this.invoices.get(options.where.id) ?? null, + update: async (entity: unknown, id: string, patch: Record) => { + if (entity === Invoice) Object.assign(this.invoices.get(id)!, patch); + else Object.assign(this.state!, patch); + }, + query: async () => [], + getRepository: () => ({ + findOne: async (options: { where: { id: string } }) => + this.invoices.get(options.where.id) ?? null, + }), + }; + + asDataSource(): DataSource { + return { + manager: this.manager, + getRepository: this.manager.getRepository, + query: async () => LINES, + transaction: async (body: (m: unknown) => Promise) => { + this.onTransaction?.(); + return body(this.manager); + }, + } as unknown as DataSource; + } +} + +/** The source system comes from the access token, so the service is handed a session, not config. */ +const SESSION = { systemNumber: SYSTEM_NUMBER, systemType: "SYS" }; + +const build = ( + db: FakeDb, + postSigned: jest.Mock, + cfg: EimsConfig = config(), + postBearer: jest.Mock = jest.fn(), + getSessionContext: jest.Mock = jest.fn().mockResolvedValue(SESSION), +) => + new EimsInvoiceRegistrationService( + db.asDataSource(), + { get: () => cfg } as unknown as ConfigService, + { postSigned, postBearer } as unknown as EimsClientService, + { getSessionContext } as unknown as EimsAuthService, + ); + +/** Document number the fixtures register under; `/v1/verify` must echo it back. */ +const DOCUMENT_NUMBER = "INV-20260807-00042"; + +/** + * `/v1/verify` success. The response spells the reference `Irn` while the request sends lowercase + * `irn`. + * + * The fixture is deliberately *coherent* — same IRN on both sides. The supplied Postman collection + * pairs a saved request and a saved response whose literal IRNs disagree, which is an artefact of + * the mock rather than gateway behaviour; asserting against that inconsistency would encode the + * mock's bug as a requirement. Resolution requires the returned `Irn` to match the one asked for, + * and these fixtures exercise that honestly. + */ +const verifyResponse = (over: Record = {}) => ({ + statusCode: 200, + message: "SUCCESS", + body: { + Irn: IRN, + TransactionType: "B2B", + DocumentDetails: { Type: "INV", DocumentNumber: DOCUMENT_NUMBER, Date: "07-08-2026T09:05:03" }, + Version: "1", + ...over, + }, +}); + +const okResponse = (irn = IRN) => + ({ statusCode: 200, message: "SUCCESS", body: { irn, ackDate: "2026-08-07T09:05:03Z[Etc/UTC]" } }); + +const apiError = (kind: string, status?: number) => + new EimsApiException(kind as never, `EIMS register failed (${status ?? "-"})`, status); + +describe("EimsInvoiceRegistrationService.registerInvoiceWithEims", () => { + it("registers, persists the IRN and advances the chain", async () => { + const db = new FakeDb([invoiceRow()]); + const postSigned = jest.fn().mockResolvedValue(okResponse()); + + const view = await build(db, postSigned).registerInvoiceWithEims(INVOICE_ID); + + expect(postSigned).toHaveBeenCalledTimes(1); + expect(postSigned.mock.calls[0][0]).toBe("/v1/register"); + expect(view).toMatchObject({ + eimsStatus: EimsInvoiceStatus.Registered, + eimsIrn: IRN, + eimsInvoiceCounter: 7, + eimsAckDate: "2026-08-07T09:05:03Z[Etc/UTC]", + }); + expect(db.state).toMatchObject({ + previousIrn: IRN, + nextInvoiceCounter: 8, + inFlightInvoiceId: null, + inFlightCounter: null, + blockedReason: null, + }); + }); + + it("sends the exact reserved counter and previous IRN to the mapper", async () => { + const db = new FakeDb([invoiceRow()], { nextInvoiceCounter: 42, previousIrn: "PRIOR-IRN" }); + const postSigned = jest.fn().mockResolvedValue(okResponse()); + + await build(db, postSigned).registerInvoiceWithEims(INVOICE_ID); + + const request = postSigned.mock.calls[0][1] as EimsInvoiceRequest; + expect(request.SourceSystem.InvoiceCounter).toBe(42); + expect(request.ReferenceDetails.PreviousIrn).toBe("PRIOR-IRN"); + expect(request.DocumentDetails.DocumentNumber).toBe("INV-20260807-00042"); + expect(request.SourceSystem.SystemNumber).toBe(SYSTEM_NUMBER); + }); + + it("takes SourceSystem from the token session, not from configuration", async () => { + const db = new FakeDb([invoiceRow()]); + const postSigned = jest.fn().mockResolvedValue(okResponse()); + // Config disagrees on purpose: only the session may reach the wire. + const cfg = config(); + (cfg as { systemNumber: string }).systemNumber = "CONFIG-ONLY"; + (cfg as { systemType: string }).systemType = "MAN"; + + await build( + db, + postSigned, + cfg, + jest.fn(), + jest.fn().mockResolvedValue({ systemNumber: "FROM-TOKEN", systemType: "POS" }), + ).registerInvoiceWithEims(INVOICE_ID); + + const request = postSigned.mock.calls[0][1] as EimsInvoiceRequest; + expect(request.SourceSystem.SystemNumber).toBe("FROM-TOKEN"); + expect(request.SourceSystem.SystemType).toBe("POS"); + }); + + it("does not consume a counter when authentication fails", async () => { + const db = new FakeDb([invoiceRow()]); + const postSigned = jest.fn(); + const getSessionContext = jest.fn().mockRejectedValue(new Error("login failed")); + + await expect( + build(db, postSigned, config(), jest.fn(), getSessionContext).registerInvoiceWithEims( + INVOICE_ID, + ), + ).rejects.toThrow(/login failed/); + + expect(postSigned).not.toHaveBeenCalled(); + expect(db.state).toMatchObject({ nextInvoiceCounter: 7, inFlightInvoiceId: null }); + expect(db.invoices.get(INVOICE_ID)!.eimsStatus).toBe(EimsInvoiceStatus.NotSubmitted); + }); + + it("is idempotent — an invoice with an IRN never reaches EIMS", async () => { + const db = new FakeDb([ + invoiceRow({ eimsIrn: IRN, eimsStatus: EimsInvoiceStatus.Registered }), + ]); + const postSigned = jest.fn(); + + const view = await build(db, postSigned).registerInvoiceWithEims(INVOICE_ID); + + expect(postSigned).not.toHaveBeenCalled(); + expect(view.eimsIrn).toBe(IRN); + }); + + it("lets only one of two concurrent calls reach EIMS", async () => { + const db = new FakeDb([invoiceRow()]); + let resolvePost: (v: unknown) => void = () => {}; + const postSigned = jest + .fn() + .mockImplementation(() => new Promise((resolve) => (resolvePost = resolve))); + const service = build(db, postSigned); + + const first = service.registerInvoiceWithEims(INVOICE_ID); + // Let the first reservation commit and its HTTP call start; it is now parked on `resolvePost`. + await new Promise((resolve) => setImmediate(resolve)); + expect(postSigned).toHaveBeenCalledTimes(1); + + const second = service.registerInvoiceWithEims(INVOICE_ID); + + await expect(second).rejects.toBeInstanceOf(ConflictException); + resolvePost(okResponse()); + await first; + expect(postSigned).toHaveBeenCalledTimes(1); + }); + + it("blocks a different invoice while a submission is in flight (survives a restart)", async () => { + // A committed reservation left behind by a dead process. + const db = new FakeDb( + [ + invoiceRow({ eimsStatus: EimsInvoiceStatus.Submitting, eimsInvoiceCounter: 7 }), + invoiceRow({ id: OTHER_INVOICE_ID, invoiceNumber: "INV-20260807-00043" }), + ], + { inFlightInvoiceId: INVOICE_ID, inFlightCounter: 7, nextInvoiceCounter: 8 }, + ); + const postSigned = jest.fn(); + + await expect( + build(db, postSigned).registerInvoiceWithEims(OTHER_INVOICE_ID), + ).rejects.toThrow(/already in flight/); + expect(postSigned).not.toHaveBeenCalled(); + }); + + it("fails locally on incomplete tax configuration, with zero HTTP calls", async () => { + const db = new FakeDb([invoiceRow()]); + const postSigned = jest.fn(); + + await expect( + build(db, postSigned, config({ taxCode: "", taxRatePercent: null })).registerInvoiceWithEims( + INVOICE_ID, + ), + ).rejects.toBeInstanceOf(BadRequestException); + + expect(postSigned).not.toHaveBeenCalled(); + expect(db.invoices.get(INVOICE_ID)!.eimsStatus).toBe(EimsInvoiceStatus.NotSubmitted); + expect(db.state).toMatchObject({ nextInvoiceCounter: 7, inFlightInvoiceId: null }); + }); + + it.each([ + ["SCHEMA_VALIDATION", 400], + ["RULE_VALIDATION", 406], + ])("marks %s (%i) FAILED and clears the global block", async (kind, status) => { + const db = new FakeDb([invoiceRow()]); + const postSigned = jest.fn().mockRejectedValue(apiError(kind, status)); + + await expect(build(db, postSigned).registerInvoiceWithEims(INVOICE_ID)).rejects.toBeInstanceOf( + EimsApiException, + ); + + expect(db.invoices.get(INVOICE_ID)).toMatchObject({ + eimsStatus: EimsInvoiceStatus.Failed, + eimsIrn: null, + }); + expect(db.state).toMatchObject({ + inFlightInvoiceId: null, + blockedReason: null, + previousIrn: null, + nextInvoiceCounter: 8, // consumed: the attempt reached the gateway + }); + }); + + it("treats a success response with no IRN as a failed registration", async () => { + const db = new FakeDb([invoiceRow()]); + const postSigned = jest.fn().mockResolvedValue({ statusCode: 200, body: { irn: "" } }); + + await expect(build(db, postSigned).registerInvoiceWithEims(INVOICE_ID)).rejects.toThrow( + /returned no IRN/, + ); + expect(db.invoices.get(INVOICE_ID)!.eimsStatus).toBe(EimsInvoiceStatus.Failed); + expect(db.state).toMatchObject({ inFlightInvoiceId: null, blockedReason: null }); + }); + + it("marks a timeout UNKNOWN and keeps the system blocked", async () => { + const db = new FakeDb([invoiceRow()]); + const postSigned = jest.fn().mockRejectedValue(apiError("TIMEOUT")); + + await expect(build(db, postSigned).registerInvoiceWithEims(INVOICE_ID)).rejects.toBeInstanceOf( + EimsApiException, + ); + + expect(db.invoices.get(INVOICE_ID)).toMatchObject({ + eimsStatus: EimsInvoiceStatus.Unknown, + eimsIrn: null, + }); + expect(db.state!.inFlightInvoiceId).toBe(INVOICE_ID); + expect(db.state!.blockedReason).toMatch(/never acknowledged/); + expect(db.state!.previousIrn).toBeNull(); + }); + + it("an UNKNOWN result blocks a different invoice too", async () => { + const db = new FakeDb([invoiceRow(), invoiceRow({ id: OTHER_INVOICE_ID })]); + const postSigned = jest.fn().mockRejectedValueOnce(apiError("TIMEOUT")); + const service = build(db, postSigned); + + await expect(service.registerInvoiceWithEims(INVOICE_ID)).rejects.toBeInstanceOf( + EimsApiException, + ); + await expect(service.registerInvoiceWithEims(OTHER_INVOICE_ID)).rejects.toThrow( + /registration is blocked/, + ); + expect(postSigned).toHaveBeenCalledTimes(1); + }); + + it("never reuses a counter once an attempt has begun", async () => { + const db = new FakeDb([invoiceRow(), invoiceRow({ id: OTHER_INVOICE_ID })]); + const postSigned = jest + .fn() + .mockRejectedValueOnce(apiError("RULE_VALIDATION", 406)) + .mockResolvedValueOnce(okResponse()); + const service = build(db, postSigned); + + await expect(service.registerInvoiceWithEims(INVOICE_ID)).rejects.toBeInstanceOf( + EimsApiException, + ); + await service.registerInvoiceWithEims(OTHER_INVOICE_ID); + + expect((postSigned.mock.calls[0][1] as EimsInvoiceRequest).SourceSystem.InvoiceCounter).toBe(7); + expect((postSigned.mock.calls[1][1] as EimsInvoiceRequest).SourceSystem.InvoiceCounter).toBe(8); + }); +}); + +describe("EimsInvoiceRegistrationService.verifyInvoiceWithEims", () => { + it("verifies the stored IRN over the unsigned bearer transport", async () => { + const db = new FakeDb([invoiceRow({ eimsIrn: IRN })]); + const postSigned = jest.fn(); + const postBearer = jest.fn().mockResolvedValue(verifyResponse()); + + const result = await build(db, postSigned, config(), postBearer).verifyInvoiceWithEims( + INVOICE_ID, + ); + + // Lowercase `irn`, raw body — not a signed envelope. `postSigned` must stay untouched. + expect(postBearer).toHaveBeenCalledWith("/v1/verify", { irn: IRN }); + expect(postSigned).not.toHaveBeenCalled(); + expect(result.body).toMatchObject({ Irn: IRN }); + }); + + it("rejects a 200 that carries no Irn", async () => { + const db = new FakeDb([invoiceRow({ eimsIrn: IRN })]); + const postBearer = jest.fn().mockResolvedValue({ statusCode: 200, body: { Irn: " " } }); + + await expect( + build(db, jest.fn(), config(), postBearer).verifyInvoiceWithEims(INVOICE_ID), + ).rejects.toThrow(/returned no Irn/); + }); + + it("refuses to verify an invoice with no IRN", async () => { + const db = new FakeDb([invoiceRow({ eimsStatus: EimsInvoiceStatus.Unknown })]); + const postBearer = jest.fn(); + + await expect( + build(db, jest.fn(), config(), postBearer).verifyInvoiceWithEims(INVOICE_ID), + ).rejects.toThrow(/no EIMS IRN to verify/); + expect(postBearer).not.toHaveBeenCalled(); + }); +}); + +describe("EimsInvoiceRegistrationService.resolveEimsRegistration", () => { + const blocked = () => + new FakeDb([invoiceRow({ eimsStatus: EimsInvoiceStatus.Unknown, eimsInvoiceCounter: 7 })], { + inFlightInvoiceId: INVOICE_ID, + inFlightCounter: 7, + nextInvoiceCounter: 8, + blockedReason: "never acknowledged", + }); + + it("records a confirmed IRN, resumes the chain and clears the block", async () => { + const db = blocked(); + const postBearer = jest.fn().mockResolvedValue(verifyResponse()); + + const view = await build(db, jest.fn(), config(), postBearer).resolveEimsRegistration( + INVOICE_ID, + { irn: IRN }, + ); + + // The IRN is confirmed at the gateway before it is ever written. + expect(postBearer).toHaveBeenCalledWith("/v1/verify", { irn: IRN }); + expect(view).toMatchObject({ eimsStatus: EimsInvoiceStatus.Registered, eimsIrn: IRN }); + expect(db.state).toMatchObject({ + previousIrn: IRN, + inFlightInvoiceId: null, + blockedReason: null, + }); + }); + + it("refuses an IRN the gateway answers with a different one, leaving the block intact", async () => { + const db = blocked(); + const postBearer = jest + .fn() + .mockResolvedValue(verifyResponse({ Irn: "0000000000000000000000000000000000000000" })); + + await expect( + build(db, jest.fn(), config(), postBearer).resolveEimsRegistration(INVOICE_ID, { irn: IRN }), + ).rejects.toThrow(/answered the lookup for IRN/); + + expect(db.invoices.get(INVOICE_ID)).toMatchObject({ + eimsStatus: EimsInvoiceStatus.Unknown, + eimsIrn: null, + }); + expect(db.state).toMatchObject({ + inFlightInvoiceId: INVOICE_ID, + blockedReason: "never acknowledged", + previousIrn: null, + }); + }); + + it("refuses an IRN whose document number is not this invoice, leaving the block intact", async () => { + const db = blocked(); + const postBearer = jest.fn().mockResolvedValue( + verifyResponse({ + DocumentDetails: { Type: "INV", DocumentNumber: "INV-20260807-99999" }, + }), + ); + + await expect( + build(db, jest.fn(), config(), postBearer).resolveEimsRegistration(INVOICE_ID, { irn: IRN }), + ).rejects.toThrow(/not INV-20260807-00042/); + + expect(db.invoices.get(INVOICE_ID)).toMatchObject({ + eimsStatus: EimsInvoiceStatus.Unknown, + eimsIrn: null, + }); + expect(db.state).toMatchObject({ + inFlightInvoiceId: INVOICE_ID, + blockedReason: "never acknowledged", + previousIrn: null, + }); + }); + + it("refuses an IRN the gateway does not acknowledge at all", async () => { + const db = blocked(); + const postBearer = jest.fn().mockResolvedValue({ statusCode: 200, body: {} }); + + await expect( + build(db, jest.fn(), config(), postBearer).resolveEimsRegistration(INVOICE_ID, { irn: IRN }), + ).rejects.toThrow(/returned no Irn/); + expect(db.state!.blockedReason).toBe("never acknowledged"); + }); + + it("discards the attempt, leaving the chain where it was", async () => { + const db = blocked(); + const postBearer = jest.fn(); + + const view = await build(db, jest.fn(), config(), postBearer).resolveEimsRegistration( + INVOICE_ID, + { discard: true }, + ); + + expect(view).toMatchObject({ eimsStatus: EimsInvoiceStatus.Failed, eimsIrn: null }); + expect(postBearer).not.toHaveBeenCalled(); // nothing to confirm + expect(db.state).toMatchObject({ + previousIrn: null, + inFlightInvoiceId: null, + blockedReason: null, + }); + }); + + it("refuses to resolve an invoice that is not the in-flight one", async () => { + const db = blocked(); + db.invoices.set(OTHER_INVOICE_ID, invoiceRow({ id: OTHER_INVOICE_ID })); + const postBearer = jest.fn().mockResolvedValue(verifyResponse()); + + await expect( + build(db, jest.fn(), config(), postBearer).resolveEimsRegistration(OTHER_INVOICE_ID, { + irn: IRN, + }), + ).rejects.toThrow(/in-flight EIMS submission is invoice/); + }); + + it("requires either an IRN or an explicit discard", async () => { + await expect( + build(blocked(), jest.fn()).resolveEimsRegistration(INVOICE_ID, {}), + ).rejects.toBeInstanceOf(BadRequestException); + }); +}); diff --git a/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.ts b/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.ts new file mode 100644 index 000000000..4ff9ccfb8 --- /dev/null +++ b/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.ts @@ -0,0 +1,497 @@ +import { + BadRequestException, + ConflictException, + Injectable, + Logger, + NotFoundException, +} from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { InjectDataSource } from "@nestjs/typeorm"; +import { DataSource, EntityManager } from "typeorm"; +import type { QueryDeepPartialEntity } from "typeorm/query-builder/QueryPartialEntity.js"; + +import { EimsConfig } from "../../config/eims.config"; +import { Invoice } from "../billing/entities/invoice.entity"; +import { + EimsInvoiceRequest, + EimsMapperLine, + toEimsInvoice, +} from "../billing/eims-invoice.mapper"; +import { EimsAuthService } from "./eims-auth.service"; +import { EimsClientService } from "./eims-client.service"; +import { EimsApiException } from "./eims.errors"; +import { EimsSystemState } from "./entities/eims-system-state.entity"; +import { + assertEimsInvoiceConfig, + buildEimsContext, + buildEimsSeller, +} from "./eims-invoice-context"; +import { + EimsInvoiceError, + EimsInvoiceStatus, + EimsInvoiceStatusView, + EimsRegisterResponse, + EimsVerifyRequest, + EimsVerifyResponse, +} from "./eims-registration.types"; + +/** + * Failure kinds where the gateway gave a complete answer: the document was rejected and is + * definitively not registered. These clear the system-wide block; anything else does not. + */ +const DETERMINISTIC_KINDS = new Set(["SCHEMA_VALIDATION", "RULE_VALIDATION", "AUTH", "FORBIDDEN"]); + +interface Reservation { + stateId: string; + invoiceCounter: number; + previousIrn: string; +} + +/** + * Registers a single invoice with MoR EIMS. + * + * Sequencing is a **durable reservation**: the counter is consumed and the holder recorded in a + * committed transaction *before* the request leaves the process, and the network call happens + * outside any transaction. That gives three properties the naive design could not: + * + * - a counter is never reused once an attempt has begun, even across a crash; + * - a crash mid-flight leaves the reservation standing, so nothing blindly resubmits a document + * that may already have reached MoR; + * - an ambiguous result blocks every invoice for the system number, not just its own, because + * `PreviousIrn` is unknown and any later document would chain to a stale IRN. + * + * Signing, authentication and error normalisation belong to `EimsClientService`. Manual only — + * nothing in invoice creation calls this. + */ +@Injectable() +export class EimsInvoiceRegistrationService { + private readonly logger = new Logger(EimsInvoiceRegistrationService.name); + + constructor( + @InjectDataSource() private readonly dataSource: DataSource, + private readonly config: ConfigService, + private readonly client: EimsClientService, + private readonly auth: EimsAuthService, + ) {} + + private get cfg(): EimsConfig { + return this.config.get("eims")!; + } + + async registerInvoiceWithEims(invoiceId: string): Promise { + const cfg = this.cfg; + // Static seller/tax configuration is validated before anything is locked, allocated or sent. + assertEimsInvoiceConfig(cfg); + + const invoice = await this.loadInvoiceForMapping(invoiceId); + if (invoice.eimsIrn) return this.toView(invoice); + + // Authenticate before reserving: the source system comes from the token, and the state row is + // keyed by it. A login failure here costs nothing — no counter has been consumed yet. + const session = await this.auth.getSessionContext(); + + const reservation = await this.reserve(invoiceId, session.systemNumber); + if (!reservation) return this.getEimsStatus(invoiceId); + + // The request can only be built now: InvoiceCounter and PreviousIrn come from the reservation. + const request = toEimsInvoice( + invoice, + buildEimsSeller(cfg), + buildEimsContext(cfg, { + // Our own invoice number is the document number; EIMS only requires it to be unique. + documentNumber: invoice.invoiceNumber, + invoiceCounter: reservation.invoiceCounter, + previousIrn: reservation.previousIrn, + session, + }), + ); + + let irn: string; + let ackDate: string | undefined; + try { + // Deliberately outside every transaction — no DB lock is held across the wire. + const result = await this.submit(request); + irn = result.irn; + ackDate = result.ackDate; + } catch (err) { + await this.settleFailure(invoiceId, reservation, err); + throw err; + } + + await this.settleSuccess(invoiceId, reservation, irn, ackDate); + this.logger.log( + `Invoice ${invoice.invoiceNumber} registered with EIMS (counter ${reservation.invoiceCounter})`, + ); + return this.getEimsStatus(invoiceId); + } + + /** + * Verify a registered invoice at `POST /v1/verify`. + * + * Requires a stored IRN. An invoice whose submission was never acknowledged cannot be reconciled + * here — the gateway offers no lookup by document number — so it must be resolved with MoR and + * recorded through `resolveEimsRegistration`. + */ + async verifyInvoiceWithEims(invoiceId: string): Promise { + const invoice = await this.loadInvoiceRow(this.dataSource.manager, invoiceId); + if (!invoice.eimsIrn) { + throw new BadRequestException({ + code: "EIMS_NO_IRN", + message: + `Invoice ${invoice.invoiceNumber} has no EIMS IRN to verify (status ${invoice.eimsStatus}). ` + + "EIMS can only be queried by IRN, so an unacknowledged submission must be resolved with MoR first.", + }); + } + return this.queryVerify(invoice.eimsIrn); + } + + /** + * `POST /v1/verify` for one IRN, with the one check that always applies: the gateway must echo + * an `Irn` back. A 200 without it is not a confirmation of anything. + * + * The request property is lowercase `irn`; the response spells it `Irn`. The two are never + * compared — the supplied collection's own fixture uses different example values on each side, + * so equality there would assert a property of the mock rather than of the gateway. + * + * Bearer-authenticated but unsigned, via `postBearer` — see that method for why. + */ + private async queryVerify(irn: string): Promise { + const response = await this.client.postBearer( + "/v1/verify", + { irn }, + ); + if (!response?.body?.Irn?.trim()) { + throw new EimsApiException( + "SCHEMA_VALIDATION", + "EIMS verify returned no Irn in its response body", + response?.statusCode, + ); + } + return response; + } + + /** + * Refuse a manual resolution unless the gateway confirms *both* halves of the claim: that this + * IRN is the one it holds, and that it belongs to this invoice. + * + * The document-number check is against `DocumentDetails.DocumentNumber`, which registration set + * from our own `invoiceNumber` — the only field tying an IRN back to a row in this database. + * + * Recording a wrong IRN is not a local mistake: it marks an unregistered invoice as filed and + * chains every later document to a stranger's reference, so both checks are refusals rather + * than warnings. + */ + private async assertIrnBelongsToInvoice( + irn: string, + expectedDocumentNumber: string, + ): Promise { + const response = await this.queryVerify(irn); + const returnedIrn = response.body?.Irn?.trim(); + const documentNumber = response.body?.DocumentDetails?.DocumentNumber?.trim(); + + if (returnedIrn !== irn) { + throw new ConflictException({ + code: "EIMS_RESOLVE_IRN_MISMATCH", + message: + `EIMS answered the lookup for IRN ${irn} with ${returnedIrn ?? "(none)"}. ` + + "Refusing to record it — recheck the IRN in the MoR portal.", + }); + } + + if (documentNumber !== expectedDocumentNumber) { + throw new ConflictException({ + code: "EIMS_RESOLVE_DOCUMENT_MISMATCH", + message: + `EIMS reports IRN ${irn} against document ${documentNumber ?? "(none)"}, not ` + + `${expectedDocumentNumber}. Refusing to record it — recheck the IRN in the MoR portal.`, + }); + } + } + + /** + * Manual reconciliation of a blocked system number. + * + * With an `irn` (found in the MoR portal) the invoice is recorded as registered and the chain + * resumes from it. With `discard` the invoice is marked failed and the chain resumes from the + * previous IRN. Either way the block is cleared — this is the only exit from an ambiguous result. + * + * An IRN is never taken on trust: it is verified at the gateway first, and the document it + * belongs to must be *this* invoice. A transposed digit would otherwise chain every later + * document to a stranger's IRN and mark this invoice registered when it is not. + */ + async resolveEimsRegistration( + invoiceId: string, + input: { irn?: string; discard?: boolean }, + ): Promise { + const irn = input.irn?.trim(); + if (!irn && !input.discard) { + throw new BadRequestException({ + code: "EIMS_RESOLVE_INPUT_REQUIRED", + message: "Provide the IRN confirmed with MoR, or discard: true to abandon the submission", + }); + } + + // Outside the transaction: no lock is held across the wire, and a refused verification must + // leave the block exactly as it was. + if (irn) { + const invoice = await this.loadInvoiceRow(this.dataSource.manager, invoiceId); + await this.assertIrnBelongsToInvoice(irn, invoice.invoiceNumber); + } + + // Same source of truth as registration: the state row is keyed by the token's system number. + const session = await this.auth.getSessionContext(); + + await this.dataSource.transaction(async (manager) => { + const state = await this.lockSystemState(manager, session.systemNumber); + if (state.inFlightInvoiceId && state.inFlightInvoiceId !== invoiceId) { + throw new ConflictException({ + code: "EIMS_RESOLVE_WRONG_INVOICE", + message: `The in-flight EIMS submission is invoice ${state.inFlightInvoiceId}, not ${invoiceId}`, + }); + } + const invoice = await this.lockInvoice(manager, invoiceId); + if (invoice.eimsIrn) { + throw new ConflictException({ + code: "EIMS_ALREADY_REGISTERED", + message: `Invoice ${invoice.invoiceNumber} already has IRN ${invoice.eimsIrn}`, + }); + } + + await manager.update(Invoice, invoiceId, { + eimsStatus: irn ? EimsInvoiceStatus.Registered : EimsInvoiceStatus.Failed, + eimsIrn: irn ?? null, + }); + await manager.update(EimsSystemState, state.id, { + // Only a confirmed IRN may advance the chain; a discard leaves it where it was. + ...(irn ? { previousIrn: irn } : {}), + inFlightInvoiceId: null, + inFlightCounter: null, + blockedReason: null, + }); + }); + + this.logger.warn( + `EIMS block on invoice ${invoiceId} resolved manually (${irn ? "IRN recorded" : "discarded"})`, + ); + return this.getEimsStatus(invoiceId); + } + + async getEimsStatus(invoiceId: string): Promise { + return this.toView(await this.loadInvoiceRow(this.dataSource.manager, invoiceId)); + } + + // ── transactions ───────────────────────────────────────────────────────────────────────────── + + /** + * TX1. Consume a counter and record the holder, committed before any HTTP call. Returns `null` + * when the invoice turned out to be registered already (checked under the lock). + */ + private async reserve(invoiceId: string, systemNumber: string): Promise { + return this.dataSource.transaction(async (manager) => { + const state = await this.lockSystemState(manager, systemNumber); + + if (state.blockedReason) { + throw new ConflictException({ + code: "EIMS_SYSTEM_BLOCKED", + message: + `EIMS registration is blocked for system ${systemNumber}: ${state.blockedReason}. ` + + "Resolve the affected invoice before registering anything else.", + }); + } + if (state.inFlightInvoiceId) { + throw new ConflictException({ + code: "EIMS_SUBMISSION_IN_FLIGHT", + message: + `A submission for invoice ${state.inFlightInvoiceId} is already in flight on system ` + + `${systemNumber}. Wait for it to settle, or resolve it if the process was interrupted.`, + }); + } + + const invoice = await this.lockInvoice(manager, invoiceId); + if (invoice.eimsIrn) return null; + + const invoiceCounter = Number(state.nextInvoiceCounter); + const previousIrn = state.previousIrn ?? ""; + + // Counter consumed here, not on success: once an attempt begins it can never be reused, + // whatever happens next. A gap is harmless at MoR; a collision is not. + await manager.update(EimsSystemState, state.id, { + nextInvoiceCounter: invoiceCounter + 1, + inFlightInvoiceId: invoiceId, + inFlightCounter: invoiceCounter, + }); + await manager.update(Invoice, invoiceId, { + eimsStatus: EimsInvoiceStatus.Submitting, + eimsInvoiceCounter: invoiceCounter, + eimsSubmittedAt: new Date(), + eimsLastError: null, + }); + + return { stateId: state.id, invoiceCounter, previousIrn }; + }); + } + + /** TX2a. Record the IRN, advance the chain, release the reservation. */ + private async settleSuccess( + invoiceId: string, + reservation: Reservation, + irn: string, + ackDate?: string, + ): Promise { + await this.dataSource.transaction(async (manager) => { + await this.lockInvoice(manager, invoiceId); + await manager.update(Invoice, invoiceId, { + eimsStatus: EimsInvoiceStatus.Registered, + eimsIrn: irn, + eimsAckDate: ackDate ?? null, + eimsLastError: null, + }); + await manager.update(EimsSystemState, reservation.stateId, { + previousIrn: irn, + inFlightInvoiceId: null, + inFlightCounter: null, + blockedReason: null, + }); + }); + } + + /** + * TX2b. A deterministic rejection releases the reservation; an ambiguous result keeps it and + * blocks the system number, because `PreviousIrn` is now unknown for every later document. + * The counter stays consumed either way. + */ + private async settleFailure( + invoiceId: string, + reservation: Reservation, + err: unknown, + ): Promise { + const api = err instanceof EimsApiException ? err : null; + const deterministic = api ? DETERMINISTIC_KINDS.has(api.kind) : false; + const status = deterministic ? EimsInvoiceStatus.Failed : EimsInvoiceStatus.Unknown; + const lastError: EimsInvoiceError = { + kind: api?.kind ?? "UNKNOWN", + message: (err as Error)?.message ?? "unknown error", + httpStatus: api?.httpStatus, + details: api?.details, + at: new Date().toISOString(), + }; + + await this.dataSource.transaction(async (manager) => { + await manager.update(Invoice, invoiceId, { + eimsStatus: status, + eimsLastError: lastError, + } as QueryDeepPartialEntity); + + await manager.update( + EimsSystemState, + reservation.stateId, + deterministic + ? { inFlightInvoiceId: null, inFlightCounter: null, blockedReason: null } + : { + blockedReason: + `Invoice ${invoiceId} was submitted with counter ${reservation.invoiceCounter} but ` + + `never acknowledged (${lastError.kind}). Its IRN is unknown, so no further document ` + + "can be chained until it is resolved with MoR.", + }, + ); + }); + + this.logger.error(`Invoice ${invoiceId} EIMS registration ${status}: ${lastError.message}`); + } + + // ── internals ──────────────────────────────────────────────────────────────────────────────── + + /** A non-empty IRN is the only success signal; anything else is a failed registration. */ + private async submit(request: EimsInvoiceRequest): Promise<{ irn: string; ackDate?: string }> { + const response = await this.client.postSigned( + "/v1/register", + request, + ); + const irn = response?.body?.irn; + if (!irn) { + // The gateway answered, so this is deterministic: the document is not registered. + throw new EimsApiException( + "SCHEMA_VALIDATION", + `EIMS register returned no IRN${response?.body?.errorMessage ? `: ${response.body.errorMessage}` : ""}`, + response?.statusCode, + ); + } + return { irn, ackDate: response.body?.ackDate }; + } + + private async lockInvoice(manager: EntityManager, invoiceId: string): Promise { + const invoice = await manager + .createQueryBuilder(Invoice, "invoice") + .setLock("pessimistic_write") + .where("invoice.id = :invoiceId", { invoiceId }) + .getOne(); + if (!invoice) throw new NotFoundException(`Invoice ${invoiceId} not found`); + return invoice; + } + + /** Locks the system-state row, creating it on first use. */ + private async lockSystemState( + manager: EntityManager, + systemNumber: string, + ): Promise { + const select = () => + manager + .createQueryBuilder(EimsSystemState, "state") + .setLock("pessimistic_write") + .where("state.system_number = :systemNumber", { systemNumber }) + .getOne(); + + const existing = await select(); + if (existing) return existing; + + await manager.query( + `INSERT INTO freight.eims_system_state (system_number) VALUES ($1) + ON CONFLICT (system_number) DO NOTHING`, + [systemNumber], + ); + const created = await select(); + if (!created) throw new Error(`Could not initialise EIMS system state for ${systemNumber}`); + return created; + } + + /** Header + buyer + lines — everything the mapper needs. */ + private async loadInvoiceForMapping( + invoiceId: string, + ): Promise { + const invoice = await this.dataSource.getRepository(Invoice).findOne({ + where: { id: invoiceId }, + relations: { company: true, companyProfile: true }, + }); + if (!invoice) throw new NotFoundException(`Invoice ${invoiceId} not found`); + + const lines: EimsMapperLine[] = await this.dataSource.query( + `SELECT charge_type AS "chargeType", description, quantity, unit_rate AS "unitRate", + amount, currency, metadata + FROM freight.invoice_lines + WHERE invoice_id = $1 AND deleted_at IS NULL + ORDER BY created_at ASC`, + [invoiceId], + ); + return Object.assign(invoice, { lines }); + } + + private async loadInvoiceRow(manager: EntityManager, invoiceId: string): Promise { + const invoice = await manager.findOne(Invoice, { where: { id: invoiceId } }); + if (!invoice) throw new NotFoundException(`Invoice ${invoiceId} not found`); + return invoice; + } + + private toView(invoice: Invoice): EimsInvoiceStatusView { + const counter = invoice.eimsInvoiceCounter; + return { + invoiceId: invoice.id, + invoiceNumber: invoice.invoiceNumber, + eimsStatus: invoice.eimsStatus ?? EimsInvoiceStatus.NotSubmitted, + eimsIrn: invoice.eimsIrn ?? null, + eimsInvoiceCounter: counter === null || counter === undefined ? null : Number(counter), + eimsSubmittedAt: invoice.eimsSubmittedAt ?? null, + eimsAckDate: invoice.eimsAckDate ?? null, + eimsLastError: invoice.eimsLastError ?? null, + }; + } +} diff --git a/apps/edr-freight-api/src/modules/eims/eims-invoice.controller.ts b/apps/edr-freight-api/src/modules/eims/eims-invoice.controller.ts new file mode 100644 index 000000000..f47cfe9d9 --- /dev/null +++ b/apps/edr-freight-api/src/modules/eims/eims-invoice.controller.ts @@ -0,0 +1,68 @@ +import { Body, Controller, Get, Param, ParseUUIDPipe, Post } from "@nestjs/common"; +import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger"; + +import { BookingStaff } from "../../common/booking-guards"; +import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry"; +import { ResolveEimsRegistrationDto } from "./dto/resolve-eims-registration.dto"; +import { EimsInvoiceRegistrationService } from "./eims-invoice-registration.service"; + +/** + * Manual EIMS actions on an existing invoice. + * + * Invoices are produced by the freight workflow, not by a person, so these routes are **not** the + * normal production path — they exist for controlled testing and exceptional operations. Automatic + * submission after an invoice is issued is a separate phase; nothing here is called by it. + * + * `eims_register` and `eims_resolve` are intentionally left out of every role preset and assigned + * to named admins instead. They are also separate permissions: resolving clears the system-wide + * chain block and can record an IRN against an invoice, which is a supervisor action, not an + * operational one. Only `eims/status` rides on the ordinary `invoices:view`. + * + * Filing gets its own permission (`invoices:eims_register`) rather than riding on an existing key: + * registration is irreversible at MoR, so it must not follow from the right to download a PDF. + * The key is seeded through FINANCE_PERMISSIONS, which reaches `iam.permissions` via + * ADVANCED_BACKOFFICE_PERMISSIONS → BOOKING_RULE_ENGINE_PERMISSIONS → EDR_FREIGHT_PERMISSIONS. + */ +@ApiTags("eims") +@ApiBearerAuth() +@Controller("invoices") +export class EimsInvoiceController { + constructor(private readonly registration: EimsInvoiceRegistrationService) {} + + @Post(":id/eims/register") + @BookingStaff(FREIGHT_PERMS.invoices.eimsRegister) + @ApiOperation({ + summary: + "Register the invoice with MoR EIMS. Idempotent — an invoice that already has an IRN is returned unchanged.", + }) + register(@Param("id", ParseUUIDPipe) id: string) { + return this.registration.registerInvoiceWithEims(id); + } + + @Post(":id/eims/verify") + @BookingStaff(FREIGHT_PERMS.invoices.eimsRegister) + @ApiOperation({ summary: "Verify the invoice's stored IRN against EIMS" }) + verify(@Param("id", ParseUUIDPipe) id: string) { + return this.registration.verifyInvoiceWithEims(id); + } + + @Post(":id/eims/resolve") + @BookingStaff(FREIGHT_PERMS.invoices.eimsResolve) + @ApiOperation({ + summary: + "Resolve an unacknowledged submission: record the IRN confirmed with MoR, or discard it. Clears the system-wide block.", + }) + resolve( + @Param("id", ParseUUIDPipe) id: string, + @Body() dto: ResolveEimsRegistrationDto, + ) { + return this.registration.resolveEimsRegistration(id, dto); + } + + @Get(":id/eims/status") + @BookingStaff(FREIGHT_PERMS.invoices.view) + @ApiOperation({ summary: "EIMS registration status, IRN and last error for the invoice" }) + status(@Param("id", ParseUUIDPipe) id: string) { + return this.registration.getEimsStatus(id); + } +} diff --git a/apps/edr-freight-api/src/modules/eims/eims-registration.types.ts b/apps/edr-freight-api/src/modules/eims/eims-registration.types.ts new file mode 100644 index 000000000..ad6a3aa34 --- /dev/null +++ b/apps/edr-freight-api/src/modules/eims/eims-registration.types.ts @@ -0,0 +1,87 @@ +import { EimsErrorResponse } from "./eims.types"; + +/** + * Registration state of one invoice at MoR EIMS. + * + * `UNKNOWN` is not a synonym for failure: the request left this process and no answer came back, + * so the invoice may or may not be registered at the gateway. It is never auto-retried — a resend + * would risk a duplicate registration. + */ +export enum EimsInvoiceStatus { + NotSubmitted = "NOT_SUBMITTED", + Submitting = "SUBMITTING", + Registered = "REGISTERED", + Failed = "FAILED", + Unknown = "UNKNOWN", +} + +/** `body` of a successful `POST /v1/register`, as observed in the collection. */ +export interface EimsRegisterResponseBody { + irn: string; + ackDate?: string; + signedQR?: string; + signedInvoice?: string; + status?: string; + documentNumber?: string; + errorMessage?: string | null; +} + +export interface EimsRegisterResponse { + statusCode?: number; + message?: string; + body?: EimsRegisterResponseBody; +} + +/** + * Inner request of `POST /v1/verify`. The wire property is lowercase `irn` and is required — + * omitting it yields a 400 "SCHEMA ERROR" reporting `$: required property 'irn' not found`. + */ +export interface EimsVerifyRequest { + irn: string; +} + +/** + * `body` of a successful `POST /v1/verify` — the stored document echoed back. Note the casing + * flip against the request: the response spells the reference `Irn`. + * + * Only the fields we actually assert on are typed; the rest of the echoed document (SellerDetails, + * BuyerDetails, ItemList, …) is carried through untyped because nothing here reads it. + */ +export interface EimsVerifyResponseBody { + Irn?: string; + TransactionType?: string; + DocumentDetails?: { + Type?: string; + DocumentNumber?: string; + Date?: string; + }; + Version?: string; + [section: string]: unknown; +} + +export interface EimsVerifyResponse { + statusCode?: number; + message?: string; + body?: EimsVerifyResponseBody; +} + +/** Persisted failure detail. Carries the gateway's own error fields only — never our envelope. */ +export interface EimsInvoiceError { + kind: string; + message: string; + httpStatus?: number; + details?: EimsErrorResponse; + at: string; +} + +/** What the status endpoint returns, and what a later invoice-detail panel will render. */ +export interface EimsInvoiceStatusView { + invoiceId: string; + invoiceNumber: string; + eimsStatus: EimsInvoiceStatus; + eimsIrn: string | null; + eimsInvoiceCounter: number | null; + eimsSubmittedAt: Date | null; + eimsAckDate: string | null; + eimsLastError: EimsInvoiceError | null; +} diff --git a/apps/edr-freight-api/src/modules/eims/eims-signer.service.spec.ts b/apps/edr-freight-api/src/modules/eims/eims-signer.service.spec.ts new file mode 100644 index 000000000..5e408ddf7 --- /dev/null +++ b/apps/edr-freight-api/src/modules/eims/eims-signer.service.spec.ts @@ -0,0 +1,118 @@ +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createVerify, generateKeyPairSync } from "node:crypto"; +import { ConfigService } from "@nestjs/config"; +import { EimsCredentialsProvider } from "./eims-credentials.provider"; +import { EimsSignerService, toSignedBody } from "./eims-signer.service"; + +/** + * Test-only key material: generated per run, never a production key. The "certificate" fixture is + * an arbitrary byte blob — the point is that its exact bytes survive base64 round-tripping, not + * that it is a valid X.509 chain. + */ +const CERTIFICATE_FIXTURE = "Subject: CN=TEST\n-----BEGIN CERTIFICATE-----\nZm9vYmFy\n-----END CERTIFICATE-----\n"; + +let dir: string; +let keyPath: string; +let certPath: string; +let publicKeyPem: string; +let signer: EimsSignerService; + +beforeAll(() => { + dir = mkdtempSync(join(tmpdir(), "eims-signer-")); + keyPath = join(dir, "private_key.key"); + certPath = join(dir, "certificate.pem.txt"); + + const { privateKey, publicKey } = generateKeyPairSync("rsa", { modulusLength: 2048 }); + writeFileSync(keyPath, privateKey.export({ type: "pkcs8", format: "pem" })); + writeFileSync(certPath, CERTIFICATE_FIXTURE, "utf8"); + publicKeyPem = publicKey.export({ type: "spki", format: "pem" }).toString(); + + const config = { + get: () => ({ privateKeyPath: keyPath, certificatePath: certPath }), + } as unknown as ConfigService; + signer = new EimsSignerService(new EimsCredentialsProvider(config)); +}); + +afterAll(() => rmSync(dir, { recursive: true, force: true })); + +const login = () => ({ clientId: "cid", clientSecret: "secret", apikey: "key", tin: "0000000000" }); + +const verify = (payload: string, signature: string): boolean => + createVerify("RSA-SHA512").update(payload, "utf8").verify(publicKeyPem, signature, "base64"); + +describe("EimsSignerService", () => { + it("produces a signature that verifies against the matching public key", () => { + const signed = signer.signRequest(login()); + expect(verify(JSON.stringify(signed.request), signed.signature)).toBe(true); + }); + + it("fails verification when a single request field changes", () => { + const signed = signer.signRequest(login()); + const tampered = JSON.stringify({ ...signed.request, tin: "9999999999" }); + expect(verify(tampered, signed.signature)).toBe(false); + }); + + it("emits a 256-byte signature for an RSA-2048 key", () => { + const signed = signer.signRequest(login()); + expect(Buffer.from(signed.signature, "base64")).toHaveLength(256); + }); + + it("sends the certificate as base64 of the file's exact bytes", () => { + const signed = signer.signRequest(login()); + expect(signed.certificate).toBe(readFileSync(certPath).toString("base64")); + expect(Buffer.from(signed.certificate, "base64").equals(readFileSync(certPath))).toBe(true); + }); + + it("signs the inner request only, and the wire body carries those exact bytes", () => { + const signed = signer.signRequest(login()); + const body = toSignedBody(signed); + + // The signed string appears verbatim inside the transmitted envelope. + expect(body).toContain(`"request":${JSON.stringify(signed.request)}`); + // Compact, never pretty-printed. + expect(body).not.toMatch(/\n/); + expect(JSON.parse(body)).toEqual({ + request: login(), + signature: signed.signature, + certificate: signed.certificate, + }); + }); + + it("does not mutate the request object", () => { + const request = login(); + const signed = signer.signRequest(request); + expect(signed.request).toBe(request); + expect(request).toEqual(login()); + }); + + it("reuses the loaded key and certificate across calls", () => { + const first = signer.signRequest(login()); + const second = signer.signRequest(login()); + // PKCS#1 v1.5 is deterministic: same key + same payload ⇒ identical signature. + expect(second.signature).toBe(first.signature); + expect(second.certificate).toBe(first.certificate); + }); +}); + +describe("EimsCredentialsProvider", () => { + const providerFor = (paths: { privateKeyPath?: string; certificatePath?: string }) => + new EimsCredentialsProvider({ get: () => paths } as unknown as ConfigService); + + it("fails clearly when the key path is unset", () => { + expect(() => providerFor({}).getPrivateKey()).toThrow(/EIMS_PRIVATE_KEY_PATH is not set/); + }); + + it("fails clearly when the key file is missing", () => { + expect(() => providerFor({ privateKeyPath: join(dir, "nope.key") }).getPrivateKey()).toThrow( + /could not be read or parsed/, + ); + }); + + it("fails clearly when the certificate file is empty", () => { + const emptyPath = join(dir, "empty.txt"); + writeFileSync(emptyPath, ""); + expect(() => providerFor({ certificatePath: emptyPath }).getCertificateBase64()).toThrow(/is empty/); + }); +}); diff --git a/apps/edr-freight-api/src/modules/eims/eims-signer.service.ts b/apps/edr-freight-api/src/modules/eims/eims-signer.service.ts new file mode 100644 index 000000000..babec6b44 --- /dev/null +++ b/apps/edr-freight-api/src/modules/eims/eims-signer.service.ts @@ -0,0 +1,37 @@ +import { createSign } from "node:crypto"; +import { Injectable } from "@nestjs/common"; +import { EimsCredentialsProvider } from "./eims-credentials.provider"; +import { EimsSignedRequest } from "./eims.types"; + +/** + * Signs EIMS request objects, reproducing the process that produced a working live access token: + * + * 1. compact `JSON.stringify` of the **inner** request object only, + * 2. those exact UTF-8 bytes, + * 3. RSA + SHA-512 (`SHA512withRSA`, PKCS#1 v1.5 — Node's default RSA padding), + * 4. base64 of the raw signature bytes (256 bytes for an RSA-2048 key), + * 5. base64 of the certificate file's exact bytes. + * + * The outer `{request, signature, certificate}` envelope is never itself signed, and the request + * object is never mutated after serialization. + */ +@Injectable() +export class EimsSignerService { + constructor(private readonly credentials: EimsCredentialsProvider) {} + + signRequest(request: T): EimsSignedRequest { + const payload = JSON.stringify(request); + const signature = createSign("RSA-SHA512") + .update(payload, "utf8") + .sign(this.credentials.getPrivateKey(), "base64"); + + return { request, signature, certificate: this.credentials.getCertificateBase64() }; + } +} + +/** + * Exact wire body for a signed envelope. Serializing here (rather than handing axios an object) + * keeps one serializer in play: the `request` segment of this string is byte-identical to the + * string that was signed. + */ +export const toSignedBody = (signed: EimsSignedRequest): string => JSON.stringify(signed); diff --git a/apps/edr-freight-api/src/modules/eims/eims-test-fixtures.ts b/apps/edr-freight-api/src/modules/eims/eims-test-fixtures.ts new file mode 100644 index 000000000..79fe30f96 --- /dev/null +++ b/apps/edr-freight-api/src/modules/eims/eims-test-fixtures.ts @@ -0,0 +1,78 @@ +import { EimsConfig, EimsInvoiceConfig } from "../../config/eims.config"; + +/** + * Fixtures shared by the EIMS specs. + * + * Deliberately not a `.spec.ts`: importing fixtures from a spec file makes jest execute that + * file's `describe` blocks inside every importing suite, so the same tests run — and report — + * twice. + */ + +export const EIMS_SYSTEM_NUMBER = "B0360154BA"; +export const EIMS_SYSTEM_TYPE = "SYS"; + +export const eimsInvoiceConfig = (over: Partial = {}): EimsInvoiceConfig => ({ + sellerLegalName: "Ethio-Djibouti Railway S.C.", + sellerVatNumber: "0000000000", + sellerPhone: "0911223344", + sellerEmail: "finance@example.et", + sellerRegion: "13", + sellerWereda: "574", + sellerCity: null, + sellerSubCity: null, + sellerHouseNumber: null, + sellerLocality: null, + taxCode: "VAT15", + taxRatePercent: 15, + exciseTaxValue: 0, + incomeWithholdValue: 0, + transactionWithholdValue: 0, + transactionType: "B2B", + natureOfSupplies: "Service", + paymentMode: "CASH", + paymentTerm: "IMMIDIATE", + unitDefault: "PCS", + buyerCountryCode: null, + cashierName: null, + salesPersonName: null, + ...over, +}); + +export const eimsConfig = (over: Partial = {}): EimsConfig => ({ + enabled: true, + baseUrl: "https://core.mor.gov.et", + clientId: "cid", + clientSecret: "super-secret-value", + apiKey: "super-secret-apikey", + tin: "0000034558", + systemNumber: EIMS_SYSTEM_NUMBER, + systemType: EIMS_SYSTEM_TYPE, + privateKeyPath: "/dev/null", + certificatePath: "/dev/null", + httpTimeoutMs: 30_000, + tokenSkewMs: 45_000, + autoSubmit: false, + autoSubmitCron: "0 */5 * * * *", + autoSubmitMaxAgeDays: 3, + invoice: eimsInvoiceConfig(), + ...over, +}); + +/** + * A structurally real access token. MoR stamps the source-system identity into the JWT payload and + * `EimsAuthService` reads it from there; only the payload segment is meaningful, since the token is + * never verified locally — it is MoR's, signed with MoR's key. + * + * Pass a claim as `undefined` to omit it (spreading beats `delete`, which the defaults would undo). + */ +export const eimsToken = (claims: Record = {}): string => { + const payload = { systemNumber: EIMS_SYSTEM_NUMBER, systemType: EIMS_SYSTEM_TYPE, ...claims }; + for (const [key, value] of Object.entries(payload)) { + if (value === undefined) delete (payload as Record)[key]; + } + return [ + "eyJhbGciOiJSUzI1NiJ9", + Buffer.from(JSON.stringify(payload)).toString("base64url"), + "signature", + ].join("."); +}; diff --git a/apps/edr-freight-api/src/modules/eims/eims.errors.ts b/apps/edr-freight-api/src/modules/eims/eims.errors.ts new file mode 100644 index 000000000..3be21fdd3 --- /dev/null +++ b/apps/edr-freight-api/src/modules/eims/eims.errors.ts @@ -0,0 +1,89 @@ +import { BadGatewayException, ServiceUnavailableException } from "@nestjs/common"; +import { AxiosError } from "axios"; +import { EimsErrorResponse } from "./eims.types"; + +export type EimsFailureKind = + | "NETWORK" + | "TIMEOUT" + | "SCHEMA_VALIDATION" + | "AUTH" + | "FORBIDDEN" + | "RULE_VALIDATION" + | "SERVER" + | "UNKNOWN"; + +/** Raised when EIMS is disabled or its credential files are unusable. */ +export class EimsConfigException extends ServiceUnavailableException { + constructor(message: string) { + super({ code: "EIMS_NOT_CONFIGURED", message }); + } +} + +/** + * A failed EIMS call. Carries only the gateway's own error reporting — never the request body, + * signature, certificate, bearer token or any configured secret. + */ +export class EimsApiException extends BadGatewayException { + constructor( + readonly kind: EimsFailureKind, + message: string, + readonly httpStatus?: number, + readonly details?: EimsErrorResponse, + ) { + super({ code: `EIMS_${kind}`, message }); + } +} + +const SAFE_KEYS = ["message", "statusCode", "code", "details", "body"] as const; + +/** + * Keep only the gateway's error-reporting fields. Anything else a response might carry — an echoed + * request, a token, a signature — is dropped before it can reach a log or an exception payload. + */ +export function redactEimsBody(data: unknown): EimsErrorResponse | undefined { + if (!data || typeof data !== "object") return undefined; + const source = data as Record; + const safe: Record = {}; + for (const key of SAFE_KEYS) { + if (source[key] !== undefined) safe[key] = source[key]; + } + return Object.keys(safe).length > 0 ? (safe as EimsErrorResponse) : undefined; +} + +const kindFor = (status: number): EimsFailureKind => { + if (status === 400) return "SCHEMA_VALIDATION"; + if (status === 401) return "AUTH"; + if (status === 403) return "FORBIDDEN"; + if (status === 406) return "RULE_VALIDATION"; + if (status >= 500) return "SERVER"; + return "UNKNOWN"; +}; + +/** First error line the gateway gives us, whichever shape it used. */ +const describe = (body: EimsErrorResponse | undefined): string => { + if (!body) return "no error body"; + const detail = body.details?.find((d) => d.errorMessage)?.errorMessage; + return [body.message, body.code && `code=${body.code}`, detail].filter(Boolean).join(" ") || "no error body"; +}; + +/** + * Normalise anything thrown by an EIMS HTTP call into an `EimsApiException`. `operation` is a + * short label such as `"login"` or `"POST /v1/register"` — never a payload. + */ +export function toEimsApiException(err: unknown, operation: string): EimsApiException { + if (err instanceof EimsApiException) return err; + + if (err instanceof AxiosError) { + if (err.code === "ECONNABORTED" || err.code === "ETIMEDOUT") { + return new EimsApiException("TIMEOUT", `EIMS ${operation} timed out`); + } + if (!err.response) { + return new EimsApiException("NETWORK", `EIMS ${operation} could not reach the gateway (${err.code ?? "no code"})`); + } + const status = err.response.status; + const body = redactEimsBody(err.response.data); + return new EimsApiException(kindFor(status), `EIMS ${operation} failed (${status}): ${describe(body)}`, status, body); + } + + return new EimsApiException("UNKNOWN", `EIMS ${operation} failed: ${(err as Error)?.message ?? "unknown error"}`); +} diff --git a/apps/edr-freight-api/src/modules/eims/eims.module.ts b/apps/edr-freight-api/src/modules/eims/eims.module.ts new file mode 100644 index 000000000..678b21b52 --- /dev/null +++ b/apps/edr-freight-api/src/modules/eims/eims.module.ts @@ -0,0 +1,37 @@ +import { HttpModule } from "@nestjs/axios"; +import { Module } from "@nestjs/common"; +import { TypeOrmModule } from "@nestjs/typeorm"; + +import { Invoice } from "../billing/entities/invoice.entity"; +import { EimsAuthService } from "./eims-auth.service"; +import { EimsAutoSubmitService } from "./eims-auto-submit.service"; +import { EimsClientService } from "./eims-client.service"; +import { EimsCredentialsProvider } from "./eims-credentials.provider"; +import { EimsInvoiceController } from "./eims-invoice.controller"; +import { EimsInvoiceRegistrationService } from "./eims-invoice-registration.service"; +import { EimsSignerService } from "./eims-signer.service"; +import { EimsSystemState } from "./entities/eims-system-state.entity"; + +/** + * MoR EIMS e-invoicing: signed transport, authentication, and manual single-invoice registration. + * + * Exports only what other modules will consume; the credential loader and signer stay internal so + * the private key has exactly one user. Nothing here is called from invoice creation. + */ +@Module({ + imports: [ + HttpModule.register({ timeout: Number(process.env.EIMS_HTTP_TIMEOUT_MS) || 30_000 }), + TypeOrmModule.forFeature([EimsSystemState, Invoice]), + ], + controllers: [EimsInvoiceController], + providers: [ + EimsCredentialsProvider, + EimsSignerService, + EimsAuthService, + EimsClientService, + EimsInvoiceRegistrationService, + EimsAutoSubmitService, + ], + exports: [EimsAuthService, EimsClientService, EimsInvoiceRegistrationService], +}) +export class EimsModule {} diff --git a/apps/edr-freight-api/src/modules/eims/eims.types.ts b/apps/edr-freight-api/src/modules/eims/eims.types.ts new file mode 100644 index 000000000..6e74604bd --- /dev/null +++ b/apps/edr-freight-api/src/modules/eims/eims.types.ts @@ -0,0 +1,46 @@ +/** + * Wire types for the MoR EIMS gateway, taken from the supplied Postman collection. + * + * Every protected payload is the same envelope: the business object under `request`, a base64 + * RSA-SHA512 signature over the *inner* object only, and the base64 certificate bundle. + */ +export interface EimsSignedRequest { + request: T; + signature: string; + certificate: string; +} + +/** Inner request of `POST /auth/login`. Note the lowercase `apikey` — that is the wire name. */ +export interface EimsLoginRequest { + clientId: string; + clientSecret: string; + apikey: string; + tin: string; +} + +export interface EimsLoginData { + accessToken: string; + refreshToken: string; + /** Observed as a UUID on login and `null` on refresh; unused today. */ + encryptionKey: string | null; + /** Seconds. Observed value: 3600. */ + expiresIn: number; +} + +export interface EimsLoginResponse { + data: EimsLoginData; + status: string; +} + +/** + * Error bodies differ per failure mode: gateway errors carry `message`/`code`/`details`, + * schema errors carry a JSON-Schema violation array under `body`, rule errors carry + * `[{portion, errorMessage[]}]` under `body`. Only these fields are ever surfaced or logged. + */ +export interface EimsErrorResponse { + message?: string; + statusCode?: number; + code?: string; + details?: { errorMessage?: string; field?: string }[]; + body?: unknown; +} diff --git a/apps/edr-freight-api/src/modules/eims/entities/eims-system-state.entity.ts b/apps/edr-freight-api/src/modules/eims/entities/eims-system-state.entity.ts new file mode 100644 index 000000000..ac6489c93 --- /dev/null +++ b/apps/edr-freight-api/src/modules/eims/entities/eims-system-state.entity.ts @@ -0,0 +1,42 @@ +import { BaseEntity } from "@edr/api-common"; +import { Column, Entity } from "typeorm"; + +/** + * One row per MoR system number, holding the sequence state EIMS expects across registrations: + * the next `SourceSystem.InvoiceCounter` and the IRN that the next document must chain to via + * `ReferenceDetails.PreviousIrn`. + * + * Registration locks this row `FOR UPDATE` for the duration of the submission, which is what keeps + * two concurrent registrations from claiming the same counter or breaking the IRN chain. + */ +@Entity({ schema: "freight", name: "eims_system_state" }) +export class EimsSystemState extends BaseEntity { + @Column({ name: "system_number", type: "varchar", length: 32, unique: true }) + systemNumber!: string; + + /** Counter to send on the next registration; advanced only once an attempt has consumed it. */ + @Column({ name: "next_invoice_counter", type: "bigint", default: 1 }) + nextInvoiceCounter!: number; + + /** IRN of the last successful registration; null until the first one succeeds. */ + @Column({ name: "previous_irn", type: "varchar", length: 64, nullable: true }) + previousIrn?: string | null; + + /** + * Invoice holding the current reservation. Committed before the HTTP call, so it survives a + * crash and blocks a blind resubmission of a document that may already have reached MoR. + */ + @Column({ name: "in_flight_invoice_id", type: "uuid", nullable: true }) + inFlightInvoiceId?: string | null; + + /** Counter handed to the in-flight submission. */ + @Column({ name: "in_flight_counter", type: "bigint", nullable: true }) + inFlightCounter?: number | null; + + /** + * Why registration is blocked for this system number. Set when a submission ends ambiguously: + * the IRN is unknown, so no further document can chain correctly until it is resolved. + */ + @Column({ name: "blocked_reason", type: "text", nullable: true }) + blockedReason?: string | null; +} diff --git a/apps/edr-freight-api/src/modules/facilities/facilities.controller.ts b/apps/edr-freight-api/src/modules/facilities/facilities.controller.ts index 451c4d03b..7db35d432 100644 --- a/apps/edr-freight-api/src/modules/facilities/facilities.controller.ts +++ b/apps/edr-freight-api/src/modules/facilities/facilities.controller.ts @@ -10,7 +10,12 @@ import { FacilitiesService } from './facilities.service'; @ApiTags('Facilities') @Controller('facilities') -@BookingStaff(FREIGHT_PERMS.facilities.view) +// Class gate lists every key its routes use: Nest runs class AND method +// guards, so a key missing here would deny before the route's own key runs. +@BookingStaff([ + FREIGHT_PERMS.facilities.view, + FREIGHT_PERMS.facilities.manage, +]) export class FacilitiesController { constructor(private readonly facilitiesService: FacilitiesService) {} diff --git a/apps/edr-freight-api/src/modules/gps-tracking/gps-tracking.controller.ts b/apps/edr-freight-api/src/modules/gps-tracking/gps-tracking.controller.ts index 8bd4a31d8..d0cc2d1b4 100644 --- a/apps/edr-freight-api/src/modules/gps-tracking/gps-tracking.controller.ts +++ b/apps/edr-freight-api/src/modules/gps-tracking/gps-tracking.controller.ts @@ -19,7 +19,12 @@ import { RegisterDeviceDto, UpdateDeviceDto } from './dto/gps-device.dto'; @ApiTags('gps-tracking') @ApiBearerAuth() @Controller('gps') -@BookingStaff(FREIGHT_PERMS.tracking.view) +// Class gate lists every key its routes use: Nest runs class AND method +// guards, so a key missing here would deny before the route's own key runs. +@BookingStaff([ + FREIGHT_PERMS.tracking.view, + FREIGHT_PERMS.tracking.manage, +]) export class GpsTrackingController { constructor(private readonly gps: GpsTrackingService) {} diff --git a/apps/edr-freight-api/src/modules/incidents/incidents.controller.ts b/apps/edr-freight-api/src/modules/incidents/incidents.controller.ts index b9cebd653..2e9dfb169 100644 --- a/apps/edr-freight-api/src/modules/incidents/incidents.controller.ts +++ b/apps/edr-freight-api/src/modules/incidents/incidents.controller.ts @@ -22,7 +22,14 @@ import { IncidentStatus, IncidentType } from './entities/incident.entity'; // No incidents-specific permission exists in the registry, so this reuses the // (real) drivers.* fleet-road keys — incident records are driver-safety data // (driver stats / incident history). TODO: add a dedicated incidents:* key. -@BookingStaff(FREIGHT_PERMS.drivers.view) +// Class gate lists every key its routes use: Nest runs class AND method +// guards, so a key missing here would deny before the route's own key runs. +@BookingStaff([ + FREIGHT_PERMS.drivers.view, + FREIGHT_PERMS.drivers.create, + FREIGHT_PERMS.drivers.update, + FREIGHT_PERMS.drivers.delete, +]) export class IncidentsController { constructor(private readonly incidentsService: IncidentsService) {} diff --git a/apps/edr-freight-api/src/modules/interchange-documents/interchange-documents.controller.ts b/apps/edr-freight-api/src/modules/interchange-documents/interchange-documents.controller.ts index 61a13744e..b5e92ca39 100644 --- a/apps/edr-freight-api/src/modules/interchange-documents/interchange-documents.controller.ts +++ b/apps/edr-freight-api/src/modules/interchange-documents/interchange-documents.controller.ts @@ -15,7 +15,14 @@ import { InterchangeDocumentsService } from './interchange-documents.service'; @ApiBearerAuth() @Controller('interchange-documents') // Class-level view guard; each write route adds its own manage permission below. -@BookingStaff(FREIGHT_PERMS.interchangeDocuments.view) +// Class gate lists every key its routes use: Nest runs class AND method +// guards, so a key missing here would deny before the route's own key runs. +@BookingStaff([ + FREIGHT_PERMS.interchangeDocuments.view, + FREIGHT_PERMS.interchangeDocuments.generate, + FREIGHT_PERMS.interchangeDocuments.acknowledge, + FREIGHT_PERMS.interchangeDocuments.dispute, +]) export class InterchangeDocumentsController { constructor(private readonly service: InterchangeDocumentsService) {} diff --git a/apps/edr-freight-api/src/modules/maintenance/maintenance-due-alert.spec.ts b/apps/edr-freight-api/src/modules/maintenance/maintenance-due-alert.spec.ts index 7aac5e863..5726123cf 100644 --- a/apps/edr-freight-api/src/modules/maintenance/maintenance-due-alert.spec.ts +++ b/apps/edr-freight-api/src/modules/maintenance/maintenance-due-alert.spec.ts @@ -1,6 +1,7 @@ import { NotificationAudience } from '@edr/types'; import { MaintenanceService } from './maintenance.service'; +import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; /** * The daily due-alert: a SCHEDULED item that crossed its km or date threshold @@ -37,6 +38,10 @@ describe('MaintenanceService.sendDueAlerts', () => { expect(notify).toHaveBeenCalledWith( expect.objectContaining({ audience: NotificationAudience.BACKOFFICE, + // The fleet desk, not every employee in the company. + recipients: { + permissionKeys: [FREIGHT_PERMS.maintenance.getNotification], + }, title: 'Maintenance due — ET-9875', body: expect.stringContaining('driven 50200 km (due at 50000 km)'), }), diff --git a/apps/edr-freight-api/src/modules/maintenance/maintenance.service.ts b/apps/edr-freight-api/src/modules/maintenance/maintenance.service.ts index dd9da55a8..54aab6e36 100644 --- a/apps/edr-freight-api/src/modules/maintenance/maintenance.service.ts +++ b/apps/edr-freight-api/src/modules/maintenance/maintenance.service.ts @@ -15,6 +15,7 @@ import { UpsertMaintenanceIntervalDto, } from './dto/create-maintenance.dto'; import { NotificationInboxService } from '../notification-inbox/notification-inbox.service'; +import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; @Injectable() export class MaintenanceService { @@ -53,7 +54,9 @@ export class MaintenanceService { ? `driven ${item.currentKm} km (due at ${item.nextDueKm} km)` : `due ${new Date(item.nextDueDate as Date).toLocaleDateString()}`; await this.inbox.notify({ - recipients: { allBackoffice: true }, + recipients: { + permissionKeys: [FREIGHT_PERMS.maintenance.getNotification], + }, audience: NotificationAudience.BACKOFFICE, type: NotificationType.GENERIC, title: `Maintenance due — ${item.plateNumber}`, diff --git a/apps/edr-freight-api/src/modules/notification-inbox/notification-recipients.service.ts b/apps/edr-freight-api/src/modules/notification-inbox/notification-recipients.service.ts index e3e711d19..d8e338dbf 100644 --- a/apps/edr-freight-api/src/modules/notification-inbox/notification-recipients.service.ts +++ b/apps/edr-freight-api/src/modules/notification-inbox/notification-recipients.service.ts @@ -14,7 +14,9 @@ import { ExternalProfileRepository } from "../companies/external-profile.reposit * - `companyProfileId` → resolved to its company, then to that company's users. * - `organizationId` → all current employees of the org (backoffice staff). * - `permissionKeys` → current employees (any org) holding any of these - * permission keys (e.g. department/role-scoped targeting). + * permission keys — how every staff-facing notification is targeted. There is + * deliberately no "all backoffice" selector: staff notifications belong to a + * desk, and the `:get_notification` keys name which one. */ @Injectable() export class NotificationRecipientsService { @@ -69,18 +71,6 @@ export class NotificationRecipientsService { } } - if (recipients.allBackoffice) { - try { - for (const uid of await this.backoffice.getAllCurrentEmployeeUserIds()) { - ids.add(uid); - } - } catch (err) { - this.logger.warn( - `Failed to resolve allBackoffice recipients: ${(err as Error).message}`, - ); - } - } - if (recipients.permissionKeys?.length) { try { for (const uid of await this.backoffice.getEmployeeUserIdsByPermission( diff --git a/apps/edr-freight-api/src/modules/procurement/procurement.controller.ts b/apps/edr-freight-api/src/modules/procurement/procurement.controller.ts index 374b2bd53..e96b9dc74 100644 --- a/apps/edr-freight-api/src/modules/procurement/procurement.controller.ts +++ b/apps/edr-freight-api/src/modules/procurement/procurement.controller.ts @@ -13,7 +13,14 @@ import { @ApiTags('Procurement & Asset Lifecycle') @Controller('procurement') -@BookingStaff(FREIGHT_PERMS.procurement.view) +// Class gate lists every key its routes use: Nest runs class AND method +// guards, so a key missing here would deny before the route's own key runs. +@BookingStaff([ + FREIGHT_PERMS.procurement.view, + FREIGHT_PERMS.procurement.vendorManage, + FREIGHT_PERMS.procurement.acquisitionManage, + FREIGHT_PERMS.procurement.disposalManage, +]) export class ProcurementController { constructor(private readonly procurementService: ProcurementService) {} 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 ed61f08b0..96812275d 100644 --- a/apps/edr-freight-api/src/modules/routes/routes.controller.ts +++ b/apps/edr-freight-api/src/modules/routes/routes.controller.ts @@ -27,7 +27,15 @@ import { RoutesService } from './routes.service'; @ApiTags('routes') @ApiBearerAuth() @Controller('routes') -@FleetView(FREIGHT_PERMS.routes.view) +// Class gate lists every key its routes use: Nest runs class AND method +// guards, so a key missing here would deny before the route's own key runs. +@FleetView([ + FREIGHT_PERMS.routes.view, + FREIGHT_PERMS.routes.create, + FREIGHT_PERMS.routes.update, + FREIGHT_PERMS.routes.hardDelete, + FREIGHT_PERMS.routes.delete, +]) export class RoutesController { constructor(private readonly routesService: RoutesService) {} diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/priority-rule-change-requests.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/priority-rule-change-requests.service.ts index a80221c68..1526769d3 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/priority-rule-change-requests.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/priority-rule-change-requests.service.ts @@ -22,6 +22,7 @@ import { PriorityRuleChangeStatus, } from '../entities/priority-rule-change-request.entity'; import { PriorityConfigsService } from './priority-configs.service'; +import { FREIGHT_PERMS } from '../../../seed/freight-permissions.registry'; /** Backoffice rule-engine page — where both queue and rules live. */ const RULES_LINK = '/dashboard/rules/priority-configs'; @@ -221,7 +222,9 @@ export class PriorityRuleChangeRequestsService { ): void { void this.inbox .notify({ - recipients: { allBackoffice: true }, + recipients: { + permissionKeys: [FREIGHT_PERMS.ruleEngine.getNotification], + }, audience: NotificationAudience.BACKOFFICE, type: NotificationType.REQUEST_SUBMITTED, title, diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/rate-change-requests.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/rate-change-requests.service.ts index 6dde40d7d..8cc97f343 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/rate-change-requests.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/rate-change-requests.service.ts @@ -19,6 +19,7 @@ import { } from '../entities/rate-change-request.entity'; import { Rate } from '../entities/rate.entity'; import { RatesService } from './rates.service'; +import { FREIGHT_PERMS } from '../../../seed/freight-permissions.registry'; /** Backoffice page where both the queue and the rates live. */ const RATES_LINK = '/dashboard/rules/rates'; @@ -234,7 +235,9 @@ export class RateChangeRequestsService { private notifyTeam(title: string, body: string, request: RateChangeRequest): void { void this.inbox .notify({ - recipients: { allBackoffice: true }, + recipients: { + permissionKeys: [FREIGHT_PERMS.ruleEngine.getNotification], + }, audience: NotificationAudience.BACKOFFICE, type: NotificationType.REQUEST_SUBMITTED, title, 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 1a3baa6b4..9eb6c0bf0 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 @@ -313,6 +313,8 @@ export class BookingWindowService implements OnModuleInit { // Fire-and-forget: a slow SMS/email gateway must not stall the tick loop // (the `ticking` guard would otherwise delay every schedule's transition). void this.notifyWindowOpened(schedule); + // Intercity rides along whatever train passes, export included. + void this.notifyIntercityCorridorScheduled(schedule); this.logger.log(`Export booking window opened for schedule ${schedule.id}`); return true; } @@ -365,7 +367,10 @@ export class BookingWindowService implements OnModuleInit { } // Only announce the first opening of the day; reopen cycles don't re-notify. // Fire-and-forget so a slow SMS/email gateway never stalls the tick loop. - if (schedule.bookingCycleNo === 1) void this.notifyWindowOpened(schedule); + if (schedule.bookingCycleNo === 1) { + void this.notifyWindowOpened(schedule); + void this.notifyIntercityCorridorScheduled(schedule); + } this.logger.log( `[WINDOW] ${schedule.id} PRE_WINDOW→OPEN — booking window opened ` + `(cycle ${schedule.bookingCycleNo})`, @@ -710,6 +715,149 @@ export class BookingWindowService implements OnModuleInit { } } + /** + * SMS + email + inbox the owner of every waiting intercity booking whose + * corridor lies on this schedule's route. + * + * Intercity (DOMESTIC) bookings carry no date — the customer books a corridor + * and the cargo waits in a pool until staff ride it along a passing + * import/export train (see IntercityService). Until now that wait was silent: + * `notifyWindowOpened` only reaches companies holding an ACTIVE contract whose + * `contract_routes` match the train's exact origin→destination, and an + * intercity booking is neither contracted nor necessarily end-to-end. + * + * Corridor match mirrors `IntercityService.corridorOnRoute` exactly — both + * yards on the route with origin strictly before destination, falling back to + * the train's own origin/destination when the route has fewer than two + * milestones — so nobody is told about a train they can never be placed on. + */ + private async notifyIntercityCorridorScheduled( + schedule: TrainSchedule, + ): Promise { + try { + const rows: Array<{ + bookingId: string; + companyId: string; + phone: string | null; + email: string | null; + corridor: string; + }> = await this.dataSource.query( + // `stops` is the schedule's stop list, with the two-stop + // origin→destination pseudo-route as the legacy fallback — the same + // shape IntercityService.milestoneSequenceOf builds in TypeScript. + `WITH ms AS ( + SELECT yard_id, sequence_no + FROM freight.route_milestones + WHERE route_id = $1 AND deleted_at IS NULL + ), + stops AS ( + SELECT yard_id, sequence_no FROM ms WHERE (SELECT count(*) FROM ms) >= 2 + UNION ALL + SELECT v.yard_id, v.seq + FROM (VALUES ($2::uuid, 1), ($3::uuid, 2)) AS v(yard_id, seq) + WHERE (SELECT count(*) FROM ms) < 2 + ) + SELECT DISTINCT + b.id AS "bookingId", + b.company_id AS "companyId", + ${companyNotifyPhoneExpr('co')} AS phone, + COALESCE(co.email, co.general_manager_email) AS email, + COALESCE(oy.label, oy.code) || ' to ' || + COALESCE(dy.label, dy.code) AS corridor + FROM freight.bookings b + JOIN stops o ON o.yard_id = b.origin_yard_id + JOIN stops d ON d.yard_id = b.destination_yard_id + AND d.sequence_no > o.sequence_no + JOIN freight.companies co ON co.id = b.company_id AND co.deleted_at IS NULL + JOIN freight.yards oy ON oy.id = b.origin_yard_id + JOIN freight.yards dy ON dy.id = b.destination_yard_id + ${primaryContactUserJoin('co')} + WHERE b.deleted_at IS NULL + AND b.trade_direction = 'DOMESTIC' + AND b.train_schedule_id IS NULL + -- Same waiting pool IntercityService.findWaitingIntercityBookings + -- draws candidates from: commercial paid/executed, government approved. + AND ((b.is_government = false AND b.status IN ('FULLY_EXECUTED', 'PAID')) + OR (b.is_government = true AND b.status = 'APPROVED')) + -- Once per booking, not once per train. A booking can sit in the + -- pool for weeks while several trains open a window on its corridor, + -- and "trains run your corridor, you are queued" is the same message + -- every time. The inbox row written below is the marker. + -- ponytail: unindexed jsonb probe over freight.notifications; add a + -- partial index on (data->>'intercityCorridorBookingId') if the + -- table grows enough for this to show up in the tick loop. + AND NOT EXISTS ( + SELECT 1 FROM freight.notifications n + WHERE n.data->>'intercityCorridorBookingId' = b.id::text)`, + [schedule.routeId, schedule.originStationId, schedule.destinationStationId], + ); + if (!rows.length) return; + + const depart = schedule.scheduledDepartureDate.toLocaleDateString('en-GB', { + timeZone: BATCH_TIMEZONE, + }); + const msgFor = (corridors: string[]) => + `A train is scheduled on your intercity corridor ${corridors.join(', ')}, ` + + `departing ${depart}. EDR will confirm once your cargo is placed on a train.`; + + // One inbox item per booking (its `data` is the once-per-booking marker + // the query above reads), but one SMS/email per company — a customer with + // three waiting bookings gets one message naming all three corridors. + const byCompany = new Map< + string, + { phone: string | null; email: string | null; corridors: string[] } + >(); + for (const row of rows) { + const entry = byCompany.get(row.companyId) ?? { + phone: row.phone, + email: row.email, + corridors: [], + }; + if (!entry.corridors.includes(row.corridor)) entry.corridors.push(row.corridor); + byCompany.set(row.companyId, entry); + + await this.inbox.notify({ + recipients: { companyId: row.companyId }, + audience: NotificationAudience.PORTAL, + type: NotificationType.SCHEDULE_UPDATE, + title: 'Train scheduled on your corridor', + body: msgFor([row.corridor]), + link: `/bookings/${row.bookingId}`, + data: { + intercityCorridorBookingId: row.bookingId, + trainScheduleId: schedule.id, + }, + }); + } + + for (const [companyId, entry] of byCompany) { + const msg = msgFor(entry.corridors); + if (entry.phone) { + await this.notifications + .directSend('sms', entry.phone, msg) + .catch((e) => + this.logger.warn(`Intercity corridor SMS failed: ${(e as Error).message}`), + ); + } + if (entry.email) { + await this.notifications + .directSend('email', entry.email, msg) + .catch((e) => + this.logger.warn(`Intercity corridor email failed: ${(e as Error).message}`), + ); + } + this.logger.log( + `Notified company ${companyId} of ${entry.corridors.length} intercity ` + + `corridor(s) served by schedule ${schedule.id}`, + ); + } + } catch (err) { + this.logger.warn( + `notifyIntercityCorridorScheduled failed for ${schedule.id}: ${(err as Error).message}`, + ); + } + } + private async setPhase( schedule: TrainSchedule, patch: Partial< diff --git a/apps/edr-freight-api/src/modules/train-scheduling/intercity-corridor-notify.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/intercity-corridor-notify.spec.ts new file mode 100644 index 000000000..3b6648c19 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/intercity-corridor-notify.spec.ts @@ -0,0 +1,110 @@ +import { BookingWindowService } from './booking-window.service'; +import type { TrainSchedule } from '../train-schedules/entities/train-schedule.entity'; + +/** + * Intercity corridor announcement: when a train's booking window opens, every + * customer with a waiting intercity booking on that corridor is told over SMS, + * email and the portal inbox. + * + * The corridor SQL itself is EXPLAIN-validated against the dev database; what + * this covers is the fan-out shape around it — one inbox row per booking + * (that row's `data` is the once-per-booking marker the query dedupes on) but + * one SMS/email per company, naming every corridor at once. + */ +describe('BookingWindowService — intercity corridor announcement', () => { + const schedule = { + id: 'sched-1', + routeId: 'route-1', + originStationId: 'yard-o', + destinationStationId: 'yard-d', + scheduledDepartureDate: new Date('2026-08-01T06:00:00.000Z'), + } as unknown as TrainSchedule; + + const build = (rows: unknown[]) => { + const query = jest.fn().mockResolvedValue(rows); + const directSend = jest.fn().mockResolvedValue(undefined); + const notify = jest.fn().mockResolvedValue(undefined); + const service = new BookingWindowService( + { query, getRepository: () => ({ update: jest.fn() }) } as never, + { findById: jest.fn(), findAll: jest.fn() } as never, + {} as never, + {} as never, + { directSend } as never, + { notify } as never, + { emitPhase: jest.fn() } as never, + ); + const run = (): Promise => + ( + service as unknown as { + notifyIntercityCorridorScheduled: (s: TrainSchedule) => Promise; + } + ).notifyIntercityCorridorScheduled(schedule); + return { run, query, directSend, notify }; + }; + + it('sends one inbox item per booking and one SMS/email per company', async () => { + const { run, directSend, notify } = build([ + { + bookingId: 'bk-1', + companyId: 'co-1', + phone: '+251900000001', + email: 'ops@co1.example', + corridor: 'Dire Dawa to Adama', + }, + { + bookingId: 'bk-2', + companyId: 'co-1', + phone: '+251900000001', + email: 'ops@co1.example', + corridor: 'Adama to Mojo', + }, + ]); + + await run(); + + // Per booking: the marker keeps the next train on this corridor from + // re-announcing the same thing to the same booking. + expect(notify).toHaveBeenCalledTimes(2); + expect(notify.mock.calls.map((c) => c[0].data.intercityCorridorBookingId)).toEqual([ + 'bk-1', + 'bk-2', + ]); + expect(notify.mock.calls[0][0].recipients).toEqual({ companyId: 'co-1' }); + expect(notify.mock.calls[0][0].link).toBe('/bookings/bk-1'); + + // Per company: two bookings, one SMS and one email, both corridors named. + expect(directSend).toHaveBeenCalledTimes(2); + const [smsChannel, smsTo, smsBody] = directSend.mock.calls[0]; + expect([smsChannel, smsTo]).toEqual(['sms', '+251900000001']); + expect(smsBody).toContain('Dire Dawa to Adama, Adama to Mojo'); + expect(smsBody).toContain('01/08/2026'); + expect(directSend.mock.calls[1][0]).toBe('email'); + }); + + it('sends nothing when no waiting booking rides this corridor', async () => { + const { run, directSend, notify } = build([]); + + await run(); + + expect(notify).not.toHaveBeenCalled(); + expect(directSend).not.toHaveBeenCalled(); + }); + + it('skips the channels a company has no contact for', async () => { + const { run, directSend, notify } = build([ + { + bookingId: 'bk-3', + companyId: 'co-2', + phone: null, + email: 'ops@co2.example', + corridor: 'Dire Dawa to Adama', + }, + ]); + + await run(); + + expect(notify).toHaveBeenCalledTimes(1); + expect(directSend).toHaveBeenCalledTimes(1); + expect(directSend.mock.calls[0][0]).toBe('email'); + }); +}); diff --git a/apps/edr-freight-api/src/modules/trains/train-builder.controller.ts b/apps/edr-freight-api/src/modules/trains/train-builder.controller.ts index a5cd1ff5c..834663170 100644 --- a/apps/edr-freight-api/src/modules/trains/train-builder.controller.ts +++ b/apps/edr-freight-api/src/modules/trains/train-builder.controller.ts @@ -31,7 +31,15 @@ import { TrainBuilderService } from './train-builder.service'; @ApiTags('train-builder') @ApiBearerAuth() @Controller('train-builder') -@FleetView(FREIGHT_PERMS.trains.view) +// Class gate lists every key its routes use: Nest runs class AND method +// guards, so a key missing here would deny before the route's own key runs. +@FleetView([ + FREIGHT_PERMS.trains.view, + FREIGHT_PERMS.trains.create, + FREIGHT_PERMS.trains.update, + FREIGHT_PERMS.trains.assignWagons, + FREIGHT_PERMS.trains.delete, +]) export class TrainBuilderController { constructor(private readonly trainBuilderService: TrainBuilderService) {} diff --git a/apps/edr-freight-api/src/modules/trains/trains.controller.ts b/apps/edr-freight-api/src/modules/trains/trains.controller.ts index 173a738fa..ede540ce2 100644 --- a/apps/edr-freight-api/src/modules/trains/trains.controller.ts +++ b/apps/edr-freight-api/src/modules/trains/trains.controller.ts @@ -19,7 +19,14 @@ import { TrainsService } from "./trains.service"; @ApiTags("trains") @Controller("trains") -@FleetView(FREIGHT_PERMS.trains.view) +// Class gate lists every key its routes use: Nest runs class AND method +// guards, so a key missing here would deny before the route's own key runs. +@FleetView([ + FREIGHT_PERMS.trains.view, + FREIGHT_PERMS.trains.create, + FREIGHT_PERMS.trains.update, + FREIGHT_PERMS.trains.delete, +]) export class TrainsController { constructor(private readonly trainsService: TrainsService) {} diff --git a/apps/edr-freight-api/src/modules/vehicles/vehicles.controller.ts b/apps/edr-freight-api/src/modules/vehicles/vehicles.controller.ts index 737574dd9..d9e2ba224 100644 --- a/apps/edr-freight-api/src/modules/vehicles/vehicles.controller.ts +++ b/apps/edr-freight-api/src/modules/vehicles/vehicles.controller.ts @@ -20,7 +20,14 @@ import { FleetHistoryService } from '../fleet-history/fleet-history.service'; @ApiTags('vehicles') @ApiBearerAuth() @Controller('vehicles') -@BookingStaff(FREIGHT_PERMS.vehicles.view) +// Class gate lists every key its routes use: Nest runs class AND method +// guards, so a key missing here would deny before the route's own key runs. +@BookingStaff([ + FREIGHT_PERMS.vehicles.view, + FREIGHT_PERMS.vehicles.create, + FREIGHT_PERMS.vehicles.update, + FREIGHT_PERMS.vehicles.delete, +]) export class VehiclesController { constructor( private readonly vehiclesService: VehiclesService, diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-fee.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-fee.service.ts index aa30b31e7..576933c01 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-fee.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-fee.service.ts @@ -8,6 +8,7 @@ import { NotificationInboxService } from '../notification-inbox/notification-inb import { CreateFeeRuleDto, UpdateFeeRuleDto } from './dto/fee-rule.dto'; import { FeeRuleBasis, FeeRuleType, WarehouseFeeRule, WarehouseFeeTier } from './entities/warehouse-fee-rule.entity'; import { WarehouseFeeRuleRepository } from './warehouse-fee-rule.repository'; +import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; interface ItemAttributes { arrivedAt: Date | null; @@ -178,7 +179,11 @@ export class WarehouseFeeService { const total = alerts.reduce((sum, r) => sum + r.accruedAmount, 0); try { await this.inbox.notify({ - recipients: { allBackoffice: true }, + recipients: { + permissionKeys: [ + FREIGHT_PERMS.warehouseFeeInvoices.getNotification, + ], + }, audience: NotificationAudience.BACKOFFICE, type: NotificationType.BOOKING_STATUS, title: 'Warehouse fee accruals need attention', diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inspection.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inspection.controller.ts index cdf34cd66..551f840b3 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inspection.controller.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inspection.controller.ts @@ -23,9 +23,13 @@ import { WarehouseInspectionService } from './warehouse-inspection.service'; // Baseline read: inspection reports are opened from inventory screens too — // either view permission grants reads; writes stack their own per route. @Controller() +// Class gate lists every key its routes use: Nest runs class AND method +// guards, so a key missing here would deny before the route's own key runs. @BookingStaff([ FREIGHT_PERMS.warehouseInspectionReports.view, FREIGHT_PERMS.warehouseInventory.view, + FREIGHT_PERMS.warehouseInspectionReports.create, + FREIGHT_PERMS.warehouseInspectionReports.update, ]) export class WarehouseInspectionController { constructor(private readonly inspectionService: WarehouseInspectionService) {} diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-zones.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-zones.controller.ts index 594fd7a6f..04cbacd28 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-zones.controller.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-zones.controller.ts @@ -12,7 +12,13 @@ import { WarehouseZonesService } from './warehouse-zones.service'; // receive/move pickers) — either view permission grants reads; writes stack // their specific permission per route. @Controller('warehouse-zones') -@BookingStaff([FREIGHT_PERMS.warehouseZones.view, FREIGHT_PERMS.warehouseInventory.view]) +// Class gate lists every key its routes use: Nest runs class AND method +// guards, so a key missing here would deny before the route's own key runs. +@BookingStaff([ + FREIGHT_PERMS.warehouseZones.view, + FREIGHT_PERMS.warehouseInventory.view, + FREIGHT_PERMS.warehouseZones.update, +]) export class WarehouseZonesController { constructor(private readonly zonesService: WarehouseZonesService) {} diff --git a/apps/edr-freight-api/src/scripts/eims-login.ts b/apps/edr-freight-api/src/scripts/eims-login.ts new file mode 100644 index 000000000..f9cc60d27 --- /dev/null +++ b/apps/edr-freight-api/src/scripts/eims-login.ts @@ -0,0 +1,40 @@ +import "dotenv/config"; +import axios from "axios"; +import { HttpService } from "@nestjs/axios"; +import { ConfigService } from "@nestjs/config"; +import eimsConfig, { EimsConfig } from "../config/eims.config"; +import { EimsAuthService } from "../modules/eims/eims-auth.service"; +import { EimsCredentialsProvider } from "../modules/eims/eims-credentials.provider"; +import { EimsSignerService } from "../modules/eims/eims-signer.service"; + +/** + * Manual, developer-run live check of EIMS authentication. + * + * Run explicitly: pnpm --filter @edr/freight-api eims:login + * + * Reads credentials from the local .env only. Never runs at boot, never runs in the test suite, + * and prints no token, secret, signature or certificate — only whether login succeeded. + */ +async function main(): Promise { + const config = eimsConfig() as EimsConfig; + if (!config.enabled) { + throw new Error("EIMS_ENABLED is not true — set it in .env before running this check"); + } + + const configService = { get: () => config } as unknown as ConfigService; + const http = new HttpService(axios.create()); + const credentials = new EimsCredentialsProvider(configService); + const auth = new EimsAuthService(http, configService, new EimsSignerService(credentials)); + + console.log(`POST ${config.baseUrl}/auth/login (tin=${config.tin})`); + const token = await auth.getValidAccessToken(); + console.log(`✔ login succeeded — access token received (${token.length} chars, not printed)`); + + const cached = await auth.getValidAccessToken(); + console.log(`✔ second call served from cache: ${cached === token}`); +} + +main().catch((err: Error) => { + console.error(`✘ ${err.message}`); + process.exitCode = 1; +}); diff --git a/apps/edr-freight-api/src/seed/edr-freight.seed.ts b/apps/edr-freight-api/src/seed/edr-freight.seed.ts index 22f7fe5ef..d90fe7b80 100644 --- a/apps/edr-freight-api/src/seed/edr-freight.seed.ts +++ b/apps/edr-freight-api/src/seed/edr-freight.seed.ts @@ -1,6 +1,7 @@ import { BOOKING_RULE_ENGINE_PERMISSIONS, BOOKING_RULE_ENGINE_PERMISSION_KEYS, + deriveReadPermissions, POSITION_PERMISSION_PRESETS, ROLE_PERMISSION_PRESETS, } from './freight-permissions.registry'; @@ -190,7 +191,7 @@ const POSITION_TYPE_PERMISSIONS = [ }, ] as const; -export const EDR_FREIGHT_PERMISSIONS = [ +const EDR_FREIGHT_VIEWABLE_PERMISSIONS = [ ...EMPLOYEE_REGISTRATION_PERMISSIONS, ...ROLE_ASSIGNMENT_PERMISSIONS, ...HIERARCHY_UNIT_PERMISSIONS, @@ -200,6 +201,15 @@ export const EDR_FREIGHT_PERMISSIONS = [ ...BOOKING_RULE_ENGINE_PERMISSIONS, ]; +export const EDR_FREIGHT_PERMISSIONS = [ + ...EDR_FREIGHT_VIEWABLE_PERMISSIONS, + // API-read twin of every `:view` key above — grants the module's GET routes + // without putting it in the backoffice sidebar. Seeded so they can be + // assigned; no role or position preset below grants one, that stays + // hand-curated in iam.position_type_permissions. + ...deriveReadPermissions(EDR_FREIGHT_VIEWABLE_PERMISSIONS), +]; + export { BOOKING_RULE_ENGINE_PERMISSION_KEYS } from './freight-permissions.registry'; export const EDR_FREIGHT_ROLES: FreightSeedRole[] = [ diff --git a/apps/edr-freight-api/src/seed/freight-notification-permissions.seeder.ts b/apps/edr-freight-api/src/seed/freight-notification-permissions.seeder.ts new file mode 100644 index 000000000..0bea4ad95 --- /dev/null +++ b/apps/edr-freight-api/src/seed/freight-notification-permissions.seeder.ts @@ -0,0 +1,103 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { Application, Permission } from '@tria-plc/iamapi-common'; +import { DataSource } from 'typeorm'; + +import { + NOTIFICATION_PERMISSIONS, + NOTIFICATION_PERMISSION_ANCHORS, +} from './freight-permissions.registry'; + +const EDR_FREIGHT_APP_KEY = 'edr_freight_app'; + +/** + * The three tables a permission grant can arrive on, with the column naming + * the grantee. These are compile-time constants — the only values interpolated + * into the SQL below. + */ +const CARRIERS = [ + ['iam.position_permissions', 'position_id'], + ['iam.position_type_permissions', 'position_type_id'], + ['iam.role_permissions', 'role_id'], +] as const; + +/** + * Backfills the `:get_notification` keys. + * + * Those keys are new, so on deploy nobody holds them and every staff + * notification would resolve to zero recipients — silently, because + * `NotificationInboxService.notify` logs an empty resolve at debug level. This + * grants each new key to whoever already holds that desk's anchor key (the + * permission gating the page the notification links to), across all three + * carriers. + * + * Deliberately NOT gated behind SEED_EDR_ORG: that flag is off everywhere + * except e2e/integration, and this has to run wherever the notifications do. + * For the same reason it inserts the permission rows itself rather than + * trusting EdrOrgSeeder. Idempotent — safe on every boot, and safe to leave in + * place permanently. + */ +@Injectable() +export class FreightNotificationPermissionsSeeder { + private readonly logger = new Logger(FreightNotificationPermissionsSeeder.name); + + constructor(private readonly dataSource: DataSource) {} + + async run(): Promise { + const application = await this.dataSource + .getRepository(Application) + .findOne({ where: { key: EDR_FREIGHT_APP_KEY }, select: { id: true } }); + + if (!application?.id) { + this.logger.warn( + `Application '${EDR_FREIGHT_APP_KEY}' not found — notification permission backfill skipped`, + ); + return; + } + + // Ids are left to the column default and never sent: iam.permissions has + // two unique columns (PK id, UQ key) and ON CONFLICT can only target one, + // so a hand-minted id that some retired key already owns would slip past + // ON CONFLICT (key) and die on the PK. Same reasoning as EdrOrgSeeder. + await this.dataSource + .getRepository(Permission) + .createQueryBuilder() + .insert() + .values( + NOTIFICATION_PERMISSIONS.map((permission) => ({ + key: permission.key, + name: { ...permission.name }, + applicationId: application.id as string, + })), + ) + .orIgnore() + .execute(); + + let granted = 0; + for (const [key, anchors] of Object.entries( + NOTIFICATION_PERMISSION_ANCHORS, + )) { + for (const [table, granteeColumn] of CARRIERS) { + const result: unknown = await this.dataSource.query( + `INSERT INTO ${table} (${granteeColumn}, permission_id) + SELECT DISTINCT g.${granteeColumn}, target.id + FROM ${table} g + JOIN iam.permissions anchor + ON anchor.id = g.permission_id AND anchor.key = ANY($1) + JOIN iam.permissions target ON target.key = $2 + WHERE NOT EXISTS ( + SELECT 1 + FROM ${table} existing + WHERE existing.${granteeColumn} = g.${granteeColumn} + AND existing.permission_id = target.id)`, + [anchors, key], + ); + // node-postgres returns [rows, rowCount] for a bare INSERT. + granted += (Array.isArray(result) ? (result[1] as number) : 0) ?? 0; + } + } + + this.logger.log( + `Ensured ${NOTIFICATION_PERMISSIONS.length} notification permissions, backfilled ${granted} grant(s)`, + ); + } +} diff --git a/apps/edr-freight-api/src/seed/freight-permissions.registry.spec.ts b/apps/edr-freight-api/src/seed/freight-permissions.registry.spec.ts index caab85c3e..d134a7986 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.spec.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.spec.ts @@ -1,4 +1,9 @@ import { EDR_FREIGHT_PERMISSIONS } from './edr-freight.seed'; +import { + NOTIFICATION_PERMISSIONS, + NOTIFICATION_PERMISSION_ANCHORS, + readTwinOf, +} from './freight-permissions.registry'; describe('EDR_FREIGHT_PERMISSIONS', () => { // The seeder inserts the whole catalog in one ON CONFLICT (key) DO UPDATE @@ -10,4 +15,73 @@ describe('EDR_FREIGHT_PERMISSIONS', () => { expect(duplicates).toEqual([]); }); + + // The `:read` ids are derived rather than hand-written, so a collision + // would surface here instead of as a primary-key violation on a fresh + // database. + it('has no duplicate ids', () => { + const ids = EDR_FREIGHT_PERMISSIONS.map((permission) => permission.id); + const duplicates = [...new Set(ids.filter((id, i) => ids.indexOf(id) !== i))]; + + expect(duplicates).toEqual([]); + }); + + // `:read` grants a module's GET routes without putting it in the backoffice + // sidebar. Every `:view` needs its twin or that module has no way to be + // granted API-only access. + it('gives every :view key a :read twin', () => { + const keys = new Set(EDR_FREIGHT_PERMISSIONS.map((p) => p.key)); + const missing = [...keys] + .filter((key) => key.endsWith(':view')) + .map((key) => readTwinOf(key)) + .filter((twin): twin is string => twin !== null && !keys.has(twin)); + + expect(missing).toEqual([]); + }); + + it('mints every :read id in the v5 block', () => { + const reads = EDR_FREIGHT_PERMISSIONS.filter((p) => p.key.endsWith(':read')); + + expect(reads.length).toBeGreaterThan(0); + // Version nibble 5 — the source `:view` ids are all v4, so the two sets + // cannot overlap however many keys are added. + for (const read of reads) { + expect(read.id).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-5[0-9a-f]{3}-[0-9a-f]{4}-[0-9a-f]{12}$/, + ); + } + }); +}); + +describe('NOTIFICATION_PERMISSION_ANCHORS', () => { + const catalog = new Set(EDR_FREIGHT_PERMISSIONS.map((p) => p.key)); + + // FreightNotificationPermissionsSeeder backfills each get_notification key + // onto whoever holds its anchor. A typo'd anchor matches no permission row, + // so the key is granted to nobody and every notification on that desk + // silently resolves to zero recipients — notify() logs that at debug level. + it('anchors every notification key on keys that exist', () => { + const missing = Object.values(NOTIFICATION_PERMISSION_ANCHORS) + .flat() + .filter((key) => !catalog.has(key)); + + expect(missing).toEqual([]); + }); + + it('seeds every notification key into the catalog', () => { + const missing = NOTIFICATION_PERMISSIONS.map((p) => p.key).filter( + (key) => !catalog.has(key), + ); + + expect(missing).toEqual([]); + }); + + it('gives every notification key an anchor to backfill from', () => { + const anchored = new Set(Object.keys(NOTIFICATION_PERMISSION_ANCHORS)); + const unanchored = NOTIFICATION_PERMISSIONS.map((p) => p.key).filter( + (key) => !anchored.has(key), + ); + + expect(unanchored).toEqual([]); + }); }); 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 b103dead4..3c2be2e09 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts @@ -31,6 +31,15 @@ export type RuleEngineResourceSlug = const slugToResourceKey = (slug: RuleEngineResourceSlug): string => slug.replace(/-/g, "_"); +const VIEW_KEY_SUFFIX = ":view"; +const READ_KEY_SUFFIX = ":read"; + +/** The `:read` twin of a `:view` key, or null if `key` is not a view key. */ +export const readTwinOf = (key: string): string | null => + key.endsWith(VIEW_KEY_SUFFIX) + ? `${key.slice(0, -VIEW_KEY_SUFFIX.length)}${READ_KEY_SUFFIX}` + : null; + const perm = (id: string, key: string, en: string): FreightPermissionSeed => ({ id, key, @@ -458,6 +467,21 @@ export const FINANCE_PERMISSIONS: FreightPermissionSeed[] = [ "edr_freight_app:invoices:export", "Download invoice document", ), + // Filing with the tax authority is its own grant: registration is irreversible at MoR, so it + // must not ride along with the right to download an invoice PDF. + perm( + "d2b00001-0001-4000-8000-000000000005", + "edr_freight_app:invoices:eims_register", + "Register invoice with MoR EIMS", + ), + // Separate from registering: resolving an unacknowledged submission clears the + // system-wide chain block and can record an IRN against an invoice, so it is a + // supervisor/admin action rather than an operational one. + perm( + "d2b00001-0001-4000-8000-000000000006", + "edr_freight_app:invoices:eims_resolve", + "Resolve a blocked MoR EIMS submission", + ), ]; // E. First / last mile operations @@ -1333,6 +1357,54 @@ export const AUDIENCE_GAP_PERMISSIONS: FreightPermissionSeed[] = [ ), ]; +// O. Notification recipient selectors. NOT route guards — these are never +// passed to FreightPermissionGuard/assertFreightPermission and never appear in +// the frontend's RequirePermission. They exist so ops can tune who gets pinged +// WITHOUT changing who can open the page. Before them every staff notification +// used the `allBackoffice` selector, i.e. every current employee in every org. +export const NOTIFICATION_PERMISSIONS: FreightPermissionSeed[] = [ + perm( + "f3a00001-0001-4000-8000-000000000001", + "edr_freight_app:bookings:get_notification", + "Receive booking desk notifications", + ), + perm( + "f3a00001-0001-4000-8000-000000000002", + "edr_freight_app:bookings:clearance_get_notification", + "Receive booking clearance notifications", + ), + perm( + "f3a00001-0001-4000-8000-000000000003", + "edr_freight_app:contracts:get_notification", + "Receive contract desk notifications", + ), + perm( + "f3a00001-0001-4000-8000-000000000004", + "edr_freight_app:contracts:clearance_get_notification", + "Receive contract clearance notifications", + ), + perm( + "f3a00001-0001-4000-8000-000000000005", + "edr_freight_app:customers:get_notification", + "Receive customer desk notifications", + ), + perm( + "f3a00001-0001-4000-8000-000000000006", + "edr_freight_app:maintenance:get_notification", + "Receive maintenance due notifications", + ), + perm( + "f3a00001-0001-4000-8000-000000000007", + "edr_freight_app:rule_engine:get_notification", + "Receive rule-change approval notifications", + ), + perm( + "f3a00001-0001-4000-8000-000000000008", + "edr_freight_app:warehouse_fee_invoices:get_notification", + "Receive warehouse fee accrual notifications", + ), +]; + export const ADVANCED_BACKOFFICE_PERMISSIONS: FreightPermissionSeed[] = [ ...CUSTOMER_PERMISSIONS, ...FINANCE_PERMISSIONS, @@ -1346,6 +1418,7 @@ export const ADVANCED_BACKOFFICE_PERMISSIONS: FreightPermissionSeed[] = [ ...STAFF_IAM_PERMISSIONS, ...AUDIENCE_GAP_PERMISSIONS, ...GRANULAR_SPLIT_PERMISSIONS, + ...NOTIFICATION_PERMISSIONS, ]; export const BOOKING_RULE_ENGINE_PERMISSIONS = [ @@ -1359,6 +1432,59 @@ export const BOOKING_RULE_ENGINE_PERMISSIONS = [ export const BOOKING_RULE_ENGINE_PERMISSION_KEYS = BOOKING_RULE_ENGINE_PERMISSIONS.map((p) => p.key); +/** + * Id for a derived `:read` key: the source `:view` id with its version nibble + * moved 4 → 5. + * + * Unique because the source ids are, and disjoint from every hand-written id + * because those are all v4-shaped. Nothing index-derived, so a new `:view` + * landing mid-list cannot shift ids already seeded — the trap the + * RULE_ENGINE_RESOURCE_SLUGS comment warns about. + * + * (`EdrOrgSeeder.ensurePermissions` deliberately never sends an id — the + * column default wins and `key` is the identity every consumer resolves by. + * These exist to satisfy the seed type and keep the array self-consistent.) + */ +const readPermissionId = (viewId: string): string => + `${viewId.slice(0, 14)}5${viewId.slice(15)}`; + +/** + * API-read twin of every `:view` key. + * + * `:view` does three jobs at once — backoffice sidebar entry (App.tsx), route + * admission (RequirePermission), and API read. That bundling means a user who + * only needs another module's list endpoint for a form dropdown has to be + * granted the whole module, page and all. `:read` unbundles it: the + * guard accepts it wherever the matching `:view` is required on a GET, and the + * frontend never looks at it, so the module stays out of the menu. + * + * Derived rather than hand-written so a new `:view` key gets its twin for + * free. Grants stay hand-curated in `iam.position_type_permissions` — nothing + * here hands a `:read` to anyone. + */ +export const deriveReadPermissions = ( + seeds: readonly FreightPermissionSeed[], +): FreightPermissionSeed[] => + seeds + .filter((p) => p.key.endsWith(VIEW_KEY_SUFFIX)) + .map((p) => + perm( + readPermissionId(p.id), + readTwinOf(p.key) as string, + `Read ${p.name.en.replace(/^View /, "")} (API only)`, + ), + ); + +/** + * Read twins of the freight-domain catalog, for `PERMISSIONS_CATALOG`. The + * IAM/hierarchy keys seeded alongside it live in `edr-freight.seed.ts` and get + * theirs there — each twin is derived from its own source row, so the two call + * sites agree on any key they share without coordination. + */ +export const FREIGHT_READ_PERMISSIONS = deriveReadPermissions( + BOOKING_RULE_ENGINE_PERMISSIONS, +); + export const FREIGHT_PERMS = { bookings: { view: "edr_freight_app:bookings:view", @@ -1384,6 +1510,10 @@ export const FREIGHT_PERMS = { wagonCancellationVoid: "edr_freight_app:bookings:wagon_cancellation_void", wagonCancellationRebook: "edr_freight_app:bookings:wagon_cancellation_rebook", + // Notification selectors, not route guards — see NOTIFICATION_PERMISSIONS. + getNotification: "edr_freight_app:bookings:get_notification", + clearanceGetNotification: + "edr_freight_app:bookings:clearance_get_notification", }, contracts: { view: "edr_freight_app:contracts:view", @@ -1420,6 +1550,10 @@ export const FREIGHT_PERMS = { editDocument: "edr_freight_app:contracts:edit_document", finalInvoiceRaise: "edr_freight_app:contracts:final_invoice_raise", finalInvoiceConfirm: "edr_freight_app:contracts:final_invoice_confirm", + // Notification selectors, not route guards — see NOTIFICATION_PERMISSIONS. + getNotification: "edr_freight_app:contracts:get_notification", + clearanceGetNotification: + "edr_freight_app:contracts:clearance_get_notification", }, trainScheduling: { view: "edr_freight_app:train_scheduling:view", @@ -1448,6 +1582,10 @@ export const FREIGHT_PERMS = { `edr_freight_app:rule_engine:${slugToResourceKey(slug)}:delete`, approve: (slug: RuleEngineApprovableSlug) => `edr_freight_app:rule_engine:${slugToResourceKey(slug)}:approve`, + // Notification selector, not a route guard — see NOTIFICATION_PERMISSIONS. + // One key for the whole rules desk: every preset grants the rule-engine + // view keys as a block, and both producers link to /dashboard/rules/*. + getNotification: "edr_freight_app:rule_engine:get_notification", }, allocation: { manage: "edr_freight_app:allocation:manage", @@ -1459,6 +1597,8 @@ export const FREIGHT_PERMS = { deactivate: "edr_freight_app:customers:deactivate", verify: "edr_freight_app:customers:verify", resetPassword: "edr_freight_app:customers:reset-password", + // Notification selector, not a route guard — see NOTIFICATION_PERMISSIONS. + getNotification: "edr_freight_app:customers:get_notification", }, payments: { view: "edr_freight_app:payments:view", @@ -1466,6 +1606,8 @@ export const FREIGHT_PERMS = { invoices: { view: "edr_freight_app:invoices:view", export: "edr_freight_app:invoices:export", + eimsRegister: "edr_freight_app:invoices:eims_register", + eimsResolve: "edr_freight_app:invoices:eims_resolve", }, firstMile: { view: "edr_freight_app:first_mile:view", @@ -1584,6 +1726,8 @@ export const FREIGHT_PERMS = { create: "edr_freight_app:maintenance:create", update: "edr_freight_app:maintenance:update", delete: "edr_freight_app:maintenance:delete", + // Notification selector, not a route guard — see NOTIFICATION_PERMISSIONS. + getNotification: "edr_freight_app:maintenance:get_notification", }, fleetReports: { view: "edr_freight_app:fleet_reports:view", @@ -1653,6 +1797,8 @@ export const FREIGHT_PERMS = { generate: "edr_freight_app:warehouse_fee_invoices:generate", cancel: "edr_freight_app:warehouse_fee_invoices:cancel", pay: "edr_freight_app:warehouse_fee_invoices:pay", + // Notification selector, not a route guard — see NOTIFICATION_PERMISSIONS. + getNotification: "edr_freight_app:warehouse_fee_invoices:get_notification", }, settings: { fileUpload: { @@ -1757,6 +1903,40 @@ export const FREIGHT_PERMS = { }, } as const; +/** + * Backfill sources for the `:get_notification` keys: a new key is + * granted to whoever already holds ANY of its anchors. The anchor is the key + * that gates the page the notification deep-links to — if you cannot open the + * page, you were never the intended recipient. The rule-engine desk has no page + * of its own, so it anchors on the keys that let you ACT on a filed change. + * + * Consumed only by FreightNotificationPermissionsSeeder. Keeping it here means + * the registry spec can assert every anchor still exists in the catalog — a + * typo'd anchor backfills nobody, silently. + */ +export const NOTIFICATION_PERMISSION_ANCHORS: Record = { + [FREIGHT_PERMS.bookings.getNotification]: [FREIGHT_PERMS.bookings.view], + [FREIGHT_PERMS.bookings.clearanceGetNotification]: [ + FREIGHT_PERMS.bookings.clearanceView, + ], + [FREIGHT_PERMS.contracts.getNotification]: [FREIGHT_PERMS.contracts.view], + [FREIGHT_PERMS.contracts.clearanceGetNotification]: [ + FREIGHT_PERMS.contracts.clearanceReview, + FREIGHT_PERMS.contracts.clearanceEtActions, + FREIGHT_PERMS.contracts.clearanceDjActions, + FREIGHT_PERMS.contracts.opsClearanceReview, + ], + [FREIGHT_PERMS.customers.getNotification]: [FREIGHT_PERMS.customers.view], + [FREIGHT_PERMS.maintenance.getNotification]: [FREIGHT_PERMS.maintenance.view], + [FREIGHT_PERMS.ruleEngine.getNotification]: [ + FREIGHT_PERMS.ruleEngine.approve("rates"), + FREIGHT_PERMS.ruleEngine.update("priority-configs"), + ], + [FREIGHT_PERMS.warehouseFeeInvoices.getNotification]: [ + FREIGHT_PERMS.warehouseFeeInvoices.view, + ], +}; + /** Both arms of a freight-type-split permission (for one-of route guards). */ export const bothFreightTypes = (p: { bulk: string; @@ -1815,6 +1995,19 @@ const STAFF_DASHBOARD_KEYS: string[] = [ FREIGHT_PERMS.reports.view, ]; +// Notification desks — recipient selectors, not access. A preset gets a desk +// key only where it actually works that queue, which is why the GL presets take +// the clearance pair and nothing else: they hold no bookings:view and no intake +// keys, so intake pings would only be noise they cannot act on. +const BOOKING_DESK_NOTIFICATION_KEYS: string[] = [ + FREIGHT_PERMS.bookings.getNotification, + FREIGHT_PERMS.contracts.getNotification, +]; +const CLEARANCE_DESK_NOTIFICATION_KEYS: string[] = [ + FREIGHT_PERMS.bookings.clearanceGetNotification, + FREIGHT_PERMS.contracts.clearanceGetNotification, +]; + export const ROLE_PERMISSION_PRESETS = { // Marketing / line staff: drives a booking from intake through line-staff // approval and contract generation/signing — i.e. until the contract is ready @@ -1837,6 +2030,8 @@ export const ROLE_PERMISSION_PRESETS = { FREIGHT_PERMS.contracts.approveLineStaff, FREIGHT_PERMS.contracts.editDocument, ...allRuleEngineViewKeys(), + ...BOOKING_DESK_NOTIFICATION_KEYS, + FREIGHT_PERMS.ruleEngine.getNotification, ], // Operations Officer: train scheduling + wagon allocation + transit/complete // + fleet management (wagons, trains, locomotives, routes, containers, cargo). @@ -1869,6 +2064,11 @@ export const ROLE_PERMISSION_PRESETS = { FREIGHT_PERMS.bookings.reviewDocuments, FREIGHT_PERMS.bookings.finalizeClearance, ...allRuleEngineViewKeys(), + ...BOOKING_DESK_NOTIFICATION_KEYS, + // They run Path A clearance review from the booking detail, so the booking + // clearance desk is theirs too — but not the contract one, which is GL's. + FREIGHT_PERMS.bookings.clearanceGetNotification, + FREIGHT_PERMS.ruleEngine.getNotification, ], director: [ ...STAFF_DASHBOARD_KEYS, @@ -1880,6 +2080,7 @@ export const ROLE_PERMISSION_PRESETS = { FREIGHT_PERMS.contracts.approveDirector, FREIGHT_PERMS.contracts.generateContract, ...allRuleEngineViewKeys(), + ...BOOKING_DESK_NOTIFICATION_KEYS, ], ceo: [ ...STAFF_DASHBOARD_KEYS, @@ -1889,12 +2090,17 @@ export const ROLE_PERMISSION_PRESETS = { FREIGHT_PERMS.contracts.view, FREIGHT_PERMS.contracts.approveCeo, ...allRuleEngineViewKeys(), + ...BOOKING_DESK_NOTIFICATION_KEYS, ], finance: [ ...STAFF_DASHBOARD_KEYS, FREIGHT_PERMS.bookings.view, FREIGHT_PERMS.invoices.view, FREIGHT_PERMS.invoices.export, + // Deliberately NOT granted here: invoices:eims_register and invoices:eims_resolve. + // Invoices are filed with MoR by the workflow, not by a person, so filing is not a + // Finance job function — the endpoints exist for controlled testing and exceptional + // operations, and are assigned to named admins rather than a role preset. FREIGHT_PERMS.payments.view, FREIGHT_PERMS.bookings.wagonCancellationView, ], @@ -1920,6 +2126,7 @@ export const ROLE_PERMISSION_PRESETS = { FREIGHT_PERMS.bookings.uploadClearanceOutput, FREIGHT_PERMS.bookings.finalizeClearance, FREIGHT_PERMS.bookings.operations, + ...CLEARANCE_DESK_NOTIFICATION_KEYS, ], // GL Djibouti (edr_gl_djibouti): DO/RO collection, gatepass, loading milestones, // damage reports. Read-only on the contract; no booking creation. @@ -1931,6 +2138,7 @@ export const ROLE_PERMISSION_PRESETS = { FREIGHT_PERMS.bookings.clearanceView, FREIGHT_PERMS.bookings.uploadClearanceOutput, FREIGHT_PERMS.bookings.operations, + ...CLEARANCE_DESK_NOTIFICATION_KEYS, ], // Marketing handles intake through contract (same as line staff here) and, // for non-customs bookings, reviews/finalizes the customer's clearance @@ -1960,6 +2168,7 @@ export const ROLE_PERMISSION_PRESETS = { ...bothFreightTypes(FREIGHT_PERMS.contracts.signStaff), FREIGHT_PERMS.contracts.suspend, FREIGHT_PERMS.contracts.editDocument, + ...BOOKING_DESK_NOTIFICATION_KEYS, ], orgManager: [...BOOKING_RULE_ENGINE_PERMISSION_KEYS], } as const; @@ -1986,6 +2195,8 @@ export const POSITION_PERMISSION_PRESETS = { FREIGHT_PERMS.customers.view, FREIGHT_PERMS.customers.verify, FREIGHT_PERMS.customers.deactivate, + // …and therefore the profile-review pings company-notifier emits. + FREIGHT_PERMS.customers.getNotification, // The chief also owns the customer-facing support inbox. FREIGHT_PERMS.support.agentView, FREIGHT_PERMS.support.agentSend, @@ -2079,7 +2290,10 @@ export const POSITION_PERMISSION_PRESETS = { /** Derive the module bucket from the resource segment of a permission key. */ const moduleOf = (key: string): string => key.split(":")[1] ?? "other"; -export const PERMISSIONS_CATALOG = BOOKING_RULE_ENGINE_PERMISSIONS.map((p) => ({ +export const PERMISSIONS_CATALOG = [ + ...BOOKING_RULE_ENGINE_PERMISSIONS, + ...FREIGHT_READ_PERMISSIONS, +].map((p) => ({ key: p.key, label: p.name.en, module: moduleOf(p.key), diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index 6fd666a8d..bad3cbfe5 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -1,36 +1,3 @@ -import { - ArrowLeftRight, - Boxes, - Building2, - BarChart3, - Container, - FileSignature, - FileText, - Hammer, - History, - LayoutDashboard, - LayoutGrid, - MapPin, - Network, - Package, - PackageCheck, - PackageOpen, - Paperclip, - Receipt, - ScrollText, - Send, - Settings, - ShieldCheck, - Ship, - SlidersHorizontal, - Train, - Truck, - Users, - Wallet, - LifeBuoy, - TrainFront, - XCircle, -} from "lucide-react"; import { useEffect } from "react"; import { Navigate, @@ -42,11 +9,7 @@ import { useParams, } from "react-router-dom"; -import { - FreightDashboardLayout, - type SidebarItem, - type SidebarSection, -} from "@/components/layout"; +import { FreightDashboardLayout, type SidebarItem } from "@/components/layout"; import { useAuth } from "./auth/useAuth"; import LoadingScreen from "./components/LoadingScreen"; import LoginPage from "./pages/auth/LoginPage"; @@ -84,11 +47,12 @@ import AuditLogsPage from "./pages/audit/AuditLogsPage"; import { RequirePermission } from "./components/auth/RequirePermission"; import { FREIGHT_PERMS, - hasPermission as hasFreightPermission, isDjiboutiGl, isEthiopianGl, isSuperAdmin, } from "./lib/permissions"; +import { NO_ACCESS_PATH, resolveLandingPath } from "./lib/landing"; +import NoAccessPage from "./pages/NoAccessPage"; import FileUploadSettingsPage from "./pages/documents/FileUploadSettingsPage"; import DropdownSettingsPage from "./pages/dropdown_settings/DropdownSettingsPage"; import ContractTemplatesPage from "./pages/contract_templates/ContractTemplatesPage"; @@ -108,7 +72,6 @@ import CompliancePage from "./pages/fleet/CompliancePage"; import IncidentsPage from "./pages/fleet/IncidentsPage"; import WorkOrdersPage from "./pages/fleet/WorkOrdersPage"; import ProcurementPage from "./pages/fleet/ProcurementPage"; -import { getCategorySidebarChildren } from "./pages/ruleEngine/config/resources"; import RuleEngineLegacyRedirect from "./pages/ruleEngine/RuleEngineLegacyRedirect"; import RuleEngineResourcePage from "./pages/ruleEngine/RuleEngineResourcePage"; import CargoTypesPage from "./pages/ruleEngine/CargoTypesPage"; @@ -151,615 +114,15 @@ import FaydaCallbackPage from "./pages/FaydaCallbackPage"; import { UserManagementRoutes } from "./user-management/route"; import SetPassword from "./shared/components/SetPassword"; import SupportInboxPage from "./pages/support/SupportInboxPage"; - -const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ - { - title: "Main menu", - items: [ - { - label: "Overview", - href: "/dashboard/overview", - icon: , - permission: FREIGHT_PERMS.overview.view, - }, - { - label: "Reports", - href: "/dashboard/reports", - icon: , - permission: FREIGHT_PERMS.reports.view, - }, - { - label: "Customers", - href: "/dashboard/customers", - icon: , - permission: FREIGHT_PERMS.customers.view, - }, - { - label: "Contracts", - href: "/dashboard/contract-requests", - icon: , - permission: FREIGHT_PERMS.contracts.view, - }, - { - label: "Bookings", - href: "/dashboard/booking-requests", - icon: , - permission: FREIGHT_PERMS.bookings.view, - }, - { - label: "Wagon cancellations", - href: "/dashboard/wagon-cancellations", - icon: , - permission: FREIGHT_PERMS.bookings.wagonCancellationView, - }, - // Operations hub: per-shipment clearance-document review for services - // WITHOUT customs clearing (self-clearance) — bookings only. - { - label: "Clearance Documents", - href: "/dashboard/contracts/clearance-documents", - icon: , - permission: FREIGHT_PERMS.contracts.opsClearanceReview, - }, - { - label: "Payments", - href: "/dashboard/payments", - icon: , - permission: FREIGHT_PERMS.payments.view, - }, - { - label: "Invoices", - href: "/dashboard/invoices", - icon: , - permission: FREIGHT_PERMS.invoices.view, - }, - { - label: "Support", - href: "/dashboard/support", - icon: , - permission: FREIGHT_PERMS.support.agentView, - }, - ...demoItems, - ], - }, - { - // title: "Port & Terminal", - items: [ - { - label: "Operations", - icon: , - children: [ - { - label: "Clearance", - href: "/dashboard/contracts/clearance", - icon: , - permission: [ - FREIGHT_PERMS.contracts.clearanceReview, - FREIGHT_PERMS.contracts.clearanceEtActions, - ], - }, - // { - // label: "Shipment Requests", - // href: "/dashboard/shipment-requests", - // icon: , - // permission: FREIGHT_PERMS.contracts.createBooking, - // }, - // Operations Path A queue: per-booking self-clearance review for - // GENERAL non-customs booking instances (and legacy self-clear bookings). - // { - // label: "Self-Clearance Review", - // href: "/dashboard/contracts/ops-clearance", - // icon: , - // permission: FREIGHT_PERMS.contracts.opsClearanceReview, - // }, - { - label: "GL Djibouti Clearance", - href: "/dashboard/gl-djibouti/clearance", - icon: , - permission: FREIGHT_PERMS.contracts.clearanceDjActions, - }, - { - label: "Train Schedules", - href: "/dashboard/operations/train-scheduling-v2", - icon: , - permission: FREIGHT_PERMS.trainScheduling.view, - }, - { - label: "Batch Board", - href: "/dashboard/operations/batch-board", - icon: , - permission: FREIGHT_PERMS.trainScheduling.view, - }, - { - label: "First Mile", - href: "/dashboard/operations/first-mile", - icon: , - permission: FREIGHT_PERMS.firstMile.view, - }, - { - label: "Last Mile", - href: "/dashboard/operations/last-mile", - icon: , - permission: FREIGHT_PERMS.lastMile.view, - }, - ], - }, - { - label: "Fleet Management", - icon: , - children: [ - { - label: "Fleet Dashboard", - href: "/dashboard/fleet-dashboard", - icon: , - permission: FREIGHT_PERMS.fleetDashboard.view, - }, - { - label: "Routes", - href: "/dashboard/routes", - icon: , - permission: [FREIGHT_PERMS.routes.view, FREIGHT_PERMS.fleet.view], - }, - { - label: "Locomotives", - href: "/dashboard/locomotives", - icon: , - permission: [ - FREIGHT_PERMS.locomotives.view, - FREIGHT_PERMS.fleet.view, - ], - }, - { - label: "Train Builder", - href: "/dashboard/train-builder", - icon: , - permission: [FREIGHT_PERMS.trains.view, FREIGHT_PERMS.fleet.view], - }, - - // { - // label: "Wagon types", - // href: "/dashboard/wagon-types", - // icon: , - // }, - { - label: "Wagons", - href: "/dashboard/wagons", - icon: , - permission: [FREIGHT_PERMS.wagons.view, FREIGHT_PERMS.fleet.view], - }, - { - label: "Wagon Transfers", - href: "/dashboard/wagon-transfers", - icon: , - permission: [ - FREIGHT_PERMS.wagons.transferView, - FREIGHT_PERMS.wagons.view, - ], - }, - { - label: "Vehicles", - href: "/dashboard/vehicles", - icon: , - permission: FREIGHT_PERMS.vehicles.view, - }, - { - label: "Drivers", - href: "/dashboard/drivers", - icon: , - permission: FREIGHT_PERMS.drivers.view, - }, - { - label: "Track Vehicles", - href: "/dashboard/tracking", - icon: , - permission: FREIGHT_PERMS.tracking.view, - }, - { - label: "Fuel Purchases", - href: "/dashboard/fuel-purchases", - icon: , - permission: FREIGHT_PERMS.fuel.view, - }, - { - label: "Fuel Analytics", - href: "/dashboard/fuel-stats", - icon: , - permission: FREIGHT_PERMS.fuel.view, - }, - { - label: "Maintenance", - href: "/dashboard/maintenance", - icon: , - permission: FREIGHT_PERMS.maintenance.view, - }, - { - label: "Work Orders", - href: "/dashboard/work-orders", - icon: , - permission: FREIGHT_PERMS.maintenance.view, - }, - { - label: "Compliance & Alerts", - href: "/dashboard/compliance", - icon: , - permission: FREIGHT_PERMS.fleet.view, - }, - { - label: "Incidents", - href: "/dashboard/incidents", - icon: , - permission: FREIGHT_PERMS.fleet.view, - }, - { - label: "Procurement", - href: "/dashboard/procurement", - icon: , - permission: FREIGHT_PERMS.fleet.view, - }, - { - label: "Financial Reports", - href: "/dashboard/financial-reports", - icon: , - permission: FREIGHT_PERMS.fleetReports.view, - }, - // { - // label: "Containers", - // href: "/dashboard/containers", - // icon: , - // }, - // { - // label: "Cargoes", - // href: "/dashboard/cargoes", - // icon: , - // }, - ], - }, - { - label: "Imports", - href: "/dashboard/import-warehouse", - icon: , - permission: FREIGHT_PERMS.warehouseInventory.view, - children: [ - { - label: "Import Overview", - href: "/dashboard/import-warehouse", - icon: , - permission: FREIGHT_PERMS.warehouseInventory.view, - }, - { - label: "Arrival Queue", - href: "/dashboard/arrival-queue", - icon: , - permission: FREIGHT_PERMS.warehouseInventory.view, - }, - { - label: "Import Trucks", - href: "/dashboard/import-trucks", - icon: , - permission: FREIGHT_PERMS.warehouseInventory.view, - }, - { - label: "EDR Last Mile Returns", - href: "/dashboard/edr-last-mile-returns", - icon: , - permission: FREIGHT_PERMS.warehouseInventory.view, - }, - { - label: "Container Returns", - href: "/dashboard/container-returns", - icon: , - permission: FREIGHT_PERMS.warehouseInventory.view, - }, - { - label: "Terminal Inventory", - href: "/dashboard/warehouse-inventory?direction=IMPORT", - icon: , - permission: FREIGHT_PERMS.warehouseInventory.view, - }, - { - label: "Inventory Inquiry", - href: "/dashboard/inventory-inquiry", - icon: , - permission: FREIGHT_PERMS.warehouseInventory.view, - }, - ], - }, - { - label: "Exports", - href: "/dashboard/export-warehouse", - icon: , - permission: FREIGHT_PERMS.warehouseInventory.view, - children: [ - { - label: "Export Overview", - href: "/dashboard/export-warehouse", - icon: , - permission: FREIGHT_PERMS.warehouseInventory.view, - }, - { - label: "Loading Queue", - href: "/dashboard/loading-queue", - icon: , - permission: FREIGHT_PERMS.warehouseInventory.view, - }, - { - label: "Loaded Inventory", - href: "/dashboard/loaded-inventory", - icon: , - permission: FREIGHT_PERMS.warehouseInventory.view, - }, - { - label: "Dispatch Queue", - href: "/dashboard/dispatch-queue", - icon: , - permission: FREIGHT_PERMS.warehouseInventory.view, - }, - { - label: "Djibouti Unloading", - href: "/dashboard/export-djibouti-unloading", - icon: , - permission: FREIGHT_PERMS.warehouseInventory.view, - }, - { - label: "Interchange Documents", - href: "/dashboard/interchange-documents", - icon: , - permission: FREIGHT_PERMS.interchangeDocuments.view, - }, - { - label: "Terminal Inventory", - href: "/dashboard/warehouse-inventory?direction=EXPORT", - icon: , - permission: FREIGHT_PERMS.warehouseInventory.view, - }, - ], - }, - { - label: "Intercity", - href: "/dashboard/intercity", - icon: , - permission: FREIGHT_PERMS.trainScheduling.view, - children: [ - { - label: "Intercity Cargo", - href: "/dashboard/intercity", - icon: , - permission: FREIGHT_PERMS.warehouseInventory.view, - }, - ], - }, - { - label: "Warehouse Management", - icon: , - children: [ - { - label: "Warehouse Dashboard", - href: "/dashboard/warehouse-dashboard", - icon: , - permission: FREIGHT_PERMS.warehouseDashboard.view, - }, - { - // Yard-wide, not per-direction: the gate sees import and export - // trucks at the same barrier. - label: "Trucks on Site", - href: "/dashboard/trucks-on-site", - icon: , - permission: FREIGHT_PERMS.warehouseInventory.view, - }, - { - label: "Warehouses", - href: "/dashboard/warehouses", - icon: , - permission: FREIGHT_PERMS.warehouses.view, - }, - { - label: "Allocation & Fees", - href: "/dashboard/warehouse-rules", - icon: , - permission: [ - FREIGHT_PERMS.warehouseAllocationRules.view, - FREIGHT_PERMS.warehouseFeeRules.view, - ], - }, - { - label: "Fee Invoices", - href: "/dashboard/warehouse-fee-invoices", - icon: , - permission: FREIGHT_PERMS.warehouseFeeInvoices.view, - }, - ], - }, - ], - }, - { - title: "Freight configuration", - mutedTitle: true, - items: [ - { - label: "File settings", - href: "/dashboard/file-settings", - icon: , - permission: FREIGHT_PERMS.admin, - }, - { - label: "Dropdown settings", - href: "/dashboard/dropdown-settings", - icon: , - permission: FREIGHT_PERMS.admin, - }, - { - label: "Contract templates", - href: "/dashboard/contract-templates", - icon: , - permission: FREIGHT_PERMS.admin, - }, - { - label: "Audit logs", - href: "/dashboard/audit-logs", - icon: , - permission: FREIGHT_PERMS.audit.view, - }, - { - label: "Configuration", - href: "/dashboard/configuration", - icon: , - children: [ - ...getCategorySidebarChildren("configuration"), - { - label: "Train scheduling rules", - href: "/dashboard/configuration/train-scheduling-rules", - permission: FREIGHT_PERMS.trainScheduling.rulesManage, - }, - { - label: "Trade access", - href: "/dashboard/configuration/trade-access", - permission: FREIGHT_PERMS.admin, - }, - { - label: "Exchange rate", - href: "/dashboard/configuration/exchange-rate", - permission: FREIGHT_PERMS.admin, - }, - ], - }, - { - label: "Rules", - href: "/dashboard/rules", - icon: , - children: getCategorySidebarChildren("rules"), - }, - - { - label: "Staff", - href: "/user-management", - icon: , - permission: [ - FREIGHT_PERMS.admin, - FREIGHT_PERMS.staff.roles.view, - FREIGHT_PERMS.staff.employeeRegistration.view, - FREIGHT_PERMS.staff.roleAssignment.view, - ], - }, - ], - }, -]; - -/** Hrefs of the two document-clearance menu items (stable identifiers). */ -const ET_CLEARANCE_HREF = "/dashboard/contracts/clearance"; -const DJ_CLEARANCE_HREF = "/dashboard/gl-djibouti/clearance"; - -// Routes a GL officer may reach beyond their clearance hub. Path B booking is -// part of their job (create/rebook under a cleared contract, then view that -// booking's clearance), but those routes live outside the clearance prefix — -// without this allowlist the single-prefix lock bounces them out of their own -// workflow. Matched against location.pathname (no query string). -const GL_WORKFLOW_PATH_PATTERNS: RegExp[] = [ - /^\/dashboard\/contracts\/[^/]+\/create-booking(\/|$)/, - /^\/dashboard\/bookings\/[^/]+\/clearance(\/|$)/, - // The ET hub's rows open the shipment clearance detail at this URL. - /^\/dashboard\/clearance\/[^/]+(\/|$)/, -]; - -const isEtClearanceItem = (item: SidebarItem): boolean => - item.href === ET_CLEARANCE_HREF; -const isDjClearanceItem = (item: SidebarItem): boolean => - item.href === DJ_CLEARANCE_HREF; -const isClearanceItem = (item: SidebarItem): boolean => - isEtClearanceItem(item) || isDjClearanceItem(item); - -/** - * Keep only items the user is permitted to see; drop now-empty sections. - * - * Position-scoped visibility (super_admin sees everything): - * - Super Admin → sees all items (all permissions pass, all tabs visible) - * - Ethiopian GL → sees ONLY the ET document-clearance page. - * - Djibouti GL → sees ONLY the DJ clearance page. - * - Everyone else → sees everything they have permission for, EXCEPT the two - * clearance pages (those are GL-only). - */ -const filterSidebarByPermission = ( - sections: SidebarSection[], - user: ReturnType["user"], -): SidebarSection[] => { - // Superadmin sees every section and item — no permission filtering. - if (isSuperAdmin(user)) return sections; - - const etGl = isEthiopianGl(user); - const djGl = isDjiboutiGl(user); - - const permissionAllowed = (item: SidebarItem): boolean => { - if (!item.permission) return true; - const keys = Array.isArray(item.permission) - ? item.permission - : [item.permission]; - return keys.some((key) => hasFreightPermission(user, key)); - }; - - // Recursive: children are filtered first; a group (item with children) stays - // only while it still has visible children — so parents without their own - // permission key never leak a whole subtree the user cannot open. - const filterItems = (items: SidebarItem[]): SidebarItem[] => - items - .map((item) => - item.children - ? { ...item, children: filterItems(item.children) } - : item, - ) - .filter((item) => { - if (etGl || djGl) { - // GL positions are locked to their single clearance page (parents - // survive only as the path to that page). - const isTarget = etGl ? isEtClearanceItem : isDjClearanceItem; - return isTarget(item) || (item.children?.length ?? 0) > 0; - } - // Everyone else: hide the GL-only clearance pages entirely. - if (isClearanceItem(item)) return false; - if (!permissionAllowed(item)) return false; - if (item.children) return item.children.length > 0; - return true; - }); - - return sections - .map((section) => ({ - ...section, - items: filterItems(section.items), - })) - .filter((section) => section.items.length > 0); -}; - -const APP_TITLE = "EDR Freight Backoffice"; - -/** Flatten sidebar sections (incl. nested children) into {href, label} pairs. */ -const flattenSidebarItems = ( - sections: SidebarSection[], -): { href: string; label: string }[] => - sections.flatMap((section) => - section.items.flatMap((item) => [ - ...(item.href ? [{ href: item.href, label: item.label }] : []), - ...(item.children ?? []) - .filter((child): child is SidebarItem & { href: string } => - Boolean(child.href), - ) - .map((child) => ({ href: child.href, label: child.label })), - ]), - ); - -/** Find the sidebar label whose href matches (exactly or as a prefix of) the current path. */ -const findActiveSidebarLabel = ( - pathname: string, - sections: SidebarSection[], -): string | undefined => { - const path = pathname.toLowerCase(); - const candidates = flattenSidebarItems(sections) - .map(({ href, label }) => ({ - label, - href: href.split("?")[0].toLowerCase(), - })) - .sort((a, b) => b.href.length - a.href.length); - - return candidates.find( - ({ href }) => path === href || path.startsWith(`${href}/`), - )?.label; -}; +import { + APP_TITLE, + buildSidebarSections, + DJ_CLEARANCE_HREF, + ET_CLEARANCE_HREF, + filterSidebarByPermission, + findActiveSidebarLabel, + GL_WORKFLOW_PATH_PATTERNS, +} from "@/components/layout/sidebar-sections"; const DashboardShell = () => { const navigate = useNavigate(); @@ -840,17 +203,19 @@ const App = () => { ); } + const landingPath = resolveLandingPath(user); + return ( {UserManagementRoutes()} {/* } /> */} } /> } /> - } /> - } - /> + } /> + {/* Landing is per-user: /dashboard/overview is gated on overview:view, so + a fixed target strands anyone without that key on a blank page. */} + } /> + } /> }> } /> } /> @@ -1554,7 +919,9 @@ const App = () => { } /> - } /> + {/* Post-login landing: the browser is still on /auth, which matches + nothing here, so this is what actually decides where users start. */} + } /> ); }; @@ -1575,3 +942,4 @@ function LegacyGlEthiopiaClearanceRedirect() { } export default App; + diff --git a/apps/edr-freight-web/backoffice/src/components/auth/RequirePermission.tsx b/apps/edr-freight-web/backoffice/src/components/auth/RequirePermission.tsx index 4e35fc712..c67a4da69 100644 --- a/apps/edr-freight-web/backoffice/src/components/auth/RequirePermission.tsx +++ b/apps/edr-freight-web/backoffice/src/components/auth/RequirePermission.tsx @@ -1,30 +1,42 @@ import type { ReactNode } from "react"; -import { Navigate } from "react-router-dom"; +import { Navigate, useLocation } from "react-router-dom"; import { useAuth } from "@/auth/useAuth"; +import { resolveLandingPath } from "@/lib/landing"; import { hasPermission } from "@/lib/permissions"; +import NoAccessPage from "@/pages/NoAccessPage"; interface RequirePermissionProps { /** Permission key(s); access is granted if the user has ANY of them. */ permission: string | string[]; - /** Where to send users who lack the permission. */ + /** Where to send users who lack the permission. Defaults to their landing page. */ redirectTo?: string; children: ReactNode; } /** * Page-level guard: renders children only when the current user holds one of - * the given permissions, otherwise redirects (default: overview). + * the given permissions, otherwise redirects to a page they can actually reach. + * + * The fallback must not be a fixed path. It used to be `/dashboard/overview`, + * which is itself gated on `overview:view` — a user without that key was sent + * to the page that had just rejected them, and React Router rendered a blank + * frame instead of navigating. */ export function RequirePermission({ permission, - redirectTo = "/dashboard/overview", + redirectTo, children, }: RequirePermissionProps) { const { user } = useAuth(); + const location = useLocation(); const keys = Array.isArray(permission) ? permission : [permission]; const allowed = keys.some((key) => hasPermission(user, key)); - if (!allowed) return ; - return <>{children}; + if (allowed) return <>{children}; + + const target = redirectTo ?? resolveLandingPath(user); + // Belt and braces: never navigate to the page we are already on. + if (target === location.pathname) return ; + return ; } diff --git a/apps/edr-freight-web/backoffice/src/components/customers/badges.tsx b/apps/edr-freight-web/backoffice/src/components/customers/badges.tsx index 046493b0f..edcc4a517 100644 --- a/apps/edr-freight-web/backoffice/src/components/customers/badges.tsx +++ b/apps/edr-freight-web/backoffice/src/components/customers/badges.tsx @@ -16,6 +16,7 @@ import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions"; import { api } from "@/services/api"; import type { + CompanyNationality, CompanyProfile, CompanyStatus, CompanyType, @@ -88,6 +89,31 @@ export function CompanyTypeBadge({ type }: { type: CompanyType }) { ); } +const NATIONALITY_COLOR: Record = { + ethiopian: "edr-green", + foreign: "blue", +}; + +export function CompanyNationalityBadge({ + nationality, +}: { + nationality?: CompanyNationality | null; +}) { + if (!nationality) return null; + return ( + + {humanize(nationality)} + + ); +} + /** * Profile chips for a company row: one chip per role (Importer / Exporter / …) * carrying its reference code. Caps at three (a company has at most three diff --git a/apps/edr-freight-web/backoffice/src/components/customers/index.ts b/apps/edr-freight-web/backoffice/src/components/customers/index.ts index 1ccd29f41..81f25fcb2 100644 --- a/apps/edr-freight-web/backoffice/src/components/customers/index.ts +++ b/apps/edr-freight-web/backoffice/src/components/customers/index.ts @@ -1,5 +1,6 @@ export { BookingStatusBadge, + CompanyNationalityBadge, CompanyStatusBadge, CompanyTypeBadge, InvoiceStatusBadge, diff --git a/apps/edr-freight-web/backoffice/src/components/layout/sidebar-sections.tsx b/apps/edr-freight-web/backoffice/src/components/layout/sidebar-sections.tsx new file mode 100644 index 000000000..914a9cceb --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/layout/sidebar-sections.tsx @@ -0,0 +1,660 @@ +import { + ArrowLeftRight, + Boxes, + Building2, + BarChart3, + Container, + FileSignature, + FileText, + Hammer, + History, + LayoutDashboard, + LayoutGrid, + MapPin, + Network, + Package, + PackageCheck, + PackageOpen, + Paperclip, + Receipt, + ScrollText, + Send, + Settings, + ShieldCheck, + Ship, + SlidersHorizontal, + Train, + Truck, + Users, + Wallet, + LifeBuoy, + TrainFront, + XCircle, +} from "lucide-react"; + +import type { AuthUser } from "@/auth/types"; + +import type { SidebarItem, SidebarSection } from "./types"; +import { + FREIGHT_PERMS, + hasPermission as hasFreightPermission, + isDjiboutiGl, + isEthiopianGl, + isSuperAdmin, +} from "@/lib/permissions"; +import { getCategorySidebarChildren } from "@/pages/ruleEngine/config/resources"; + +/** + * Backoffice sidebar model: the nav tree, its permission filter, and the + * flatten/lookup helpers. Lives outside App.tsx so `lib/landing.ts` can resolve + * a user's first reachable route without importing the route tree (App.tsx + * imports RequirePermission, which imports landing — that would cycle). + */ +export const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ + { + title: "Main menu", + items: [ + { + label: "Overview", + href: "/dashboard/overview", + icon: , + permission: FREIGHT_PERMS.overview.view, + }, + { + label: "Reports", + href: "/dashboard/reports", + icon: , + permission: FREIGHT_PERMS.reports.view, + }, + { + label: "Customers", + href: "/dashboard/customers", + icon: , + permission: FREIGHT_PERMS.customers.view, + }, + { + label: "Contracts", + href: "/dashboard/contract-requests", + icon: , + permission: FREIGHT_PERMS.contracts.view, + }, + { + label: "Bookings", + href: "/dashboard/booking-requests", + icon: , + permission: FREIGHT_PERMS.bookings.view, + }, + { + label: "Wagon cancellations", + href: "/dashboard/wagon-cancellations", + icon: , + permission: FREIGHT_PERMS.bookings.wagonCancellationView, + }, + // Operations hub: per-shipment clearance-document review for services + // WITHOUT customs clearing (self-clearance) — bookings only. + { + label: "Clearance Documents", + href: "/dashboard/contracts/clearance-documents", + icon: , + permission: FREIGHT_PERMS.contracts.opsClearanceReview, + }, + { + label: "Payments", + href: "/dashboard/payments", + icon: , + permission: FREIGHT_PERMS.payments.view, + }, + { + label: "Invoices", + href: "/dashboard/invoices", + icon: , + permission: FREIGHT_PERMS.invoices.view, + }, + { + label: "Support", + href: "/dashboard/support", + icon: , + permission: FREIGHT_PERMS.support.agentView, + }, + ...demoItems, + ], + }, + { + // title: "Port & Terminal", + items: [ + { + label: "Operations", + icon: , + children: [ + { + label: "Clearance", + href: "/dashboard/contracts/clearance", + icon: , + permission: [ + FREIGHT_PERMS.contracts.clearanceReview, + FREIGHT_PERMS.contracts.clearanceEtActions, + ], + }, + // { + // label: "Shipment Requests", + // href: "/dashboard/shipment-requests", + // icon: , + // permission: FREIGHT_PERMS.contracts.createBooking, + // }, + // Operations Path A queue: per-booking self-clearance review for + // GENERAL non-customs booking instances (and legacy self-clear bookings). + // { + // label: "Self-Clearance Review", + // href: "/dashboard/contracts/ops-clearance", + // icon: , + // permission: FREIGHT_PERMS.contracts.opsClearanceReview, + // }, + { + label: "GL Djibouti Clearance", + href: "/dashboard/gl-djibouti/clearance", + icon: , + permission: FREIGHT_PERMS.contracts.clearanceDjActions, + }, + { + label: "Train Schedules", + href: "/dashboard/operations/train-scheduling-v2", + icon: , + permission: FREIGHT_PERMS.trainScheduling.view, + }, + { + label: "Batch Board", + href: "/dashboard/operations/batch-board", + icon: , + permission: FREIGHT_PERMS.trainScheduling.view, + }, + { + label: "First Mile", + href: "/dashboard/operations/first-mile", + icon: , + permission: FREIGHT_PERMS.firstMile.view, + }, + { + label: "Last Mile", + href: "/dashboard/operations/last-mile", + icon: , + permission: FREIGHT_PERMS.lastMile.view, + }, + ], + }, + { + label: "Fleet Management", + icon: , + children: [ + { + label: "Fleet Dashboard", + href: "/dashboard/fleet-dashboard", + icon: , + permission: FREIGHT_PERMS.fleetDashboard.view, + }, + { + label: "Routes", + href: "/dashboard/routes", + icon: , + permission: [FREIGHT_PERMS.routes.view, FREIGHT_PERMS.fleet.view], + }, + { + label: "Locomotives", + href: "/dashboard/locomotives", + icon: , + permission: [ + FREIGHT_PERMS.locomotives.view, + FREIGHT_PERMS.fleet.view, + ], + }, + { + label: "Train Builder", + href: "/dashboard/train-builder", + icon: , + permission: [FREIGHT_PERMS.trains.view, FREIGHT_PERMS.fleet.view], + }, + + // { + // label: "Wagon types", + // href: "/dashboard/wagon-types", + // icon: , + // }, + { + label: "Wagons", + href: "/dashboard/wagons", + icon: , + permission: [FREIGHT_PERMS.wagons.view, FREIGHT_PERMS.fleet.view], + }, + { + label: "Wagon Transfers", + href: "/dashboard/wagon-transfers", + icon: , + permission: [ + FREIGHT_PERMS.wagons.transferView, + FREIGHT_PERMS.wagons.view, + ], + }, + { + label: "Vehicles", + href: "/dashboard/vehicles", + icon: , + permission: FREIGHT_PERMS.vehicles.view, + }, + { + label: "Drivers", + href: "/dashboard/drivers", + icon: , + permission: FREIGHT_PERMS.drivers.view, + }, + { + label: "Track Vehicles", + href: "/dashboard/tracking", + icon: , + permission: FREIGHT_PERMS.tracking.view, + }, + { + label: "Fuel Purchases", + href: "/dashboard/fuel-purchases", + icon: , + permission: FREIGHT_PERMS.fuel.view, + }, + { + label: "Fuel Analytics", + href: "/dashboard/fuel-stats", + icon: , + permission: FREIGHT_PERMS.fuel.view, + }, + { + label: "Maintenance", + href: "/dashboard/maintenance", + icon: , + permission: FREIGHT_PERMS.maintenance.view, + }, + { + label: "Work Orders", + href: "/dashboard/work-orders", + icon: , + permission: FREIGHT_PERMS.maintenance.view, + }, + { + label: "Compliance & Alerts", + href: "/dashboard/compliance", + icon: , + permission: FREIGHT_PERMS.fleet.view, + }, + { + label: "Incidents", + href: "/dashboard/incidents", + icon: , + permission: FREIGHT_PERMS.fleet.view, + }, + { + label: "Procurement", + href: "/dashboard/procurement", + icon: , + permission: FREIGHT_PERMS.fleet.view, + }, + { + label: "Financial Reports", + href: "/dashboard/financial-reports", + icon: , + permission: FREIGHT_PERMS.fleetReports.view, + }, + // { + // label: "Containers", + // href: "/dashboard/containers", + // icon: , + // }, + // { + // label: "Cargoes", + // href: "/dashboard/cargoes", + // icon: , + // }, + ], + }, + { + label: "Imports", + href: "/dashboard/import-warehouse", + icon: , + permission: FREIGHT_PERMS.warehouseInventory.view, + children: [ + { + label: "Import Overview", + href: "/dashboard/import-warehouse", + icon: , + permission: FREIGHT_PERMS.warehouseInventory.view, + }, + { + label: "Arrival Queue", + href: "/dashboard/arrival-queue", + icon: , + permission: FREIGHT_PERMS.warehouseInventory.view, + }, + { + label: "Import Trucks", + href: "/dashboard/import-trucks", + icon: , + permission: FREIGHT_PERMS.warehouseInventory.view, + }, + { + label: "EDR Last Mile Returns", + href: "/dashboard/edr-last-mile-returns", + icon: , + permission: FREIGHT_PERMS.warehouseInventory.view, + }, + { + label: "Container Returns", + href: "/dashboard/container-returns", + icon: , + permission: FREIGHT_PERMS.warehouseInventory.view, + }, + { + label: "Terminal Inventory", + href: "/dashboard/warehouse-inventory?direction=IMPORT", + icon: , + permission: FREIGHT_PERMS.warehouseInventory.view, + }, + { + label: "Inventory Inquiry", + href: "/dashboard/inventory-inquiry", + icon: , + permission: FREIGHT_PERMS.warehouseInventory.view, + }, + ], + }, + { + label: "Exports", + href: "/dashboard/export-warehouse", + icon: , + permission: FREIGHT_PERMS.warehouseInventory.view, + children: [ + { + label: "Export Overview", + href: "/dashboard/export-warehouse", + icon: , + permission: FREIGHT_PERMS.warehouseInventory.view, + }, + { + label: "Loading Queue", + href: "/dashboard/loading-queue", + icon: , + permission: FREIGHT_PERMS.warehouseInventory.view, + }, + { + label: "Loaded Inventory", + href: "/dashboard/loaded-inventory", + icon: , + permission: FREIGHT_PERMS.warehouseInventory.view, + }, + { + label: "Dispatch Queue", + href: "/dashboard/dispatch-queue", + icon: , + permission: FREIGHT_PERMS.warehouseInventory.view, + }, + { + label: "Djibouti Unloading", + href: "/dashboard/export-djibouti-unloading", + icon: , + permission: FREIGHT_PERMS.warehouseInventory.view, + }, + { + label: "Interchange Documents", + href: "/dashboard/interchange-documents", + icon: , + permission: FREIGHT_PERMS.interchangeDocuments.view, + }, + { + label: "Terminal Inventory", + href: "/dashboard/warehouse-inventory?direction=EXPORT", + icon: , + permission: FREIGHT_PERMS.warehouseInventory.view, + }, + ], + }, + { + label: "Intercity", + href: "/dashboard/intercity", + icon: , + permission: FREIGHT_PERMS.trainScheduling.view, + children: [ + { + label: "Intercity Cargo", + href: "/dashboard/intercity", + icon: , + permission: FREIGHT_PERMS.warehouseInventory.view, + }, + ], + }, + { + label: "Warehouse Management", + icon: , + children: [ + { + label: "Warehouse Dashboard", + href: "/dashboard/warehouse-dashboard", + icon: , + permission: FREIGHT_PERMS.warehouseDashboard.view, + }, + { + // Yard-wide, not per-direction: the gate sees import and export + // trucks at the same barrier. + label: "Trucks on Site", + href: "/dashboard/trucks-on-site", + icon: , + permission: FREIGHT_PERMS.warehouseInventory.view, + }, + { + label: "Warehouses", + href: "/dashboard/warehouses", + icon: , + permission: FREIGHT_PERMS.warehouses.view, + }, + { + label: "Allocation & Fees", + href: "/dashboard/warehouse-rules", + icon: , + permission: [ + FREIGHT_PERMS.warehouseAllocationRules.view, + FREIGHT_PERMS.warehouseFeeRules.view, + ], + }, + { + label: "Fee Invoices", + href: "/dashboard/warehouse-fee-invoices", + icon: , + permission: FREIGHT_PERMS.warehouseFeeInvoices.view, + }, + ], + }, + ], + }, + { + title: "Freight configuration", + mutedTitle: true, + items: [ + { + label: "File settings", + href: "/dashboard/file-settings", + icon: , + permission: FREIGHT_PERMS.admin, + }, + { + label: "Dropdown settings", + href: "/dashboard/dropdown-settings", + icon: , + permission: FREIGHT_PERMS.admin, + }, + { + label: "Contract templates", + href: "/dashboard/contract-templates", + icon: , + permission: FREIGHT_PERMS.admin, + }, + { + label: "Audit logs", + href: "/dashboard/audit-logs", + icon: , + permission: FREIGHT_PERMS.audit.view, + }, + { + label: "Configuration", + href: "/dashboard/configuration", + icon: , + children: [ + ...getCategorySidebarChildren("configuration"), + { + label: "Train scheduling rules", + href: "/dashboard/configuration/train-scheduling-rules", + permission: FREIGHT_PERMS.trainScheduling.rulesManage, + }, + { + label: "Trade access", + href: "/dashboard/configuration/trade-access", + permission: FREIGHT_PERMS.admin, + }, + { + label: "Exchange rate", + href: "/dashboard/configuration/exchange-rate", + permission: FREIGHT_PERMS.admin, + }, + ], + }, + { + label: "Rules", + href: "/dashboard/rules", + icon: , + children: getCategorySidebarChildren("rules"), + }, + + { + label: "Staff", + href: "/user-management", + icon: , + permission: [ + FREIGHT_PERMS.admin, + FREIGHT_PERMS.staff.roles.view, + FREIGHT_PERMS.staff.employeeRegistration.view, + FREIGHT_PERMS.staff.roleAssignment.view, + ], + }, + ], + }, +]; + +/** Hrefs of the two document-clearance menu items (stable identifiers). */ +export const ET_CLEARANCE_HREF = "/dashboard/contracts/clearance"; +export const DJ_CLEARANCE_HREF = "/dashboard/gl-djibouti/clearance"; + +// Routes a GL officer may reach beyond their clearance hub. Path B booking is +// part of their job (create/rebook under a cleared contract, then view that +// booking's clearance), but those routes live outside the clearance prefix — +// without this allowlist the single-prefix lock bounces them out of their own +// workflow. Matched against location.pathname (no query string). +export const GL_WORKFLOW_PATH_PATTERNS: RegExp[] = [ + /^\/dashboard\/contracts\/[^/]+\/create-booking(\/|$)/, + /^\/dashboard\/bookings\/[^/]+\/clearance(\/|$)/, + // The ET hub's rows open the shipment clearance detail at this URL. + /^\/dashboard\/clearance\/[^/]+(\/|$)/, +]; + +const isEtClearanceItem = (item: SidebarItem): boolean => + item.href === ET_CLEARANCE_HREF; +const isDjClearanceItem = (item: SidebarItem): boolean => + item.href === DJ_CLEARANCE_HREF; +const isClearanceItem = (item: SidebarItem): boolean => + isEtClearanceItem(item) || isDjClearanceItem(item); + +/** + * Keep only items the user is permitted to see; drop now-empty sections. + * + * Position-scoped visibility (super_admin sees everything): + * - Super Admin → sees all items (all permissions pass, all tabs visible) + * - Ethiopian GL → sees ONLY the ET document-clearance page. + * - Djibouti GL → sees ONLY the DJ clearance page. + * - Everyone else → sees everything they have permission for, EXCEPT the two + * clearance pages (those are GL-only). + */ +export const filterSidebarByPermission = ( + sections: SidebarSection[], + user: AuthUser | null | undefined, +): SidebarSection[] => { + // Superadmin sees every section and item — no permission filtering. + if (isSuperAdmin(user)) return sections; + + const etGl = isEthiopianGl(user); + const djGl = isDjiboutiGl(user); + + const permissionAllowed = (item: SidebarItem): boolean => { + if (!item.permission) return true; + const keys = Array.isArray(item.permission) + ? item.permission + : [item.permission]; + return keys.some((key) => hasFreightPermission(user, key)); + }; + + // Recursive: children are filtered first; a group (item with children) stays + // only while it still has visible children — so parents without their own + // permission key never leak a whole subtree the user cannot open. + const filterItems = (items: SidebarItem[]): SidebarItem[] => + items + .map((item) => + item.children + ? { ...item, children: filterItems(item.children) } + : item, + ) + .filter((item) => { + if (etGl || djGl) { + // GL positions are locked to their single clearance page (parents + // survive only as the path to that page). + const isTarget = etGl ? isEtClearanceItem : isDjClearanceItem; + return isTarget(item) || (item.children?.length ?? 0) > 0; + } + // Everyone else: hide the GL-only clearance pages entirely. + if (isClearanceItem(item)) return false; + if (!permissionAllowed(item)) return false; + if (item.children) return item.children.length > 0; + return true; + }); + + return sections + .map((section) => ({ + ...section, + items: filterItems(section.items), + })) + .filter((section) => section.items.length > 0); +}; + +export const APP_TITLE = "EDR Freight Backoffice"; + +/** Flatten sidebar sections (incl. nested children) into {href, label} pairs. */ +export const flattenSidebarItems = ( + sections: SidebarSection[], +): { href: string; label: string }[] => + sections.flatMap((section) => + section.items.flatMap((item) => [ + ...(item.href ? [{ href: item.href, label: item.label }] : []), + ...(item.children ?? []) + .filter((child): child is SidebarItem & { href: string } => + Boolean(child.href), + ) + .map((child) => ({ href: child.href, label: child.label })), + ]), + ); + +/** Find the sidebar label whose href matches (exactly or as a prefix of) the current path. */ +export const findActiveSidebarLabel = ( + pathname: string, + sections: SidebarSection[], +): string | undefined => { + const path = pathname.toLowerCase(); + const candidates = flattenSidebarItems(sections) + .map(({ href, label }) => ({ + label, + href: href.split("?")[0].toLowerCase(), + })) + .sort((a, b) => b.href.length - a.href.length); + + return candidates.find( + ({ href }) => path === href || path.startsWith(`${href}/`), + )?.label; +}; diff --git a/apps/edr-freight-web/backoffice/src/lib/landing.test.ts b/apps/edr-freight-web/backoffice/src/lib/landing.test.ts new file mode 100644 index 000000000..e286e197d --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/lib/landing.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from "vitest"; + +import type { AuthUser } from "@/auth/types"; +import { FREIGHT_PERMS } from "./permissions"; +import { NO_ACCESS_PATH, resolveLandingPath } from "./landing"; + +const withPermissions = (...keys: string[]): AuthUser => ({ + permissionKeys: keys, +}); + +const withRole = (roleKey: string): AuthUser => ({ roles: [{ key: roleKey }] }); + +describe("resolveLandingPath", () => { + it("keeps normal staff on the overview", () => { + expect(resolveLandingPath(withPermissions(FREIGHT_PERMS.overview.view))).toBe( + "/dashboard/overview", + ); + }); + + it("sends super admins to the overview (they pass every check)", () => { + expect(resolveLandingPath(withRole("super_admin"))).toBe( + "/dashboard/overview", + ); + }); + + it("sends IAM-only admins to the user-management dashboard", () => { + // A pure unit_admin holds `can:*` IAM keys and no edr_freight_app:* key, so + // the freight sidebar filters down to nothing. Landing them on the overview + // is what produced the blank page. + for (const role of ["unit_admin", "admin", "organization_admin"]) { + expect(resolveLandingPath(withRole(role))).toBe( + "/user-management/user_management-dashboard", + ); + } + }); + + it("lands staff without overview:view on a page they can see", () => { + const target = resolveLandingPath( + withPermissions(FREIGHT_PERMS.warehouses.view), + ); + expect(target).not.toBe("/dashboard/overview"); + expect(target.startsWith("/dashboard/")).toBe(true); + }); + + it("never returns /user-management for a freight-only staff key", () => { + // The Staff sidebar item points at /user-management, which redirects by + // ROLE — returning it here would ping-pong for a user with no IAM role. + expect(resolveLandingPath(withPermissions(FREIGHT_PERMS.staff.roles.view))).toBe( + NO_ACCESS_PATH, + ); + }); + + it("falls back to the no-access page when nothing is granted", () => { + expect(resolveLandingPath({})).toBe(NO_ACCESS_PATH); + expect(resolveLandingPath(null)).toBe(NO_ACCESS_PATH); + }); +}); diff --git a/apps/edr-freight-web/backoffice/src/lib/landing.ts b/apps/edr-freight-web/backoffice/src/lib/landing.ts new file mode 100644 index 000000000..2cfd6f5d1 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/lib/landing.ts @@ -0,0 +1,51 @@ +import { + buildSidebarSections, + filterSidebarByPermission, + flattenSidebarItems, +} from "@/components/layout/sidebar-sections"; + +import type { AuthUser } from "@/auth/types"; + +import { isSuperAdmin } from "./permissions"; + +/** + * IAM roles that grant the user-management subtree but no freight permissions. + * Mirrors the `allowedRoles` on the org-admin route group in + * `user-management/route.tsx` — a role outside this list is bounced by its + * PrivateRoute, so landing anyone else there would just bounce again. + */ +const IAM_ADMIN_ROLES = ["admin", "organization_admin", "unit_admin"]; + +/** Terminal page for accounts with nothing granted. Never permission-gated. */ +export const NO_ACCESS_PATH = "/no-access"; + +/** + * The first route a user can actually reach. + * + * Every redirect in the app funnels through here instead of hardcoding + * `/dashboard/overview`: that page is itself gated on `overview:view`, so + * sending a user who lacks the key there redirects them to the page that just + * rejected them — React Router renders nothing and the user sees a blank frame. + * A pure `unit_admin` holds only `can:*` IAM keys and no `edr_freight_app:*` + * key at all, so this hit them on every login. + */ +export function resolveLandingPath(user: AuthUser | null | undefined): string { + const visible = filterSidebarByPermission(buildSidebarSections([]), user); + + // Only /dashboard/* items are safe landings. The "Staff" item points at + // /user-management, which re-redirects by ROLE — a freight user holding + // staff:* keys but no IAM role would ping-pong between the two. + const firstFreightPage = flattenSidebarItems(visible).find((item) => + item.href.startsWith("/dashboard/"), + )?.href; + if (firstFreightPage) return firstFreightPage; + + if (isSuperAdmin(user)) return "/user-management/dashboard"; + + const roleKeys = (user?.roles ?? []).map((role) => role.key ?? ""); + if (roleKeys.some((key) => IAM_ADMIN_ROLES.includes(key))) { + return "/user-management/user_management-dashboard"; + } + + return NO_ACCESS_PATH; +} diff --git a/apps/edr-freight-web/backoffice/src/pages/NoAccessPage.tsx b/apps/edr-freight-web/backoffice/src/pages/NoAccessPage.tsx new file mode 100644 index 000000000..06d19ac5f --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/NoAccessPage.tsx @@ -0,0 +1,32 @@ +import { ShieldOff } from "lucide-react"; + +import { useAuth } from "@/auth/useAuth"; +import { Button } from "@/components/ui/button"; + +/** + * Terminal page for an account with no freight permissions and no IAM admin + * role. Reached via `resolveLandingPath`, which needs one destination that can + * never bounce — every other page is permission-gated. + */ +export default function NoAccessPage() { + const { user, logout } = useAuth(); + + return ( +
+
+ +

No access assigned

+

+ Your account has no permissions assigned yet, so there are no pages to + show. Contact your administrator to have a position or role assigned. +

+ {user?.email ? ( +

Signed in as {user.email}

+ ) : null} + +
+
+ ); +} diff --git a/apps/edr-freight-web/backoffice/src/pages/auth/LoginPage.tsx b/apps/edr-freight-web/backoffice/src/pages/auth/LoginPage.tsx index 811d7e34b..db4533d21 100644 --- a/apps/edr-freight-web/backoffice/src/pages/auth/LoginPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/auth/LoginPage.tsx @@ -64,7 +64,9 @@ const LoginPage = () => { try { await verifyMfa({ email: normalizedIdentifier, otp: otp.trim() }); - navigate("/dashboard/overview", { replace: true }); + // "/" resolves to the user's own landing page once the session lands — + // not every user can see the overview. + navigate("/", { replace: true }); } catch (err) { setError(extractApiError(err).message); } finally { diff --git a/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx index a9e56a6d8..f31ce02e9 100644 --- a/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx @@ -812,6 +812,14 @@ export default function CustomerDetailPage() { } /> + diff --git a/apps/edr-freight-web/backoffice/src/pages/customers/CustomersPage.tsx b/apps/edr-freight-web/backoffice/src/pages/customers/CustomersPage.tsx index 34f90ffff..815615a1b 100644 --- a/apps/edr-freight-web/backoffice/src/pages/customers/CustomersPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/customers/CustomersPage.tsx @@ -31,6 +31,7 @@ import { useMemo, useState } from "react"; import { useNavigate } from "react-router-dom"; import { + CompanyNationalityBadge, CompanyStatusBadge, ProfileChips, formatDate, @@ -149,6 +150,7 @@ export default function CustomersPage() { {c.name} + TIN {c.tin} diff --git a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/CargoTypesPage.tsx b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/CargoTypesPage.tsx index 57c239885..e3fb54526 100644 --- a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/CargoTypesPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/CargoTypesPage.tsx @@ -30,6 +30,7 @@ import { } from "lucide-react"; import { useAuth } from "@/auth/useAuth"; +import { resolveLandingPath } from "@/lib/landing"; import { canAccessRuleEngineResource } from "@/lib/permissions"; import RuleEngineFormDialog from "@/components/ruleEngine/RuleEngineFormDialog"; import { @@ -239,8 +240,8 @@ const CargoTypesPage = () => { [levelNodes, term], ); - if (!config) return ; - if (!canView) return ; + if (!config) return ; + if (!canView) return ; // A bad/stale :id (after data loads) → fall back to the root list. if (!isLoading && currentId && !current) return ; diff --git a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/RuleEngineResourcePage.tsx b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/RuleEngineResourcePage.tsx index 885fcb82b..3a3629155 100644 --- a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/RuleEngineResourcePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/RuleEngineResourcePage.tsx @@ -1,4 +1,5 @@ import { useAuth } from "@/auth/useAuth"; +import { resolveLandingPath } from "@/lib/landing"; import { canAccessRuleEngineResource, canApproveRuleEngineChange, @@ -584,7 +585,7 @@ const RuleEngineResourcePage = () => { } if (!canView) { - return ; + return ; } const openCreate = () => { diff --git a/apps/edr-freight-web/backoffice/src/routes/RootRedirect.tsx b/apps/edr-freight-web/backoffice/src/routes/RootRedirect.tsx index 77ad29531..48f3ce6aa 100644 --- a/apps/edr-freight-web/backoffice/src/routes/RootRedirect.tsx +++ b/apps/edr-freight-web/backoffice/src/routes/RootRedirect.tsx @@ -2,19 +2,15 @@ import type { ReactElement } from "react"; import { Navigate } from "react-router-dom"; import { useAuth } from "@/auth/useAuth"; +import { resolveLandingPath } from "@/lib/landing"; /** - * Root landing redirect for the vendored IAM pages. Sends super admins to the - * user-management dashboard and everyone else to the freight overview. Minimal - * replacement for the source app's @/routes/RootRedirect (not copied over). + * Root landing redirect for the vendored IAM pages. Delegates to the shared + * landing resolver so it cannot drift from the app's other redirect sinks. */ export function RootRedirect(): ReactElement { const { user } = useAuth(); - const roles = (user?.roles ?? []).map((r) => r.key).filter(Boolean); - if (roles.includes("super_admin")) { - return ; - } - return ; + return ; } export default RootRedirect; diff --git a/apps/edr-freight-web/backoffice/src/types/customer.ts b/apps/edr-freight-web/backoffice/src/types/customer.ts index ed77307f9..cd2d67842 100644 --- a/apps/edr-freight-web/backoffice/src/types/customer.ts +++ b/apps/edr-freight-web/backoffice/src/types/customer.ts @@ -21,6 +21,9 @@ export type CompanyStatus = "active" | "pending" | "suspended" | "blacklisted"; /** Mirrors backend `CompanyKind` — commercial customer vs. government entity. */ export type CompanyKind = "commercial" | "government"; +/** Mirrors backend `CompanyNationality`. */ +export type CompanyNationality = "ethiopian" | "foreign"; + /** Mirrors backend `ProfileType` (the role a company plays). */ export type ProfileType = | "importer" @@ -207,6 +210,7 @@ export interface Company { vatNumber?: string | null; fanNumber?: string | null; country: string; + nationality?: CompanyNationality | null; address?: string | null; phone?: string | null; email?: string | null; diff --git a/apps/edr-freight-web/backoffice/src/user-management/Applayout.tsx b/apps/edr-freight-web/backoffice/src/user-management/Applayout.tsx index ade96250d..37ab26be8 100644 --- a/apps/edr-freight-web/backoffice/src/user-management/Applayout.tsx +++ b/apps/edr-freight-web/backoffice/src/user-management/Applayout.tsx @@ -40,8 +40,10 @@ export const AppLayout = () => { drives collapse on desktop and the Sheet drawer on mobile. */} + {/* "/" resolves to the user's own landing page — an IAM-only admin + has no overview to go back to. */} diff --git a/apps/edr-freight-web/backoffice/src/user-management/route.tsx b/apps/edr-freight-web/backoffice/src/user-management/route.tsx index af29cdc5e..f1e2a8611 100644 --- a/apps/edr-freight-web/backoffice/src/user-management/route.tsx +++ b/apps/edr-freight-web/backoffice/src/user-management/route.tsx @@ -2,6 +2,7 @@ import type { ReactElement } from "react"; import { Navigate, Outlet, Route } from "react-router-dom"; import { useAuth } from "@/auth/useAuth"; +import { NO_ACCESS_PATH, resolveLandingPath } from "@/lib/landing"; import { isSuperAdmin } from "@/lib/permissions"; import { WithPermission } from "@/shared/hooks/useHas"; import PendingExternalUsers from "@/super-admin/components/externalUsers/PendingExternalUsers"; @@ -85,7 +86,7 @@ function PrivateRoute({ allowedRoles }: { allowedRoles: string[] }): ReactElemen .filter((k): k is string => Boolean(k)); const allowed = isSuperAdmin(user) || allowedRoles.some((r) => roleKeys.includes(r)); - return allowed ? : ; + return allowed ? : ; } export const UserManagementRedirect = () => { @@ -100,7 +101,9 @@ export const UserManagementRedirect = () => { return ; } - return ; // fallback + // No IAM role: "/" resolves back here for anyone whose only sidebar entry is + // Staff, so bounce to the terminal page instead of ping-ponging. + return ; }; /** diff --git a/apps/edr-passenger-web/portal/tailwind.config.js b/apps/edr-passenger-web/portal/tailwind.config.js index 1bbfa07ed..651afb46f 100644 --- a/apps/edr-passenger-web/portal/tailwind.config.js +++ b/apps/edr-passenger-web/portal/tailwind.config.js @@ -1,4 +1,3 @@ - /** @type {import('tailwindcss').Config} */ export default { darkMode: "class", diff --git a/deploy/position-type-grant-mapping.sql b/deploy/position-type-grant-mapping.sql deleted file mode 100644 index 901191dd4..000000000 --- a/deploy/position-type-grant-mapping.sql +++ /dev/null @@ -1,119 +0,0 @@ --- Position-type grant mapping for the granular permission system rollout. --- Grants the NEW granular keys to every hand-curated position type that holds --- the old broad key whose routes the new keys took over. Idempotent (unique --- constraint on (position_type_id, permission_id) + ON CONFLICT DO NOTHING). --- --- PREREQUISITE: run the seeded API once first (SEED_EDR_ORG=true) so --- EdrOrgSeeder has created the new permission rows this script references. --- Running it too early is not destructive but silently under-applies: keys that --- do not exist yet simply match nothing (measured: 11 of 42 rows land pre-seed, --- because invoices:view/export and payments:view already exist on dev). Re-run --- after seeding — it is safe to run any number of times. --- --- Verified 2026-08-07 on a virgin restore of the live dev DB: seeded boot, then --- this script → 42 rows inserted, second run → 0 rows, final per-key grant --- counts identical to the reference environment. Per-user API probes across 20 --- departmental test accounts confirm the keys resolve through /me and gate --- routes correctly. - -BEGIN; - --- Helper shape used throughout: --- holders of => also grant - --- 1. bookings:view holders => dashboard/read keys that replaced blanket access -INSERT INTO iam.position_type_permissions (position_type_id, permission_id) -SELECT DISTINCT ptp.position_type_id, pnew.id -FROM iam.position_type_permissions ptp -JOIN iam.permissions pold ON pold.id = ptp.permission_id - AND pold.key = 'edr_freight_app:bookings:view' -JOIN iam.permissions pnew ON pnew.key IN ( - 'edr_freight_app:overview:view', - 'edr_freight_app:reports:view', - 'edr_freight_app:invoices:view', - 'edr_freight_app:invoices:export', - 'edr_freight_app:payments:view' -) -ON CONFLICT (position_type_id, permission_id) DO NOTHING; - --- 2. train_scheduling:update holders => the write actions split out of it -INSERT INTO iam.position_type_permissions (position_type_id, permission_id) -SELECT DISTINCT ptp.position_type_id, pnew.id -FROM iam.position_type_permissions ptp -JOIN iam.permissions pold ON pold.id = ptp.permission_id - AND pold.key = 'edr_freight_app:train_scheduling:update' -JOIN iam.permissions pnew ON pnew.key IN ( - 'edr_freight_app:train_scheduling:dispatch', - 'edr_freight_app:train_scheduling:mark_paid', - 'edr_freight_app:train_scheduling:expire_booking' -) -ON CONFLICT (position_type_id, permission_id) DO NOTHING; - --- 3. GL Djibouti clearance holders => final-invoice raise + confirm -INSERT INTO iam.position_type_permissions (position_type_id, permission_id) -SELECT DISTINCT ptp.position_type_id, pnew.id -FROM iam.position_type_permissions ptp -JOIN iam.permissions pold ON pold.id = ptp.permission_id - AND pold.key = 'edr_freight_app:contracts:clearance_dj_actions' -JOIN iam.permissions pnew ON pnew.key IN ( - 'edr_freight_app:contracts:final_invoice_raise', - 'edr_freight_app:contracts:final_invoice_confirm' -) -ON CONFLICT (position_type_id, permission_id) DO NOTHING; - --- 4. GL Ethiopia clearance holders => final-invoice confirm -INSERT INTO iam.position_type_permissions (position_type_id, permission_id) -SELECT DISTINCT ptp.position_type_id, pnew.id -FROM iam.position_type_permissions ptp -JOIN iam.permissions pold ON pold.id = ptp.permission_id - AND pold.key = 'edr_freight_app:contracts:clearance_et_actions' -JOIN iam.permissions pnew ON pnew.key = 'edr_freight_app:contracts:final_invoice_confirm' -ON CONFLICT (position_type_id, permission_id) DO NOTHING; - --- 5. Contract-intake holders (any staff_accept flavour) => edit_document -INSERT INTO iam.position_type_permissions (position_type_id, permission_id) -SELECT DISTINCT ptp.position_type_id, pnew.id -FROM iam.position_type_permissions ptp -JOIN iam.permissions pold ON pold.id = ptp.permission_id - AND pold.key IN ( - 'edr_freight_app:bookings:staff_accept', - 'edr_freight_app:contracts:staff_accept:bulk', - 'edr_freight_app:contracts:staff_accept:container' - ) -JOIN iam.permissions pnew ON pnew.key = 'edr_freight_app:contracts:edit_document' -ON CONFLICT (position_type_id, permission_id) DO NOTHING; - --- 6. Support inbox ownership (decision 2026-08-07): marketing department types -INSERT INTO iam.position_type_permissions (position_type_id, permission_id) -SELECT pt.id, p.id -FROM iam.position_types pt -CROSS JOIN iam.permissions p -WHERE (pt.name::text ILIKE '%marketing%' OR pt.key ILIKE '%marketing%') - AND p.key IN ( - 'edr_freight_app:support:agent_view', - 'edr_freight_app:support:agent_send' - ) -ON CONFLICT (position_type_id, permission_id) DO NOTHING; - --- 7. Companion view keys. --- Most freight controllers carry a class-level `:view` guard, and Nest --- runs class AND method guards — so a type holding only `:` is --- denied before the action key is ever checked. Grant the module's view key --- alongside every action key the type already holds. View-only, so it widens --- reads within a module the type already operates in, never across modules. -INSERT INTO iam.position_type_permissions (position_type_id, permission_id) -SELECT DISTINCT ptp.position_type_id, pview.id -FROM iam.position_type_permissions ptp -JOIN iam.permissions pact ON pact.id = ptp.permission_id - AND pact.key LIKE 'edr_freight_app:%' -JOIN iam.permissions pview ON pview.key = regexp_replace(pact.key, ':[^:]+$', ':view') -ON CONFLICT (position_type_id, permission_id) DO NOTHING; - -COMMIT; - --- Verification: expected non-zero counts per new key after running. --- SELECT p.key, count(*) FROM iam.position_type_permissions ptp --- JOIN iam.permissions p ON p.id = ptp.permission_id --- WHERE p.key IN ('edr_freight_app:overview:view','edr_freight_app:support:agent_view', --- 'edr_freight_app:train_scheduling:dispatch','edr_freight_app:contracts:final_invoice_raise') --- GROUP BY p.key; diff --git a/packages/types/src/freight/notifications.ts b/packages/types/src/freight/notifications.ts index e3ca5b219..d51006c7f 100644 --- a/packages/types/src/freight/notifications.ts +++ b/packages/types/src/freight/notifications.ts @@ -90,13 +90,16 @@ export interface NotificationRecipients { companyProfileId?: string; /** Backoffice: all current employees of this organization. */ organizationId?: string; - /** Backoffice: every current employee across all organizations. */ - allBackoffice?: boolean; /** * Backoffice: current employees (any org) who hold ANY of these permission - * keys — e.g. notify only marketing, not every employee. Super/org admins - * are not implicitly included; add `allBackoffice`/explicit userIds too if - * admins should also see it. + * keys. Resolution mirrors the request-time guard `hasFreightPermission` — + * IAM role grants, direct position grants, position TYPE grants, delegated + * positions, and the `super_admin` bypass. `organization_admin` is NOT + * implicitly included (it only bypasses approval steps, not permission + * checks); grant it a key explicitly if it should be notified. + * + * Prefer the dedicated `:get_notification` keys over reusing a domain + * key: they let ops tune who gets pinged without touching who has access. */ permissionKeys?: string[]; } diff --git a/scripts/deploy/sync-env-from-env-manager.sh b/scripts/deploy/sync-env-from-env-manager.sh new file mode 100644 index 000000000..6a405ab28 --- /dev/null +++ b/scripts/deploy/sync-env-from-env-manager.sh @@ -0,0 +1,86 @@ +#!/usr/bin/env bash +# Sync .env files from the Env Manager API into the repo. +# +# Usage: +# ENV_MANAGER_TOKEN=xxx ./scripts/deploy/sync-env-from-server.sh freight-api freight-portal freight-backoffice +# +# You normally only need to pass the token via the action secret, and BRANCH +# via the job-level env (e.g. `BRANCH: ${{ github.ref_name }}` in the workflow): +# env: +# BRANCH: ${{ github.ref_name }} +# ENV_MANAGER_TOKEN: ${{ secrets.ENV_MANAGER_TOKEN }} +# +# API layout (one endpoint per service): +# GET https://env.smart.aaca.gov.et/api/env/edr//?format=dotenv +# Header: Authorization: Bearer +set -euo pipefail + +PROJECT="edr" +ENV_MANAGER_URL="https://env.smart.aaca.gov.et" +BRANCH="${BRANCH:?BRANCH is required}" +ENV_MANAGER_TOKEN="${ENV_MANAGER_TOKEN:?ENV_MANAGER_TOKEN is required}" + +declare -A SERVICE_ENV_TARGET=( + ["freight_api"]="apps/edr-freight-api/.env" + ["freight_portal"]="apps/edr-freight-web/portal/.env" + ["freight_backoffice"]="apps/edr-freight-web/backoffice/.env" + ["gps_tracker"]="apps/edr-gps-tracker/.env" + ["passenger_api"]="apps/edr-passenger-api/.env" + ["passenger_portal"]="apps/edr-passenger-web/portal/.env" + ["passenger_backoffice"]="apps/edr-passenger-web/backoffice/.env" + ["payment_api"]="apps/edr-payment-api/.env" +) + +for service in "$@"; do + branch_api_name="${BRANCH//-/_}" + service_api_name="${service//-/_}" + dest="${SERVICE_ENV_TARGET[${service_api_name}]:-}" + if [[ -z "${dest}" ]]; then + echo "Unknown service: ${service}" >&2 + exit 1 + fi + + url="${ENV_MANAGER_URL}/api/env/${PROJECT}/${branch_api_name}/${service_api_name}?format=dotenv" + mkdir -p "$(dirname "${dest}")" + + tmp_file="$(mktemp)" + trap 'rm -f "${tmp_file}"' RETURN 2>/dev/null || true + + http_status=$(curl -fsS -o "${tmp_file}" -w "%{http_code}" \ + -H "Authorization: Bearer ${ENV_MANAGER_TOKEN}" \ + "${url}") || { + echo "Failed to fetch env for '${service}' from ${url}" >&2 + rm -f "${tmp_file}" + exit 1 + } + + if [[ "${http_status}" != "200" ]]; then + echo "Env Manager returned HTTP ${http_status} for '${service}' (${url})" >&2 + rm -f "${tmp_file}" + exit 1 + fi + + if [[ ! -s "${tmp_file}" ]]; then + echo "Env Manager returned an empty response for '${service}' (${url})" >&2 + rm -f "${tmp_file}" + exit 1 + fi + + mv "${tmp_file}" "${dest}" + echo "Synced ${url} -> ${dest}" + + port_value=$(sed -n -E 's/^[[:space:]]*PORT[[:space:]]*=[[:space:]]*"?([^"#]+)"?[[:space:]]*(#.*)?$/\1/p' "${dest}" | head -n1 | tr -d '[:space:]') + if [[ -z "${port_value}" ]]; then + echo "Missing required PORT in env file for '${service}' (${dest})" >&2 + exit 1 + fi + + if [[ -n "${GITHUB_ENV:-}" ]]; then + service_var=$(echo "${service}" | tr '[:lower:]-' '[:upper:]_') + echo "${service_var}_PORT=${port_value}" >> "${GITHUB_ENV}" + echo "Exported ${service_var}_PORT from ${dest}" + # Forward NEXT_PUBLIC_* and VITE_* vars so docker compose build can inject them as build args. + grep -E '^[[:space:]]*(NEXT_PUBLIC_|VITE_)[A-Za-z0-9_]+=' "${dest}" \ + | sed -E 's/^[[:space:]]*//' >> "${GITHUB_ENV}" || true + fi +done