mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 05:25:41 +00:00
3
.gitignore
vendored
3
.gitignore
vendored
@@ -49,3 +49,6 @@ test-results/
|
|||||||
playwright-report/
|
playwright-report/
|
||||||
blob-report/
|
blob-report/
|
||||||
RUNNING_LOCALLY.md
|
RUNNING_LOCALLY.md
|
||||||
|
|
||||||
|
# Generated per-shard compose file for the integration suite (it.mjs).
|
||||||
|
integration/.it-shards.yaml
|
||||||
|
|||||||
@@ -98,10 +98,10 @@ FAYDA_PRIVATE_KEY_BASE64=
|
|||||||
# OAuth redirect_uri for MOBILE clients (must be registered with eSignet)
|
# OAuth redirect_uri for MOBILE clients (must be registered with eSignet)
|
||||||
FAYDA_REDIRECT_URI=http://localhost:3001/api/fayda/verification/complete
|
FAYDA_REDIRECT_URI=http://localhost:3001/api/fayda/verification/complete
|
||||||
# OAuth redirect_uri for WEB clients. Defaults to FAYDA_REDIRECT_URI when unset.
|
# OAuth redirect_uri for WEB clients. Defaults to FAYDA_REDIRECT_URI when unset.
|
||||||
FAYDA_WEB_REDIRECT_URI=http://localhost:3000/callback
|
FAYDA_WEB_REDIRECT_URI=http://localhost:3000/fayda/callback
|
||||||
# OAuth redirect_uri for the customer portal (its own origin — must also be
|
# OAuth redirect_uri for the customer portal (its own origin — must also be
|
||||||
# registered with eSignet). Defaults to FAYDA_WEB_REDIRECT_URI when unset.
|
# registered with eSignet). Defaults to FAYDA_WEB_REDIRECT_URI when unset.
|
||||||
FAYDA_PORTAL_REDIRECT_URI=http://localhost:5173/callback
|
FAYDA_PORTAL_REDIRECT_URI=http://localhost:5173/fayda/callback
|
||||||
CLIENT_ASSERTION_TYPE=urn:ietf:params:oauth:client-assertion-type:jwt-bearer
|
CLIENT_ASSERTION_TYPE=urn:ietf:params:oauth:client-assertion-type:jwt-bearer
|
||||||
FAYDA_SCOPE=openid profile email phone address
|
FAYDA_SCOPE=openid profile email phone address
|
||||||
FAYDA_ACR_VALUES=mosip:idp:acr:generated-code
|
FAYDA_ACR_VALUES=mosip:idp:acr:generated-code
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ flowchart TD
|
|||||||
S0(["Customer visits portal"]):::start
|
S0(["Customer visits portal"]):::start
|
||||||
S0 --> S1["Signup via IAM<br/>GET /auth/check-availability @Public<br/>POST /otp/send + /otp/verify (P)"]:::port
|
S0 --> S1["Signup via IAM<br/>GET /auth/check-availability @Public<br/>POST /otp/send + /otp/verify (P)"]:::port
|
||||||
S1 --> S2{"Identity proofing<br/>(VeriFayda)?"}:::dec
|
S1 --> S2{"Identity proofing<br/>(VeriFayda)?"}:::dec
|
||||||
S2 -->|"Yes"| S3["POST /fayda/verification/start →<br/>/callback → /complete<br/>upsert iam.users (verified_by=fayda) (P)"]:::port
|
S2 -->|"Yes"| S3["POST /fayda/verification/start →<br/>/fayda/callback → /complete<br/>upsert iam.users (verified_by=fayda) (P)"]:::port
|
||||||
S2 -->|"No"| S4
|
S2 -->|"No"| S4
|
||||||
S3 --> S4["POST /companies/onboarding/start<br/>draft company (placeholder TIN, PENDING) (P)"]:::port
|
S3 --> S4["POST /companies/onboarding/start<br/>draft company (placeholder TIN, PENDING) (P)"]:::port
|
||||||
S4 --> S4b["Wizard: PATCH /profile, /onboarding-step,<br/>upload license + docs<br/>GET /onboarding/requirements (P)"]:::port
|
S4 --> S4b["Wizard: PATCH /profile, /onboarding-step,<br/>upload license + docs<br/>GET /onboarding/requirements (P)"]:::port
|
||||||
|
|||||||
@@ -131,7 +131,7 @@ sequenceDiagram
|
|||||||
`HasActiveDelegationGuard` as **global `APP_GUARD`s** — *every* route is JWT-protected unless it
|
`HasActiveDelegationGuard` as **global `APP_GUARD`s** — *every* route is JWT-protected unless it
|
||||||
carries `@Public()`. Fine-grained `FreightPermissionGuard([perm])` decorators add permission checks
|
carries `@Public()`. Fine-grained `FreightPermissionGuard([perm])` decorators add permission checks
|
||||||
on staff routes. Explicitly **public** endpoints: `GET /api/files/:fileId`, `POST /api/otp/{send,verify}`,
|
on staff routes. Explicitly **public** endpoints: `GET /api/files/:fileId`, `POST /api/otp/{send,verify}`,
|
||||||
`GET /api/auth/check-availability`, the `fayda/verification/*` + `/callback` endpoints,
|
`GET /api/auth/check-availability`, the `fayda/verification/*` + `/fayda/callback` endpoints,
|
||||||
`GET /api/payments/{checkout,receipt/:orderId}`, and the service-to-service `POST /api/internal/payments/mark-paid`.
|
`GET /api/payments/{checkout,receipt/:orderId}`, and the service-to-service `POST /api/internal/payments/mark-paid`.
|
||||||
Real login / JWT issuance lives in the **external IAM package**, not this repo. (Note: `@edr/api-common`'s
|
Real login / JWT issuance lives in the **external IAM package**, not this repo. (Note: `@edr/api-common`'s
|
||||||
`@Public` and `@tria-plc/api-common`'s `@IsPublic` both set the same `"isPublic"` metadata key the guard reads.)
|
`@Public` and `@tria-plc/api-common`'s `@IsPublic` both set the same `"isPublic"` metadata key the guard reads.)
|
||||||
@@ -288,7 +288,7 @@ flowchart TD
|
|||||||
chk --> otp["POST /otp/send + /otp/verify (P) @Public"]
|
chk --> otp["POST /otp/send + /otp/verify (P) @Public"]
|
||||||
otp --> fayda{"Identity proofing?"}
|
otp --> fayda{"Identity proofing?"}
|
||||||
fayda -->|"VeriFayda 2.0"| fstart["POST /fayda/verification/start<br/>→ eSignet authorize URL"]
|
fayda -->|"VeriFayda 2.0"| fstart["POST /fayda/verification/start<br/>→ eSignet authorize URL"]
|
||||||
fstart --> fcb["Fayda redirect → GET /callback (ack)<br/>→ GET /fayda/verification/complete<br/>(PKCE code exchange → upsert iam.users)"]
|
fstart --> fcb["Fayda redirect → GET /fayda/callback (ack)<br/>→ GET /fayda/verification/complete<br/>(PKCE code exchange → upsert iam.users)"]
|
||||||
fcb --> onb
|
fcb --> onb
|
||||||
fayda -->|"skip"| onb
|
fayda -->|"skip"| onb
|
||||||
|
|
||||||
@@ -313,7 +313,7 @@ drives the required document set. Booking guards elsewhere `403` if the acting p
|
|||||||
| POST | `/api/fayda/verification/start` | start eSignet session (PKCE) | `@Public` + OptionalJwt | (B) verifayda.service |
|
| POST | `/api/fayda/verification/start` | start eSignet session (PKCE) | `@Public` + OptionalJwt | (B) verifayda.service |
|
||||||
| GET | `/api/fayda/verification/complete` | code→identity, upsert `iam.users` | `@Public` | (B) verifayda.service |
|
| GET | `/api/fayda/verification/complete` | code→identity, upsert `iam.users` | `@Public` | (B) verifayda.service |
|
||||||
| GET | `/api/fayda/verification/status` | current user's Fayda link | JwtGuard | — |
|
| GET | `/api/fayda/verification/status` | current user's Fayda link | JwtGuard | — |
|
||||||
| GET | `/callback` | passive Fayda redirect ack (no `/api`) | `@Public` | popup postMessage |
|
| GET | `/fayda/callback` | passive Fayda redirect ack (no `/api`) | `@Public` | popup postMessage |
|
||||||
| GET·PUT | `/api/me/signature` | reusable signature (MinIO, base64) | JwtGuard | (P)(B) signatures.service |
|
| GET·PUT | `/api/me/signature` | reusable signature (MinIO, base64) | JwtGuard | (P)(B) signatures.service |
|
||||||
| GET | `/api/test_user1` · `/api/test_user2` | permission-guard demo | `PermissionGuard` | (B) demo pages |
|
| GET | `/api/test_user1` · `/api/test_user2` | permission-guard demo | `PermissionGuard` | (B) demo pages |
|
||||||
| GET | `/api/companies/getInfo` · `/profile` · `/dashboard` | company info / KPIs | JwtGuard | (P) companies.service |
|
| GET | `/api/companies/getInfo` · `/profile` · `/dashboard` | company info / KPIs | JwtGuard | (P) companies.service |
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ import { NotificationInboxModule } from "./modules/notification-inbox/notificati
|
|||||||
import { SupportChatModule } from "./modules/support-chat/support-chat.module";
|
import { SupportChatModule } from "./modules/support-chat/support-chat.module";
|
||||||
import { FileUploadSettingsModule } from "./modules/file-upload-settings/file-upload-settings.module";
|
import { FileUploadSettingsModule } from "./modules/file-upload-settings/file-upload-settings.module";
|
||||||
import { DropdownSettingsModule } from "./modules/dropdown-settings/dropdown-settings.module";
|
import { DropdownSettingsModule } from "./modules/dropdown-settings/dropdown-settings.module";
|
||||||
|
import { ExchangeSettingsModule } from "./modules/exchange-settings/exchange-settings.module";
|
||||||
import { ContractTemplatesModule } from "./modules/contract-templates/contract-templates.module";
|
import { ContractTemplatesModule } from "./modules/contract-templates/contract-templates.module";
|
||||||
import { OtpModule } from "./modules/otp/otp.module";
|
import { OtpModule } from "./modules/otp/otp.module";
|
||||||
import { HealthModule } from "./modules/health/health.module";
|
import { HealthModule } from "./modules/health/health.module";
|
||||||
@@ -192,6 +193,7 @@ import { LoginAudienceMiddleware } from "./modules/auth/login-audience.middlewar
|
|||||||
SupportChatModule,
|
SupportChatModule,
|
||||||
FileUploadSettingsModule,
|
FileUploadSettingsModule,
|
||||||
DropdownSettingsModule,
|
DropdownSettingsModule,
|
||||||
|
ExchangeSettingsModule,
|
||||||
ContractTemplatesModule,
|
ContractTemplatesModule,
|
||||||
OtpModule,
|
OtpModule,
|
||||||
HealthModule,
|
HealthModule,
|
||||||
|
|||||||
@@ -24,12 +24,13 @@ export default registerAs("app", () => ({
|
|||||||
},
|
},
|
||||||
// Consumed by @edr/api-common ExchangeModule.forRootAsync (see bookings.module.ts).
|
// Consumed by @edr/api-common ExchangeModule.forRootAsync (see bookings.module.ts).
|
||||||
cbeExchange: {
|
cbeExchange: {
|
||||||
/** ethio.forex CBET page — scraped for USD buying/selling rates. */
|
/** CBE daily-exchange-rates JSON — USD `transactionalSelling` is used. */
|
||||||
scrapeUrl:
|
scrapeUrl:
|
||||||
process.env.CBE_EXCHANGE_SCRAPE_URL ??
|
process.env.CBE_EXCHANGE_SCRAPE_URL ??
|
||||||
process.env.CBE_EXCHANGE_API_URL ??
|
process.env.CBE_EXCHANGE_API_URL ??
|
||||||
"https://ethio.forex/bank/CBET",
|
"https://combanketh.et/cbeapi/daily-exchange-rates/?_limit=1&_sort=Date%3ADESC",
|
||||||
fallbackRate: numberFromEnv("CBE_EXCHANGE_FALLBACK_RATE", 130),
|
// No fallback env var: the fallback lives in freight.exchange_settings,
|
||||||
|
// maintained by the backoffice and by write-back on every successful fetch.
|
||||||
cacheTtlMs: numberFromEnv("CBE_EXCHANGE_CACHE_TTL_MS", 3_600_000),
|
cacheTtlMs: numberFromEnv("CBE_EXCHANGE_CACHE_TTL_MS", 3_600_000),
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -3,7 +3,10 @@ import Handlebars from 'handlebars';
|
|||||||
/** One numbered clause of a dynamic article, with optional nested bullets. */
|
/** One numbered clause of a dynamic article, with optional nested bullets. */
|
||||||
export interface RenderedClause {
|
export interface RenderedClause {
|
||||||
text: string;
|
text: string;
|
||||||
/** Computed outline number, e.g. "3" or "2.1.4". */
|
/**
|
||||||
|
* Computed outline marker for this clause at its own level: "3" at depth 1,
|
||||||
|
* "b" at depth 2, "iv" at depth 3, cycling back to arabic at depth 4.
|
||||||
|
*/
|
||||||
number: string;
|
number: string;
|
||||||
/** Nesting level: 1 = clause, 2 = sub-clause (x.y), 3 = x.y.z, … */
|
/** Nesting level: 1 = clause, 2 = sub-clause (x.y), 3 = x.y.z, … */
|
||||||
depth: number;
|
depth: number;
|
||||||
@@ -35,6 +38,51 @@ const CLAUSE_NUMBER_RE = /^(?:(\d+(?:\.\d+)+)[.)]?|(\d+)[.)])(?:\s+|$)/;
|
|||||||
/** Deepest supported sub-clause level (1.1.1.1.1.1). */
|
/** Deepest supported sub-clause level (1.1.1.1.1.1). */
|
||||||
const MAX_CLAUSE_DEPTH = 6;
|
const MAX_CLAUSE_DEPTH = 6;
|
||||||
|
|
||||||
|
/** 1 → "a", 2 → "b", … 27 → "aa". */
|
||||||
|
function toAlpha(n: number): string {
|
||||||
|
let out = '';
|
||||||
|
let value = n;
|
||||||
|
while (value > 0) {
|
||||||
|
const rem = (value - 1) % 26;
|
||||||
|
out = String.fromCharCode(97 + rem) + out;
|
||||||
|
value = Math.floor((value - 1) / 26);
|
||||||
|
}
|
||||||
|
return out || 'a';
|
||||||
|
}
|
||||||
|
|
||||||
|
const ROMAN: Array<[number, string]> = [
|
||||||
|
[1000, 'm'], [900, 'cm'], [500, 'd'], [400, 'cd'],
|
||||||
|
[100, 'c'], [90, 'xc'], [50, 'l'], [40, 'xl'],
|
||||||
|
[10, 'x'], [9, 'ix'], [5, 'v'], [4, 'iv'], [1, 'i'],
|
||||||
|
];
|
||||||
|
|
||||||
|
/** 1 → "i", 4 → "iv", 9 → "ix". */
|
||||||
|
function toRoman(n: number): string {
|
||||||
|
let value = n;
|
||||||
|
let out = '';
|
||||||
|
for (const [amount, numeral] of ROMAN) {
|
||||||
|
while (value >= amount) {
|
||||||
|
out += numeral;
|
||||||
|
value -= amount;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out || 'i';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Word-processor outline markers, cycling by depth the way Quill's own list
|
||||||
|
* rendering does: 1. → a. → i. → 1. … Depth 1 keeps plain arabic numerals so
|
||||||
|
* top-level clauses read as "1.", "2." in the contract; the marker is the
|
||||||
|
* clause's own counter at its level, NOT a dotted path — "a" under clause 2 is
|
||||||
|
* "a", not "2.a".
|
||||||
|
*/
|
||||||
|
export function clauseMarker(counter: number, depth: number): string {
|
||||||
|
const style = (depth - 1) % 3;
|
||||||
|
if (style === 1) return toAlpha(counter);
|
||||||
|
if (style === 2) return toRoman(counter);
|
||||||
|
return String(counter);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse a template article body into clauses. Format: one clause per line.
|
* Parse a template article body into clauses. Format: one clause per line.
|
||||||
* A leading outline number ("2. ", "2.1 ", "2.1.3 ") nests the line as a
|
* A leading outline number ("2. ", "2.1 ", "2.1.3 ") nests the line as a
|
||||||
@@ -82,7 +130,7 @@ export function parseArticleBody(body: string): Pick<RenderedArticle, 'paragraph
|
|||||||
|
|
||||||
clauses.push({
|
clauses.push({
|
||||||
text: match ? line.slice(match[0].length).trim() : line,
|
text: match ? line.slice(match[0].length).trim() : line,
|
||||||
number: counters.slice(0, depth).join('.'),
|
number: clauseMarker(counters[depth - 1], depth),
|
||||||
depth,
|
depth,
|
||||||
bullets: [],
|
bullets: [],
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -166,6 +166,8 @@ export class ContractDocumentViewModelBuilder {
|
|||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
}),
|
}),
|
||||||
contractYear: new Date().getFullYear(),
|
contractYear: new Date().getFullYear(),
|
||||||
|
contractStartDate: this.formatDate(contract.contractValidFrom),
|
||||||
|
contractEndDate: this.formatDate(contract.contractValidUntil),
|
||||||
client: {
|
client: {
|
||||||
companyName: contract.company?.name ?? 'Client',
|
companyName: contract.company?.name ?? 'Client',
|
||||||
companyAddress: this.valueOrDash(contract.company?.address),
|
companyAddress: this.valueOrDash(contract.company?.address),
|
||||||
@@ -281,7 +283,8 @@ export class ContractDocumentViewModelBuilder {
|
|||||||
|
|
||||||
private buildSchedule(contract: Contract): ContractViewModel['schedule'] {
|
private buildSchedule(contract: Contract): ContractViewModel['schedule'] {
|
||||||
const firstRoute = this.firstRoute(contract);
|
const firstRoute = this.firstRoute(contract);
|
||||||
const cargoScope = (contract.cargoScope ?? [])[0];
|
const scope = contract.cargoScope ?? [];
|
||||||
|
const cargoScope = scope[0];
|
||||||
const cargoName =
|
const cargoName =
|
||||||
cargoScope?.cargoType?.cargoTypeName ||
|
cargoScope?.cargoType?.cargoTypeName ||
|
||||||
cargoScope?.cargoFreeText ||
|
cargoScope?.cargoFreeText ||
|
||||||
@@ -289,6 +292,28 @@ export class ContractDocumentViewModelBuilder {
|
|||||||
? `${cargoScope.containerSize} container`
|
? `${cargoScope.containerSize} container`
|
||||||
: 'Container cargo');
|
: 'Container cargo');
|
||||||
|
|
||||||
|
// A contract's scope can list several cargo lines (e.g. coffee in 20ft and
|
||||||
|
// 40ft); name each distinctly rather than collapsing to the first.
|
||||||
|
const containerType = [
|
||||||
|
...new Set(scope.map((s) => s.containerSize ?? '').filter(Boolean)),
|
||||||
|
].join(', ');
|
||||||
|
const cargoTypeName = [
|
||||||
|
...new Set(
|
||||||
|
scope
|
||||||
|
.map((s) => s.cargoType?.cargoTypeName ?? s.cargoFreeText ?? '')
|
||||||
|
.filter(Boolean),
|
||||||
|
),
|
||||||
|
].join(', ');
|
||||||
|
const cargoSummary = scope
|
||||||
|
.map((s) => {
|
||||||
|
const name = s.cargoType?.cargoTypeName ?? s.cargoFreeText ?? null;
|
||||||
|
const size = s.containerSize ? `(${s.containerSize})` : null;
|
||||||
|
const cap = s.quantityCap ? `× ${Number(s.quantityCap)}` : null;
|
||||||
|
return [name, size, cap].filter(Boolean).join(' ');
|
||||||
|
})
|
||||||
|
.filter(Boolean)
|
||||||
|
.join('; ');
|
||||||
|
|
||||||
return {
|
return {
|
||||||
originLabel: this.yardLabel(firstRoute?.originYard),
|
originLabel: this.yardLabel(firstRoute?.originYard),
|
||||||
destinationLabel: this.yardLabel(firstRoute?.destinationYard),
|
destinationLabel: this.yardLabel(firstRoute?.destinationYard),
|
||||||
@@ -302,6 +327,9 @@ export class ContractDocumentViewModelBuilder {
|
|||||||
scheduledDate: this.formatDate(null),
|
scheduledDate: this.formatDate(null),
|
||||||
contractType: this.valueOrDash(contract.contractType),
|
contractType: this.valueOrDash(contract.contractType),
|
||||||
cargoDescription: this.valueOrDash(cargoName),
|
cargoDescription: this.valueOrDash(cargoName),
|
||||||
|
cargoTypeName: this.valueOrDash(cargoTypeName),
|
||||||
|
containerType: this.valueOrDash(containerType),
|
||||||
|
cargoSummary: this.valueOrDash(cargoSummary),
|
||||||
totalWeightVgm: '—',
|
totalWeightVgm: '—',
|
||||||
equipmentReturn: this.valueOrDash(contract.equipmentReturn),
|
equipmentReturn: this.valueOrDash(contract.equipmentReturn),
|
||||||
// A hazardous contract names the declared class + UN number on the
|
// A hazardous contract names the declared class + UN number on the
|
||||||
|
|||||||
@@ -27,18 +27,32 @@ describe('parseArticleBody', () => {
|
|||||||
expect(parsed.clauses).toEqual([]);
|
expect(parsed.clauses).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('nests numbered sub-clauses by their outline token and renumbers sequentially', () => {
|
it('nests sub-clauses by outline token and marks each level 1. → a. → i.', () => {
|
||||||
const parsed = parseArticleBody(
|
const parsed = parseArticleBody(
|
||||||
'1. Scope\n5.1 Rail transport\n1.1.1 Wagon supply\n2. Payment',
|
'1. Scope\n5.1 Rail transport\n1.1.1 Wagon supply\n2. Payment',
|
||||||
);
|
);
|
||||||
expect(parsed.clauses.map((c) => [c.number, c.depth, c.text])).toEqual([
|
expect(parsed.clauses.map((c) => [c.number, c.depth, c.text])).toEqual([
|
||||||
['1', 1, 'Scope'],
|
['1', 1, 'Scope'],
|
||||||
['1.1', 2, 'Rail transport'],
|
['a', 2, 'Rail transport'],
|
||||||
['1.1.1', 3, 'Wagon supply'],
|
['i', 3, 'Wagon supply'],
|
||||||
['2', 1, 'Payment'],
|
['2', 1, 'Payment'],
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('cycles markers back to arabic at depth 4 and counts each level on its own', () => {
|
||||||
|
const parsed = parseArticleBody(
|
||||||
|
'1. One\n1.1 Alpha\n1.2 Beta\n1.2.1 Roman one\n1.2.2 Roman two\n1.2.2.1 Deep',
|
||||||
|
);
|
||||||
|
expect(parsed.clauses.map((c) => [c.number, c.depth])).toEqual([
|
||||||
|
['1', 1],
|
||||||
|
['a', 2],
|
||||||
|
['b', 2],
|
||||||
|
['i', 3],
|
||||||
|
['ii', 3],
|
||||||
|
['1', 4],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
it('clamps a sub-clause with no open parent to the next available level', () => {
|
it('clamps a sub-clause with no open parent to the next available level', () => {
|
||||||
const parsed = parseArticleBody('1.1.1 Orphan sub-clause\nSecond clause.');
|
const parsed = parseArticleBody('1.1.1 Orphan sub-clause\nSecond clause.');
|
||||||
expect(parsed.clauses.map((c) => [c.number, c.depth])).toEqual([
|
expect(parsed.clauses.map((c) => [c.number, c.depth])).toEqual([
|
||||||
@@ -90,6 +104,8 @@ describe('dynamic template rendering (edr-dynamic.hbs)', () => {
|
|||||||
template: { ...meta, title: 'Bulk Import Contract', templateFile: 'edr-dynamic.hbs' },
|
template: { ...meta, title: 'Bulk Import Contract', templateFile: 'edr-dynamic.hbs' },
|
||||||
contractDate: '1 January 2026',
|
contractDate: '1 January 2026',
|
||||||
contractYear: 2026,
|
contractYear: 2026,
|
||||||
|
contractStartDate: '1 January 2026',
|
||||||
|
contractEndDate: '31 December 2026',
|
||||||
client: {
|
client: {
|
||||||
companyName: 'Abyssinia Trading PLC',
|
companyName: 'Abyssinia Trading PLC',
|
||||||
companyAddress: 'Bole Sub-city, Addis Ababa',
|
companyAddress: 'Bole Sub-city, Addis Ababa',
|
||||||
@@ -117,6 +133,9 @@ describe('dynamic template rendering (edr-dynamic.hbs)', () => {
|
|||||||
scheduledDate: '—',
|
scheduledDate: '—',
|
||||||
contractType: 'GENERAL',
|
contractType: 'GENERAL',
|
||||||
cargoDescription: 'Steel billets',
|
cargoDescription: 'Steel billets',
|
||||||
|
cargoTypeName: 'Steel billets',
|
||||||
|
containerType: '—',
|
||||||
|
cargoSummary: 'Steel billets × 2,800',
|
||||||
totalWeightVgm: '—',
|
totalWeightVgm: '—',
|
||||||
equipmentReturn: '—',
|
equipmentReturn: '—',
|
||||||
hazardousLabel: 'No',
|
hazardousLabel: 'No',
|
||||||
@@ -192,6 +211,42 @@ describe('dynamic template rendering (edr-dynamic.hbs)', () => {
|
|||||||
expect(html).toContain('#1b9e7a');
|
expect(html).toContain('#1b9e7a');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('shows the contract validity window in the commercial schedule annex', () => {
|
||||||
|
const html = renderer.render(dynamicView());
|
||||||
|
expect(html).toContain('Valid from');
|
||||||
|
expect(html).toContain('Valid until');
|
||||||
|
expect(html).toContain('1 January 2026');
|
||||||
|
expect(html).toContain('31 December 2026');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('interpolates the start/end date placeholders inside article text', () => {
|
||||||
|
const view = dynamicView();
|
||||||
|
expect(
|
||||||
|
interpolateTemplateText(
|
||||||
|
'In force {{contractStartDate}} to {{contractEndDate}}.',
|
||||||
|
view,
|
||||||
|
),
|
||||||
|
).toBe('In force 1 January 2026 to 31 December 2026.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows cargo type and container type in the commercial schedule annex', () => {
|
||||||
|
const html = renderer.render(dynamicView());
|
||||||
|
expect(html).toContain('Cargo type');
|
||||||
|
expect(html).toContain('Container type');
|
||||||
|
expect(html).toContain('Cargo scope');
|
||||||
|
expect(html).toContain('Steel billets × 2,800');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('interpolates the cargo/container placeholders inside article text', () => {
|
||||||
|
const view = dynamicView();
|
||||||
|
const body =
|
||||||
|
'Cargo: {{schedule.cargoTypeName}} in {{schedule.containerType}} ' +
|
||||||
|
'({{schedule.freightType}}). Scope: {{schedule.cargoSummary}}.';
|
||||||
|
expect(interpolateTemplateText(body, view)).toBe(
|
||||||
|
'Cargo: Steel billets in — (BULK). Scope: Steel billets × 2,800.',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('renders the live rate schedule lane under the pricing article', () => {
|
it('renders the live rate schedule lane under the pricing article', () => {
|
||||||
const html = renderer.render(dynamicView());
|
const html = renderer.render(dynamicView());
|
||||||
expect(html).toContain('Rate Schedule');
|
expect(html).toContain('Rate Schedule');
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ describe('ContractRendererService', () => {
|
|||||||
template,
|
template,
|
||||||
contractDate: '1 January 2026',
|
contractDate: '1 January 2026',
|
||||||
contractYear: 2026,
|
contractYear: 2026,
|
||||||
|
contractStartDate: '1 January 2026',
|
||||||
|
contractEndDate: '31 December 2026',
|
||||||
client: {
|
client: {
|
||||||
companyName: 'Test Co',
|
companyName: 'Test Co',
|
||||||
companyAddress: 'Addis Ababa',
|
companyAddress: 'Addis Ababa',
|
||||||
@@ -43,6 +45,9 @@ describe('ContractRendererService', () => {
|
|||||||
scheduledDate: '1 January 2026',
|
scheduledDate: '1 January 2026',
|
||||||
contractType: 'NEW',
|
contractType: 'NEW',
|
||||||
cargoDescription: 'Container cargo',
|
cargoDescription: 'Container cargo',
|
||||||
|
cargoTypeName: 'Coffee',
|
||||||
|
containerType: '40ft',
|
||||||
|
cargoSummary: 'Coffee (40ft) × 12',
|
||||||
totalWeightVgm: '24 tons',
|
totalWeightVgm: '24 tons',
|
||||||
equipmentReturn: 'RETURN',
|
equipmentReturn: 'RETURN',
|
||||||
hazardousLabel: 'No',
|
hazardousLabel: 'No',
|
||||||
|
|||||||
@@ -41,6 +41,13 @@ export interface ContractViewModel {
|
|||||||
template: ContractTemplateMeta;
|
template: ContractTemplateMeta;
|
||||||
contractDate: string;
|
contractDate: string;
|
||||||
contractYear: number;
|
contractYear: number;
|
||||||
|
/**
|
||||||
|
* The contract's validity window (`contract_valid_from` / `_until`). Distinct
|
||||||
|
* from `contractDate`, which is the day the document is generated — these are
|
||||||
|
* the dates the contract is actually in force between. "—" when unset.
|
||||||
|
*/
|
||||||
|
contractStartDate: string;
|
||||||
|
contractEndDate: string;
|
||||||
client: {
|
client: {
|
||||||
companyName: string;
|
companyName: string;
|
||||||
companyAddress: string;
|
companyAddress: string;
|
||||||
@@ -68,6 +75,16 @@ export interface ContractViewModel {
|
|||||||
scheduledDate: string;
|
scheduledDate: string;
|
||||||
contractType: string;
|
contractType: string;
|
||||||
cargoDescription: string;
|
cargoDescription: string;
|
||||||
|
/**
|
||||||
|
* The named cargo type on its own (e.g. "Coffee"), separate from
|
||||||
|
* `cargoDescription` which folds in free text and a container fallback.
|
||||||
|
* Lets a clause name the commodity without the surrounding prose.
|
||||||
|
*/
|
||||||
|
cargoTypeName: string;
|
||||||
|
/** Container size alone, e.g. "20ft" / "40ft"; "—" for bulk. */
|
||||||
|
containerType: string;
|
||||||
|
/** Every cargo line on the contract, e.g. "Coffee (40ft) × 12". */
|
||||||
|
cargoSummary: string;
|
||||||
totalWeightVgm: string;
|
totalWeightVgm: string;
|
||||||
equipmentReturn: string;
|
equipmentReturn: string;
|
||||||
hazardousLabel: string;
|
hazardousLabel: string;
|
||||||
@@ -133,6 +150,8 @@ export class ContractViewModelBuilder {
|
|||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
}),
|
}),
|
||||||
contractYear: new Date().getFullYear(),
|
contractYear: new Date().getFullYear(),
|
||||||
|
contractStartDate: this.formatDate(booking.contractValidFrom),
|
||||||
|
contractEndDate: this.formatDate(booking.contractValidUntil),
|
||||||
client: {
|
client: {
|
||||||
companyName: booking.company?.name ?? 'Client',
|
companyName: booking.company?.name ?? 'Client',
|
||||||
companyAddress: this.valueOrDash(booking.company?.address),
|
companyAddress: this.valueOrDash(booking.company?.address),
|
||||||
@@ -195,6 +214,21 @@ export class ContractViewModelBuilder {
|
|||||||
'Bulk commodity'
|
'Bulk commodity'
|
||||||
: booking.cargoType?.cargoTypeName || 'Container cargo';
|
: booking.cargoType?.cargoTypeName || 'Container cargo';
|
||||||
const totalWeight = Number(booking.cargoTotalWeightVgm || 0);
|
const totalWeight = Number(booking.cargoTotalWeightVgm || 0);
|
||||||
|
// A booking may carry both sizes; name each one once, in the order booked.
|
||||||
|
const containerType = [
|
||||||
|
...new Set(
|
||||||
|
(booking.bookingContainers ?? [])
|
||||||
|
.map(
|
||||||
|
(line) =>
|
||||||
|
line.containerType?.label ??
|
||||||
|
(line.containerType?.sizeFt
|
||||||
|
? `${line.containerType.sizeFt}ft`
|
||||||
|
: line.containerSize) ??
|
||||||
|
'',
|
||||||
|
)
|
||||||
|
.filter(Boolean),
|
||||||
|
),
|
||||||
|
].join(', ');
|
||||||
|
|
||||||
return {
|
return {
|
||||||
originLabel: this.yardLabel(booking.originYard),
|
originLabel: this.yardLabel(booking.originYard),
|
||||||
@@ -207,6 +241,13 @@ export class ContractViewModelBuilder {
|
|||||||
scheduledDate: this.formatDate(booking.scheduledDate),
|
scheduledDate: this.formatDate(booking.scheduledDate),
|
||||||
contractType: this.valueOrDash(booking.contractType),
|
contractType: this.valueOrDash(booking.contractType),
|
||||||
cargoDescription: this.valueOrDash(cargoName),
|
cargoDescription: this.valueOrDash(cargoName),
|
||||||
|
cargoTypeName: this.valueOrDash(booking.cargoType?.cargoTypeName),
|
||||||
|
containerType: this.valueOrDash(containerType),
|
||||||
|
cargoSummary: this.valueOrDash(
|
||||||
|
[cargoName, containerType ? `(${containerType})` : null]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' '),
|
||||||
|
),
|
||||||
totalWeightVgm:
|
totalWeightVgm:
|
||||||
totalWeight > 0 ? `${totalWeight.toLocaleString()} tons` : '—',
|
totalWeight > 0 ? `${totalWeight.toLocaleString()} tons` : '—',
|
||||||
equipmentReturn: this.valueOrDash(booking.equipmentReturn),
|
equipmentReturn: this.valueOrDash(booking.equipmentReturn),
|
||||||
|
|||||||
@@ -125,12 +125,30 @@
|
|||||||
<th>Hazardous cargo</th>
|
<th>Hazardous cargo</th>
|
||||||
<td>{{schedule.hazardousLabel}}</td>
|
<td>{{schedule.hazardousLabel}}</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Cargo type</th>
|
||||||
|
<td>{{schedule.cargoTypeName}}</td>
|
||||||
|
<th>Container type</th>
|
||||||
|
<td>{{schedule.containerType}}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Cargo scope</th>
|
||||||
|
<td>{{schedule.cargoSummary}}</td>
|
||||||
|
<th>Freight type</th>
|
||||||
|
<td>{{schedule.freightType}}</td>
|
||||||
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Equipment return</th>
|
<th>Equipment return</th>
|
||||||
<td>{{schedule.equipmentReturn}}</td>
|
<td>{{schedule.equipmentReturn}}</td>
|
||||||
<th>Payment currency</th>
|
<th>Payment currency</th>
|
||||||
<td>{{paymentArticle}}</td>
|
<td>{{paymentArticle}}</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Valid from</th>
|
||||||
|
<td>{{contractStartDate}}</td>
|
||||||
|
<th>Valid until</th>
|
||||||
|
<td>{{contractEndDate}}</td>
|
||||||
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import { AppModule } from "./app.module";
|
|||||||
* ~13.4MB on the wire. Express defaults to 100kb, which rejected any real stamp
|
* ~13.4MB on the wire. Express defaults to 100kb, which rejected any real stamp
|
||||||
* image with a 413 "request entity too large".
|
* image with a 413 "request entity too large".
|
||||||
*/
|
*/
|
||||||
const JSON_BODY_LIMIT = '20mb';
|
const JSON_BODY_LIMIT = "20mb";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Static /etc/hosts-style overrides from `DNS_HOST_OVERRIDES`, formatted as
|
* Static /etc/hosts-style overrides from `DNS_HOST_OVERRIDES`, formatted as
|
||||||
@@ -44,7 +44,9 @@ function applyDnsHostOverrides(): void {
|
|||||||
}
|
}
|
||||||
if (overrides.size === 0) return;
|
if (overrides.size === 0) return;
|
||||||
|
|
||||||
const dns = createRequire(__filename)("node:dns") as typeof import("node:dns");
|
const dns = createRequire(__filename)(
|
||||||
|
"node:dns",
|
||||||
|
) as typeof import("node:dns");
|
||||||
const originalLookup = dns.lookup.bind(dns);
|
const originalLookup = dns.lookup.bind(dns);
|
||||||
// `dns.lookup` is overloaded (options optional, all/family variants); the
|
// `dns.lookup` is overloaded (options optional, all/family variants); the
|
||||||
// cast keeps that surface intact while we intercept only mapped hostnames.
|
// cast keeps that surface intact while we intercept only mapped hostnames.
|
||||||
@@ -63,7 +65,9 @@ function applyDnsHostOverrides(): void {
|
|||||||
) => void;
|
) => void;
|
||||||
const family = ip.includes(":") ? 6 : 4;
|
const family = ip.includes(":") ? 6 : 4;
|
||||||
const wantsAll =
|
const wantsAll =
|
||||||
typeof options === "object" && options !== null && (options as { all?: boolean }).all;
|
typeof options === "object" &&
|
||||||
|
options !== null &&
|
||||||
|
(options as { all?: boolean }).all;
|
||||||
|
|
||||||
process.nextTick(() =>
|
process.nextTick(() =>
|
||||||
wantsAll ? done(null, [{ address: ip, family }]) : done(null, ip, family),
|
wantsAll ? done(null, [{ address: ip, family }]) : done(null, ip, family),
|
||||||
@@ -77,7 +81,15 @@ function applyDnsHostOverrides(): void {
|
|||||||
|
|
||||||
applyDnsHostOverrides();
|
applyDnsHostOverrides();
|
||||||
|
|
||||||
async function bootstrap() {
|
/**
|
||||||
|
* Build the app with every global the production process applies, but do NOT
|
||||||
|
* listen. Exported so a test harness can boot the REAL app in its own process
|
||||||
|
* (integration/src/app.ts) and get the same prefix, pipe, filter, interceptor
|
||||||
|
* and body-parser configuration — replaying this list by hand is how an e2e
|
||||||
|
* harness silently drifts from production (routes 404 without the "api"
|
||||||
|
* prefix, responses lose the transform envelope).
|
||||||
|
*/
|
||||||
|
export async function createFreightApp(): Promise<NestExpressApplication> {
|
||||||
const app = await NestFactory.create<NestExpressApplication>(AppModule);
|
const app = await NestFactory.create<NestExpressApplication>(AppModule);
|
||||||
|
|
||||||
// Nest's own body-parser API, NOT `app.use(json(...))` from express: express
|
// Nest's own body-parser API, NOT `app.use(json(...))` from express: express
|
||||||
@@ -86,14 +98,14 @@ async function bootstrap() {
|
|||||||
// pnpm's hoisted dev store and died as MODULE_NOT_FOUND in the production
|
// pnpm's hoisted dev store and died as MODULE_NOT_FOUND in the production
|
||||||
// image, where `pnpm deploy --prod` installs declared dependencies only.
|
// image, where `pnpm deploy --prod` installs declared dependencies only.
|
||||||
// This also RECONFIGURES the default parsers rather than racing them.
|
// This also RECONFIGURES the default parsers rather than racing them.
|
||||||
app.useBodyParser('json', { limit: JSON_BODY_LIMIT });
|
app.useBodyParser("json", { limit: JSON_BODY_LIMIT });
|
||||||
app.useBodyParser('urlencoded', { limit: JSON_BODY_LIMIT, extended: true });
|
app.useBodyParser("urlencoded", { limit: JSON_BODY_LIMIT, extended: true });
|
||||||
|
|
||||||
// Dev CORS: reflect any localhost origin and allow credentials so the
|
// Dev CORS: reflect any localhost origin and allow credentials so the
|
||||||
// freight portal (5173), passenger portal (5174), backoffices (5183/5184)
|
// freight portal (5173), passenger portal (5174), backoffices (5183/5184)
|
||||||
// and any other dev port can call the API with cookies + Authorization.
|
// and any other dev port can call the API with cookies + Authorization.
|
||||||
// For production, restrict `origin` to known FQDNs.
|
// For production, restrict `origin` to known FQDNs.
|
||||||
|
|
||||||
app.enableCors({
|
app.enableCors({
|
||||||
origin: true, // reflect request origin
|
origin: true, // reflect request origin
|
||||||
credentials: true,
|
credentials: true,
|
||||||
@@ -130,8 +142,11 @@ async function bootstrap() {
|
|||||||
maxAge: 86400, // cache preflight for 24h to cut chatter in dev
|
maxAge: 86400, // cache preflight for 24h to cut chatter in dev
|
||||||
});
|
});
|
||||||
|
|
||||||
// /callback stays un-prefixed: it's the Fayda OAuth redirect_uri ack endpoint.
|
// /fayda/callback stays un-prefixed: it's the Fayda OAuth redirect_uri ack
|
||||||
app.setGlobalPrefix("api", { exclude: ["callback"] });
|
// endpoint. Exact path, not "fayda" — exclusion is an exact route match, so
|
||||||
|
// "fayda" would leave /fayda/callback prefixed (404 at the registered
|
||||||
|
// redirect_uri) while still reading as if it covered the whole subtree.
|
||||||
|
app.setGlobalPrefix("api", { exclude: ["fayda/callback"] });
|
||||||
// enableImplicitConversion is OFF: class-transformer's implicit boolean
|
// enableImplicitConversion is OFF: class-transformer's implicit boolean
|
||||||
// coercion turns any non-empty multipart/form-data string (including the
|
// coercion turns any non-empty multipart/form-data string (including the
|
||||||
// literal "false") into `true`, silently corrupting flags like isHazardous
|
// literal "false") into `true`, silently corrupting flags like isHazardous
|
||||||
@@ -154,13 +169,21 @@ async function bootstrap() {
|
|||||||
const document = SwaggerModule.createDocument(app, config);
|
const document = SwaggerModule.createDocument(app, config);
|
||||||
SwaggerModule.setup("api/docs", app, document);
|
SwaggerModule.setup("api/docs", app, document);
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function bootstrap() {
|
||||||
|
const app = await createFreightApp();
|
||||||
const port = parseInt(process.env.PORT ?? "3001", 10);
|
const port = parseInt(process.env.PORT ?? "3001", 10);
|
||||||
// await app.listen(port, "0.0.0.0");
|
// await app.listen(port, "0.0.0.0");
|
||||||
await app.listen(
|
await app.listen(port);
|
||||||
|
|
||||||
port)
|
|
||||||
// eslint-disable-next-line no-console
|
// eslint-disable-next-line no-console
|
||||||
console.log(`[freight-api] listening on port ${port}`);
|
console.log(`[freight-api] listening on port ${port}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
bootstrap();
|
// Only self-start when this file IS the entrypoint. The Dockerfile's
|
||||||
|
// `CMD ["node", "dist/main.js"]` still boots; importers get `createFreightApp`
|
||||||
|
// without the process binding a port behind their back.
|
||||||
|
if (require.main === module) {
|
||||||
|
bootstrap();
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { MigrationInterface, QueryRunner, Table } from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Single-row store for the USD→ETB fallback used when the CBE exchange-rate
|
||||||
|
* endpoint is unreachable. The live CBE rate always wins; every successful
|
||||||
|
* fetch overwrites this row, so it holds the last known good rate rather than
|
||||||
|
* a constant that drifts. Operators can also set it by hand during an outage.
|
||||||
|
*
|
||||||
|
* Seeded with the CBE USD transactional selling rate on 2026-08-04, so the
|
||||||
|
* fallback is usable before the first successful fetch.
|
||||||
|
*/
|
||||||
|
export class CreateExchangeSettings3240000000000 implements MigrationInterface {
|
||||||
|
name = 'CreateExchangeSettings3240000000000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.createTable(
|
||||||
|
new Table({
|
||||||
|
schema: 'freight',
|
||||||
|
name: 'exchange_settings',
|
||||||
|
columns: [
|
||||||
|
{ name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'gen_random_uuid()' },
|
||||||
|
{ name: 'fallback_rate', type: 'numeric', precision: 18, scale: 6 },
|
||||||
|
// AUTO when written by the CBE sync, MANUAL when set in the backoffice.
|
||||||
|
{ name: 'fallback_source', type: 'varchar', length: '16', default: "'AUTO'" },
|
||||||
|
{ name: 'last_synced_at', type: 'timestamptz', isNullable: true },
|
||||||
|
// IAM user id (iam.users) — no FK, iam schema is externally owned.
|
||||||
|
{ name: 'updated_by_id', type: 'uuid', isNullable: true },
|
||||||
|
{ name: 'created_at', type: 'timestamptz', default: 'now()' },
|
||||||
|
{ name: 'updated_at', type: 'timestamptz', default: 'now()' },
|
||||||
|
{ name: 'deleted_at', type: 'timestamptz', isNullable: true },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
INSERT INTO freight.exchange_settings (fallback_rate, fallback_source)
|
||||||
|
VALUES (162.416500, 'AUTO')
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.dropTable('freight.exchange_settings', true);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
import { Module, forwardRef } from "@nestjs/common";
|
import { Module, forwardRef } from "@nestjs/common";
|
||||||
import { UserTradeAccessModule } from "../user-trade-access/user-trade-access.module";
|
import { UserTradeAccessModule } from "../user-trade-access/user-trade-access.module";
|
||||||
import { ConfigService } from "@nestjs/config";
|
|
||||||
import { TypeOrmModule } from "@nestjs/typeorm";
|
import { TypeOrmModule } from "@nestjs/typeorm";
|
||||||
import { ExchangeModule, ExchangeOptions } from "@edr/api-common";
|
|
||||||
|
import { registerExchangeModule } from "../exchange-settings/exchange-module-options";
|
||||||
|
|
||||||
// import { CustomersModule } from '../customers/customers.module';
|
// import { CustomersModule } from '../customers/customers.module';
|
||||||
import { CompaniesModule } from '../companies/companies.module';
|
import { CompaniesModule } from '../companies/companies.module';
|
||||||
@@ -86,11 +86,7 @@ import { VehiclesModule } from "../vehicles/vehicles.module";
|
|||||||
RuleEngineModule,
|
RuleEngineModule,
|
||||||
FileUploadSettingsModule,
|
FileUploadSettingsModule,
|
||||||
SignaturesModule,
|
SignaturesModule,
|
||||||
ExchangeModule.forRootAsync({
|
registerExchangeModule(),
|
||||||
inject: [ConfigService],
|
|
||||||
useFactory: (config: ConfigService): ExchangeOptions =>
|
|
||||||
config.get<ExchangeOptions>("app.cbeExchange") ?? {},
|
|
||||||
}),
|
|
||||||
],
|
],
|
||||||
controllers: [BookingsController],
|
controllers: [BookingsController],
|
||||||
providers: [
|
providers: [
|
||||||
|
|||||||
@@ -408,6 +408,30 @@ export class CompaniesController {
|
|||||||
return this.companiesService.completeIdentityVerification(user.id, dto);
|
return this.companiesService.completeIdentityVerification(user.id, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post("identity/gm/same-as-owner")
|
||||||
|
@ApiOperation({
|
||||||
|
summary:
|
||||||
|
"Declare the General Manager is the company's owner, copying the owner's verified identity across. " +
|
||||||
|
"Refused until the owner is Fayda-verified — there would be nothing proven to copy.",
|
||||||
|
})
|
||||||
|
async setGmSameAsOwner(
|
||||||
|
@CurrentUser() user: CurrentIamUser,
|
||||||
|
): Promise<CompanyIdentityStateDto> {
|
||||||
|
return this.companiesService.setGmSameAsOwner(user.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete("identity/gm")
|
||||||
|
@ApiOperation({
|
||||||
|
summary:
|
||||||
|
"Clear the General Manager's identity — the \"same as owner\" declaration or a verification, and the details either wrote. " +
|
||||||
|
"Leaves the GM open to be verified in their own right, or typed where Fayda is optional.",
|
||||||
|
})
|
||||||
|
async clearGmIdentity(
|
||||||
|
@CurrentUser() user: CurrentIamUser,
|
||||||
|
): Promise<CompanyIdentityStateDto> {
|
||||||
|
return this.companiesService.clearGmIdentity(user.id);
|
||||||
|
}
|
||||||
|
|
||||||
@Delete("identity/fayda/poa")
|
@Delete("identity/fayda/poa")
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary:
|
summary:
|
||||||
|
|||||||
@@ -21,9 +21,11 @@ import { POA_DELEGATION_FILE_KEY } from "../file-upload-settings/poa-delegation.
|
|||||||
* is named, both nationalities must verify them, and their details come from
|
* is named, both nationalities must verify them, and their details come from
|
||||||
* the verified payload rather than the form.
|
* the verified payload rather than the form.
|
||||||
*
|
*
|
||||||
* The owner is NOT the general manager — GM is a separate, plain typed role
|
* The owner is NOT the general manager. The GM is proved the same way, by one
|
||||||
* the portal offers a "same as owner" copy for, but it is never itself
|
* of two routes — verifying in their own right, or being declared the owner,
|
||||||
* Fayda-verified or gated on.
|
* which reuses that verification rather than making one human prove themselves
|
||||||
|
* twice. It stays out of the trading gate either way: the GM names who to talk
|
||||||
|
* to, not what the company may do.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
interface Ctx {
|
interface Ctx {
|
||||||
@@ -418,10 +420,12 @@ describe("Ethiopian companies verify with Fayda; foreign companies verify identi
|
|||||||
).resolves.toBeDefined();
|
).resolves.toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("still requires a Fayda-verified PoA from a foreign company", async () => {
|
it("accepts a typed PoA from a foreign company, whose representative may hold no Fayda ID", async () => {
|
||||||
// The owner's credential is nationality-specific; the representative's is
|
// Fayda is an Ethiopian national ID, so only an Ethiopian company's
|
||||||
// not. A PoA acts for the company inside Ethiopia whoever owns it, so a
|
// representative can be held to it. A foreign company is offered the
|
||||||
// typed foreign name is not a representative the platform can accept.
|
// verification and uses it where its representative holds one, but a typed
|
||||||
|
// name stays sufficient — holding it to Fayda would leave a foreign
|
||||||
|
// company whose representative has no Fayda ID unable to trade at all.
|
||||||
const { service } = makeService({
|
const { service } = makeService({
|
||||||
profileTypes: [ProfileType.importer],
|
profileTypes: [ProfileType.importer],
|
||||||
nationality: CompanyNationality.Foreign,
|
nationality: CompanyNationality.Foreign,
|
||||||
@@ -434,6 +438,48 @@ describe("Ethiopian companies verify with Fayda; foreign companies verify identi
|
|||||||
files: [paper()],
|
files: [paper()],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.createCompanyProfileForUser(
|
||||||
|
"user-1",
|
||||||
|
ProfileType.freightForwarder,
|
||||||
|
),
|
||||||
|
).resolves.toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still refuses a foreign company that named no PoA at all", async () => {
|
||||||
|
// The typed fallback is a different credential, not a waiver: a freight
|
||||||
|
// forwarder acts on other companies' behalf and needs a representative
|
||||||
|
// whatever its nationality.
|
||||||
|
const { service } = makeService({
|
||||||
|
profileTypes: [ProfileType.importer],
|
||||||
|
nationality: CompanyNationality.Foreign,
|
||||||
|
attributes: { ownerPassportNumber: "P1234567" },
|
||||||
|
files: [paper()],
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.createCompanyProfileForUser(
|
||||||
|
"user-1",
|
||||||
|
ProfileType.freightForwarder,
|
||||||
|
),
|
||||||
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("holds an Ethiopian company to a Fayda-verified PoA, typed details notwithstanding", async () => {
|
||||||
|
// The relaxation above is scoped to foreign companies only — an Ethiopian
|
||||||
|
// representative holds a Fayda ID, so typing a name must not substitute.
|
||||||
|
const { service } = makeService({
|
||||||
|
profileTypes: [ProfileType.importer],
|
||||||
|
nationality: CompanyNationality.Ethiopian,
|
||||||
|
attributes: {
|
||||||
|
...OWNER_VERIFIED,
|
||||||
|
poaName: "Abebe Bekele",
|
||||||
|
poaEmail: "abebe@example.com",
|
||||||
|
poaPhone: "+251911000000",
|
||||||
|
},
|
||||||
|
files: [paper()],
|
||||||
|
});
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
service.createCompanyProfileForUser(
|
service.createCompanyProfileForUser(
|
||||||
"user-1",
|
"user-1",
|
||||||
@@ -463,4 +509,119 @@ describe("Ethiopian companies verify with Fayda; foreign companies verify identi
|
|||||||
),
|
),
|
||||||
).rejects.toBeInstanceOf(BadRequestException);
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// General manager
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
it("reuses the owner's verified identity when the GM is declared the same person", async () => {
|
||||||
|
// The GM is very often the owner. Copying the proven identity is the whole
|
||||||
|
// point — asking one human to complete two verifications proves nothing
|
||||||
|
// extra, and typing the details instead would forge a verified badge.
|
||||||
|
const { service, ctx } = makeService({
|
||||||
|
attributes: {
|
||||||
|
...OWNER_VERIFIED,
|
||||||
|
ownerEmail: "abebe@example.com",
|
||||||
|
ownerPhone: "+251911222333",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const state = await service.setGmSameAsOwner("user-1");
|
||||||
|
|
||||||
|
expect(state.gm.verified).toBe(true);
|
||||||
|
expect(state.gmSameAsOwner).toBe(true);
|
||||||
|
expect(state.gm.name).toBe("Abebe Bikila");
|
||||||
|
expect(ctx.attributes.gmFaydaSub).toBe("owner-sub");
|
||||||
|
// The notifiers mail the flat column, so a linked GM has to land there too.
|
||||||
|
expect(ctx.attributes.generalManagerEmail).toBe("abebe@example.com");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses to declare the GM is the owner while the owner is unverified", async () => {
|
||||||
|
// Without a verification there is no proven identity to copy — only typed
|
||||||
|
// text, which would arrive wearing a badge it had not earned.
|
||||||
|
const { service } = makeService({ attributes: {} });
|
||||||
|
|
||||||
|
await expect(service.setGmSameAsOwner("user-1")).rejects.toBeInstanceOf(
|
||||||
|
BadRequestException,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lets the GM verify as the same human as the owner", async () => {
|
||||||
|
// The owner/PoA collision check exists because self-delegation is not
|
||||||
|
// delegation. It must not fire here: the GM being the owner is a supported
|
||||||
|
// answer, so verifying with the owner's own Fayda sub has to succeed.
|
||||||
|
const { service, ctx } = makeService({
|
||||||
|
attributes: { ...OWNER_VERIFIED },
|
||||||
|
verification: {
|
||||||
|
purpose: "VERIFY",
|
||||||
|
verified: true,
|
||||||
|
sub: "owner-sub",
|
||||||
|
fullName: "Abebe Bikila",
|
||||||
|
email: "abebe@example.com",
|
||||||
|
phoneNumber: "+251911222333",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const state = await service.completeIdentityVerification("user-1", {
|
||||||
|
subject: "gm",
|
||||||
|
code: "c",
|
||||||
|
state: "s",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(state.gm.verified).toBe(true);
|
||||||
|
expect(ctx.attributes.gmFaydaSub).toBe("owner-sub");
|
||||||
|
expect(ctx.attributes.generalManagerName).toBe("Abebe Bikila");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still refuses a PoA who is the owner", async () => {
|
||||||
|
// The GM exemption above must not have widened into the PoA.
|
||||||
|
const { service } = makeService({
|
||||||
|
attributes: { ...OWNER_VERIFIED },
|
||||||
|
verification: {
|
||||||
|
purpose: "VERIFY",
|
||||||
|
verified: true,
|
||||||
|
sub: "owner-sub",
|
||||||
|
fullName: "Abebe Bikila",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.completeIdentityVerification("user-1", {
|
||||||
|
subject: "poa",
|
||||||
|
code: "c",
|
||||||
|
state: "s",
|
||||||
|
}),
|
||||||
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports a pre-existing typed GM as unverified rather than blank", async () => {
|
||||||
|
// Companies onboarded before the GM was verifiable have typed details and
|
||||||
|
// no gm* attributes. Those details are still what the notifiers mail, so
|
||||||
|
// they must survive — flagged unverified so the portal offers the upgrade.
|
||||||
|
const { service, company } = makeService({
|
||||||
|
attributes: {
|
||||||
|
...OWNER_VERIFIED,
|
||||||
|
generalManagerName: "Legacy Manager",
|
||||||
|
generalManagerEmail: "legacy@example.com",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const state = service.getCompanyIdentityState(company() as never);
|
||||||
|
|
||||||
|
expect(state.gm.verified).toBe(false);
|
||||||
|
expect(state.gm.name).toBe("Legacy Manager");
|
||||||
|
expect(state.gm.email).toBe("legacy@example.com");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not let an unproven GM block the company from trading", async () => {
|
||||||
|
// The GM names who to talk to, not what the company may do. Capturing it
|
||||||
|
// through Fayda changed how it is collected, not whether it gates.
|
||||||
|
const { service } = makeService({
|
||||||
|
attributes: { ...OWNER_VERIFIED },
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.createCompanyProfileForUser("user-1", ProfileType.importer),
|
||||||
|
).resolves.toBeDefined();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import {
|
|||||||
buildCompanyIdentityState,
|
buildCompanyIdentityState,
|
||||||
CompanyIdentityStateDto,
|
CompanyIdentityStateDto,
|
||||||
CompleteIdentityVerificationDto,
|
CompleteIdentityVerificationDto,
|
||||||
|
IDENTITY_SUBJECTS,
|
||||||
IdentitySubject,
|
IdentitySubject,
|
||||||
} from "./dto/complete-identity-verification.dto";
|
} from "./dto/complete-identity-verification.dto";
|
||||||
import { ETradeService } from "./services/etrade.service";
|
import { ETradeService } from "./services/etrade.service";
|
||||||
@@ -122,31 +123,47 @@ const REQUIRED_POA_FIELDS: { key: string; label: string }[] = [
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* `attributes` key prefix per verifiable person. The owner is NOT the general
|
* `attributes` key prefix per verifiable person. The owner is NOT the general
|
||||||
* manager — GM is a plain typed role (the portal offers a "same as owner" copy
|
* manager: the owner is who the verification proves the company through, the
|
||||||
* once the owner is verified), while the owner is who this verification
|
* GM is personnel it names. They're very often the same human, which is what
|
||||||
* actually proves. They're very often the same human; that's what the copy is
|
* the portal's "same as owner" copy is for.
|
||||||
* for.
|
|
||||||
*/
|
*/
|
||||||
const IDENTITY_PREFIX: Record<IdentitySubject, "owner" | "poa"> = {
|
const IDENTITY_PREFIX: Record<IdentitySubject, string> = {
|
||||||
owner: "owner",
|
owner: "owner",
|
||||||
poa: "poa",
|
poa: "poa",
|
||||||
|
gm: "gm",
|
||||||
};
|
};
|
||||||
|
|
||||||
const IDENTITY_LABEL: Record<IdentitySubject, string> = {
|
const IDENTITY_LABEL: Record<IdentitySubject, string> = {
|
||||||
owner: "owner",
|
owner: "owner",
|
||||||
poa: "Power of Attorney",
|
poa: "Power of Attorney",
|
||||||
|
gm: "General Manager",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Typed GM columns a GM verification also writes. Three notifier services mail
|
||||||
|
* `company.generalManagerEmail` directly, so leaving these behind would mean a
|
||||||
|
* verified GM whose address the system never actually uses.
|
||||||
|
*/
|
||||||
|
const GM_TYPED_FIELDS = [
|
||||||
|
"generalManagerName",
|
||||||
|
"generalManagerEmail",
|
||||||
|
"generalManagerPhone",
|
||||||
|
] as const;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Identity fields a Fayda verification owns outright, per person. Once verified
|
* Identity fields a Fayda verification owns outright, per person. Once verified
|
||||||
* these can no longer be typed — the government IdP is the source, so an edit
|
* these can no longer be typed — the government IdP is the source, so an edit
|
||||||
* that disagrees with it is either a mistake or an attempt to launder the
|
* that disagrees with it is either a mistake or an attempt to launder the
|
||||||
* guarantee away. The GM fields are deliberately absent: GM is never itself
|
* guarantee away.
|
||||||
* Fayda-verified, so it stays freely editable regardless of the owner's state.
|
*
|
||||||
|
* The GM's entries are its typed columns: a verified GM is locked the same way
|
||||||
|
* the others are, while an unverified one (a foreign company's, or a record
|
||||||
|
* that predates this) stays freely editable.
|
||||||
*/
|
*/
|
||||||
const IDENTITY_OWNED_FIELDS: Record<IdentitySubject, string[]> = {
|
const IDENTITY_OWNED_FIELDS: Record<IdentitySubject, string[]> = {
|
||||||
owner: ["ownerName", "ownerEmail", "ownerPhone", "ownerAddress"],
|
owner: ["ownerName", "ownerEmail", "ownerPhone", "ownerAddress"],
|
||||||
poa: ["poaName", "poaEmail", "poaPhone", "poaAddress"],
|
poa: ["poaName", "poaEmail", "poaPhone", "poaAddress"],
|
||||||
|
gm: [...GM_TYPED_FIELDS],
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -834,7 +851,7 @@ export class CompaniesService {
|
|||||||
|
|
||||||
// Renaming a Fayda-verified person by hand would launder the guarantee
|
// Renaming a Fayda-verified person by hand would launder the guarantee
|
||||||
// away, so the fields the verification owns are refused once it exists.
|
// away, so the fields the verification owns are refused once it exists.
|
||||||
for (const subject of ["owner", "poa"] as IdentitySubject[]) {
|
for (const subject of IDENTITY_SUBJECTS) {
|
||||||
if (!attrUpdates[`${IDENTITY_PREFIX[subject]}FaydaSub`]) continue;
|
if (!attrUpdates[`${IDENTITY_PREFIX[subject]}FaydaSub`]) continue;
|
||||||
for (const field of IDENTITY_OWNED_FIELDS[subject]) {
|
for (const field of IDENTITY_OWNED_FIELDS[subject]) {
|
||||||
const incoming = (dto as Record<string, unknown>)[field];
|
const incoming = (dto as Record<string, unknown>)[field];
|
||||||
@@ -2693,12 +2710,17 @@ export class CompaniesService {
|
|||||||
|
|
||||||
// The owner delegating power of attorney to themselves is not a
|
// The owner delegating power of attorney to themselves is not a
|
||||||
// delegation — it would let one identity satisfy both halves of the check.
|
// delegation — it would let one identity satisfy both halves of the check.
|
||||||
const other: IdentitySubject = dto.subject === "poa" ? "owner" : "poa";
|
// Only owner/PoA collide this way: the GM is very often the owner, and
|
||||||
const otherSub = company.attributes?.[`${IDENTITY_PREFIX[other]}FaydaSub`];
|
// saying so is a supported answer rather than a conflict, so it is left out
|
||||||
if (otherSub && otherSub === result.sub) {
|
// of this check entirely.
|
||||||
throw new BadRequestException(
|
if (dto.subject === "owner" || dto.subject === "poa") {
|
||||||
`This identity is already registered as the company's ${IDENTITY_LABEL[other]}. The Power of Attorney must be a different person from the owner.`,
|
const other: IdentitySubject = dto.subject === "poa" ? "owner" : "poa";
|
||||||
);
|
const otherSub = company.attributes?.[`${IDENTITY_PREFIX[other]}FaydaSub`];
|
||||||
|
if (otherSub && otherSub === result.sub) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`This identity is already registered as the company's ${IDENTITY_LABEL[other]}. The Power of Attorney must be a different person from the owner.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
@@ -2714,13 +2736,26 @@ export class CompaniesService {
|
|||||||
...(result.address ? { [`${prefix}Address`]: result.address } : {}),
|
...(result.address ? { [`${prefix}Address`]: result.address } : {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// A GM verification also lands on the typed columns the rest of the system
|
||||||
|
// already reads (the booking, train-scheduling and contract notifiers all
|
||||||
|
// mail `generalManagerEmail`), and clears any earlier "same as owner"
|
||||||
|
// declaration — verifying in their own right is the GM answering for
|
||||||
|
// themselves.
|
||||||
|
if (dto.subject === "gm") {
|
||||||
|
identity.gmSameAsOwner = false;
|
||||||
|
if (result.fullName) identity.generalManagerName = result.fullName;
|
||||||
|
if (result.email) identity.generalManagerEmail = result.email;
|
||||||
|
if (result.phoneNumber)
|
||||||
|
identity.generalManagerPhone = normalizeE164(result.phoneNumber);
|
||||||
|
}
|
||||||
|
|
||||||
// An approved company's *owner* is its identity proof, so re-verifying one
|
// An approved company's *owner* is its identity proof, so re-verifying one
|
||||||
// is staged for backoffice review rather than quietly rewriting a live
|
// is staged for backoffice review rather than quietly rewriting a live
|
||||||
// record. The PoA is personnel — the company names its own representative,
|
// record. The PoA and GM are personnel — the company names its own
|
||||||
// and the delegation letter backing them is what the reviewer sees — so a
|
// representative and manager, and the delegation letter backing the PoA is
|
||||||
// PoA verification lands live, matching the typed PoA fields in
|
// what the reviewer sees — so those land live, matching their typed
|
||||||
// `SELF_SERVICE_ATTRIBUTES`.
|
// counterparts in `SELF_SERVICE_ATTRIBUTES`.
|
||||||
if (company.status === CompanyStatus.Active && dto.subject !== "poa") {
|
if (company.status === CompanyStatus.Active && dto.subject === "owner") {
|
||||||
await this.stageIdentityChange(company, userId, identity);
|
await this.stageIdentityChange(company, userId, identity);
|
||||||
return this.getCompanyIdentityState(company);
|
return this.getCompanyIdentityState(company);
|
||||||
}
|
}
|
||||||
@@ -2734,6 +2769,85 @@ export class CompaniesService {
|
|||||||
return this.getCompanyIdentityState(updated);
|
return this.getCompanyIdentityState(updated);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Declare that the General Manager is the company's owner.
|
||||||
|
*
|
||||||
|
* The GM is very often the owner, and making that human verify twice buys
|
||||||
|
* nothing — the owner's verification already proves them. So this copies the
|
||||||
|
* owner's verified identity across rather than starting a second flow, and
|
||||||
|
* records `gmSameAsOwner` so the portal can show it as a declaration rather
|
||||||
|
* than as a verification the GM passed in their own right.
|
||||||
|
*
|
||||||
|
* Refused until the owner is actually verified: without that there is no
|
||||||
|
* proven identity to copy, only typed text that would arrive wearing a
|
||||||
|
* verified badge.
|
||||||
|
*/
|
||||||
|
async setGmSameAsOwner(userId: string): Promise<CompanyIdentityStateDto> {
|
||||||
|
const { company } = await this.getCompanyInfoByUserId(userId);
|
||||||
|
const attrs = company.attributes ?? {};
|
||||||
|
const ownerSub = attrs.ownerFaydaSub as string | undefined;
|
||||||
|
if (!ownerSub) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
"Verify the company owner with Fayda first — there is no proven identity to reuse yet.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const copied: Record<string, unknown> = {
|
||||||
|
gmSameAsOwner: true,
|
||||||
|
gmFaydaSub: ownerSub,
|
||||||
|
gmFaydaVerifiedAt: attrs.ownerFaydaVerifiedAt ?? new Date().toISOString(),
|
||||||
|
gmName: attrs.ownerName ?? null,
|
||||||
|
gmEmail: attrs.ownerEmail ?? null,
|
||||||
|
gmPhone: attrs.ownerPhone ?? null,
|
||||||
|
gmAddress: attrs.ownerAddress ?? null,
|
||||||
|
gmBirthdate: attrs.ownerBirthdate ?? null,
|
||||||
|
gmGender: attrs.ownerGender ?? null,
|
||||||
|
// Kept in step for the notifiers, same as a GM verification does.
|
||||||
|
generalManagerName: attrs.ownerName ?? null,
|
||||||
|
generalManagerEmail: attrs.ownerEmail ?? null,
|
||||||
|
generalManagerPhone: attrs.ownerPhone ?? null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const updated = await this.companiesRepo.update(company.id, {
|
||||||
|
attributes: { ...attrs, ...copied },
|
||||||
|
});
|
||||||
|
if (!updated)
|
||||||
|
throw new NotFoundException(`Company ${company.id} not found`);
|
||||||
|
updated.companyProfiles = company.companyProfiles;
|
||||||
|
return this.getCompanyIdentityState(updated);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Undo the "same as owner" declaration, clearing the copied identity so the
|
||||||
|
* GM can be verified in their own right (or typed, where Fayda is optional).
|
||||||
|
*/
|
||||||
|
async clearGmIdentity(userId: string): Promise<CompanyIdentityStateDto> {
|
||||||
|
const { company } = await this.getCompanyInfoByUserId(userId);
|
||||||
|
const attrs = { ...(company.attributes ?? {}) };
|
||||||
|
for (const key of [
|
||||||
|
"gmSameAsOwner",
|
||||||
|
"gmFaydaSub",
|
||||||
|
"gmFaydaVerifiedAt",
|
||||||
|
"gmName",
|
||||||
|
"gmEmail",
|
||||||
|
"gmPhone",
|
||||||
|
"gmAddress",
|
||||||
|
"gmBirthdate",
|
||||||
|
"gmGender",
|
||||||
|
...GM_TYPED_FIELDS,
|
||||||
|
]) {
|
||||||
|
attrs[key] = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await this.companiesRepo.update(company.id, {
|
||||||
|
attributes: attrs,
|
||||||
|
});
|
||||||
|
if (!updated)
|
||||||
|
throw new NotFoundException(`Company ${company.id} not found`);
|
||||||
|
updated.companyProfiles = company.companyProfiles;
|
||||||
|
return this.getCompanyIdentityState(updated);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Drop the Power of Attorney entirely — the verified identity, the details it
|
* Drop the Power of Attorney entirely — the verified identity, the details it
|
||||||
* wrote and the delegation paper together.
|
* wrote and the delegation paper together.
|
||||||
@@ -2864,14 +2978,28 @@ export class CompaniesService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// The representative is not. A PoA acts for the company inside Ethiopia
|
|
||||||
// whoever owns it, so they are always an Ethiopian holding a Fayda ID —
|
|
||||||
// a foreign company nominates one rather than typing a name.
|
|
||||||
const poaNamed = POA_ATTRIBUTES.some((k) =>
|
const poaNamed = POA_ATTRIBUTES.some((k) =>
|
||||||
(company.attributes?.[k] as string | undefined)?.trim(),
|
(company.attributes?.[k] as string | undefined)?.trim(),
|
||||||
);
|
);
|
||||||
if (!opts.requirePoa && !poaNamed) return;
|
if (!opts.requirePoa && !poaNamed) return;
|
||||||
|
|
||||||
|
// Fayda is an Ethiopian national ID, so only an Ethiopian company's
|
||||||
|
// representative can be held to it. A foreign company is offered the
|
||||||
|
// verification and nominates a Fayda-holding representative where it can,
|
||||||
|
// but a typed name has to remain sufficient — otherwise a foreign company
|
||||||
|
// whose representative holds no Fayda ID could never trade at all. Mirrors
|
||||||
|
// `poaProven` in buildCompanyIdentityState; the two must agree.
|
||||||
|
if (state.passportRequired) {
|
||||||
|
if (!state.poa.verified && !state.poa.name?.trim()) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
opts.requirePoa
|
||||||
|
? "Name your Power of Attorney — a freight forwarder cannot operate without one."
|
||||||
|
: "Complete the Power of Attorney you named, or remove the representative.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!state.poa.verified) {
|
if (!state.poa.verified) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
opts.requirePoa
|
opts.requirePoa
|
||||||
|
|||||||
@@ -5,13 +5,15 @@ import { Company, CompanyNationality } from "../entities/company.entity";
|
|||||||
import { ProfileType } from "../entities/company-profile.entity";
|
import { ProfileType } from "../entities/company-profile.entity";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The two people a company is verified through — its owner and its Power of
|
* The three people a company is verified through — its owner, its Power of
|
||||||
* Attorney. "Owner" is not the same as the General Manager: a company's GM is
|
* Attorney and its General Manager. The owner is the person the company's
|
||||||
* a plain typed role (with a "same as owner" copy the portal offers), while
|
* existence is proven by; the other two are personnel it names.
|
||||||
* the owner is the person this verification proves. They're very often the
|
*
|
||||||
* same human, which is exactly what the copy is for.
|
* The GM is very often the owner, which is what the portal's "same as owner"
|
||||||
|
* copy is for: that path reuses the owner's verified identity outright rather
|
||||||
|
* than asking the same human to verify twice.
|
||||||
*/
|
*/
|
||||||
export const IDENTITY_SUBJECTS = ["owner", "poa"] as const;
|
export const IDENTITY_SUBJECTS = ["owner", "poa", "gm"] as const;
|
||||||
export type IdentitySubject = (typeof IDENTITY_SUBJECTS)[number];
|
export type IdentitySubject = (typeof IDENTITY_SUBJECTS)[number];
|
||||||
|
|
||||||
export class CompleteIdentityVerificationDto {
|
export class CompleteIdentityVerificationDto {
|
||||||
@@ -73,6 +75,19 @@ export class CompanyIdentityStateDto {
|
|||||||
@ApiProperty({ type: IdentityVerificationStateDto })
|
@ApiProperty({ type: IdentityVerificationStateDto })
|
||||||
poa!: IdentityVerificationStateDto;
|
poa!: IdentityVerificationStateDto;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
type: IdentityVerificationStateDto,
|
||||||
|
description:
|
||||||
|
"General manager. `verified` is true both when the GM verified with Fayda in their own right and when the company declared the GM is the owner — in the latter case the owner's Fayda sub backs it.",
|
||||||
|
})
|
||||||
|
gm!: IdentityVerificationStateDto;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description:
|
||||||
|
"True when the GM's identity is the owner's, declared through the portal's \"same as owner\" copy rather than a separate verification.",
|
||||||
|
})
|
||||||
|
gmSameAsOwner!: boolean;
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
description:
|
description:
|
||||||
"False while a mandatory requirement (Fayda for Ethiopian, passport for foreign) is still outstanding.",
|
"False while a mandatory requirement (Fayda for Ethiopian, passport for foreign) is still outstanding.",
|
||||||
@@ -81,11 +96,28 @@ export class CompanyIdentityStateDto {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** `attributes` key prefix per person. */
|
/** `attributes` key prefix per person. */
|
||||||
const PREFIX: Record<IdentitySubject, "owner" | "poa"> = {
|
const PREFIX: Record<IdentitySubject, string> = {
|
||||||
owner: "owner",
|
owner: "owner",
|
||||||
poa: "poa",
|
poa: "poa",
|
||||||
|
gm: "gm",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Typed GM fields, kept in step with the Fayda-written ones.
|
||||||
|
*
|
||||||
|
* The GM predates this verification: its details are plain company columns
|
||||||
|
* that three notifier services mail (booking-lifecycle, train-scheduling and
|
||||||
|
* contract notifiers all read `company.generalManagerEmail`). A verification
|
||||||
|
* therefore writes BOTH — the `gm*` attributes carry the proof, these carry
|
||||||
|
* the value everything else already reads — and an unverified company keeps
|
||||||
|
* showing whatever was typed before this existed.
|
||||||
|
*/
|
||||||
|
const GM_TYPED_KEYS = {
|
||||||
|
name: "generalManagerName",
|
||||||
|
email: "generalManagerEmail",
|
||||||
|
phone: "generalManagerPhone",
|
||||||
|
} as const;
|
||||||
|
|
||||||
/** company.attributes keys that together mean "a PoA was entered". */
|
/** company.attributes keys that together mean "a PoA was entered". */
|
||||||
const POA_KEYS = [
|
const POA_KEYS = [
|
||||||
"poaName",
|
"poaName",
|
||||||
@@ -101,7 +133,7 @@ function stateFor(
|
|||||||
): IdentityVerificationStateDto {
|
): IdentityVerificationStateDto {
|
||||||
const p = PREFIX[subject];
|
const p = PREFIX[subject];
|
||||||
const read = (key: string) => (attrs[key] as string | undefined) ?? null;
|
const read = (key: string) => (attrs[key] as string | undefined) ?? null;
|
||||||
return {
|
const state: IdentityVerificationStateDto = {
|
||||||
verified: Boolean(read(`${p}FaydaSub`)),
|
verified: Boolean(read(`${p}FaydaSub`)),
|
||||||
name: read(`${p}Name`),
|
name: read(`${p}Name`),
|
||||||
phone: read(`${p}Phone`),
|
phone: read(`${p}Phone`),
|
||||||
@@ -111,6 +143,18 @@ function stateFor(
|
|||||||
birthdate: read(`${p}Birthdate`),
|
birthdate: read(`${p}Birthdate`),
|
||||||
gender: read(`${p}Gender`),
|
gender: read(`${p}Gender`),
|
||||||
};
|
};
|
||||||
|
if (subject !== "gm") return state;
|
||||||
|
|
||||||
|
// Companies onboarded before the GM was verifiable have typed details and no
|
||||||
|
// `gm*` attributes at all. Report those rather than a blank card — they are
|
||||||
|
// still what the notifiers mail — leaving `verified` false so the portal
|
||||||
|
// offers the upgrade instead of pretending the identity is proven.
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
name: state.name ?? read(GM_TYPED_KEYS.name),
|
||||||
|
email: state.email ?? read(GM_TYPED_KEYS.email),
|
||||||
|
phone: state.phone ?? read(GM_TYPED_KEYS.phone),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -144,14 +188,34 @@ export function buildCompanyIdentityState(
|
|||||||
(p) => p.type === ProfileType.freightForwarder,
|
(p) => p.type === ProfileType.freightForwarder,
|
||||||
) || POA_KEYS.some((k) => (attrs[k] as string | undefined)?.trim());
|
) || POA_KEYS.some((k) => (attrs[k] as string | undefined)?.trim());
|
||||||
|
|
||||||
// Only the *owner's* credential is nationality-specific. A Power of Attorney
|
const gm = stateFor(attrs, "gm");
|
||||||
// acts for the company inside Ethiopia whoever owns it, so the PoA is always
|
const gmSameAsOwner = Boolean(attrs.gmSameAsOwner);
|
||||||
// proven with Fayda — a foreign company nominates a representative who holds
|
|
||||||
// one rather than typing a name nothing backs.
|
|
||||||
const ownerProven = faydaRequired
|
const ownerProven = faydaRequired
|
||||||
? owner.verified
|
? owner.verified
|
||||||
: !passportRequired || Boolean(owner.passportNumber);
|
: !passportRequired || Boolean(owner.passportNumber);
|
||||||
const complete = ownerProven && (!poaDue || poa.verified);
|
|
||||||
|
|
||||||
return { faydaRequired, passportRequired, owner, poa, complete };
|
// Fayda is an Ethiopian national ID, so only an Ethiopian company's
|
||||||
|
// personnel can be held to it. A foreign company may nominate a
|
||||||
|
// representative who holds one — and is offered the verification — but a
|
||||||
|
// typed name has to remain sufficient, or a foreign company whose PoA has no
|
||||||
|
// Fayda ID could never finish onboarding.
|
||||||
|
const poaProven = faydaRequired
|
||||||
|
? poa.verified
|
||||||
|
: poa.verified || Boolean(poa.name?.trim());
|
||||||
|
|
||||||
|
// The GM is deliberately absent from this verdict: it names who to talk to,
|
||||||
|
// not what the company may do, and it has never gated trading. Capturing it
|
||||||
|
// through Fayda changes how it is collected, not whether it is required.
|
||||||
|
const complete = ownerProven && (!poaDue || poaProven);
|
||||||
|
|
||||||
|
return {
|
||||||
|
faydaRequired,
|
||||||
|
passportRequired,
|
||||||
|
owner,
|
||||||
|
poa,
|
||||||
|
gm,
|
||||||
|
gmSameAsOwner,
|
||||||
|
complete,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -208,6 +208,9 @@ export class ContractTemplatesService {
|
|||||||
year: "numeric",
|
year: "numeric",
|
||||||
}),
|
}),
|
||||||
contractYear: now.getFullYear(),
|
contractYear: now.getFullYear(),
|
||||||
|
// Representative validity window for the admin preview only.
|
||||||
|
contractStartDate: `1 January ${now.getFullYear()}`,
|
||||||
|
contractEndDate: `31 December ${now.getFullYear()}`,
|
||||||
client: {
|
client: {
|
||||||
companyName: "Abyssinia Trading PLC",
|
companyName: "Abyssinia Trading PLC",
|
||||||
companyAddress: "Bole Sub-city, Woreda 03, H.No 1234, Addis Ababa",
|
companyAddress: "Bole Sub-city, Woreda 03, H.No 1234, Addis Ababa",
|
||||||
@@ -239,6 +242,11 @@ export class ContractTemplatesService {
|
|||||||
scheduledDate: "—",
|
scheduledDate: "—",
|
||||||
contractType: "GENERAL",
|
contractType: "GENERAL",
|
||||||
cargoDescription: isBulk ? "Steel billets — 2,800 MT" : "40ft containers — FMCG cargo",
|
cargoDescription: isBulk ? "Steel billets — 2,800 MT" : "40ft containers — FMCG cargo",
|
||||||
|
cargoTypeName: isBulk ? "Steel billets" : "Coffee",
|
||||||
|
containerType: isBulk ? "—" : "40ft",
|
||||||
|
cargoSummary: isBulk
|
||||||
|
? "Steel billets × 2,800"
|
||||||
|
: "Coffee (40ft) × 12; Sesame (20ft) × 6",
|
||||||
totalWeightVgm: "—",
|
totalWeightVgm: "—",
|
||||||
equipmentReturn: isBulk ? "—" : "With empty return",
|
equipmentReturn: isBulk ? "—" : "With empty return",
|
||||||
hazardousLabel: "No",
|
hazardousLabel: "No",
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import { Module, forwardRef } from '@nestjs/common';
|
import { Module, forwardRef } from '@nestjs/common';
|
||||||
import { UserTradeAccessModule } from '../user-trade-access/user-trade-access.module';
|
import { UserTradeAccessModule } from '../user-trade-access/user-trade-access.module';
|
||||||
import { ConfigService } from '@nestjs/config';
|
|
||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
import { ExchangeModule, ExchangeOptions } from '@edr/api-common';
|
|
||||||
|
|
||||||
|
import { registerExchangeModule } from '../exchange-settings/exchange-module-options';
|
||||||
import { BillingModule } from '../billing/billing.module';
|
import { BillingModule } from '../billing/billing.module';
|
||||||
import { CompaniesModule } from '../companies/companies.module';
|
import { CompaniesModule } from '../companies/companies.module';
|
||||||
import { FilesModule } from '../files/files.module';
|
import { FilesModule } from '../files/files.module';
|
||||||
@@ -102,11 +101,7 @@ import { ContractDocumentViewModelBuilder } from '../../contracts/contract-docum
|
|||||||
// by ContractBookingService.createUnderContract. forwardRef because
|
// by ContractBookingService.createUnderContract. forwardRef because
|
||||||
// TrainSchedulingModule already imports ContractsModule.
|
// TrainSchedulingModule already imports ContractsModule.
|
||||||
forwardRef(() => TrainSchedulingModule),
|
forwardRef(() => TrainSchedulingModule),
|
||||||
ExchangeModule.forRootAsync({
|
registerExchangeModule(),
|
||||||
inject: [ConfigService],
|
|
||||||
useFactory: (config: ConfigService): ExchangeOptions =>
|
|
||||||
config.get<ExchangeOptions>('app.cbeExchange') ?? {},
|
|
||||||
}),
|
|
||||||
],
|
],
|
||||||
controllers: [ContractsController, GlExchangeController],
|
controllers: [ContractsController, GlExchangeController],
|
||||||
providers: [
|
providers: [
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { IsNumber, Max, Min } from "class-validator";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Operator-set USD→ETB fallback. Bounded well outside any plausible published
|
||||||
|
* rate but far short of a fat-fingered magnitude error — this value multiplies
|
||||||
|
* real invoice amounts whenever CBE is unreachable.
|
||||||
|
*/
|
||||||
|
export class UpdateExchangeSettingDto {
|
||||||
|
@IsNumber({ maxDecimalPlaces: 6 })
|
||||||
|
@Min(1)
|
||||||
|
@Max(10_000)
|
||||||
|
fallbackRate!: number;
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { BaseEntity } from "@edr/api-common";
|
||||||
|
import { Column, Entity } from "typeorm";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the stored fallback rate was written by the automatic sync (after a
|
||||||
|
* successful CBE fetch) or typed in by an operator in the backoffice.
|
||||||
|
*/
|
||||||
|
export type ExchangeFallbackSource = "AUTO" | "MANUAL";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Single-row table holding the USD→ETB fallback used when the CBE endpoint is
|
||||||
|
* unreachable. The live CBE rate always wins; this is only consulted on
|
||||||
|
* failure, and is overwritten by every successful fetch so it tracks the last
|
||||||
|
* known good rate.
|
||||||
|
*/
|
||||||
|
@Entity({ schema: "freight", name: "exchange_settings" })
|
||||||
|
export class ExchangeSetting extends BaseEntity {
|
||||||
|
/** USD→ETB rate served while the CBE endpoint is failing. */
|
||||||
|
@Column({
|
||||||
|
name: "fallback_rate",
|
||||||
|
type: "numeric",
|
||||||
|
precision: 18,
|
||||||
|
scale: 6,
|
||||||
|
transformer: {
|
||||||
|
to: (value: number) => value,
|
||||||
|
from: (value: string | null) => (value === null ? null : Number(value)),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
fallbackRate!: number;
|
||||||
|
|
||||||
|
/** `AUTO` when written by the sync, `MANUAL` when set in the backoffice. */
|
||||||
|
@Column({
|
||||||
|
name: "fallback_source",
|
||||||
|
type: "varchar",
|
||||||
|
length: 16,
|
||||||
|
default: "AUTO",
|
||||||
|
})
|
||||||
|
fallbackSource!: ExchangeFallbackSource;
|
||||||
|
|
||||||
|
/** When the fallback last changed — i.e. the last successful CBE fetch. */
|
||||||
|
@Column({ name: "last_synced_at", type: "timestamptz", nullable: true })
|
||||||
|
lastSyncedAt?: Date | null;
|
||||||
|
|
||||||
|
/** IAM user id of the last operator to set the rate manually. */
|
||||||
|
@Column({ name: "updated_by_id", type: "uuid", nullable: true })
|
||||||
|
updatedById?: string | null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { ExchangeModule, ExchangeOptions } from "@edr/api-common";
|
||||||
|
import { ConfigService } from "@nestjs/config";
|
||||||
|
import { DynamicModule } from "@nestjs/common";
|
||||||
|
|
||||||
|
import { ExchangeSettingsService } from "./exchange-settings.service";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The app's single `ExchangeModule` registration shape: CBE endpoint config
|
||||||
|
* from `app.cbeExchange`, with the DB-backed fallback wired in.
|
||||||
|
*
|
||||||
|
* `ExchangeModule` is registered per-feature-module (bookings, contracts,
|
||||||
|
* warehouses), so this keeps the three call sites identical rather than
|
||||||
|
* letting their options drift apart.
|
||||||
|
*/
|
||||||
|
export function registerExchangeModule(): DynamicModule {
|
||||||
|
return ExchangeModule.forRootAsync({
|
||||||
|
inject: [ConfigService, ExchangeSettingsService],
|
||||||
|
useFactory: (
|
||||||
|
config: ConfigService,
|
||||||
|
settings: ExchangeSettingsService,
|
||||||
|
): ExchangeOptions => ({
|
||||||
|
...(config.get<ExchangeOptions>("app.cbeExchange") ?? {}),
|
||||||
|
loadFallbackRate: () => settings.loadFallbackRate(),
|
||||||
|
saveFallbackRate: (rate: number) => settings.saveFallbackRate(rate),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { Body, Controller, Get, Patch } from "@nestjs/common";
|
||||||
|
import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||||
|
import { CurrentUser } from "@edr/api-common";
|
||||||
|
import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/current-user.type";
|
||||||
|
|
||||||
|
import { FreightAdmin } from "../../common/booking-guards";
|
||||||
|
import { UpdateExchangeSettingDto } from "./dto/update-exchange-setting.dto";
|
||||||
|
import { ExchangeSettingsService } from "./exchange-settings.service";
|
||||||
|
|
||||||
|
@ApiTags("exchange-settings")
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@Controller("exchange-settings")
|
||||||
|
export class ExchangeSettingsController {
|
||||||
|
constructor(private readonly service: ExchangeSettingsService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@FreightAdmin()
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "Current USD→ETB fallback rate and CBE feed health",
|
||||||
|
})
|
||||||
|
async get() {
|
||||||
|
const setting = await this.service.get();
|
||||||
|
const status = this.service.getFeedStatus();
|
||||||
|
|
||||||
|
return {
|
||||||
|
fallbackRate: setting.fallbackRate,
|
||||||
|
fallbackSource: setting.fallbackSource,
|
||||||
|
lastSyncedAt: setting.lastSyncedAt,
|
||||||
|
updatedById: setting.updatedById,
|
||||||
|
feed: status,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch()
|
||||||
|
@FreightAdmin()
|
||||||
|
@ApiOperation({
|
||||||
|
summary:
|
||||||
|
"Set the USD→ETB fallback by hand (used only while CBE is unreachable)",
|
||||||
|
})
|
||||||
|
async update(
|
||||||
|
@Body() dto: UpdateExchangeSettingDto,
|
||||||
|
@CurrentUser() user: TCurrentUser,
|
||||||
|
) {
|
||||||
|
const updated = await this.service.setManualRate(
|
||||||
|
dto.fallbackRate,
|
||||||
|
user?.id ?? null,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
fallbackRate: updated.fallbackRate,
|
||||||
|
fallbackSource: updated.fallbackSource,
|
||||||
|
lastSyncedAt: updated.lastSyncedAt,
|
||||||
|
updatedById: updated.updatedById,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { Global, Module } from "@nestjs/common";
|
||||||
|
import { TypeOrmModule } from "@nestjs/typeorm";
|
||||||
|
|
||||||
|
import { ExchangeSetting } from "./entities/exchange-setting.entity";
|
||||||
|
import { ExchangeSettingsController } from "./exchange-settings.controller";
|
||||||
|
import { registerExchangeModule } from "./exchange-module-options";
|
||||||
|
import { ExchangeSettingsService } from "./exchange-settings.service";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Global so the several `ExchangeModule.forRootAsync` registrations (bookings,
|
||||||
|
* contracts, warehouses) can inject {@link ExchangeSettingsService} into their
|
||||||
|
* options factory without each importing this module.
|
||||||
|
*
|
||||||
|
* Also registers its own `ExchangeModule` so `ExchangeSettingsController` can
|
||||||
|
* report the live CBE feed status (`ExchangeService.getProviderStatus()`)
|
||||||
|
* alongside the DB-backed fallback rate.
|
||||||
|
*/
|
||||||
|
@Global()
|
||||||
|
@Module({
|
||||||
|
imports: [TypeOrmModule.forFeature([ExchangeSetting]), registerExchangeModule()],
|
||||||
|
controllers: [ExchangeSettingsController],
|
||||||
|
providers: [ExchangeSettingsService],
|
||||||
|
exports: [ExchangeSettingsService],
|
||||||
|
})
|
||||||
|
export class ExchangeSettingsModule {}
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
import { Injectable, Logger } from "@nestjs/common";
|
||||||
|
import { InjectRepository } from "@nestjs/typeorm";
|
||||||
|
import { Repository } from "typeorm";
|
||||||
|
|
||||||
|
import { ExchangeSetting } from "./entities/exchange-setting.entity";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rate used before the row exists and before the first successful CBE fetch —
|
||||||
|
* the CBE USD transactional selling rate on 2026-08-04.
|
||||||
|
*/
|
||||||
|
const SEED_FALLBACK_RATE = 162.4165;
|
||||||
|
|
||||||
|
/** Health of the CBE feed, as surfaced to the backoffice. */
|
||||||
|
export interface ExchangeFeedStatus {
|
||||||
|
/** Rate most recently observed, whatever its source. */
|
||||||
|
rate: number | null;
|
||||||
|
/** `live` means CBE answered; `stored`/`default` mean it is failing. */
|
||||||
|
source: "live" | "stored" | null;
|
||||||
|
/** ISO timestamp of the last successful fetch. */
|
||||||
|
lastSuccessAt: string | null;
|
||||||
|
/** Message from the most recent failure, cleared on success. */
|
||||||
|
lastError: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Owns the single `exchange_settings` row: the USD→ETB fallback used when the
|
||||||
|
* CBE endpoint is unreachable.
|
||||||
|
*
|
||||||
|
* The live CBE rate is always preferred. This value is only read on failure,
|
||||||
|
* and every successful fetch overwrites it, so it tracks the last known good
|
||||||
|
* rate rather than drifting into a stale constant.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class ExchangeSettingsService {
|
||||||
|
private readonly logger = new Logger(ExchangeSettingsService.name);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Feed health, recorded from the exchange provider's callbacks rather than
|
||||||
|
* read off an injected `ExchangeService`. The provider is registered several
|
||||||
|
* times (bookings, contracts, warehouses), so no single instance sees every
|
||||||
|
* fetch — and injecting one here would be circular, since those
|
||||||
|
* registrations inject *this* service.
|
||||||
|
*/
|
||||||
|
private feed: ExchangeFeedStatus = {
|
||||||
|
rate: null,
|
||||||
|
source: null,
|
||||||
|
lastSuccessAt: null,
|
||||||
|
lastError: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(ExchangeSetting)
|
||||||
|
private readonly repository: Repository<ExchangeSetting>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/** Health of the CBE feed as last observed by any provider instance. */
|
||||||
|
getFeedStatus(): ExchangeFeedStatus {
|
||||||
|
return { ...this.feed };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The settings row, created at the seed rate on first access. */
|
||||||
|
async get(): Promise<ExchangeSetting> {
|
||||||
|
const existing = await this.repository.findOne({ where: {} });
|
||||||
|
if (existing) return existing;
|
||||||
|
|
||||||
|
return this.repository.save(
|
||||||
|
this.repository.create({
|
||||||
|
fallbackRate: SEED_FALLBACK_RATE,
|
||||||
|
fallbackSource: "AUTO",
|
||||||
|
lastSyncedAt: null,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads the stored fallback for the exchange provider. Returns `null` on any
|
||||||
|
* failure so the provider falls through to its own static default rather
|
||||||
|
* than propagating a database error into a pricing call.
|
||||||
|
*/
|
||||||
|
async loadFallbackRate(): Promise<number | null> {
|
||||||
|
// Only reached when the live fetch failed, so this call is itself the
|
||||||
|
// signal that the feed is down.
|
||||||
|
try {
|
||||||
|
const { fallbackRate } = await this.get();
|
||||||
|
const usable = Number.isFinite(fallbackRate) && fallbackRate > 0;
|
||||||
|
this.feed = {
|
||||||
|
...this.feed,
|
||||||
|
rate: usable ? fallbackRate : this.feed.rate,
|
||||||
|
source: "stored",
|
||||||
|
lastError: this.feed.lastError ?? "CBE endpoint unreachable",
|
||||||
|
};
|
||||||
|
return usable ? fallbackRate : null;
|
||||||
|
} catch (err) {
|
||||||
|
const message = (err as Error).message;
|
||||||
|
this.feed = { ...this.feed, source: "stored", lastError: message };
|
||||||
|
this.logger.warn(`Could not read stored exchange fallback: ${message}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Records a freshly fetched live rate as the new fallback. Marked `AUTO`,
|
||||||
|
* overwriting a manual entry — a manual rate is a stopgap for while CBE is
|
||||||
|
* down, so a working CBE feed takes precedence again.
|
||||||
|
*/
|
||||||
|
async saveFallbackRate(rate: number): Promise<void> {
|
||||||
|
// Only called after a successful fetch, so the feed is confirmed healthy.
|
||||||
|
this.feed = {
|
||||||
|
rate,
|
||||||
|
source: "live",
|
||||||
|
lastSuccessAt: new Date().toISOString(),
|
||||||
|
lastError: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const current = await this.get();
|
||||||
|
await this.repository.update(current.id, {
|
||||||
|
fallbackRate: rate,
|
||||||
|
fallbackSource: "AUTO",
|
||||||
|
lastSyncedAt: new Date(),
|
||||||
|
updatedById: null,
|
||||||
|
});
|
||||||
|
this.logger.log(`Exchange fallback synced from CBE: ${rate} ETB/USD`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Operator sets the fallback by hand, e.g. during a prolonged CBE outage. */
|
||||||
|
async setManualRate(
|
||||||
|
rate: number,
|
||||||
|
updatedById?: string | null,
|
||||||
|
): Promise<ExchangeSetting> {
|
||||||
|
const current = await this.get();
|
||||||
|
await this.repository.update(current.id, {
|
||||||
|
fallbackRate: rate,
|
||||||
|
fallbackSource: "MANUAL",
|
||||||
|
updatedById: updatedById ?? null,
|
||||||
|
});
|
||||||
|
this.logger.warn(
|
||||||
|
`Exchange fallback set manually to ${rate} ETB/USD by ${updatedById ?? "unknown user"}`,
|
||||||
|
);
|
||||||
|
return this.get();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,23 @@
|
|||||||
import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query } from '@nestjs/common';
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Delete,
|
||||||
|
Get,
|
||||||
|
HttpCode,
|
||||||
|
HttpStatus,
|
||||||
|
Param,
|
||||||
|
ParseUUIDPipe,
|
||||||
|
Patch,
|
||||||
|
Post,
|
||||||
|
Query,
|
||||||
|
} from '@nestjs/common';
|
||||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
|
|
||||||
import { FleetManage, StaffReference } from '../../common/booking-guards';
|
import {
|
||||||
|
BookingStaff,
|
||||||
|
FleetManage,
|
||||||
|
StaffReference,
|
||||||
|
} from '../../common/booking-guards';
|
||||||
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||||
import { CreateLocomotiveDto } from './dto/create-locomotive.dto';
|
import { CreateLocomotiveDto } from './dto/create-locomotive.dto';
|
||||||
import { FilterLocomotivesDto } from './dto/filter-locomotives.dto';
|
import { FilterLocomotivesDto } from './dto/filter-locomotives.dto';
|
||||||
@@ -59,4 +75,18 @@ export class LocomotivesController {
|
|||||||
decommission(@Param('id', ParseUUIDPipe) id: string) {
|
decommission(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
return this.locomotivesService.decommission(id);
|
return this.locomotivesService.decommission(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// BookingStaff, not FleetManage: the latter also accepts the coarse
|
||||||
|
// fleet:manage key, which would hand an irreversible purge to everyone who
|
||||||
|
// can edit the fleet. This action requires its own grant, nothing else.
|
||||||
|
@Delete(':id/permanent')
|
||||||
|
@BookingStaff(FREIGHT_PERMS.locomotives.hardDelete)
|
||||||
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
|
@ApiOperation({
|
||||||
|
summary:
|
||||||
|
'Permanently delete a locomotive (irreversible; refused if any train references it)',
|
||||||
|
})
|
||||||
|
purge(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
|
return this.locomotivesService.purge(id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -222,4 +222,61 @@ export class LocomotivesService {
|
|||||||
|
|
||||||
return updated;
|
return updated;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Permanently purge a locomotive — irreversible, and only for rows nothing
|
||||||
|
* references: a mistyped or duplicated entry.
|
||||||
|
*
|
||||||
|
* Every FK onto `locomotives` is NO ACTION, so Postgres would reject the
|
||||||
|
* delete with a raw constraint error. The references are resolved up front
|
||||||
|
* instead, naming the trains involved so the message says what to detach.
|
||||||
|
* Decommissioning (`decommission`) stays the answer for a real locomotive
|
||||||
|
* leaving service.
|
||||||
|
*/
|
||||||
|
async purge(id: string): Promise<void> {
|
||||||
|
const locomotive = await this.locomotivesRepository.findById(id);
|
||||||
|
if (!locomotive) {
|
||||||
|
throw new NotFoundException(`Locomotive ${id} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [builtTrains, setsViaJoin, setsDirect] = await Promise.all([
|
||||||
|
this.dataSource.query<Array<{ code: string | null }>>(
|
||||||
|
`SELECT t.code
|
||||||
|
FROM freight.train_locomotives tl
|
||||||
|
JOIN freight.trains t ON t.id = tl.train_id
|
||||||
|
WHERE tl.locomotive_id = $1`,
|
||||||
|
[id],
|
||||||
|
),
|
||||||
|
this.dataSource.query<Array<{ code: string | null }>>(
|
||||||
|
`SELECT t.code
|
||||||
|
FROM freight.train_set_locomotives tsl
|
||||||
|
JOIN freight.train_sets ts ON ts.id = tsl.train_set_id
|
||||||
|
LEFT JOIN freight.trains t ON t.id = ts.train_id
|
||||||
|
WHERE tsl.locomotive_id = $1`,
|
||||||
|
[id],
|
||||||
|
),
|
||||||
|
this.dataSource.query<Array<{ code: string | null }>>(
|
||||||
|
`SELECT t.code
|
||||||
|
FROM freight.train_sets ts
|
||||||
|
LEFT JOIN freight.trains t ON t.id = ts.train_id
|
||||||
|
WHERE ts.locomotive_id = $1`,
|
||||||
|
[id],
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const referencing = [...builtTrains, ...setsViaJoin, ...setsDirect];
|
||||||
|
if (referencing.length > 0) {
|
||||||
|
const codes = [
|
||||||
|
...new Set(referencing.map((r) => r.code).filter(Boolean)),
|
||||||
|
];
|
||||||
|
const named = codes.length > 0 ? ` (${codes.join(', ')})` : '';
|
||||||
|
throw new ConflictException(
|
||||||
|
`Locomotive ${locomotive.code} is used by ${referencing.length} train record(s)${named}; detach it before deleting it permanently. Decommission it instead to take it out of service.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.dataSource
|
||||||
|
.getRepository(Locomotive)
|
||||||
|
.delete({ id });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,62 +1,22 @@
|
|||||||
import { Injectable, Logger } from "@nestjs/common";
|
import { Injectable, Logger } from "@nestjs/common";
|
||||||
import { ConfigService } from "@nestjs/config";
|
|
||||||
import axios, { isAxiosError } from "axios";
|
|
||||||
|
|
||||||
import { NotificationStrategy } from "./notification.strategy";
|
import { NotificationStrategy } from "./notification.strategy";
|
||||||
|
import { SmsClientService } from "../sms-client.service";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class SmsNotificationStrategy implements NotificationStrategy {
|
export class SmsNotificationStrategy implements NotificationStrategy {
|
||||||
private readonly logger = new Logger(SmsNotificationStrategy.name);
|
private readonly logger = new Logger(SmsNotificationStrategy.name);
|
||||||
|
|
||||||
constructor(private readonly configService: ConfigService) {}
|
constructor(private readonly smsClient: SmsClientService) {}
|
||||||
|
|
||||||
async send(recipient: string, message: string): Promise<boolean> {
|
async send(recipient: string, message: string): Promise<boolean> {
|
||||||
const url =
|
const { queued } = await this.smsClient.sendSms({
|
||||||
this.configService.get<string>("OZIKING_SMS_URL") ??
|
to: recipient,
|
||||||
"https://notification-dev.license.aafda.gov.et/api/sms-services/ozeking/sms";
|
message,
|
||||||
|
});
|
||||||
const appKey = this.configService.get<string>("OZIKING_APP_KEY") ?? "";
|
if (!queued) {
|
||||||
if (!appKey) {
|
this.logger.error(`SMS to ${recipient} was not queued to RabbitMQ`);
|
||||||
this.logger.warn("OZIKING_APP_KEY is not set — SMS may be rejected by the API");
|
|
||||||
}
|
|
||||||
|
|
||||||
this.logger.debug(`Sending SMS to ${recipient} via ${url}`);
|
|
||||||
|
|
||||||
// axios defaults to no timeout — a hanging gateway would block the caller
|
|
||||||
// (and any transaction it sits in) indefinitely. Always bound the wait.
|
|
||||||
const timeout = Number(this.configService.get<string>("SMS_TIMEOUT_MS") ?? 8000);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await axios.post(
|
|
||||||
url,
|
|
||||||
{
|
|
||||||
to: recipient,
|
|
||||||
sourceId: this.configService.get<string>("OZIKING_SOURCE_ID") ?? "EDR",
|
|
||||||
sourceName: this.configService.get<string>("OZIKING_SOURCE_NAME") ?? "EDR Freight",
|
|
||||||
appKey,
|
|
||||||
text: message,
|
|
||||||
callbackUrl: "",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
timeout,
|
|
||||||
headers: {
|
|
||||||
accept: "*/*",
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
this.logger.debug(`SMS API response: ${response.status} ${JSON.stringify(response.data)}`);
|
|
||||||
return true;
|
|
||||||
} catch (err) {
|
|
||||||
if (isAxiosError(err)) {
|
|
||||||
this.logger.error(
|
|
||||||
`SMS API error: ${err.message} | status=${err.response?.status} | body=${JSON.stringify(err.response?.data)}`,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
this.logger.error(`SMS send failed: ${String(err)}`);
|
|
||||||
}
|
|
||||||
throw err;
|
|
||||||
}
|
}
|
||||||
|
return queued;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -265,10 +265,10 @@ export class OtpService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* SMS half of {@link dispatchEmail}; same swallow-and-report contract. Sent
|
* SMS half of {@link dispatchEmail}; same swallow-and-report contract. Sent
|
||||||
* via NotificationsService's direct-HTTP Ozeking strategy — the same
|
* via NotificationsService's `directSend`, which now routes through the
|
||||||
* transport the notification system uses — rather than the RabbitMQ
|
* same RabbitMQ `SMS_SERVICE` queue as every other SMS in freight-api, so
|
||||||
* `SMS_SERVICE` queue, so `queued: true` here means the gateway accepted the
|
* `queued: true` here means the broker confirmed ownership of the message,
|
||||||
* request, not just that a broker took ownership of the message.
|
* not that the carrier delivered it.
|
||||||
*/
|
*/
|
||||||
private async dispatchSms(
|
private async dispatchSms(
|
||||||
phone: string,
|
phone: string,
|
||||||
|
|||||||
61
apps/edr-freight-api/src/modules/routes/purge-guard.spec.ts
Normal file
61
apps/edr-freight-api/src/modules/routes/purge-guard.spec.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import { ConflictException, NotFoundException } from '@nestjs/common';
|
||||||
|
import { RoutesService } from './routes.service';
|
||||||
|
|
||||||
|
const ROUTE = {
|
||||||
|
id: 'r1',
|
||||||
|
originYard: { code: 'ADD', label: 'Addis' },
|
||||||
|
destinationYard: { code: 'DIR', label: 'Dire Dawa' },
|
||||||
|
milestones: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const makeService = (scheduleCount: number, route: unknown = ROUTE) => {
|
||||||
|
const deletes: string[] = [];
|
||||||
|
const manager = {
|
||||||
|
getRepository: (entity: { name?: string }) => ({
|
||||||
|
delete: async () => {
|
||||||
|
deletes.push(entity?.name ?? 'unknown');
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
const dataSource = {
|
||||||
|
query: jest.fn(async () => [{ count: scheduleCount }]),
|
||||||
|
transaction: jest.fn(async (cb: (m: unknown) => Promise<void>) => cb(manager)),
|
||||||
|
};
|
||||||
|
const routesRepository = {};
|
||||||
|
const svc = new RoutesService(dataSource as never, routesRepository as never);
|
||||||
|
// findById is the service's own loader; stub it to isolate the purge guard.
|
||||||
|
(svc as unknown as { findById: (id: string) => Promise<unknown> }).findById =
|
||||||
|
async () => {
|
||||||
|
if (!route) throw new NotFoundException('Route not found');
|
||||||
|
return route;
|
||||||
|
};
|
||||||
|
return { svc, dataSource, deletes };
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('RoutesService.purge', () => {
|
||||||
|
it('purges a route no schedule references', async () => {
|
||||||
|
const { svc, dataSource, deletes } = makeService(0);
|
||||||
|
await svc.purge('r1');
|
||||||
|
expect(dataSource.transaction).toHaveBeenCalled();
|
||||||
|
// Milestones then the route itself, inside one transaction.
|
||||||
|
expect(deletes).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses while train schedules reference it', async () => {
|
||||||
|
const { svc, dataSource } = makeService(4);
|
||||||
|
await expect(svc.purge('r1')).rejects.toThrow(ConflictException);
|
||||||
|
await expect(svc.purge('r1')).rejects.toThrow(/4 train schedule\(s\)/);
|
||||||
|
expect(dataSource.transaction).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('names the route in the refusal so the message is actionable', async () => {
|
||||||
|
const { svc } = makeService(1);
|
||||||
|
await expect(svc.purge('r1')).rejects.toThrow(/ADD|Addis/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('propagates a not-found route', async () => {
|
||||||
|
const { svc, dataSource } = makeService(0, null);
|
||||||
|
await expect(svc.purge('nope')).rejects.toThrow(NotFoundException);
|
||||||
|
expect(dataSource.transaction).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,7 +1,23 @@
|
|||||||
import { Body, Controller, Delete, Get, Param, ParseUUIDPipe, Patch, Post, Query } from '@nestjs/common';
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Delete,
|
||||||
|
Get,
|
||||||
|
HttpCode,
|
||||||
|
HttpStatus,
|
||||||
|
Param,
|
||||||
|
ParseUUIDPipe,
|
||||||
|
Patch,
|
||||||
|
Post,
|
||||||
|
Query,
|
||||||
|
} from '@nestjs/common';
|
||||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
|
|
||||||
import { FleetManage, FleetView } from '../../common/booking-guards';
|
import {
|
||||||
|
BookingStaff,
|
||||||
|
FleetManage,
|
||||||
|
FleetView,
|
||||||
|
} from '../../common/booking-guards';
|
||||||
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||||
import { CreateRouteDto } from './dto/create-route.dto';
|
import { CreateRouteDto } from './dto/create-route.dto';
|
||||||
import { FilterRoutesDto } from './dto/filter-routes.dto';
|
import { FilterRoutesDto } from './dto/filter-routes.dto';
|
||||||
@@ -48,6 +64,21 @@ export class RoutesController {
|
|||||||
return this.routesService.update(id, dto);
|
return this.routesService.update(id, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Declared before @Delete(':id') so "permanent" is never captured as an id.
|
||||||
|
// BookingStaff, not FleetManage: the latter also accepts the coarse
|
||||||
|
// fleet:manage key, which would hand an irreversible purge to everyone who
|
||||||
|
// can edit the fleet. This action requires its own grant, nothing else.
|
||||||
|
@Delete(':id/permanent')
|
||||||
|
@BookingStaff(FREIGHT_PERMS.routes.hardDelete)
|
||||||
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
|
@ApiOperation({
|
||||||
|
summary:
|
||||||
|
'Permanently delete a route (irreversible; refused while any train schedule references it)',
|
||||||
|
})
|
||||||
|
purge(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
|
return this.routesService.purge(id);
|
||||||
|
}
|
||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
@FleetManage(FREIGHT_PERMS.routes.delete)
|
@FleetManage(FREIGHT_PERMS.routes.delete)
|
||||||
@ApiOperation({ summary: 'Deactivate route' })
|
@ApiOperation({ summary: 'Deactivate route' })
|
||||||
|
|||||||
@@ -206,6 +206,41 @@ export class RoutesService {
|
|||||||
return this.findById(id);
|
return this.findById(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Permanently purge a route — irreversible, and only for corridors nothing
|
||||||
|
* has run on: a mistyped or duplicated definition.
|
||||||
|
*
|
||||||
|
* `train_schedules.route_id` is NO ACTION, so Postgres would reject the
|
||||||
|
* delete with a raw constraint error; the schedules are counted up front
|
||||||
|
* instead so the refusal says what is blocking. The route's own milestones
|
||||||
|
* cascade with it, which is correct — they are the route's definition, not
|
||||||
|
* history that outlives it. Deactivating (`deactivate`) stays the answer for
|
||||||
|
* a corridor that has actually been used.
|
||||||
|
*/
|
||||||
|
async purge(id: string): Promise<void> {
|
||||||
|
const route = await this.findById(id);
|
||||||
|
|
||||||
|
const [schedules] = await this.dataSource.query<Array<{ count: number }>>(
|
||||||
|
`SELECT count(*)::int AS count
|
||||||
|
FROM freight.train_schedules
|
||||||
|
WHERE route_id = $1`,
|
||||||
|
[id],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (schedules?.count > 0) {
|
||||||
|
throw new ConflictException(
|
||||||
|
`Route ${formatRouteLabel(route)} cannot be permanently deleted — ${schedules.count} train schedule(s) still reference it. Deactivate it instead, which keeps the history intact.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.dataSource.transaction(async (manager) => {
|
||||||
|
// Milestones are FK-cascaded, but delete them explicitly so the intent is
|
||||||
|
// visible here rather than depending on the constraint alone.
|
||||||
|
await manager.getRepository(RouteMilestone).delete({ routeId: id });
|
||||||
|
await manager.getRepository(Route).delete({ id });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A route IS its ordered stop list — "Addis → Adama → Dire Dawa" and
|
* A route IS its ordered stop list — "Addis → Adama → Dire Dawa" and
|
||||||
* "Addis → Dire Dawa" share endpoints but are different corridors. So the
|
* "Addis → Dire Dawa" share endpoints but are different corridors. So the
|
||||||
|
|||||||
@@ -91,7 +91,16 @@ export class BookingWindowService implements OnModuleInit {
|
|||||||
// 10-second cadence: every transition is derived from persisted timestamps
|
// 10-second cadence: every transition is derived from persisted timestamps
|
||||||
// and applied idempotently, so a finer tick only shrinks the lag between a
|
// and applied idempotently, so a finer tick only shrinks the lag between a
|
||||||
// deadline passing and the phase actually moving (was a full minute).
|
// deadline passing and the phase actually moving (was a full minute).
|
||||||
@Cron('*/10 * * * * *', { name: 'booking-window-tick', timeZone: BATCH_TIMEZONE })
|
//
|
||||||
|
// Overridable because that lag is the integration suite's pacing floor: every
|
||||||
|
// window phase, wagon allocation and expiry it waits on lands on this tick, so
|
||||||
|
// a 10s cadence costs ~5s of pure latency per wait across a few hundred waits.
|
||||||
|
// The suite runs it at `*/1 * * * * *`. Read at class-definition time, so the
|
||||||
|
// env var must be set before the module is imported.
|
||||||
|
@Cron(process.env.BOOKING_WINDOW_TICK_CRON ?? '*/10 * * * * *', {
|
||||||
|
name: 'booking-window-tick',
|
||||||
|
timeZone: BATCH_TIMEZONE,
|
||||||
|
})
|
||||||
async tick(): Promise<void> {
|
async tick(): Promise<void> {
|
||||||
if (this.ticking) return;
|
if (this.ticking) return;
|
||||||
this.ticking = true;
|
this.ticking = true;
|
||||||
|
|||||||
@@ -6,12 +6,14 @@ import { VerifaydaCallbackDto } from './verifayda.dto';
|
|||||||
/**
|
/**
|
||||||
* Plain acknowledgement endpoint for the Fayda redirect_uri when it points at
|
* Plain acknowledgement endpoint for the Fayda redirect_uri when it points at
|
||||||
* the API instead of the web app (e.g. MOBILE clients or connectivity checks).
|
* the API instead of the web app (e.g. MOBILE clients or connectivity checks).
|
||||||
* Registered at /callback (excluded from the global /api prefix in main.ts).
|
* Registered at /fayda/callback (excluded by exact path from the global /api
|
||||||
|
* prefix in main.ts — the exclusion must NOT be widened to "fayda", or
|
||||||
|
* /api/fayda/verification/* loses its prefix too).
|
||||||
* It does NOT consume the verification session — the client must still call
|
* It does NOT consume the verification session — the client must still call
|
||||||
* GET /api/fayda/verification/complete with the echoed code+state.
|
* GET /api/fayda/verification/complete with the echoed code+state.
|
||||||
*/
|
*/
|
||||||
@ApiTags('Fayda Verification')
|
@ApiTags('Fayda Verification')
|
||||||
@Controller('callback')
|
@Controller('fayda/callback')
|
||||||
export class FaydaCallbackController {
|
export class FaydaCallbackController {
|
||||||
@Get()
|
@Get()
|
||||||
@IsPublic()
|
@IsPublic()
|
||||||
|
|||||||
61
apps/edr-freight-api/src/modules/wagons/purge-guard.spec.ts
Normal file
61
apps/edr-freight-api/src/modules/wagons/purge-guard.spec.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import { ConflictException, NotFoundException } from '@nestjs/common';
|
||||||
|
import { WagonsService } from './wagons.service';
|
||||||
|
|
||||||
|
// Minimal stubs: only what purge() touches.
|
||||||
|
const makeService = (wagon: any, counts: [number, number, number], pinned = false) => {
|
||||||
|
const wagonRepo = { findOne: jest.fn().mockResolvedValue(wagon), remove: jest.fn().mockResolvedValue(undefined) };
|
||||||
|
// Keyed off the SQL so the stub survives repeated purge() calls in one test.
|
||||||
|
const dataSource = {
|
||||||
|
query: jest.fn(async (sql: string) => {
|
||||||
|
if (sql.includes('train_schedule')) return pinned ? [{ x: 1 }] : [];
|
||||||
|
if (sql.includes('wagon_movements')) return [{ count: counts[0] }];
|
||||||
|
if (sql.includes('containers')) return [{ count: counts[1] }];
|
||||||
|
if (sql.includes('train_set_wagons')) return [{ count: counts[2] }];
|
||||||
|
return [];
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
const svc = new WagonsService(wagonRepo as any, {} as any, dataSource as any);
|
||||||
|
return { svc, wagonRepo };
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('WagonsService.purge', () => {
|
||||||
|
const clean = { id: 'w1', wagonNumber: 'W-0001', trainId: null };
|
||||||
|
|
||||||
|
it('purges a wagon with no references', async () => {
|
||||||
|
const { svc, wagonRepo } = makeService(clean, [0, 0, 0]);
|
||||||
|
await svc.purge('w1');
|
||||||
|
expect(wagonRepo.remove).toHaveBeenCalledWith(clean);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses when the wagon has movement history', async () => {
|
||||||
|
const { svc, wagonRepo } = makeService(clean, [12, 0, 0]);
|
||||||
|
await expect(svc.purge('w1')).rejects.toThrow(ConflictException);
|
||||||
|
await expect(svc.purge('w1')).rejects.toThrow(/12 movement record/);
|
||||||
|
expect(wagonRepo.remove).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses when containers or train-set slots reference it', async () => {
|
||||||
|
const { svc, wagonRepo } = makeService(clean, [0, 3, 2]);
|
||||||
|
await expect(svc.purge('w1')).rejects.toThrow(/3 container\(s\), 2 train-set slot/);
|
||||||
|
expect(wagonRepo.remove).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses a coupled wagon before any count query runs', async () => {
|
||||||
|
const { svc, wagonRepo } = makeService({ ...clean, trainId: 't1' }, [0, 0, 0]);
|
||||||
|
await expect(svc.purge('w1')).rejects.toThrow(/coupled to a train/);
|
||||||
|
expect(wagonRepo.remove).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses a wagon pinned to a live schedule', async () => {
|
||||||
|
const { svc, wagonRepo } = makeService(clean, [0, 0, 0], true);
|
||||||
|
await expect(svc.purge('w1')).rejects.toThrow(/pinned to an active schedule/);
|
||||||
|
expect(wagonRepo.remove).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('404s an unknown wagon', async () => {
|
||||||
|
const wagonRepo = { findOne: jest.fn().mockResolvedValue(null), remove: jest.fn() };
|
||||||
|
const svc = new WagonsService(wagonRepo as any, {} as any, { query: jest.fn() } as any);
|
||||||
|
await expect(svc.purge('nope')).rejects.toThrow(NotFoundException);
|
||||||
|
expect(wagonRepo.remove).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,6 +3,8 @@ import {
|
|||||||
Controller,
|
Controller,
|
||||||
Delete,
|
Delete,
|
||||||
Get,
|
Get,
|
||||||
|
HttpCode,
|
||||||
|
HttpStatus,
|
||||||
Param,
|
Param,
|
||||||
ParseUUIDPipe,
|
ParseUUIDPipe,
|
||||||
Patch,
|
Patch,
|
||||||
@@ -12,7 +14,11 @@ import {
|
|||||||
import { ApiOperation, ApiTags } from '@nestjs/swagger';
|
import { ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
import { CurrentUser } from '@edr/api-common';
|
import { CurrentUser } from '@edr/api-common';
|
||||||
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
|
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
|
||||||
import { FleetManage, StaffReference } from '../../common/booking-guards';
|
import {
|
||||||
|
BookingStaff,
|
||||||
|
FleetManage,
|
||||||
|
StaffReference,
|
||||||
|
} from '../../common/booking-guards';
|
||||||
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||||
import { CreateWagonDto } from './dto/create-wagon.dto';
|
import { CreateWagonDto } from './dto/create-wagon.dto';
|
||||||
import { ListWagonsQueryDto } from './dto/list-wagons-query.dto';
|
import { ListWagonsQueryDto } from './dto/list-wagons-query.dto';
|
||||||
@@ -69,6 +75,21 @@ export class WagonsController {
|
|||||||
return this.wagonsService.update(id, dto);
|
return this.wagonsService.update(id, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Declared before @Delete(':id') so "permanent" is never captured as an id.
|
||||||
|
// BookingStaff, not FleetManage: the latter also accepts the coarse
|
||||||
|
// fleet:manage key, which would hand an irreversible purge to everyone who
|
||||||
|
// can edit the fleet. This action requires its own grant, nothing else.
|
||||||
|
@Delete(':id/permanent')
|
||||||
|
@BookingStaff(FREIGHT_PERMS.wagons.hardDelete)
|
||||||
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
|
@ApiOperation({
|
||||||
|
summary:
|
||||||
|
'Permanently delete a wagon (irreversible; refused if it has movements, containers or train-set slots)',
|
||||||
|
})
|
||||||
|
purge(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
|
return this.wagonsService.purge(id);
|
||||||
|
}
|
||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
@FleetManage(FREIGHT_PERMS.wagons.delete)
|
@FleetManage(FREIGHT_PERMS.wagons.delete)
|
||||||
@ApiOperation({ summary: 'Delete a wagon' })
|
@ApiOperation({ summary: 'Delete a wagon' })
|
||||||
|
|||||||
@@ -212,6 +212,75 @@ export class WagonsService {
|
|||||||
await this.wagonRepo.softRemove(wagon);
|
await this.wagonRepo.softRemove(wagon);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Permanently purge a wagon — irreversible, and only for rows that carry no
|
||||||
|
* history: a mistyped or duplicated entry someone wants gone for good.
|
||||||
|
*
|
||||||
|
* `wagon_movements` cascades on delete, so a wagon with movements would take
|
||||||
|
* its ledger history down with it. Rather than allow that, every reference is
|
||||||
|
* checked first and the purge is refused if any exist — soft delete (`remove`)
|
||||||
|
* stays the answer for a wagon that has actually been used.
|
||||||
|
*
|
||||||
|
* Soft-deleted wagons are purgeable, so `withDeleted` is used to find them.
|
||||||
|
*/
|
||||||
|
async purge(id: string): Promise<void> {
|
||||||
|
const wagon = await this.wagonRepo.findOne({
|
||||||
|
where: { id },
|
||||||
|
withDeleted: true,
|
||||||
|
});
|
||||||
|
if (!wagon) {
|
||||||
|
throw new NotFoundException(`Wagon ${id} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (wagon.trainId != null) {
|
||||||
|
throw new ConflictException(
|
||||||
|
`Wagon ${wagon.wagonNumber} is coupled to a train; detach it via train-builder before deleting it permanently`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (await this.isWagonPinnedToLiveSchedule(id)) {
|
||||||
|
throw new ConflictException(
|
||||||
|
`Wagon ${wagon.wagonNumber} is pinned to an active schedule and cannot be deleted permanently`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Each of these would either lose history (movements cascade) or silently
|
||||||
|
// blank a live reference (containers / train-set slots are SET NULL).
|
||||||
|
const blockers: string[] = [];
|
||||||
|
const [movements, containers, trainSetSlots] = await Promise.all([
|
||||||
|
this.dataSource.query(
|
||||||
|
`SELECT count(*)::int AS count FROM freight.wagon_movements WHERE wagon_id = $1`,
|
||||||
|
[id],
|
||||||
|
),
|
||||||
|
this.dataSource.query(
|
||||||
|
`SELECT count(*)::int AS count FROM freight.containers WHERE wagon_id = $1`,
|
||||||
|
[id],
|
||||||
|
),
|
||||||
|
this.dataSource.query(
|
||||||
|
`SELECT count(*)::int AS count FROM freight.train_set_wagons WHERE physical_wagon_id = $1`,
|
||||||
|
[id],
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
if (movements[0]?.count > 0) {
|
||||||
|
blockers.push(`${movements[0].count} movement record(s)`);
|
||||||
|
}
|
||||||
|
if (containers[0]?.count > 0) {
|
||||||
|
blockers.push(`${containers[0].count} container(s)`);
|
||||||
|
}
|
||||||
|
if (trainSetSlots[0]?.count > 0) {
|
||||||
|
blockers.push(`${trainSetSlots[0].count} train-set slot(s)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (blockers.length > 0) {
|
||||||
|
throw new ConflictException(
|
||||||
|
`Wagon ${wagon.wagonNumber} cannot be permanently deleted — it still has ${blockers.join(
|
||||||
|
', ',
|
||||||
|
)}. Delete it normally instead, which keeps the history intact.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.wagonRepo.remove(wagon);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A wagon is busy when any live (DRAFT/SCHEDULED/DISPATCHED) schedule pins it
|
* A wagon is busy when any live (DRAFT/SCHEDULED/DISPATCHED) schedule pins it
|
||||||
* to one of its slots — schedule occupancy lives on TrainSetWagon rows, not
|
* to one of its slots — schedule occupancy lives on TrainSetWagon rows, not
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import { Module, forwardRef } from '@nestjs/common';
|
import { Module, forwardRef } from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
|
||||||
import { ExchangeModule, ExchangeOptions } from '@edr/api-common';
|
|
||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
|
||||||
|
import { registerExchangeModule } from '../exchange-settings/exchange-module-options';
|
||||||
import { BillingModule } from '../billing/billing.module';
|
import { BillingModule } from '../billing/billing.module';
|
||||||
import { DocumentsModule } from '../billing/documents/documents.module';
|
import { DocumentsModule } from '../billing/documents/documents.module';
|
||||||
import { FilesModule } from '../files/files.module';
|
import { FilesModule } from '../files/files.module';
|
||||||
@@ -78,11 +77,7 @@ import { WarehousesService } from './warehouses.service';
|
|||||||
NotificationsModule,
|
NotificationsModule,
|
||||||
NotificationInboxModule,
|
NotificationInboxModule,
|
||||||
SignaturesModule,
|
SignaturesModule,
|
||||||
ExchangeModule.forRootAsync({
|
registerExchangeModule(),
|
||||||
inject: [ConfigService],
|
|
||||||
useFactory: (config: ConfigService): ExchangeOptions =>
|
|
||||||
config.get<ExchangeOptions>('app.cbeExchange') ?? {},
|
|
||||||
}),
|
|
||||||
],
|
],
|
||||||
controllers: [
|
controllers: [
|
||||||
WarehousesController,
|
WarehousesController,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { resolve } from 'path';
|
|||||||
config({ path: resolve(__dirname, '../../.env') });
|
config({ path: resolve(__dirname, '../../.env') });
|
||||||
|
|
||||||
import { NestFactory } from '@nestjs/core';
|
import { NestFactory } from '@nestjs/core';
|
||||||
|
import { DataSource } from 'typeorm';
|
||||||
import { AppModule } from '../app.module';
|
import { AppModule } from '../app.module';
|
||||||
import { Batch14TestDataSeeder } from '../seed/batch1-4-test-data.seeder';
|
import { Batch14TestDataSeeder } from '../seed/batch1-4-test-data.seeder';
|
||||||
import { Batch5TestDataSeeder } from '../seed/batch5-test-data.seeder';
|
import { Batch5TestDataSeeder } from '../seed/batch5-test-data.seeder';
|
||||||
@@ -14,19 +15,33 @@ import { IndodeFacilitySeeder } from '../seed/indode-facility.seeder';
|
|||||||
import { PricingDataSeeder } from '../seed/pricing-data.seeder';
|
import { PricingDataSeeder } from '../seed/pricing-data.seeder';
|
||||||
import { WarehouseDemoSeeder } from '../seed/warehouse-demo.seeder';
|
import { WarehouseDemoSeeder } from '../seed/warehouse-demo.seeder';
|
||||||
|
|
||||||
|
/** Demo data only — refuse to run against anything but a local dev database. */
|
||||||
|
function assertLocalhost() {
|
||||||
|
const host = process.env.DB_HOST ?? 'localhost';
|
||||||
|
if (host !== 'localhost' && host !== '127.0.0.1') {
|
||||||
|
console.error(`Refusing to seed demo data: DB_HOST is "${host}", not localhost.`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
|
assertLocalhost();
|
||||||
|
|
||||||
const app = await NestFactory.createApplicationContext(AppModule, {
|
const app = await NestFactory.createApplicationContext(AppModule, {
|
||||||
logger: ['error', 'warn', 'log'],
|
logger: ['error', 'warn', 'log'],
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await app.get(PricingDataSeeder).run();
|
// Demo seeders are intentionally not AppModule providers (they'd run on every
|
||||||
await app.get(IndodeFacilitySeeder).run();
|
// boot), so construct them against the app's DataSource instead of via DI.
|
||||||
await app.get(Batch14TestDataSeeder).run();
|
const dataSource = app.get(DataSource);
|
||||||
await app.get(Batch5TestDataSeeder).run();
|
await new PricingDataSeeder(dataSource).run();
|
||||||
await app.get(Batch7TestDataSeeder).run();
|
await new IndodeFacilitySeeder(dataSource).run();
|
||||||
await app.get(Batch8TestDataSeeder).run();
|
await new Batch14TestDataSeeder(dataSource).run();
|
||||||
await app.get(WarehouseDemoSeeder).run();
|
await new Batch5TestDataSeeder(dataSource).run();
|
||||||
|
await new Batch7TestDataSeeder(dataSource).run();
|
||||||
|
await new Batch8TestDataSeeder(dataSource).run();
|
||||||
|
await new WarehouseDemoSeeder(dataSource).run();
|
||||||
|
|
||||||
console.log('Warehouse demo data seeded.');
|
console.log('Warehouse demo data seeded.');
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -235,6 +235,7 @@ export const FLEET_RAIL_PERMISSIONS: FreightPermissionSeed[] = [
|
|||||||
perm('e1a00001-0001-4000-8000-000000000002', 'edr_freight_app:locomotives:create', 'Create locomotive'),
|
perm('e1a00001-0001-4000-8000-000000000002', 'edr_freight_app:locomotives:create', 'Create locomotive'),
|
||||||
perm('e1a00001-0001-4000-8000-000000000003', 'edr_freight_app:locomotives:update', 'Update locomotive'),
|
perm('e1a00001-0001-4000-8000-000000000003', 'edr_freight_app:locomotives:update', 'Update locomotive'),
|
||||||
perm('e1a00001-0001-4000-8000-000000000004', 'edr_freight_app:locomotives:delete', 'Delete locomotive'),
|
perm('e1a00001-0001-4000-8000-000000000004', 'edr_freight_app:locomotives:delete', 'Delete locomotive'),
|
||||||
|
perm('e1a00001-0001-4000-8000-000000000005', 'edr_freight_app:locomotives:hard_delete', 'Permanently delete locomotive'),
|
||||||
perm('e1b00001-0001-4000-8000-000000000001', 'edr_freight_app:wagons:view', 'View wagons'),
|
perm('e1b00001-0001-4000-8000-000000000001', 'edr_freight_app:wagons:view', 'View wagons'),
|
||||||
perm('e1b00001-0001-4000-8000-000000000002', 'edr_freight_app:wagons:create', 'Create wagon'),
|
perm('e1b00001-0001-4000-8000-000000000002', 'edr_freight_app:wagons:create', 'Create wagon'),
|
||||||
perm('e1b00001-0001-4000-8000-000000000003', 'edr_freight_app:wagons:update', 'Update wagon'),
|
perm('e1b00001-0001-4000-8000-000000000003', 'edr_freight_app:wagons:update', 'Update wagon'),
|
||||||
@@ -248,6 +249,7 @@ export const FLEET_RAIL_PERMISSIONS: FreightPermissionSeed[] = [
|
|||||||
perm('e1b00001-0001-4000-8000-000000000008', 'edr_freight_app:wagons:transfer_view', 'View wagon transfer requests'),
|
perm('e1b00001-0001-4000-8000-000000000008', 'edr_freight_app:wagons:transfer_view', 'View wagon transfer requests'),
|
||||||
perm('e1b00001-0001-4000-8000-000000000009', 'edr_freight_app:wagons:transfer_cancel', 'Withdraw a wagon transfer request'),
|
perm('e1b00001-0001-4000-8000-000000000009', 'edr_freight_app:wagons:transfer_cancel', 'Withdraw a wagon transfer request'),
|
||||||
perm('e1b00001-0001-4000-8000-00000000000a', 'edr_freight_app:wagons:transfer_close_short', 'Close a transfer request short of the requested count'),
|
perm('e1b00001-0001-4000-8000-00000000000a', 'edr_freight_app:wagons:transfer_close_short', 'Close a transfer request short of the requested count'),
|
||||||
|
perm('e1b00001-0001-4000-8000-00000000000b', 'edr_freight_app:wagons:hard_delete', 'Permanently delete wagon'),
|
||||||
perm('e1c00001-0001-4000-8000-000000000001', 'edr_freight_app:trains:view', 'View trains'),
|
perm('e1c00001-0001-4000-8000-000000000001', 'edr_freight_app:trains:view', 'View trains'),
|
||||||
perm('e1c00001-0001-4000-8000-000000000002', 'edr_freight_app:trains:create', 'Create train'),
|
perm('e1c00001-0001-4000-8000-000000000002', 'edr_freight_app:trains:create', 'Create train'),
|
||||||
perm('e1c00001-0001-4000-8000-000000000003', 'edr_freight_app:trains:update', 'Update train'),
|
perm('e1c00001-0001-4000-8000-000000000003', 'edr_freight_app:trains:update', 'Update train'),
|
||||||
@@ -257,6 +259,7 @@ export const FLEET_RAIL_PERMISSIONS: FreightPermissionSeed[] = [
|
|||||||
perm('e1d00001-0001-4000-8000-000000000002', 'edr_freight_app:routes:create', 'Create route'),
|
perm('e1d00001-0001-4000-8000-000000000002', 'edr_freight_app:routes:create', 'Create route'),
|
||||||
perm('e1d00001-0001-4000-8000-000000000003', 'edr_freight_app:routes:update', 'Update route'),
|
perm('e1d00001-0001-4000-8000-000000000003', 'edr_freight_app:routes:update', 'Update route'),
|
||||||
perm('e1d00001-0001-4000-8000-000000000004', 'edr_freight_app:routes:delete', 'Delete route'),
|
perm('e1d00001-0001-4000-8000-000000000004', 'edr_freight_app:routes:delete', 'Delete route'),
|
||||||
|
perm('e1d00001-0001-4000-8000-000000000005', 'edr_freight_app:routes:hard_delete', 'Permanently delete route'),
|
||||||
perm('e1e00001-0001-4000-8000-000000000001', 'edr_freight_app:containers:view', 'View containers'),
|
perm('e1e00001-0001-4000-8000-000000000001', 'edr_freight_app:containers:view', 'View containers'),
|
||||||
perm('e1e00001-0001-4000-8000-000000000002', 'edr_freight_app:containers:create', 'Create container'),
|
perm('e1e00001-0001-4000-8000-000000000002', 'edr_freight_app:containers:create', 'Create container'),
|
||||||
perm('e1e00001-0001-4000-8000-000000000003', 'edr_freight_app:containers:update', 'Update container'),
|
perm('e1e00001-0001-4000-8000-000000000003', 'edr_freight_app:containers:update', 'Update container'),
|
||||||
@@ -530,12 +533,19 @@ export const FREIGHT_PERMS = {
|
|||||||
create: 'edr_freight_app:locomotives:create',
|
create: 'edr_freight_app:locomotives:create',
|
||||||
update: 'edr_freight_app:locomotives:update',
|
update: 'edr_freight_app:locomotives:update',
|
||||||
delete: 'edr_freight_app:locomotives:delete',
|
delete: 'edr_freight_app:locomotives:delete',
|
||||||
|
/**
|
||||||
|
* Permanently purge the row — irreversible, and separate from `delete`
|
||||||
|
* (which only decommissions) so it can be granted to far fewer people.
|
||||||
|
*/
|
||||||
|
hardDelete: 'edr_freight_app:locomotives:hard_delete',
|
||||||
},
|
},
|
||||||
wagons: {
|
wagons: {
|
||||||
view: 'edr_freight_app:wagons:view',
|
view: 'edr_freight_app:wagons:view',
|
||||||
create: 'edr_freight_app:wagons:create',
|
create: 'edr_freight_app:wagons:create',
|
||||||
update: 'edr_freight_app:wagons:update',
|
update: 'edr_freight_app:wagons:update',
|
||||||
delete: 'edr_freight_app:wagons:delete',
|
delete: 'edr_freight_app:wagons:delete',
|
||||||
|
/** Permanently purge the row — irreversible; see locomotives.hardDelete. */
|
||||||
|
hardDelete: 'edr_freight_app:wagons:hard_delete',
|
||||||
// Requester creates a transfer request; OCC fulfils it (picks the wagons and
|
// Requester creates a transfer request; OCC fulfils it (picks the wagons and
|
||||||
// executes the move). Distinct keys so OCC can hold fulfil without request.
|
// executes the move). Distinct keys so OCC can hold fulfil without request.
|
||||||
transferRequest: 'edr_freight_app:wagons:transfer_request',
|
transferRequest: 'edr_freight_app:wagons:transfer_request',
|
||||||
@@ -562,6 +572,8 @@ export const FREIGHT_PERMS = {
|
|||||||
create: 'edr_freight_app:routes:create',
|
create: 'edr_freight_app:routes:create',
|
||||||
update: 'edr_freight_app:routes:update',
|
update: 'edr_freight_app:routes:update',
|
||||||
delete: 'edr_freight_app:routes:delete',
|
delete: 'edr_freight_app:routes:delete',
|
||||||
|
/** Permanently purge the row — irreversible; see locomotives.hardDelete. */
|
||||||
|
hardDelete: 'edr_freight_app:routes:hard_delete',
|
||||||
},
|
},
|
||||||
containers: {
|
containers: {
|
||||||
view: 'edr_freight_app:containers:view',
|
view: 'edr_freight_app:containers:view',
|
||||||
|
|||||||
@@ -2,8 +2,10 @@ import { Injectable, Logger } from '@nestjs/common';
|
|||||||
import { DataSource } from 'typeorm';
|
import { DataSource } from 'typeorm';
|
||||||
|
|
||||||
import { Booking } from '../modules/bookings/entities/booking.entity';
|
import { Booking } from '../modules/bookings/entities/booking.entity';
|
||||||
|
import { CustomerTruckAssignment } from '../modules/bookings/entities/customer-truck-assignment.entity';
|
||||||
import { CargoType } from '../modules/rule-engine/entities/cargo-type.entity';
|
import { CargoType } from '../modules/rule-engine/entities/cargo-type.entity';
|
||||||
import { CompanyProfile } from '../modules/companies/entities/company-profile.entity';
|
import { CompanyProfile } from '../modules/companies/entities/company-profile.entity';
|
||||||
|
import { EmptyContainerReturn } from '../modules/import-operations/entities/empty-container-return.entity';
|
||||||
import { Locomotive } from '../modules/locomotives/entities/locomotive.entity';
|
import { Locomotive } from '../modules/locomotives/entities/locomotive.entity';
|
||||||
import { ServiceType } from '../modules/rule-engine/entities/service-type.entity';
|
import { ServiceType } from '../modules/rule-engine/entities/service-type.entity';
|
||||||
import { Yard } from '../modules/rule-engine/entities/yard.entity';
|
import { Yard } from '../modules/rule-engine/entities/yard.entity';
|
||||||
@@ -23,6 +25,8 @@ import { WarehouseZone } from '../modules/warehouses/entities/warehouse-zone.ent
|
|||||||
* Import → Arrive Queue : an ARRIVED import train with IN_TRANSIT bookings (no inventory)
|
* Import → Arrive Queue : an ARRIVED import train with IN_TRANSIT bookings (no inventory)
|
||||||
* Import → Unloaded Queue : UNLOADED import inventory
|
* Import → Unloaded Queue : UNLOADED import inventory
|
||||||
* Import → Dispatch Queue : READY_FOR_PICKUP import inventory (PASSED)
|
* Import → Dispatch Queue : READY_FOR_PICKUP import inventory (PASSED)
|
||||||
|
* Import → Import Trucks : a customer self-haul truck assigned to an unloaded booking
|
||||||
|
* Import → Container Returns : empty container returns at two different statuses
|
||||||
*
|
*
|
||||||
* Idempotent: guarded on a sentinel booking reference. Uses dedicated WH-DEMO-* references so it
|
* Idempotent: guarded on a sentinel booking reference. Uses dedicated WH-DEMO-* references so it
|
||||||
* never collides with other seeders. To repopulate after items are walked through their lifecycle,
|
* never collides with other seeders. To repopulate after items are walked through their lifecycle,
|
||||||
@@ -166,16 +170,19 @@ export class WarehouseDemoSeeder {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 4) Import Unloaded Queue — UNLOADED import inventory (not inspected, not stored).
|
// 4) Import Unloaded Queue — UNLOADED import inventory (not inspected, not stored).
|
||||||
|
let firstUnloadedBooking: Booking | null = null;
|
||||||
for (let i = 1; i <= 3; i++) {
|
for (let i = 1; i <= 3; i++) {
|
||||||
const b = await makeBooking(`WH-DEMO-UNL-${i}`, 'IMPORT', 'IN_TRANSIT', 5000 + i * 500, i);
|
const b = await makeBooking(`WH-DEMO-UNL-${i}`, 'IMPORT', 'IN_TRANSIT', 5000 + i * 500, i);
|
||||||
await makeInventory(b, 'UNLOADED', 5000 + i * 500, {
|
await makeInventory(b, 'UNLOADED', 5000 + i * 500, {
|
||||||
arrivedAt: ago(90),
|
arrivedAt: ago(90),
|
||||||
unloadedAt: ago(45),
|
unloadedAt: ago(45),
|
||||||
});
|
});
|
||||||
|
firstUnloadedBooking ??= b;
|
||||||
created++;
|
created++;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5) Import Dispatch Queue — READY_FOR_PICKUP import inventory (inspection PASSED).
|
// 5) Import Dispatch Queue — READY_FOR_PICKUP import inventory (inspection PASSED).
|
||||||
|
let firstPickupBooking: Booking | null = null;
|
||||||
for (let i = 1; i <= 3; i++) {
|
for (let i = 1; i <= 3; i++) {
|
||||||
const b = await makeBooking(`WH-DEMO-PKR-${i}`, 'IMPORT', 'IN_TRANSIT', 5500 + i * 500, i);
|
const b = await makeBooking(`WH-DEMO-PKR-${i}`, 'IMPORT', 'IN_TRANSIT', 5500 + i * 500, i);
|
||||||
await makeInventory(b, 'READY_FOR_PICKUP', 5500 + i * 500, {
|
await makeInventory(b, 'READY_FOR_PICKUP', 5500 + i * 500, {
|
||||||
@@ -185,6 +192,50 @@ export class WarehouseDemoSeeder {
|
|||||||
inspectedAt: ago(120),
|
inspectedAt: ago(120),
|
||||||
readyForPickupAt: ago(60),
|
readyForPickupAt: ago(60),
|
||||||
});
|
});
|
||||||
|
firstPickupBooking ??= b;
|
||||||
|
created++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6) Import Trucks / booking Trucks tab — a customer self-haul truck on the unloaded booking.
|
||||||
|
if (firstUnloadedBooking) {
|
||||||
|
await this.dataSource.getRepository(CustomerTruckAssignment).save(
|
||||||
|
this.dataSource.getRepository(CustomerTruckAssignment).create({
|
||||||
|
bookingId: firstUnloadedBooking.id,
|
||||||
|
plateNumber: 'WH-DEMO-3210',
|
||||||
|
driverName: 'Demo Driver',
|
||||||
|
truckType: 'FLATBED',
|
||||||
|
assignedAt: ago(80),
|
||||||
|
arrivedAt: ago(50),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
created++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7) Container Returns — two empty returns at different stages of the return workflow.
|
||||||
|
if (firstPickupBooking) {
|
||||||
|
const returnRepo = this.dataSource.getRepository(EmptyContainerReturn);
|
||||||
|
await returnRepo.save(
|
||||||
|
returnRepo.create({
|
||||||
|
containerNumber: 'WHDU1234561',
|
||||||
|
bookingId: firstPickupBooking.id,
|
||||||
|
returnDate: ago(20),
|
||||||
|
facility: 'Indode',
|
||||||
|
status: 'RETURNED',
|
||||||
|
returnedBy: 'CUSTOMER',
|
||||||
|
statusHistory: [],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await returnRepo.save(
|
||||||
|
returnRepo.create({
|
||||||
|
containerNumber: 'WHDU1234562',
|
||||||
|
bookingId: firstPickupBooking.id,
|
||||||
|
returnDate: ago(90),
|
||||||
|
facility: 'Indode',
|
||||||
|
status: 'DOCUMENTATION_CLEARED',
|
||||||
|
returnedBy: 'CUSTOMER',
|
||||||
|
statusHistory: [],
|
||||||
|
}),
|
||||||
|
);
|
||||||
created++;
|
created++;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -292,7 +292,10 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
|||||||
label: "Locomotives",
|
label: "Locomotives",
|
||||||
href: "/dashboard/locomotives",
|
href: "/dashboard/locomotives",
|
||||||
icon: <Train />,
|
icon: <Train />,
|
||||||
permission: [FREIGHT_PERMS.locomotives.view, FREIGHT_PERMS.fleet.view],
|
permission: [
|
||||||
|
FREIGHT_PERMS.locomotives.view,
|
||||||
|
FREIGHT_PERMS.fleet.view,
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Train Builder",
|
label: "Train Builder",
|
||||||
@@ -818,7 +821,7 @@ const App = () => {
|
|||||||
{UserManagementRoutes()}
|
{UserManagementRoutes()}
|
||||||
{/* <Route path="/um/*" element={<UserManagementHostPage />} /> */}
|
{/* <Route path="/um/*" element={<UserManagementHostPage />} /> */}
|
||||||
<Route path="/health" element={<HealthCheck />} />
|
<Route path="/health" element={<HealthCheck />} />
|
||||||
<Route path="/callback" element={<FaydaCallbackPage />} />
|
<Route path="/fayda/callback" element={<FaydaCallbackPage />} />
|
||||||
<Route path="/" element={<Navigate to="/dashboard/overview" replace />} />
|
<Route path="/" element={<Navigate to="/dashboard/overview" replace />} />
|
||||||
<Route
|
<Route
|
||||||
path="/dashboard"
|
path="/dashboard"
|
||||||
@@ -1085,7 +1088,10 @@ const App = () => {
|
|||||||
<Route path="intercity" element={<IntercityPage />} />
|
<Route path="intercity" element={<IntercityPage />} />
|
||||||
<Route path="trucks-on-site" element={<TrucksOnSitePage />} />
|
<Route path="trucks-on-site" element={<TrucksOnSitePage />} />
|
||||||
<Route path="import-trucks" element={<ImportTrucksPage />} />
|
<Route path="import-trucks" element={<ImportTrucksPage />} />
|
||||||
<Route path="edr-last-mile-returns" element={<EDRLastMileReturnsPage />} />
|
<Route
|
||||||
|
path="edr-last-mile-returns"
|
||||||
|
element={<EDRLastMileReturnsPage />}
|
||||||
|
/>
|
||||||
<Route path="container-returns" element={<ContainerReturnsPage />} />
|
<Route path="container-returns" element={<ContainerReturnsPage />} />
|
||||||
<Route path="loaded-inventory" element={<LoadedInventoryPage />} />
|
<Route path="loaded-inventory" element={<LoadedInventoryPage />} />
|
||||||
<Route path="dispatch-queue" element={<DispatchQueuePage />} />
|
<Route path="dispatch-queue" element={<DispatchQueuePage />} />
|
||||||
@@ -1173,7 +1179,9 @@ const App = () => {
|
|||||||
<Route
|
<Route
|
||||||
path="routes"
|
path="routes"
|
||||||
element={
|
element={
|
||||||
<RequirePermission permission={[FREIGHT_PERMS.routes.view, FREIGHT_PERMS.fleet.view]}>
|
<RequirePermission
|
||||||
|
permission={[FREIGHT_PERMS.routes.view, FREIGHT_PERMS.fleet.view]}
|
||||||
|
>
|
||||||
<RoutesPage />
|
<RoutesPage />
|
||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
}
|
}
|
||||||
@@ -1181,7 +1189,12 @@ const App = () => {
|
|||||||
<Route
|
<Route
|
||||||
path="locomotives"
|
path="locomotives"
|
||||||
element={
|
element={
|
||||||
<RequirePermission permission={[FREIGHT_PERMS.locomotives.view, FREIGHT_PERMS.fleet.view]}>
|
<RequirePermission
|
||||||
|
permission={[
|
||||||
|
FREIGHT_PERMS.locomotives.view,
|
||||||
|
FREIGHT_PERMS.fleet.view,
|
||||||
|
]}
|
||||||
|
>
|
||||||
<FleetResourcePage />
|
<FleetResourcePage />
|
||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
}
|
}
|
||||||
@@ -1189,7 +1202,9 @@ const App = () => {
|
|||||||
<Route
|
<Route
|
||||||
path="trains"
|
path="trains"
|
||||||
element={
|
element={
|
||||||
<RequirePermission permission={[FREIGHT_PERMS.trains.view, FREIGHT_PERMS.fleet.view]}>
|
<RequirePermission
|
||||||
|
permission={[FREIGHT_PERMS.trains.view, FREIGHT_PERMS.fleet.view]}
|
||||||
|
>
|
||||||
<FleetResourcePage />
|
<FleetResourcePage />
|
||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
}
|
}
|
||||||
@@ -1197,7 +1212,9 @@ const App = () => {
|
|||||||
<Route
|
<Route
|
||||||
path="trains/:id"
|
path="trains/:id"
|
||||||
element={
|
element={
|
||||||
<RequirePermission permission={[FREIGHT_PERMS.trains.view, FREIGHT_PERMS.fleet.view]}>
|
<RequirePermission
|
||||||
|
permission={[FREIGHT_PERMS.trains.view, FREIGHT_PERMS.fleet.view]}
|
||||||
|
>
|
||||||
<TrainDetailPage />
|
<TrainDetailPage />
|
||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
}
|
}
|
||||||
@@ -1205,7 +1222,9 @@ const App = () => {
|
|||||||
<Route
|
<Route
|
||||||
path="train-builder"
|
path="train-builder"
|
||||||
element={
|
element={
|
||||||
<RequirePermission permission={[FREIGHT_PERMS.trains.view, FREIGHT_PERMS.fleet.view]}>
|
<RequirePermission
|
||||||
|
permission={[FREIGHT_PERMS.trains.view, FREIGHT_PERMS.fleet.view]}
|
||||||
|
>
|
||||||
<TrainBuilderListPage />
|
<TrainBuilderListPage />
|
||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
}
|
}
|
||||||
@@ -1213,7 +1232,9 @@ const App = () => {
|
|||||||
<Route
|
<Route
|
||||||
path="train-builder/:id"
|
path="train-builder/:id"
|
||||||
element={
|
element={
|
||||||
<RequirePermission permission={[FREIGHT_PERMS.trains.view, FREIGHT_PERMS.fleet.view]}>
|
<RequirePermission
|
||||||
|
permission={[FREIGHT_PERMS.trains.view, FREIGHT_PERMS.fleet.view]}
|
||||||
|
>
|
||||||
<TrainBuilderDetailPage />
|
<TrainBuilderDetailPage />
|
||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
}
|
}
|
||||||
@@ -1221,7 +1242,9 @@ const App = () => {
|
|||||||
<Route
|
<Route
|
||||||
path="wagons"
|
path="wagons"
|
||||||
element={
|
element={
|
||||||
<RequirePermission permission={[FREIGHT_PERMS.wagons.view, FREIGHT_PERMS.fleet.view]}>
|
<RequirePermission
|
||||||
|
permission={[FREIGHT_PERMS.wagons.view, FREIGHT_PERMS.fleet.view]}
|
||||||
|
>
|
||||||
<FleetResourcePage />
|
<FleetResourcePage />
|
||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
}
|
}
|
||||||
@@ -1242,7 +1265,12 @@ const App = () => {
|
|||||||
<Route
|
<Route
|
||||||
path="containers"
|
path="containers"
|
||||||
element={
|
element={
|
||||||
<RequirePermission permission={[FREIGHT_PERMS.containers.view, FREIGHT_PERMS.fleet.view]}>
|
<RequirePermission
|
||||||
|
permission={[
|
||||||
|
FREIGHT_PERMS.containers.view,
|
||||||
|
FREIGHT_PERMS.fleet.view,
|
||||||
|
]}
|
||||||
|
>
|
||||||
<FleetResourcePage />
|
<FleetResourcePage />
|
||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
}
|
}
|
||||||
@@ -1250,7 +1278,12 @@ const App = () => {
|
|||||||
<Route
|
<Route
|
||||||
path="cargoes"
|
path="cargoes"
|
||||||
element={
|
element={
|
||||||
<RequirePermission permission={[FREIGHT_PERMS.cargoes.view, FREIGHT_PERMS.fleet.view]}>
|
<RequirePermission
|
||||||
|
permission={[
|
||||||
|
FREIGHT_PERMS.cargoes.view,
|
||||||
|
FREIGHT_PERMS.fleet.view,
|
||||||
|
]}
|
||||||
|
>
|
||||||
<FleetResourcePage />
|
<FleetResourcePage />
|
||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
}
|
}
|
||||||
@@ -1336,7 +1369,9 @@ const App = () => {
|
|||||||
<Route
|
<Route
|
||||||
path="routes"
|
path="routes"
|
||||||
element={
|
element={
|
||||||
<RequirePermission permission={[FREIGHT_PERMS.routes.view, FREIGHT_PERMS.fleet.view]}>
|
<RequirePermission
|
||||||
|
permission={[FREIGHT_PERMS.routes.view, FREIGHT_PERMS.fleet.view]}
|
||||||
|
>
|
||||||
<RoutesPage />
|
<RoutesPage />
|
||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
}
|
}
|
||||||
@@ -1424,7 +1459,12 @@ const App = () => {
|
|||||||
<Route
|
<Route
|
||||||
path="locomotives"
|
path="locomotives"
|
||||||
element={
|
element={
|
||||||
<RequirePermission permission={[FREIGHT_PERMS.locomotives.view, FREIGHT_PERMS.fleet.view]}>
|
<RequirePermission
|
||||||
|
permission={[
|
||||||
|
FREIGHT_PERMS.locomotives.view,
|
||||||
|
FREIGHT_PERMS.fleet.view,
|
||||||
|
]}
|
||||||
|
>
|
||||||
<FleetResourcePage />
|
<FleetResourcePage />
|
||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
}
|
}
|
||||||
@@ -1432,7 +1472,9 @@ const App = () => {
|
|||||||
<Route
|
<Route
|
||||||
path="trains"
|
path="trains"
|
||||||
element={
|
element={
|
||||||
<RequirePermission permission={[FREIGHT_PERMS.trains.view, FREIGHT_PERMS.fleet.view]}>
|
<RequirePermission
|
||||||
|
permission={[FREIGHT_PERMS.trains.view, FREIGHT_PERMS.fleet.view]}
|
||||||
|
>
|
||||||
<FleetResourcePage />
|
<FleetResourcePage />
|
||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
}
|
}
|
||||||
@@ -1440,7 +1482,9 @@ const App = () => {
|
|||||||
<Route
|
<Route
|
||||||
path="trains/:id"
|
path="trains/:id"
|
||||||
element={
|
element={
|
||||||
<RequirePermission permission={[FREIGHT_PERMS.trains.view, FREIGHT_PERMS.fleet.view]}>
|
<RequirePermission
|
||||||
|
permission={[FREIGHT_PERMS.trains.view, FREIGHT_PERMS.fleet.view]}
|
||||||
|
>
|
||||||
<TrainDetailPage />
|
<TrainDetailPage />
|
||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
}
|
}
|
||||||
@@ -1448,7 +1492,9 @@ const App = () => {
|
|||||||
<Route
|
<Route
|
||||||
path="train-builder"
|
path="train-builder"
|
||||||
element={
|
element={
|
||||||
<RequirePermission permission={[FREIGHT_PERMS.trains.view, FREIGHT_PERMS.fleet.view]}>
|
<RequirePermission
|
||||||
|
permission={[FREIGHT_PERMS.trains.view, FREIGHT_PERMS.fleet.view]}
|
||||||
|
>
|
||||||
<TrainBuilderListPage />
|
<TrainBuilderListPage />
|
||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
}
|
}
|
||||||
@@ -1456,7 +1502,9 @@ const App = () => {
|
|||||||
<Route
|
<Route
|
||||||
path="train-builder/:id"
|
path="train-builder/:id"
|
||||||
element={
|
element={
|
||||||
<RequirePermission permission={[FREIGHT_PERMS.trains.view, FREIGHT_PERMS.fleet.view]}>
|
<RequirePermission
|
||||||
|
permission={[FREIGHT_PERMS.trains.view, FREIGHT_PERMS.fleet.view]}
|
||||||
|
>
|
||||||
<TrainBuilderDetailPage />
|
<TrainBuilderDetailPage />
|
||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
}
|
}
|
||||||
@@ -1464,7 +1512,9 @@ const App = () => {
|
|||||||
<Route
|
<Route
|
||||||
path="wagons"
|
path="wagons"
|
||||||
element={
|
element={
|
||||||
<RequirePermission permission={[FREIGHT_PERMS.wagons.view, FREIGHT_PERMS.fleet.view]}>
|
<RequirePermission
|
||||||
|
permission={[FREIGHT_PERMS.wagons.view, FREIGHT_PERMS.fleet.view]}
|
||||||
|
>
|
||||||
<FleetResourcePage />
|
<FleetResourcePage />
|
||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
}
|
}
|
||||||
@@ -1485,7 +1535,12 @@ const App = () => {
|
|||||||
<Route
|
<Route
|
||||||
path="containers"
|
path="containers"
|
||||||
element={
|
element={
|
||||||
<RequirePermission permission={[FREIGHT_PERMS.containers.view, FREIGHT_PERMS.fleet.view]}>
|
<RequirePermission
|
||||||
|
permission={[
|
||||||
|
FREIGHT_PERMS.containers.view,
|
||||||
|
FREIGHT_PERMS.fleet.view,
|
||||||
|
]}
|
||||||
|
>
|
||||||
<FleetResourcePage />
|
<FleetResourcePage />
|
||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
}
|
}
|
||||||
@@ -1493,7 +1548,12 @@ const App = () => {
|
|||||||
<Route
|
<Route
|
||||||
path="cargoes"
|
path="cargoes"
|
||||||
element={
|
element={
|
||||||
<RequirePermission permission={[FREIGHT_PERMS.cargoes.view, FREIGHT_PERMS.fleet.view]}>
|
<RequirePermission
|
||||||
|
permission={[
|
||||||
|
FREIGHT_PERMS.cargoes.view,
|
||||||
|
FREIGHT_PERMS.fleet.view,
|
||||||
|
]}
|
||||||
|
>
|
||||||
<FleetResourcePage />
|
<FleetResourcePage />
|
||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ export function isComplaintAuthContext(pathname = ""): boolean {
|
|||||||
pathname.startsWith("/complaints") ||
|
pathname.startsWith("/complaints") ||
|
||||||
pathname === "/complaint-form" ||
|
pathname === "/complaint-form" ||
|
||||||
pathname === "/follow-complaint" ||
|
pathname === "/follow-complaint" ||
|
||||||
pathname === "/callback"
|
pathname === "/fayda/callback"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ import {
|
|||||||
Loader,
|
Loader,
|
||||||
Modal,
|
Modal,
|
||||||
Paper,
|
Paper,
|
||||||
SegmentedControl,
|
|
||||||
Select,
|
Select,
|
||||||
Stack,
|
Stack,
|
||||||
Switch,
|
Switch,
|
||||||
@@ -49,7 +48,11 @@ import {
|
|||||||
X,
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import type { Freight } from "@edr/types";
|
import type { Freight } from "@edr/types";
|
||||||
import { ExportTrainPicker, OperationDatePicker } from "@edr/ui-common";
|
import {
|
||||||
|
CurrencySelector,
|
||||||
|
ExportTrainPicker,
|
||||||
|
OperationDatePicker,
|
||||||
|
} from "@edr/ui-common";
|
||||||
|
|
||||||
import { api } from "@/services/api";
|
import { api } from "@/services/api";
|
||||||
import { PageContainer } from "@/components/page";
|
import { PageContainer } from "@/components/page";
|
||||||
@@ -1713,47 +1716,42 @@ export default function GlCreateBookingForm() {
|
|||||||
title="Schedule"
|
title="Schedule"
|
||||||
description="Pick the binding shipment day. Only days with an open train that has enough matching wagons for the cargo can be selected."
|
description="Pick the binding shipment day. Only days with an open train that has enough matching wagons for the cargo can be selected."
|
||||||
/>
|
/>
|
||||||
{cargoQuery === null ? (
|
<Box>
|
||||||
<Alert
|
<StepLabel>Shipment day *</StepLabel>
|
||||||
color="yellow"
|
<Box mt={10} w="100%">
|
||||||
variant="light"
|
{/* Calendar stays visible before cargo is entered — all days
|
||||||
radius="md"
|
disabled with a hint, since availability depends on cargo. */}
|
||||||
icon={<AlertCircle size={16} />}
|
<OperationDatePicker
|
||||||
>
|
fullWidth
|
||||||
Enter your cargo details first — available shipment days depend
|
availableDays={cargoQuery === null ? [] : (availableDays ?? [])}
|
||||||
on the wagons your cargo needs.
|
isLoading={cargoQuery !== null && daysLoading}
|
||||||
</Alert>
|
emptyMessage={
|
||||||
) : (
|
cargoQuery === null
|
||||||
<Box>
|
? "Enter the cargo details first — available shipment days depend on the wagons the cargo needs."
|
||||||
<StepLabel>Shipment day *</StepLabel>
|
: undefined
|
||||||
<Box mt={10} w="100%">
|
}
|
||||||
<OperationDatePicker
|
value={scheduledDate}
|
||||||
fullWidth
|
onChange={(d) => {
|
||||||
availableDays={availableDays ?? []}
|
setScheduledDate(d);
|
||||||
isLoading={daysLoading}
|
// A new day invalidates the old train pick.
|
||||||
value={scheduledDate}
|
setTrainScheduleId("");
|
||||||
onChange={(d) => {
|
}}
|
||||||
setScheduledDate(d);
|
/>
|
||||||
// A new day invalidates the old train pick.
|
|
||||||
setTrainScheduleId("");
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
{showErrors && dateError && (
|
|
||||||
<Text fz="xs" c="red" mt={6}>
|
|
||||||
{dateError}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
{isExportPick && scheduledDate ? (
|
|
||||||
<ExportTrainPicker
|
|
||||||
options={exportTrainsQuery.data ?? []}
|
|
||||||
loading={exportTrainsQuery.isLoading}
|
|
||||||
value={trainScheduleId}
|
|
||||||
onChange={setTrainScheduleId}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
{showErrors && dateError && (
|
||||||
|
<Text fz="xs" c="red" mt={6}>
|
||||||
|
{dateError}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{isExportPick && scheduledDate ? (
|
||||||
|
<ExportTrainPicker
|
||||||
|
options={exportTrainsQuery.data ?? []}
|
||||||
|
loading={exportTrainsQuery.isLoading}
|
||||||
|
value={trainScheduleId}
|
||||||
|
onChange={setTrainScheduleId}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</Box>
|
||||||
</StepCard>
|
</StepCard>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -1769,16 +1767,10 @@ export default function GlCreateBookingForm() {
|
|||||||
? "Requested by the customer on their shipment request."
|
? "Requested by the customer on their shipment request."
|
||||||
: "The contract is quoted in USD — pick the currency this shipment is invoiced in."}
|
: "The contract is quoted in USD — pick the currency this shipment is invoiced in."}
|
||||||
</Text>
|
</Text>
|
||||||
<SegmentedControl
|
<CurrencySelector
|
||||||
value={isIntercity ? "ETB" : paymentCurrency}
|
value={isIntercity ? "ETB" : paymentCurrency}
|
||||||
onChange={(v) => setPaymentCurrency(v as "USD" | "ETB")}
|
onChange={setPaymentCurrency}
|
||||||
disabled={isIntercity}
|
disabled={isIntercity}
|
||||||
data={[
|
|
||||||
{ label: "USD", value: "USD" },
|
|
||||||
{ label: "ETB", value: "ETB" },
|
|
||||||
]}
|
|
||||||
color="edr-green"
|
|
||||||
radius={10}
|
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ let listener: Listener | null = null;
|
|||||||
/** Current-page path patterns where the global modal must stay silent. */
|
/** Current-page path patterns where the global modal must stay silent. */
|
||||||
const EXCLUDED_PATH_PATTERNS = [
|
const EXCLUDED_PATH_PATTERNS = [
|
||||||
/^\/auth/,
|
/^\/auth/,
|
||||||
/^\/callback/,
|
/^\/fayda/,
|
||||||
/warehouse/i,
|
/warehouse/i,
|
||||||
/first-mile/i,
|
/first-mile/i,
|
||||||
/last-mile/i,
|
/last-mile/i,
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ export interface FleetCardGridProps {
|
|||||||
/** Omit to hide the action (caller lacks the update/delete permission). */
|
/** Omit to hide the action (caller lacks the update/delete permission). */
|
||||||
onEdit?: (record: FleetRecord) => void;
|
onEdit?: (record: FleetRecord) => void;
|
||||||
onRemove?: (record: FleetRecord) => void;
|
onRemove?: (record: FleetRecord) => void;
|
||||||
|
/** Irreversible purge — omitted unless the caller holds the hard-delete grant. */
|
||||||
|
onPurge?: (record: FleetRecord) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const FleetCardGrid = ({
|
const FleetCardGrid = ({
|
||||||
@@ -35,6 +37,7 @@ const FleetCardGrid = ({
|
|||||||
onPaginationChange,
|
onPaginationChange,
|
||||||
onEdit,
|
onEdit,
|
||||||
onRemove,
|
onRemove,
|
||||||
|
onPurge,
|
||||||
}: FleetCardGridProps) => {
|
}: FleetCardGridProps) => {
|
||||||
const presentation = resolveFleetCardPresentation(config);
|
const presentation = resolveFleetCardPresentation(config);
|
||||||
|
|
||||||
@@ -183,6 +186,7 @@ const FleetCardGrid = ({
|
|||||||
layout="compact"
|
layout="compact"
|
||||||
onEdit={onEdit}
|
onEdit={onEdit}
|
||||||
onRemove={onRemove}
|
onRemove={onRemove}
|
||||||
|
onPurge={onPurge}
|
||||||
/>
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -148,7 +148,7 @@ const FleetFormDialog = ({
|
|||||||
});
|
});
|
||||||
}, [open, fields]);
|
}, [open, fields]);
|
||||||
|
|
||||||
// Receive the ?code&state relayed by the /callback popup, exchange it for
|
// Receive the ?code&state relayed by the /fayda/callback popup, exchange it for
|
||||||
// the verified identity, and prefill the matching form fields.
|
// the verified identity, and prefill the matching form fields.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open || !verifyWithFayda) return;
|
if (!open || !verifyWithFayda) return;
|
||||||
|
|||||||
@@ -1,4 +1,12 @@
|
|||||||
import { Edit2, Trash2, Eye, Users, MoreVertical, History } from "lucide-react";
|
import {
|
||||||
|
Edit2,
|
||||||
|
Trash2,
|
||||||
|
Eye,
|
||||||
|
Users,
|
||||||
|
MoreVertical,
|
||||||
|
History,
|
||||||
|
ShieldAlert,
|
||||||
|
} from "lucide-react";
|
||||||
import { ActionIcon, Menu, MenuItem, Tooltip } from "@mantine/core";
|
import { ActionIcon, Menu, MenuItem, Tooltip } from "@mantine/core";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
@@ -11,6 +19,8 @@ export interface FleetRecordActionsProps {
|
|||||||
/** Omit to hide the action (caller lacks the update/delete permission). */
|
/** Omit to hide the action (caller lacks the update/delete permission). */
|
||||||
onEdit?: (record: FleetRecord) => void;
|
onEdit?: (record: FleetRecord) => void;
|
||||||
onRemove?: (record: FleetRecord) => void;
|
onRemove?: (record: FleetRecord) => void;
|
||||||
|
/** Irreversible purge — omitted unless the caller holds the hard-delete grant. */
|
||||||
|
onPurge?: (record: FleetRecord) => void;
|
||||||
onAssignDriver?: (record: FleetRecord) => void;
|
onAssignDriver?: (record: FleetRecord) => void;
|
||||||
onHistory?: (record: FleetRecord) => void;
|
onHistory?: (record: FleetRecord) => void;
|
||||||
onViewDetail?: (record: FleetRecord) => void;
|
onViewDetail?: (record: FleetRecord) => void;
|
||||||
@@ -22,6 +32,7 @@ const FleetRecordActions = ({
|
|||||||
config,
|
config,
|
||||||
onEdit,
|
onEdit,
|
||||||
onRemove,
|
onRemove,
|
||||||
|
onPurge,
|
||||||
onAssignDriver,
|
onAssignDriver,
|
||||||
onHistory,
|
onHistory,
|
||||||
onViewDetail,
|
onViewDetail,
|
||||||
@@ -46,6 +57,7 @@ const FleetRecordActions = ({
|
|||||||
if (
|
if (
|
||||||
!onEdit &&
|
!onEdit &&
|
||||||
!onRemove &&
|
!onRemove &&
|
||||||
|
!onPurge &&
|
||||||
!showDetail &&
|
!showDetail &&
|
||||||
!showViewDetail &&
|
!showViewDetail &&
|
||||||
!showHistory &&
|
!showHistory &&
|
||||||
@@ -170,6 +182,15 @@ const FleetRecordActions = ({
|
|||||||
{removeLabel}
|
{removeLabel}
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
) : null}
|
) : null}
|
||||||
|
{onPurge ? (
|
||||||
|
<MenuItem
|
||||||
|
color="red"
|
||||||
|
onClick={() => onPurge(record)}
|
||||||
|
leftSection={<ShieldAlert size={14} strokeWidth={2} />}
|
||||||
|
>
|
||||||
|
Delete permanently
|
||||||
|
</MenuItem>
|
||||||
|
) : null}
|
||||||
</Menu.Dropdown>
|
</Menu.Dropdown>
|
||||||
</Menu>
|
</Menu>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -153,11 +153,13 @@ const RuleEngineFormDialog = ({
|
|||||||
buildInitialValues(fields, initialRecord),
|
buildInitialValues(fields, initialRecord),
|
||||||
);
|
);
|
||||||
const [position, setPosition] = useState(RULE_ENGINE_POSITION_END);
|
const [position, setPosition] = useState(RULE_ENGINE_POSITION_END);
|
||||||
|
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open) {
|
if (open) {
|
||||||
setValues(buildInitialValues(fields, initialRecord));
|
setValues(buildInitialValues(fields, initialRecord));
|
||||||
setPosition(RULE_ENGINE_POSITION_END);
|
setPosition(RULE_ENGINE_POSITION_END);
|
||||||
|
setFieldErrors({});
|
||||||
}
|
}
|
||||||
}, [open, fields, initialRecord]);
|
}, [open, fields, initialRecord]);
|
||||||
|
|
||||||
@@ -185,6 +187,9 @@ const RuleEngineFormDialog = ({
|
|||||||
const formRows = useMemo(() => buildFormRows(visibleFields), [visibleFields]);
|
const formRows = useMemo(() => buildFormRows(visibleFields), [visibleFields]);
|
||||||
|
|
||||||
const setField = (name: string, value: unknown) => {
|
const setField = (name: string, value: unknown) => {
|
||||||
|
setFieldErrors((current) =>
|
||||||
|
current[name] ? { ...current, [name]: "" } : current,
|
||||||
|
);
|
||||||
setValues((current) => {
|
setValues((current) => {
|
||||||
const next = { ...current, [name]: value };
|
const next = { ...current, [name]: value };
|
||||||
// Changing what a rate applies to (or its surcharge trigger) can invalidate
|
// Changing what a rate applies to (or its surcharge trigger) can invalidate
|
||||||
@@ -223,6 +228,10 @@ const RuleEngineFormDialog = ({
|
|||||||
const handleSubmit = (event: React.FormEvent) => {
|
const handleSubmit = (event: React.FormEvent) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const payload: Record<string, unknown> = {};
|
const payload: Record<string, unknown> = {};
|
||||||
|
// Required selects that are empty block the submit and mark themselves,
|
||||||
|
// rather than posting an incomplete payload for the API to reject.
|
||||||
|
setFieldErrors({});
|
||||||
|
let blocked = false;
|
||||||
|
|
||||||
for (const field of visibleFields) {
|
for (const field of visibleFields) {
|
||||||
// Derived fields always submit their computed value — never stale state.
|
// Derived fields always submit their computed value — never stale state.
|
||||||
@@ -241,7 +250,16 @@ const RuleEngineFormDialog = ({
|
|||||||
field.type === "select" &&
|
field.type === "select" &&
|
||||||
(raw === "" || raw === RULE_ENGINE_SELECT_NONE)
|
(raw === "" || raw === RULE_ENGINE_SELECT_NONE)
|
||||||
) {
|
) {
|
||||||
|
// A required select left empty must not silently submit nothing — the
|
||||||
|
// API rejects the payload with a message that reads as if the admin
|
||||||
|
// skipped a field they never saw cleared (e.g. yards reset by a trade
|
||||||
|
// direction change). Surface it on the field instead.
|
||||||
if (!field.required) continue;
|
if (!field.required) continue;
|
||||||
|
setFieldErrors((current) => ({
|
||||||
|
...current,
|
||||||
|
[field.name]: `${field.label} is required.`,
|
||||||
|
}));
|
||||||
|
blocked = true;
|
||||||
} else if (raw === "" || raw === undefined) {
|
} else if (raw === "" || raw === undefined) {
|
||||||
if (!field.required) continue;
|
if (!field.required) continue;
|
||||||
payload[field.name] = raw;
|
payload[field.name] = raw;
|
||||||
@@ -254,6 +272,8 @@ const RuleEngineFormDialog = ({
|
|||||||
payload.code = String(payload.code).toUpperCase();
|
payload.code = String(payload.code).toUpperCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (blocked) return;
|
||||||
|
|
||||||
if (!initialRecord && positionOptions && position !== RULE_ENGINE_POSITION_END) {
|
if (!initialRecord && positionOptions && position !== RULE_ENGINE_POSITION_END) {
|
||||||
payload.insertAfterId = position;
|
payload.insertAfterId = position;
|
||||||
}
|
}
|
||||||
@@ -340,10 +360,10 @@ const RuleEngineFormDialog = ({
|
|||||||
value={resolveSelectValue(field, values)}
|
value={resolveSelectValue(field, values)}
|
||||||
onChange={(v) => setField(field.name, v === RULE_ENGINE_SELECT_NONE ? "" : v)}
|
onChange={(v) => setField(field.name, v === RULE_ENGINE_SELECT_NONE ? "" : v)}
|
||||||
disabled={selectOptionsLoading}
|
disabled={selectOptionsLoading}
|
||||||
// Native required blocks submit while a mandatory select is empty —
|
// Mantine's Select is not a native input, so `required` only marks it
|
||||||
// without it the form posts and the API 400s (e.g. a container
|
// visually — handleSubmit is what actually blocks an empty one.
|
||||||
// customs/lashing rate with no container type picked).
|
|
||||||
required={field.required}
|
required={field.required}
|
||||||
|
error={fieldErrors[field.name] || undefined}
|
||||||
data={options
|
data={options
|
||||||
.filter((opt) => opt.value !== "")
|
.filter((opt) => opt.value !== "")
|
||||||
.map((opt) => ({
|
.map((opt) => ({
|
||||||
|
|||||||
@@ -53,6 +53,10 @@ export const URL_CONSTANTS = {
|
|||||||
NOTIFICATIONS: "/settings/notifications",
|
NOTIFICATIONS: "/settings/notifications",
|
||||||
},
|
},
|
||||||
|
|
||||||
|
EXCHANGE_SETTINGS: {
|
||||||
|
BASE: "/exchange-settings",
|
||||||
|
},
|
||||||
|
|
||||||
DROPDOWN_SETTINGS: {
|
DROPDOWN_SETTINGS: {
|
||||||
BASE: "/dropdown-settings",
|
BASE: "/dropdown-settings",
|
||||||
BY_ID: (id: string) => `/api/dropdown-settings/${id}`,
|
BY_ID: (id: string) => `/api/dropdown-settings/${id}`,
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
import { exchangeSettingsService } from "@/services/exchangeSettings.service";
|
||||||
|
import { useErrorHandler } from "@/shared/hooks/useErrorHandler";
|
||||||
|
|
||||||
|
const QUERY_KEY = ["exchangeSettings"];
|
||||||
|
|
||||||
|
export const useExchangeSettingsQuery = () =>
|
||||||
|
useQuery({
|
||||||
|
queryKey: QUERY_KEY,
|
||||||
|
queryFn: () => exchangeSettingsService.get(),
|
||||||
|
// Feed health is only interesting while it is being looked at.
|
||||||
|
staleTime: 30_000,
|
||||||
|
refetchOnWindowFocus: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const useSetExchangeFallbackRate = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { handleError } = useErrorHandler(t);
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (rate: number) => exchangeSettingsService.setFallbackRate(rate),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: QUERY_KEY });
|
||||||
|
toast.success(
|
||||||
|
t("exchangeSettings.updated", "Fallback exchange rate updated"),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
onError: handleError,
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -122,12 +122,16 @@ export const FREIGHT_PERMS = {
|
|||||||
create: "edr_freight_app:locomotives:create",
|
create: "edr_freight_app:locomotives:create",
|
||||||
update: "edr_freight_app:locomotives:update",
|
update: "edr_freight_app:locomotives:update",
|
||||||
delete: "edr_freight_app:locomotives:delete",
|
delete: "edr_freight_app:locomotives:delete",
|
||||||
|
/** Permanent purge — irreversible, granted separately from `delete`. */
|
||||||
|
hardDelete: "edr_freight_app:locomotives:hard_delete",
|
||||||
},
|
},
|
||||||
wagons: {
|
wagons: {
|
||||||
view: "edr_freight_app:wagons:view",
|
view: "edr_freight_app:wagons:view",
|
||||||
create: "edr_freight_app:wagons:create",
|
create: "edr_freight_app:wagons:create",
|
||||||
update: "edr_freight_app:wagons:update",
|
update: "edr_freight_app:wagons:update",
|
||||||
delete: "edr_freight_app:wagons:delete",
|
delete: "edr_freight_app:wagons:delete",
|
||||||
|
/** Permanent purge — irreversible, granted separately from `delete`. */
|
||||||
|
hardDelete: "edr_freight_app:wagons:hard_delete",
|
||||||
transferRequest: "edr_freight_app:wagons:transfer_request",
|
transferRequest: "edr_freight_app:wagons:transfer_request",
|
||||||
transferFulfill: "edr_freight_app:wagons:transfer_fulfill",
|
transferFulfill: "edr_freight_app:wagons:transfer_fulfill",
|
||||||
transferHistoryAll: "edr_freight_app:wagons:transfer_history_all",
|
transferHistoryAll: "edr_freight_app:wagons:transfer_history_all",
|
||||||
@@ -148,6 +152,8 @@ export const FREIGHT_PERMS = {
|
|||||||
create: "edr_freight_app:routes:create",
|
create: "edr_freight_app:routes:create",
|
||||||
update: "edr_freight_app:routes:update",
|
update: "edr_freight_app:routes:update",
|
||||||
delete: "edr_freight_app:routes:delete",
|
delete: "edr_freight_app:routes:delete",
|
||||||
|
/** Permanent purge — irreversible, granted separately from `delete`. */
|
||||||
|
hardDelete: "edr_freight_app:routes:hard_delete",
|
||||||
},
|
},
|
||||||
containers: {
|
containers: {
|
||||||
view: "edr_freight_app:containers:view",
|
view: "edr_freight_app:containers:view",
|
||||||
@@ -598,6 +604,19 @@ export function canFleetAction(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Permanent-purge check for locomotives and wagons. Unlike
|
||||||
|
* {@link canFleetAction} this does NOT fall back to the coarse fleet:manage
|
||||||
|
* key — an irreversible delete needs its own grant, and the API guards these
|
||||||
|
* endpoints the same way.
|
||||||
|
*/
|
||||||
|
export function canFleetHardDelete(
|
||||||
|
user: AuthUser | null | undefined,
|
||||||
|
resource: "locomotives" | "wagons" | "routes",
|
||||||
|
): boolean {
|
||||||
|
return hasPermission(user, FREIGHT_PERMS[resource].hardDelete);
|
||||||
|
}
|
||||||
|
|
||||||
export function isFreightAdmin(user: AuthUser | null | undefined): boolean {
|
export function isFreightAdmin(user: AuthUser | null | undefined): boolean {
|
||||||
return hasPermission(user, FREIGHT_PERMS.admin);
|
return hasPermission(user, FREIGHT_PERMS.admin);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import type { FaydaCallbackMessage } from "@/services/verifayda.service";
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Landing page for the eSignet redirect_uri (FAYDA_WEB_REDIRECT_URI →
|
* Landing page for the eSignet redirect_uri (FAYDA_WEB_REDIRECT_URI →
|
||||||
* http://localhost:5183/callback). Runs inside the verification popup:
|
* http://localhost:5183/fayda/callback). Runs inside the verification popup:
|
||||||
* relays ?code&state (or ?error) to the window that opened it via
|
* relays ?code&state (or ?error) to the window that opened it via
|
||||||
* postMessage, then closes itself. The opener performs the /complete call
|
* postMessage, then closes itself. The opener performs the /complete call
|
||||||
* so the single-use session is only consumed once, in one place.
|
* so the single-use session is only consumed once, in one place.
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import {
|
|||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
} from "@/shared/common/ui/alert-dialog";
|
} from "@/shared/common/ui/alert-dialog";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
import ExchangeRateSettingsCard from "./settings/ExchangeRateSettingsCard";
|
||||||
|
|
||||||
export default function SettingsPage() {
|
export default function SettingsPage() {
|
||||||
const [createDialogOpen, setCreateDialogOpen] = useState(false);
|
const [createDialogOpen, setCreateDialogOpen] = useState(false);
|
||||||
@@ -77,6 +78,8 @@ export default function SettingsPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6 space-y-6">
|
<div className="p-6 space-y-6">
|
||||||
|
<ExchangeRateSettingsCard />
|
||||||
|
|
||||||
<Card className="shadow-lg border-gray-200 dark:border-gray-700">
|
<Card className="shadow-lg border-gray-200 dark:border-gray-700">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-xl font-semibold">
|
<CardTitle className="text-xl font-semibold">
|
||||||
|
|||||||
@@ -21,20 +21,23 @@ import {
|
|||||||
Title,
|
Title,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
|
import ReactQuill from "react-quill-new";
|
||||||
|
import "react-quill-new/dist/quill.snow.css";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
ArrowDown,
|
ArrowDown,
|
||||||
|
ArrowLeftRight,
|
||||||
ArrowUp,
|
ArrowUp,
|
||||||
|
Boxes,
|
||||||
Building2,
|
Building2,
|
||||||
|
Container,
|
||||||
CalendarClock,
|
CalendarClock,
|
||||||
CalendarDays,
|
CalendarDays,
|
||||||
CalendarRange,
|
CalendarRange,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
Coins,
|
Coins,
|
||||||
Hash,
|
Hash,
|
||||||
ListOrdered,
|
|
||||||
ListPlus,
|
|
||||||
ListTree,
|
|
||||||
Mail,
|
Mail,
|
||||||
MapPin,
|
MapPin,
|
||||||
Package,
|
Package,
|
||||||
@@ -58,9 +61,10 @@ import {
|
|||||||
useUpdateContractTemplate,
|
useUpdateContractTemplate,
|
||||||
} from "@/hooks/contract-templates/useContractTemplates";
|
} from "@/hooks/contract-templates/useContractTemplates";
|
||||||
import type { ContractTemplateArticle } from "@/services/contract-templates.service";
|
import type { ContractTemplateArticle } from "@/services/contract-templates.service";
|
||||||
|
import { bodyToHtml, htmlToBody } from "./article-html";
|
||||||
|
|
||||||
const BODY_HINT =
|
const BODY_HINT =
|
||||||
'One clause per line. Use "New clause" for the next number (1., 2., …), "Sub-clause" for a nested number (1.1, then 1.1.1), and "Bullet" for a • point — the number or bullet is typed for you, just add the text. Placeholders are filled from the contract when the document is generated.';
|
"Enter starts a new line. Use the numbered list for clauses and Tab (or Indent) to nest — levels number 1. → a. → i. like a word processor. Numbering is assigned when the document is generated, so it always comes out sequential. Placeholders are filled from the contract.";
|
||||||
|
|
||||||
interface ArticleDraft {
|
interface ArticleDraft {
|
||||||
id?: string;
|
id?: string;
|
||||||
@@ -105,6 +109,18 @@ const QUICK_PLACEHOLDERS: PlaceholderDef[] = [
|
|||||||
icon: CalendarRange,
|
icon: CalendarRange,
|
||||||
hint: "Year the contract is signed",
|
hint: "Year the contract is signed",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
token: "{{contractStartDate}}",
|
||||||
|
label: "Start date",
|
||||||
|
icon: CalendarClock,
|
||||||
|
hint: "Date the contract's validity begins",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
token: "{{contractEndDate}}",
|
||||||
|
label: "End date",
|
||||||
|
icon: CalendarClock,
|
||||||
|
hint: "Date the contract's validity ends",
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const MORE_PLACEHOLDER_GROUPS: { label: string; items: PlaceholderDef[] }[] = [
|
const MORE_PLACEHOLDER_GROUPS: { label: string; items: PlaceholderDef[] }[] = [
|
||||||
@@ -170,6 +186,42 @@ const MORE_PLACEHOLDER_GROUPS: { label: string; items: PlaceholderDef[] }[] = [
|
|||||||
icon: Package,
|
icon: Package,
|
||||||
hint: "Description of the cargo",
|
hint: "Description of the cargo",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
token: "{{schedule.cargoTypeName}}",
|
||||||
|
label: "Cargo type",
|
||||||
|
icon: Package,
|
||||||
|
hint: "Named commodity on its own, e.g. Coffee",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
token: "{{schedule.containerType}}",
|
||||||
|
label: "Container type",
|
||||||
|
icon: Container,
|
||||||
|
hint: "Container size, e.g. 20ft / 40ft — dash for bulk",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
token: "{{schedule.cargoSummary}}",
|
||||||
|
label: "Cargo summary",
|
||||||
|
icon: Boxes,
|
||||||
|
hint: "Every cargo line, e.g. Coffee (40ft) × 12",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
token: "{{schedule.tradeDirection}}",
|
||||||
|
label: "Trade direction",
|
||||||
|
icon: ArrowLeftRight,
|
||||||
|
hint: "IMPORT / EXPORT / DOMESTIC",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
token: "{{schedule.freightType}}",
|
||||||
|
label: "Freight type",
|
||||||
|
icon: Boxes,
|
||||||
|
hint: "CONTAINER or BULK",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
token: "{{schedule.hazardousLabel}}",
|
||||||
|
label: "Hazardous",
|
||||||
|
icon: AlertTriangle,
|
||||||
|
hint: "Declared hazard class + UN number, or No",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
token: "{{schedule.totalWeightVgm}}",
|
token: "{{schedule.totalWeightVgm}}",
|
||||||
label: "Total weight",
|
label: "Total weight",
|
||||||
@@ -237,6 +289,22 @@ const ALL_PLACEHOLDERS: PlaceholderDef[] = [
|
|||||||
...MORE_PLACEHOLDER_GROUPS.flatMap((g) => g.items),
|
...MORE_PLACEHOLDER_GROUPS.flatMap((g) => g.items),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deliberately narrow toolbar: the stored body carries STRUCTURE only (clause
|
||||||
|
* depth + bullets), which is what the contract renderer numbers and lays out.
|
||||||
|
* Bold/colour/font would be dropped on save, so they are not offered —
|
||||||
|
* an author never loses formatting they were allowed to apply.
|
||||||
|
*/
|
||||||
|
const QUILL_MODULES = {
|
||||||
|
toolbar: [
|
||||||
|
[{ list: "ordered" }, { list: "bullet" }],
|
||||||
|
[{ indent: "-1" }, { indent: "+1" }],
|
||||||
|
["clean"],
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const QUILL_FORMATS = ["list", "indent"];
|
||||||
|
|
||||||
const KNOWN_TOKENS = new Set<string>([
|
const KNOWN_TOKENS = new Set<string>([
|
||||||
...ALL_PLACEHOLDERS.map((p) => p.token),
|
...ALL_PLACEHOLDERS.map((p) => p.token),
|
||||||
// Still filled by the renderer, just no longer offered as an insert button.
|
// Still filled by the renderer, just no longer offered as an insert button.
|
||||||
@@ -282,6 +350,49 @@ function matchDepth(match: RegExpExecArray | null): number | null {
|
|||||||
/** Deepest supported sub-clause level. */
|
/** Deepest supported sub-clause level. */
|
||||||
const MAX_CLAUSE_DEPTH = 6;
|
const MAX_CLAUSE_DEPTH = 6;
|
||||||
|
|
||||||
|
/** 1 → "a", 2 → "b", … 27 → "aa". Mirrors the API's `toAlpha`. */
|
||||||
|
function toAlpha(n: number): string {
|
||||||
|
let out = "";
|
||||||
|
let value = n;
|
||||||
|
while (value > 0) {
|
||||||
|
const rem = (value - 1) % 26;
|
||||||
|
out = String.fromCharCode(97 + rem) + out;
|
||||||
|
value = Math.floor((value - 1) / 26);
|
||||||
|
}
|
||||||
|
return out || "a";
|
||||||
|
}
|
||||||
|
|
||||||
|
const ROMAN: Array<[number, string]> = [
|
||||||
|
[1000, "m"], [900, "cm"], [500, "d"], [400, "cd"],
|
||||||
|
[100, "c"], [90, "xc"], [50, "l"], [40, "xl"],
|
||||||
|
[10, "x"], [9, "ix"], [5, "v"], [4, "iv"], [1, "i"],
|
||||||
|
];
|
||||||
|
|
||||||
|
/** 1 → "i", 4 → "iv". Mirrors the API's `toRoman`. */
|
||||||
|
function toRoman(n: number): string {
|
||||||
|
let value = n;
|
||||||
|
let out = "";
|
||||||
|
for (const [amount, numeral] of ROMAN) {
|
||||||
|
while (value >= amount) {
|
||||||
|
out += numeral;
|
||||||
|
value -= amount;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out || "i";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Outline marker for a clause at its own level, cycling 1. → a. → i. by depth.
|
||||||
|
* Mirrors `clauseMarker` in the API's contract-article.util.ts — the preview
|
||||||
|
* must match the generated document exactly.
|
||||||
|
*/
|
||||||
|
function clauseMarker(counter: number, depth: number): string {
|
||||||
|
const style = (depth - 1) % 3;
|
||||||
|
if (style === 1) return toAlpha(counter);
|
||||||
|
if (style === 2) return toRoman(counter);
|
||||||
|
return String(counter);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mirror of the API renderer's rules (contract-article.util.ts): one clause per
|
* Mirror of the API renderer's rules (contract-article.util.ts): one clause per
|
||||||
* line; a leading outline number ("2. ", "2.1 ") nests the line as a sub-clause
|
* line; a leading outline number ("2. ", "2.1 ") nests the line as a sub-clause
|
||||||
@@ -311,7 +422,7 @@ function parseArticleBody(body: string): ParsedBody {
|
|||||||
counters[depth - 1] += 1;
|
counters[depth - 1] += 1;
|
||||||
clauses.push({
|
clauses.push({
|
||||||
text: match ? cleaned.slice(match[0].length).trim() : cleaned,
|
text: match ? cleaned.slice(match[0].length).trim() : cleaned,
|
||||||
number: counters.slice(0, depth).join("."),
|
number: clauseMarker(counters[depth - 1], depth),
|
||||||
depth,
|
depth,
|
||||||
bullets: [],
|
bullets: [],
|
||||||
});
|
});
|
||||||
@@ -326,31 +437,6 @@ function parseArticleBody(body: string): ParsedBody {
|
|||||||
return { clauses };
|
return { clauses };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Rewrite the leading outline tokens in a body so every numbered clause line
|
|
||||||
* carries its computed sequential number (stale numbers self-heal). Lines
|
|
||||||
* without a number token and bullet lines pass through untouched.
|
|
||||||
*/
|
|
||||||
function renumberBody(body: string): string {
|
|
||||||
const counters: number[] = [];
|
|
||||||
return body
|
|
||||||
.split("\n")
|
|
||||||
.map((raw) => {
|
|
||||||
const line = raw.trim();
|
|
||||||
if (!line || line.startsWith("- ")) return raw;
|
|
||||||
const match = CLAUSE_NUMBER_RE.exec(line);
|
|
||||||
let depth = matchDepth(match) ?? 1;
|
|
||||||
depth = Math.min(depth, counters.length + 1);
|
|
||||||
counters.splice(depth);
|
|
||||||
while (counters.length < depth) counters.push(0);
|
|
||||||
counters[depth - 1] += 1;
|
|
||||||
if (!match) return raw;
|
|
||||||
const number = counters.slice(0, depth).join(".");
|
|
||||||
return `${number}. ${line.slice(match[0].length).trim()}`;
|
|
||||||
})
|
|
||||||
.join("\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Render clause text with {{placeholders}} highlighted as green chips. */
|
/** Render clause text with {{placeholders}} highlighted as green chips. */
|
||||||
function HighlightedText({ text }: { text: string }) {
|
function HighlightedText({ text }: { text: string }) {
|
||||||
const parts = text.split(/(\{\{[^{}]+\}\})/g);
|
const parts = text.split(/(\{\{[^{}]+\}\})/g);
|
||||||
@@ -674,88 +760,46 @@ function ArticleEditorModal({
|
|||||||
}: ArticleEditorModalProps) {
|
}: ArticleEditorModalProps) {
|
||||||
const [title, setTitle] = useState(initial.title);
|
const [title, setTitle] = useState(initial.title);
|
||||||
const [body, setBody] = useState(initial.body);
|
const [body, setBody] = useState(initial.body);
|
||||||
|
// Quill is uncontrolled-ish: it owns its own DOM, so seed it once from the
|
||||||
|
// stored body and let onChange convert edits back rather than re-deriving
|
||||||
|
// HTML from `body` on every keystroke (which would fight the caret).
|
||||||
|
const [html, setHtml] = useState(() => bodyToHtml(initial.body));
|
||||||
|
|
||||||
const titleRef = useRef<HTMLInputElement>(null);
|
const titleRef = useRef<HTMLInputElement>(null);
|
||||||
const bodyRef = useRef<HTMLTextAreaElement>(null);
|
const quillRef = useRef<ReactQuill>(null);
|
||||||
// Placeholders drop into whichever field held the cursor last (body default).
|
// Placeholders drop into whichever field held the cursor last (body default).
|
||||||
const lastFocused = useRef<"title" | "body">("body");
|
const lastFocused = useRef<"title" | "body">("body");
|
||||||
|
|
||||||
const insertAtCursor = (snippet: string) => {
|
/** Body is the source of truth for saving/preview; HTML is the editor view. */
|
||||||
const isTitle = lastFocused.current === "title";
|
const applyHtml = (nextHtml: string) => {
|
||||||
const el = isTitle ? titleRef.current : bodyRef.current;
|
setHtml(nextHtml);
|
||||||
const value = isTitle ? title : body;
|
setBody(htmlToBody(nextHtml));
|
||||||
const start = el?.selectionStart ?? value.length;
|
|
||||||
const end = el?.selectionEnd ?? start;
|
|
||||||
const next = value.slice(0, start) + snippet + value.slice(end);
|
|
||||||
if (isTitle) setTitle(next);
|
|
||||||
else setBody(next);
|
|
||||||
// Refocus and place the caret right after the inserted snippet once the
|
|
||||||
// controlled re-render has flushed.
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
if (!el) return;
|
|
||||||
el.focus();
|
|
||||||
const caret = start + snippet.length;
|
|
||||||
el.setSelectionRange(caret, caret);
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
const insertAtCursor = (snippet: string) => {
|
||||||
* Insert a structured line (clause / sub-clause / bullet) on a fresh line
|
if (lastFocused.current === "title") {
|
||||||
* below the one the caret is on. Clause lines get their outline number typed
|
const el = titleRef.current;
|
||||||
* in automatically ("3. ", "3.1. ", …) and every numbered line in the body is
|
const start = el?.selectionStart ?? title.length;
|
||||||
* renumbered so the text always matches the preview.
|
const end = el?.selectionEnd ?? start;
|
||||||
*/
|
setTitle(title.slice(0, start) + snippet + title.slice(end));
|
||||||
const insertStructuredLine = (kind: "clause" | "sub" | "bullet") => {
|
requestAnimationFrame(() => {
|
||||||
const el = bodyRef.current;
|
if (!el) return;
|
||||||
lastFocused.current = "body";
|
el.focus();
|
||||||
const caret = el?.selectionStart ?? body.length;
|
const caret = start + snippet.length;
|
||||||
// Structured lines never split a sentence — insert after the caret's line.
|
el.setSelectionRange(caret, caret);
|
||||||
const lineEnd = body.indexOf("\n", caret);
|
});
|
||||||
const insertAt = lineEnd === -1 ? body.length : lineEnd;
|
return;
|
||||||
const before = body.slice(0, insertAt);
|
|
||||||
const after = body.slice(insertAt); // "" or starts with "\n"
|
|
||||||
|
|
||||||
let prefix: string;
|
|
||||||
if (kind === "bullet") {
|
|
||||||
prefix = "- ";
|
|
||||||
} else {
|
|
||||||
// New clause always starts a fresh top-level number. Sub-clause nests
|
|
||||||
// one level under a clause (1 → 1.1) but adds a SIBLING when the caret
|
|
||||||
// is already on a sub-clause (1.1 → 1.2 → 1.3, not ever-deeper) — a
|
|
||||||
// third level is reached by typing its number (e.g. "1.1.1 ") directly.
|
|
||||||
const above = parseArticleBody(before);
|
|
||||||
const lastDepth = above.paragraph
|
|
||||||
? 1
|
|
||||||
: (above.clauses[above.clauses.length - 1]?.depth ?? 0);
|
|
||||||
const depth =
|
|
||||||
kind === "sub"
|
|
||||||
? lastDepth <= 1
|
|
||||||
? Math.min(lastDepth + 1, MAX_CLAUSE_DEPTH)
|
|
||||||
: lastDepth
|
|
||||||
: 1;
|
|
||||||
// Digits are placeholders — renumberBody assigns the real value.
|
|
||||||
prefix = `${Array.from({ length: depth }, () => "1").join(".")}. `;
|
|
||||||
}
|
}
|
||||||
|
// Quill tracks its own selection; insert there so the token lands where the
|
||||||
const beforeLines = before.length > 0 ? before.split("\n") : [];
|
// author was typing instead of at the end of the document.
|
||||||
const afterLines =
|
const editor = quillRef.current?.getEditor();
|
||||||
after.length > 0 ? after.slice(1).split("\n") : [];
|
if (!editor) return;
|
||||||
const insertedIdx = beforeLines.length;
|
const range = editor.getSelection(true);
|
||||||
const joined = [...beforeLines, prefix, ...afterLines].join("\n");
|
const at = range?.index ?? editor.getLength();
|
||||||
const next = kind === "bullet" ? joined : renumberBody(joined);
|
editor.deleteText(at, range?.length ?? 0);
|
||||||
setBody(next);
|
editor.insertText(at, snippet, "user");
|
||||||
|
editor.setSelection(at + snippet.length, 0);
|
||||||
// Caret lands at the end of the inserted line, ready for typing.
|
applyHtml(editor.root.innerHTML);
|
||||||
const caretTarget = next
|
|
||||||
.split("\n")
|
|
||||||
.slice(0, insertedIdx + 1)
|
|
||||||
.join("\n").length;
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
const field = bodyRef.current;
|
|
||||||
if (!field) return;
|
|
||||||
field.focus();
|
|
||||||
field.setSelectionRange(caretTarget, caretTarget);
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const parsed = useMemo(() => parseArticleBody(body), [body]);
|
const parsed = useMemo(() => parseArticleBody(body), [body]);
|
||||||
@@ -846,78 +890,24 @@ function ArticleEditorModal({
|
|||||||
|
|
||||||
<Box>
|
<Box>
|
||||||
<Text size="sm" fw={500} mb={4}>
|
<Text size="sm" fw={500} mb={4}>
|
||||||
Add structure
|
Article body
|
||||||
</Text>
|
</Text>
|
||||||
<Group gap={6} wrap="wrap">
|
<Text size="xs" c="dimmed" mb={6}>
|
||||||
<Tooltip
|
{BODY_HINT}
|
||||||
label="New line with the next clause number typed for you (1., 2., 3., …)"
|
</Text>
|
||||||
withArrow
|
<Box onFocusCapture={() => (lastFocused.current = "body")}>
|
||||||
>
|
<ReactQuill
|
||||||
<Button
|
ref={quillRef}
|
||||||
variant="light"
|
theme="snow"
|
||||||
color="edr-green"
|
value={html}
|
||||||
size="compact-sm"
|
onChange={applyHtml}
|
||||||
radius="md"
|
modules={QUILL_MODULES}
|
||||||
leftSection={<ListOrdered size={13} />}
|
formats={QUILL_FORMATS}
|
||||||
onMouseDown={(e) => e.preventDefault()}
|
placeholder="Write the article — each paragraph becomes a numbered clause."
|
||||||
onClick={() => insertStructuredLine("clause")}
|
/>
|
||||||
>
|
</Box>
|
||||||
New clause
|
|
||||||
</Button>
|
|
||||||
</Tooltip>
|
|
||||||
<Tooltip
|
|
||||||
label="Numbered point under the current clause — 1.1, then 1.2, 1.3 on each click. For a deeper level type its number yourself (e.g. 1.1.1 )"
|
|
||||||
withArrow
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
variant="light"
|
|
||||||
color="edr-green"
|
|
||||||
size="compact-sm"
|
|
||||||
radius="md"
|
|
||||||
leftSection={<ListTree size={13} />}
|
|
||||||
disabled={body.trim().length === 0}
|
|
||||||
onMouseDown={(e) => e.preventDefault()}
|
|
||||||
onClick={() => insertStructuredLine("sub")}
|
|
||||||
>
|
|
||||||
Sub-clause
|
|
||||||
</Button>
|
|
||||||
</Tooltip>
|
|
||||||
<Tooltip
|
|
||||||
label="New line with a bullet (•) under the current clause"
|
|
||||||
withArrow
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
variant="light"
|
|
||||||
color="edr-green"
|
|
||||||
size="compact-sm"
|
|
||||||
radius="md"
|
|
||||||
leftSection={<ListPlus size={13} />}
|
|
||||||
disabled={body.trim().length === 0}
|
|
||||||
onMouseDown={(e) => e.preventDefault()}
|
|
||||||
onClick={() => insertStructuredLine("bullet")}
|
|
||||||
>
|
|
||||||
Bullet
|
|
||||||
</Button>
|
|
||||||
</Tooltip>
|
|
||||||
</Group>
|
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Textarea
|
|
||||||
ref={bodyRef}
|
|
||||||
label="Article body"
|
|
||||||
description={BODY_HINT}
|
|
||||||
value={body}
|
|
||||||
onChange={(event) => setBody(event.currentTarget.value)}
|
|
||||||
onFocus={() => (lastFocused.current = "body")}
|
|
||||||
autosize
|
|
||||||
minRows={12}
|
|
||||||
maxRows={22}
|
|
||||||
styles={{
|
|
||||||
input: { fontFamily: "ui-monospace, monospace", fontSize: 13 },
|
|
||||||
}}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
|
|
||||||
{unknown.length > 0 && (
|
{unknown.length > 0 && (
|
||||||
<Group gap={6} wrap="nowrap" align="flex-start">
|
<Group gap={6} wrap="nowrap" align="flex-start">
|
||||||
<AlertTriangle size={14} className="mt-0.5 shrink-0 text-red-600" />
|
<AlertTriangle size={14} className="mt-0.5 shrink-0 text-red-600" />
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { bodyToHtml, htmlToBody } from "./article-html";
|
||||||
|
|
||||||
|
describe("article body ↔ Quill HTML", () => {
|
||||||
|
it("round-trips clauses and bullets unchanged", () => {
|
||||||
|
const body = [
|
||||||
|
"Provide written instructions for each shipment.",
|
||||||
|
"Prepare all necessary documents.",
|
||||||
|
"- Commercial invoice",
|
||||||
|
"- Packing list",
|
||||||
|
"Pay 100% in advance.",
|
||||||
|
].join("\n");
|
||||||
|
expect(htmlToBody(bodyToHtml(body))).toBe(body);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a single paragraph a single paragraph", () => {
|
||||||
|
const body = "The contract is valid once signed by both parties.";
|
||||||
|
expect(htmlToBody(bodyToHtml(body))).toBe(body);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves placeholders verbatim through a round trip", () => {
|
||||||
|
const body = "Valid until August 31, {{contractYear}} for {{client.companyName}}.";
|
||||||
|
expect(htmlToBody(bodyToHtml(body))).toBe(body);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("escapes and restores characters that are HTML-significant", () => {
|
||||||
|
const body = "Rates < 100 & > 50 apply to the Client's cargo.";
|
||||||
|
expect(htmlToBody(bodyToHtml(body))).toBe(body);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps Quill indent classes onto sub-clause depth", () => {
|
||||||
|
const html =
|
||||||
|
"<p>Top level clause.</p>" +
|
||||||
|
'<p class="ql-indent-1">Nested one level.</p>' +
|
||||||
|
'<p class="ql-indent-2">Nested two levels.</p>';
|
||||||
|
expect(htmlToBody(html)).toBe(
|
||||||
|
["Top level clause.", "1.1. Nested one level.", "1.1.1. Nested two levels."].join(
|
||||||
|
"\n",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("turns Quill bullet lists into '- ' lines", () => {
|
||||||
|
const html = "<p>Documents:</p><ul><li>Invoice</li><li>Waybill</li></ul>";
|
||||||
|
expect(htmlToBody(html)).toBe("Documents:\n- Invoice\n- Waybill");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats an ordered list as clause lines, not bullets", () => {
|
||||||
|
const html = "<ol><li>First clause.</li><li>Second clause.</li></ol>";
|
||||||
|
expect(htmlToBody(html)).toBe("1. First clause.\n1. Second clause.");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("normalises the nbsp Quill inserts and drops empty blocks", () => {
|
||||||
|
const html = "<p>Payment in advance.</p><p><br></p><p></p>";
|
||||||
|
expect(htmlToBody(html)).toBe("Payment in advance.");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns an empty editor for an empty body", () => {
|
||||||
|
expect(bodyToHtml("")).toBe("<p><br></p>");
|
||||||
|
expect(htmlToBody("<p><br></p>")).toBe("");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
/**
|
||||||
|
* Bridge between the Quill editor (HTML) and the stored article body, which is
|
||||||
|
* the structural plain text the server parses into numbered clauses
|
||||||
|
* (`parseArticleBody` in contract-article.util.ts): one clause per line, a
|
||||||
|
* leading outline token ("2.", "2.1") for depth, and "- " for bullets.
|
||||||
|
*
|
||||||
|
* Quill owns presentation; the body format owns structure. Converting on the
|
||||||
|
* way in and out keeps the renderer, the server-side renumbering, and the
|
||||||
|
* generated PDF working exactly as before.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const ESCAPES: Record<string, string> = {
|
||||||
|
"&": "&",
|
||||||
|
"<": "<",
|
||||||
|
">": ">",
|
||||||
|
};
|
||||||
|
|
||||||
|
const escapeHtml = (text: string): string =>
|
||||||
|
text.replace(/[&<>]/g, (c) => ESCAPES[c]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Body text → Quill HTML. Clauses become `<p>` carrying their outline token so
|
||||||
|
* the author sees the real numbering; bullets become a `<ul>` under the clause
|
||||||
|
* they belong to.
|
||||||
|
*/
|
||||||
|
export function bodyToHtml(body: string): string {
|
||||||
|
const lines = (body ?? "").split("\n").filter((l) => l.trim().length > 0);
|
||||||
|
if (lines.length === 0) return "<p><br></p>";
|
||||||
|
|
||||||
|
const out: string[] = [];
|
||||||
|
let inList = false;
|
||||||
|
for (const line of lines) {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (trimmed.startsWith("- ")) {
|
||||||
|
if (!inList) {
|
||||||
|
out.push("<ul>");
|
||||||
|
inList = true;
|
||||||
|
}
|
||||||
|
out.push(`<li>${escapeHtml(trimmed.slice(2).trim())}</li>`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (inList) {
|
||||||
|
out.push("</ul>");
|
||||||
|
inList = false;
|
||||||
|
}
|
||||||
|
out.push(`<p>${escapeHtml(trimmed)}</p>`);
|
||||||
|
}
|
||||||
|
if (inList) out.push("</ul>");
|
||||||
|
return out.join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Quill HTML → body text. `<li>` inside a `<ul>` becomes a "- " bullet; every
|
||||||
|
* other block becomes its own line. Quill's indent classes
|
||||||
|
* (`ql-indent-1`, …) map onto sub-clause depth, so indenting in the toolbar
|
||||||
|
* produces "1.1"-style nesting — the exact digits are placeholders, the server
|
||||||
|
* renumbers them.
|
||||||
|
*
|
||||||
|
* Runs through DOMParser rather than regex: the input is real HTML from a
|
||||||
|
* contenteditable, and entity handling (&, ) has to be right or the
|
||||||
|
* text lands in the PDF mangled.
|
||||||
|
*/
|
||||||
|
export function htmlToBody(html: string): string {
|
||||||
|
if (!html) return "";
|
||||||
|
const doc = new DOMParser().parseFromString(
|
||||||
|
`<div id="root">${html}</div>`,
|
||||||
|
"text/html",
|
||||||
|
);
|
||||||
|
const root = doc.getElementById("root");
|
||||||
|
if (!root) return "";
|
||||||
|
|
||||||
|
const lines: string[] = [];
|
||||||
|
|
||||||
|
const textOf = (el: Element): string =>
|
||||||
|
// is the nbsp Quill inserts for trailing spaces — plain space in the
|
||||||
|
// stored body, otherwise it survives into the contract text.
|
||||||
|
(el.textContent ?? "").replace(/ /g, " ").trim();
|
||||||
|
|
||||||
|
const indentOf = (el: Element): number => {
|
||||||
|
const match = /ql-indent-(\d+)/.exec(el.className ?? "");
|
||||||
|
return match ? Number(match[1]) : 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const walk = (node: Element, insideList: boolean) => {
|
||||||
|
for (const child of Array.from(node.children)) {
|
||||||
|
const tag = child.tagName.toLowerCase();
|
||||||
|
if (tag === "ul" || tag === "ol") {
|
||||||
|
// <ol> is authored numbering; the body format numbers clauses itself,
|
||||||
|
// so an ordered list is clause lines, not bullets.
|
||||||
|
walk(child, tag === "ul");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (tag === "li") {
|
||||||
|
const text = textOf(child);
|
||||||
|
if (!text) continue;
|
||||||
|
if (insideList) {
|
||||||
|
lines.push(`- ${text}`);
|
||||||
|
} else {
|
||||||
|
const depth = indentOf(child) + 1;
|
||||||
|
lines.push(`${Array.from({ length: depth }, () => "1").join(".")}. ${text}`);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (tag === "p" || tag === "div") {
|
||||||
|
const text = textOf(child);
|
||||||
|
if (text) {
|
||||||
|
const depth = indentOf(child);
|
||||||
|
lines.push(
|
||||||
|
depth > 0
|
||||||
|
? `${Array.from({ length: depth + 1 }, () => "1").join(".")}. ${text}`
|
||||||
|
: text,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Anything else (blockquote, heading, stray span): keep its text on a
|
||||||
|
// line rather than dropping the author's words.
|
||||||
|
const text = textOf(child);
|
||||||
|
if (text) lines.push(text);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
walk(root, false);
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
@@ -1,11 +1,16 @@
|
|||||||
import type { ColumnDef } from "@edr/ui-common";
|
import type { ColumnDef } from "@edr/ui-common";
|
||||||
import { Box, Button, Card, Container, Group, Modal, Select, Stack, Text, Title } from "@mantine/core";
|
import { Box, Button, Card, Container, Group, Modal, Select, Stack, Text, TextInput, Title } from "@mantine/core";
|
||||||
import { DatePickerInput } from "@mantine/dates";
|
import { DatePickerInput } from "@mantine/dates";
|
||||||
import { keepPreviousData, useMutation, useQuery } from "@tanstack/react-query";
|
import { keepPreviousData, useMutation, useQuery } from "@tanstack/react-query";
|
||||||
|
|
||||||
import { api } from "@/services/api";
|
import { api } from "@/services/api";
|
||||||
import { useAuth } from "@/auth/useAuth";
|
import { useAuth } from "@/auth/useAuth";
|
||||||
import { canFleetAction, hasPermission, FREIGHT_PERMS } from "@/lib/permissions";
|
import {
|
||||||
|
canFleetAction,
|
||||||
|
canFleetHardDelete,
|
||||||
|
hasPermission,
|
||||||
|
FREIGHT_PERMS,
|
||||||
|
} from "@/lib/permissions";
|
||||||
import Breadcrumbs from "@/components/ui/Breadcrumbs";
|
import Breadcrumbs from "@/components/ui/Breadcrumbs";
|
||||||
import { Inbox, Plus, Warehouse } from "lucide-react";
|
import { Inbox, Plus, Warehouse } from "lucide-react";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
@@ -31,6 +36,7 @@ import {
|
|||||||
type FleetResourceSlug,
|
type FleetResourceSlug,
|
||||||
} from "@/pages/fleet/config/resources";
|
} from "@/pages/fleet/config/resources";
|
||||||
import {
|
import {
|
||||||
|
isFleetPurgeable,
|
||||||
isFleetServerPaginated,
|
isFleetServerPaginated,
|
||||||
type FleetListFilters,
|
type FleetListFilters,
|
||||||
type FleetRecord,
|
type FleetRecord,
|
||||||
@@ -49,6 +55,12 @@ const FleetResourcePage = () => {
|
|||||||
const canCreate = canFleetAction(user, slug, "create");
|
const canCreate = canFleetAction(user, slug, "create");
|
||||||
const canUpdate = canFleetAction(user, slug, "update");
|
const canUpdate = canFleetAction(user, slug, "update");
|
||||||
const canDelete = canFleetAction(user, slug, "delete");
|
const canDelete = canFleetAction(user, slug, "delete");
|
||||||
|
// Irreversible purge: only locomotives/wagons expose it, and it needs its own
|
||||||
|
// grant — the coarse fleet:manage key deliberately does not unlock it.
|
||||||
|
const canPurge =
|
||||||
|
isFleetPurgeable(slug) &&
|
||||||
|
(slug === "locomotives" || slug === "wagons") &&
|
||||||
|
canFleetHardDelete(user, slug);
|
||||||
// Wagon transfer workspace: shown only to holders of a transfer capability
|
// Wagon transfer workspace: shown only to holders of a transfer capability
|
||||||
// (raise a request, fulfill one, or see the cross-yard history).
|
// (raise a request, fulfill one, or see the cross-yard history).
|
||||||
const canTransfer =
|
const canTransfer =
|
||||||
@@ -71,6 +83,10 @@ const FleetResourcePage = () => {
|
|||||||
const [formOpen, setFormOpen] = useState(false);
|
const [formOpen, setFormOpen] = useState(false);
|
||||||
const [editing, setEditing] = useState<FleetRecord | null>(null);
|
const [editing, setEditing] = useState<FleetRecord | null>(null);
|
||||||
const [removeTarget, setRemoveTarget] = useState<FleetRecord | null>(null);
|
const [removeTarget, setRemoveTarget] = useState<FleetRecord | null>(null);
|
||||||
|
const [purgeTarget, setPurgeTarget] = useState<FleetRecord | null>(null);
|
||||||
|
// Typing the record's own code is the confirmation — a purge cannot be undone,
|
||||||
|
// so a single misplaced click must not be enough to trigger it.
|
||||||
|
const [purgeConfirmText, setPurgeConfirmText] = useState("");
|
||||||
const [assigningDriver, setAssigningDriver] = useState<FleetRecord | null>(null);
|
const [assigningDriver, setAssigningDriver] = useState<FleetRecord | null>(null);
|
||||||
const [historyTarget, setHistoryTarget] = useState<FleetRecord | null>(null);
|
const [historyTarget, setHistoryTarget] = useState<FleetRecord | null>(null);
|
||||||
const [selectedDriver, setSelectedDriver] = useState<string>("");
|
const [selectedDriver, setSelectedDriver] = useState<string>("");
|
||||||
@@ -142,6 +158,7 @@ const FleetResourcePage = () => {
|
|||||||
const create = useMutation(api.fleet.create.mutationOptions());
|
const create = useMutation(api.fleet.create.mutationOptions());
|
||||||
const update = useMutation(api.fleet.update.mutationOptions());
|
const update = useMutation(api.fleet.update.mutationOptions());
|
||||||
const remove = useMutation(api.fleet.remove.mutationOptions());
|
const remove = useMutation(api.fleet.remove.mutationOptions());
|
||||||
|
const purge = useMutation(api.fleet.purge.mutationOptions());
|
||||||
|
|
||||||
const { data: wagonTypes = [], isLoading: wagonTypesLoading } = useQuery(
|
const { data: wagonTypes = [], isLoading: wagonTypesLoading } = useQuery(
|
||||||
api.wagonTypes.list.queryOptions(),
|
api.wagonTypes.list.queryOptions(),
|
||||||
@@ -385,6 +402,7 @@ const FleetResourcePage = () => {
|
|||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
onRemove={canDelete ? setRemoveTarget : undefined}
|
onRemove={canDelete ? setRemoveTarget : undefined}
|
||||||
|
onPurge={canPurge ? setPurgeTarget : undefined}
|
||||||
onAssignDriver={canUpdate ? setAssigningDriver : undefined}
|
onAssignDriver={canUpdate ? setAssigningDriver : undefined}
|
||||||
onHistory={setHistoryTarget}
|
onHistory={setHistoryTarget}
|
||||||
/>
|
/>
|
||||||
@@ -446,6 +464,37 @@ const FleetResourcePage = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** The code the operator must retype to confirm a purge. */
|
||||||
|
const purgeFields = purgeTarget
|
||||||
|
? (purgeTarget as unknown as Record<string, unknown>)
|
||||||
|
: null;
|
||||||
|
const purgeLabel = purgeFields
|
||||||
|
? String(purgeFields.wagonNumber ?? purgeFields.code ?? "")
|
||||||
|
: "";
|
||||||
|
|
||||||
|
const closePurge = () => {
|
||||||
|
setPurgeTarget(null);
|
||||||
|
setPurgeConfirmText("");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePurge = async () => {
|
||||||
|
if (!purgeTarget || !("id" in purgeTarget)) return;
|
||||||
|
try {
|
||||||
|
await purge.mutateAsync({ slug, id: String(purgeTarget.id) });
|
||||||
|
toast({ title: `${config.entityLabel} permanently deleted` });
|
||||||
|
closePurge();
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const message =
|
||||||
|
(err as { response?: { data?: { message?: string } } })?.response?.data?.message ??
|
||||||
|
"Permanent delete failed";
|
||||||
|
toast({
|
||||||
|
title: "Permanent delete failed",
|
||||||
|
description: String(message),
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleAssignDriver = async () => {
|
const handleAssignDriver = async () => {
|
||||||
if (!assigningDriver || !("id" in assigningDriver) || !selectedDriver) return;
|
if (!assigningDriver || !("id" in assigningDriver) || !selectedDriver) return;
|
||||||
try {
|
try {
|
||||||
@@ -673,6 +722,7 @@ const FleetResourcePage = () => {
|
|||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
onRemove={canDelete ? setRemoveTarget : undefined}
|
onRemove={canDelete ? setRemoveTarget : undefined}
|
||||||
|
onPurge={canPurge ? setPurgeTarget : undefined}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
@@ -718,6 +768,48 @@ const FleetResourcePage = () => {
|
|||||||
</Stack>
|
</Stack>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
opened={Boolean(purgeTarget)}
|
||||||
|
onClose={closePurge}
|
||||||
|
title={<Text fw={600}>Delete permanently</Text>}
|
||||||
|
radius="lg"
|
||||||
|
centered
|
||||||
|
>
|
||||||
|
<Stack gap="md">
|
||||||
|
<Text size="sm">
|
||||||
|
This permanently removes{" "}
|
||||||
|
<Text span fw={700}>
|
||||||
|
{purgeLabel || `this ${config.entityLabel.toLowerCase()}`}
|
||||||
|
</Text>{" "}
|
||||||
|
from the database. It cannot be undone.
|
||||||
|
</Text>
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
Only unused records can be purged — if it has any history or is still
|
||||||
|
referenced, the request is refused and you should use{" "}
|
||||||
|
{(config.removeActionLabel ?? "Delete").toLowerCase()} instead.
|
||||||
|
</Text>
|
||||||
|
<TextInput
|
||||||
|
label={`Type ${purgeLabel} to confirm`}
|
||||||
|
placeholder={purgeLabel}
|
||||||
|
value={purgeConfirmText}
|
||||||
|
onChange={(e) => setPurgeConfirmText(e.currentTarget.value)}
|
||||||
|
/>
|
||||||
|
<Group justify="flex-end">
|
||||||
|
<Button variant="default" onClick={closePurge}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
color="red"
|
||||||
|
loading={purge.isPending}
|
||||||
|
disabled={purgeConfirmText.trim() !== purgeLabel || !purgeLabel}
|
||||||
|
onClick={handlePurge}
|
||||||
|
>
|
||||||
|
Delete permanently
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
opened={Boolean(assigningDriver)}
|
opened={Boolean(assigningDriver)}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
Plus,
|
Plus,
|
||||||
Route as RouteIcon,
|
Route as RouteIcon,
|
||||||
Trash2,
|
Trash2,
|
||||||
|
ShieldAlert,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import type { ColumnDef } from "@edr/ui-common";
|
import type { ColumnDef } from "@edr/ui-common";
|
||||||
import {
|
import {
|
||||||
@@ -23,6 +24,7 @@ import {
|
|||||||
SimpleGrid,
|
SimpleGrid,
|
||||||
Stack,
|
Stack,
|
||||||
Text,
|
Text,
|
||||||
|
TextInput,
|
||||||
ThemeIcon,
|
ThemeIcon,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
@@ -38,7 +40,7 @@ import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
|
|||||||
import { api } from "@/services/api";
|
import { api } from "@/services/api";
|
||||||
import { ruleEngineService } from "@/services/ruleEngine/ruleEngine.service";
|
import { ruleEngineService } from "@/services/ruleEngine/ruleEngine.service";
|
||||||
import { useAuth } from "@/auth/useAuth";
|
import { useAuth } from "@/auth/useAuth";
|
||||||
import { canFleetAction } from "@/lib/permissions";
|
import { canFleetAction, canFleetHardDelete } from "@/lib/permissions";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
import {
|
import {
|
||||||
formatRouteLabel,
|
formatRouteLabel,
|
||||||
@@ -168,6 +170,14 @@ export default function RoutesPage() {
|
|||||||
const canCreate = canFleetAction(user, "routes", "create");
|
const canCreate = canFleetAction(user, "routes", "create");
|
||||||
const canUpdate = canFleetAction(user, "routes", "update");
|
const canUpdate = canFleetAction(user, "routes", "update");
|
||||||
const canDelete = canFleetAction(user, "routes", "delete");
|
const canDelete = canFleetAction(user, "routes", "delete");
|
||||||
|
// Irreversible purge needs its own grant — the coarse fleet:manage key that
|
||||||
|
// canFleetAction accepts deliberately does not unlock it.
|
||||||
|
const canPurge = canFleetHardDelete(user, "routes");
|
||||||
|
// Both destructive actions confirm first: deactivate is recoverable but still
|
||||||
|
// changes what operations can book, and a purge cannot be undone at all.
|
||||||
|
const [deactivateTarget, setDeactivateTarget] = useState<RouteRecord | null>(null);
|
||||||
|
const [purgeTarget, setPurgeTarget] = useState<RouteRecord | null>(null);
|
||||||
|
const [purgeConfirmText, setPurgeConfirmText] = useState("");
|
||||||
|
|
||||||
const routesQuery = useQuery({
|
const routesQuery = useQuery({
|
||||||
...api.routes.listPaged.queryOptions({
|
...api.routes.listPaged.queryOptions({
|
||||||
@@ -202,6 +212,7 @@ export default function RoutesPage() {
|
|||||||
const createMutation = useMutation(api.routes.create.mutationOptions());
|
const createMutation = useMutation(api.routes.create.mutationOptions());
|
||||||
const updateMutation = useMutation(api.routes.update.mutationOptions());
|
const updateMutation = useMutation(api.routes.update.mutationOptions());
|
||||||
const deactivateMutation = useMutation(api.routes.deactivate.mutationOptions());
|
const deactivateMutation = useMutation(api.routes.deactivate.mutationOptions());
|
||||||
|
const purgeMutation = useMutation(api.routes.purge.mutationOptions());
|
||||||
|
|
||||||
// Narrowing the result set can strand the user on a page that no longer
|
// Narrowing the result set can strand the user on a page that no longer
|
||||||
// exists (search down to 3 rows while on page 5 → empty table).
|
// exists (search down to 3 rows while on page 5 → empty table).
|
||||||
@@ -353,15 +364,40 @@ export default function RoutesPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeactivate = async (route: RouteRecord) => {
|
const handleDeactivate = async () => {
|
||||||
|
if (!deactivateTarget) return;
|
||||||
try {
|
try {
|
||||||
await deactivateMutation.mutateAsync(route.id);
|
await deactivateMutation.mutateAsync(deactivateTarget.id);
|
||||||
toast({ title: "Route marked stop working" });
|
toast({ title: "Route marked stop working" });
|
||||||
|
setDeactivateTarget(null);
|
||||||
} catch {
|
} catch {
|
||||||
toast({ title: "Update failed", description: "Could not update route status", variant: "destructive" });
|
toast({ title: "Update failed", description: "Could not update route status", variant: "destructive" });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const closePurge = () => {
|
||||||
|
setPurgeTarget(null);
|
||||||
|
setPurgeConfirmText("");
|
||||||
|
};
|
||||||
|
|
||||||
|
/** The label the operator must retype to confirm an irreversible purge. */
|
||||||
|
const purgeLabel = purgeTarget ? formatRouteLabel(purgeTarget) : "";
|
||||||
|
|
||||||
|
const handlePurge = async () => {
|
||||||
|
if (!purgeTarget) return;
|
||||||
|
try {
|
||||||
|
await purgeMutation.mutateAsync(purgeTarget.id);
|
||||||
|
toast({ title: "Route permanently deleted" });
|
||||||
|
closePurge();
|
||||||
|
} catch (error) {
|
||||||
|
toast({
|
||||||
|
title: "Permanent delete failed",
|
||||||
|
description: normalizeRouteError(error),
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleStatusChange = async (route: RouteRecord, status: RouteStatus) => {
|
const handleStatusChange = async (route: RouteRecord, status: RouteStatus) => {
|
||||||
try {
|
try {
|
||||||
await updateMutation.mutateAsync({ id: route.id, data: { status } });
|
await updateMutation.mutateAsync({ id: route.id, data: { status } });
|
||||||
@@ -465,17 +501,29 @@ export default function RoutesPage() {
|
|||||||
variant="subtle"
|
variant="subtle"
|
||||||
color="red"
|
color="red"
|
||||||
disabled={row.original.status === "STOP_WORKING" || deactivateMutation.isPending}
|
disabled={row.original.status === "STOP_WORKING" || deactivateMutation.isPending}
|
||||||
onClick={() => handleDeactivate(row.original)}
|
onClick={() => setDeactivateTarget(row.original)}
|
||||||
>
|
>
|
||||||
<Trash2 size={16} />
|
<Trash2 size={16} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
) : null}
|
) : null}
|
||||||
|
{canPurge ? (
|
||||||
|
<Tooltip label="Delete permanently">
|
||||||
|
<ActionIcon
|
||||||
|
variant="subtle"
|
||||||
|
color="red"
|
||||||
|
disabled={purgeMutation.isPending}
|
||||||
|
onClick={() => setPurgeTarget(row.original)}
|
||||||
|
>
|
||||||
|
<ShieldAlert size={16} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
) : null}
|
||||||
</Group>
|
</Group>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
}, [deactivateMutation.isPending, canUpdate, canDelete]);
|
}, [deactivateMutation.isPending, purgeMutation.isPending, canUpdate, canDelete, canPurge]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageContainer>
|
<PageContainer>
|
||||||
@@ -696,6 +744,78 @@ export default function RoutesPage() {
|
|||||||
</form>
|
</form>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
opened={Boolean(deactivateTarget)}
|
||||||
|
onClose={() => setDeactivateTarget(null)}
|
||||||
|
title={<Text fw={600}>Mark stop working</Text>}
|
||||||
|
radius="lg"
|
||||||
|
centered
|
||||||
|
>
|
||||||
|
<Stack gap="md">
|
||||||
|
<Text size="sm">
|
||||||
|
Stop new operations on{" "}
|
||||||
|
<Text span fw={700}>
|
||||||
|
{deactivateTarget ? formatRouteLabel(deactivateTarget) : ""}
|
||||||
|
</Text>
|
||||||
|
? The route keeps its history and can no longer be booked.
|
||||||
|
</Text>
|
||||||
|
<Group justify="flex-end">
|
||||||
|
<Button variant="default" onClick={() => setDeactivateTarget(null)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
color="red"
|
||||||
|
loading={deactivateMutation.isPending}
|
||||||
|
onClick={handleDeactivate}
|
||||||
|
>
|
||||||
|
Mark stop working
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
opened={Boolean(purgeTarget)}
|
||||||
|
onClose={closePurge}
|
||||||
|
title={<Text fw={600}>Delete permanently</Text>}
|
||||||
|
radius="lg"
|
||||||
|
centered
|
||||||
|
>
|
||||||
|
<Stack gap="md">
|
||||||
|
<Text size="sm">
|
||||||
|
This permanently removes{" "}
|
||||||
|
<Text span fw={700}>
|
||||||
|
{purgeLabel}
|
||||||
|
</Text>{" "}
|
||||||
|
and its stops from the database. It cannot be undone.
|
||||||
|
</Text>
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
Only unused routes can be purged — if any train schedule still
|
||||||
|
references it, the request is refused and you should mark it stop
|
||||||
|
working instead.
|
||||||
|
</Text>
|
||||||
|
<TextInput
|
||||||
|
label="Type the route to confirm"
|
||||||
|
placeholder={purgeLabel}
|
||||||
|
value={purgeConfirmText}
|
||||||
|
onChange={(e) => setPurgeConfirmText(e.currentTarget.value)}
|
||||||
|
/>
|
||||||
|
<Group justify="flex-end">
|
||||||
|
<Button variant="default" onClick={closePurge}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
color="red"
|
||||||
|
loading={purgeMutation.isPending}
|
||||||
|
disabled={purgeConfirmText.trim() !== purgeLabel || !purgeLabel}
|
||||||
|
onClick={handlePurge}
|
||||||
|
>
|
||||||
|
Delete permanently
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
opened={Boolean(viewing)}
|
opened={Boolean(viewing)}
|
||||||
onClose={() => setViewing(null)}
|
onClose={() => setViewing(null)}
|
||||||
|
|||||||
@@ -222,10 +222,10 @@ const RuleEngineResourcePage = () => {
|
|||||||
// instead of mutating: the rate keeps its current value until an approver
|
// instead of mutating: the rate keeps its current value until an approver
|
||||||
// applies the change. DRAFT rates still edit directly.
|
// applies the change. DRAFT rates still edit directly.
|
||||||
const isRates = config?.slug === "rates";
|
const isRates = config?.slug === "rates";
|
||||||
const [rateError, setRateError] = useState<string | null>(null);
|
// No error modal here: the workflow falls back to a toast when no handler is
|
||||||
|
// passed, which keeps failures visible without a dialog to dismiss.
|
||||||
const rateChangeWorkflow = useRateChangeWorkflow(
|
const rateChangeWorkflow = useRateChangeWorkflow(
|
||||||
Boolean(isRates && canView),
|
Boolean(isRates && canView),
|
||||||
setRateError,
|
|
||||||
);
|
);
|
||||||
const canApproveRates = Boolean(isRates && canApproveRuleEngineChange(user, "rates"));
|
const canApproveRates = Boolean(isRates && canApproveRuleEngineChange(user, "rates"));
|
||||||
/** rateId → its pending change, for the row badge. */
|
/** rateId → its pending change, for the row badge. */
|
||||||
@@ -265,8 +265,10 @@ const RuleEngineResourcePage = () => {
|
|||||||
useCargoTypeParentOptions(editingId, config?.slug === "cargo-types");
|
useCargoTypeParentOptions(editingId, config?.slug === "cargo-types");
|
||||||
const { data: cargoLeafOptions, isLoading: cargoLeafOptionsLoading } =
|
const { data: cargoLeafOptions, isLoading: cargoLeafOptionsLoading } =
|
||||||
useCargoLeafOptions(usesCargoTypeField);
|
useCargoLeafOptions(usesCargoTypeField);
|
||||||
|
// No "None" on rates: a rate's container scope is either a real type or the
|
||||||
|
// field is hidden entirely, so offering None only invites an unscoped rate.
|
||||||
const { data: containerTypeOptions, isLoading: containerTypeOptionsLoading } =
|
const { data: containerTypeOptions, isLoading: containerTypeOptionsLoading } =
|
||||||
useContainerTypeOptions(config?.slug === "rates", usesContainerTypeField);
|
useContainerTypeOptions(false, usesContainerTypeField);
|
||||||
const { data: liveRateOptions, isLoading: liveRateOptionsLoading } =
|
const { data: liveRateOptions, isLoading: liveRateOptionsLoading } =
|
||||||
useLiveRateOptions(usesLiveRateField);
|
useLiveRateOptions(usesLiveRateField);
|
||||||
const { data: wagonTypeOptions, isLoading: wagonTypeOptionsLoading } =
|
const { data: wagonTypeOptions, isLoading: wagonTypeOptionsLoading } =
|
||||||
@@ -695,22 +697,6 @@ const RuleEngineResourcePage = () => {
|
|||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<Modal
|
|
||||||
opened={rateError != null}
|
|
||||||
onClose={() => setRateError(null)}
|
|
||||||
title="Cannot save rate change"
|
|
||||||
centered
|
|
||||||
>
|
|
||||||
<Text size="sm" c="red">
|
|
||||||
{rateError}
|
|
||||||
</Text>
|
|
||||||
<Group justify="flex-end" mt="md">
|
|
||||||
<Button variant="light" onClick={() => setRateError(null)}>
|
|
||||||
Close
|
|
||||||
</Button>
|
|
||||||
</Group>
|
|
||||||
</Modal>
|
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
opened={priorityError != null}
|
opened={priorityError != null}
|
||||||
onClose={() => setPriorityError(null)}
|
onClose={() => setPriorityError(null)}
|
||||||
|
|||||||
@@ -0,0 +1,156 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/shared/common/ui/card";
|
||||||
|
import { Input } from "@/shared/common/ui/input";
|
||||||
|
import { Button } from "@/shared/common/ui/button";
|
||||||
|
import { AlertTriangle, CheckCircle2, RefreshCw, Save } from "lucide-react";
|
||||||
|
|
||||||
|
import {
|
||||||
|
useExchangeSettingsQuery,
|
||||||
|
useSetExchangeFallbackRate,
|
||||||
|
} from "@/hooks/useExchangeSettings";
|
||||||
|
import type { ExchangeRateSource } from "@/services/exchangeSettings.service";
|
||||||
|
|
||||||
|
/** Feed health, phrased for an operator rather than a developer. */
|
||||||
|
function feedLabel(source: ExchangeRateSource | null): {
|
||||||
|
live: boolean;
|
||||||
|
text: string;
|
||||||
|
} {
|
||||||
|
switch (source) {
|
||||||
|
case "live":
|
||||||
|
return { live: true, text: "CBE reachable — using the live rate" };
|
||||||
|
case "stored":
|
||||||
|
return {
|
||||||
|
live: false,
|
||||||
|
text: "CBE unreachable — using the fallback rate below",
|
||||||
|
};
|
||||||
|
default:
|
||||||
|
return { live: true, text: "No rate requested yet since the last restart" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatTime = (value: string | null) =>
|
||||||
|
value ? new Date(value).toLocaleString() : "never";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* USD→ETB fallback used when the CBE exchange-rate endpoint is unreachable.
|
||||||
|
* The live CBE rate always wins; every successful fetch overwrites the stored
|
||||||
|
* value, so it tracks the last known good rate on its own. Editing here is for
|
||||||
|
* a prolonged outage — the next successful CBE fetch replaces it.
|
||||||
|
*/
|
||||||
|
export default function ExchangeRateSettingsCard() {
|
||||||
|
const { data, isLoading, refetch, isFetching } = useExchangeSettingsQuery();
|
||||||
|
const setRate = useSetExchangeFallbackRate();
|
||||||
|
const [draft, setDraft] = useState<string>("");
|
||||||
|
|
||||||
|
const value = draft !== "" ? draft : (data?.fallbackRate?.toString() ?? "");
|
||||||
|
const parsed = Number(value);
|
||||||
|
const invalid = !Number.isFinite(parsed) || parsed < 1 || parsed > 10_000;
|
||||||
|
const dirty = draft !== "" && parsed !== data?.fallbackRate;
|
||||||
|
|
||||||
|
const feed = feedLabel(data?.feed?.source ?? null);
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (invalid) return;
|
||||||
|
await setRate.mutateAsync(parsed);
|
||||||
|
setDraft("");
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="shadow-lg border-gray-200 dark:border-gray-700">
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<CardTitle>Exchange rate (USD → ETB)</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Rates come from the Commercial Bank of Ethiopia. The fallback
|
||||||
|
below is used only when CBE cannot be reached, and is refreshed
|
||||||
|
automatically after every successful update.
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => refetch()}
|
||||||
|
disabled={isFetching}
|
||||||
|
>
|
||||||
|
<RefreshCw
|
||||||
|
className={`h-4 w-4 ${isFetching ? "animate-spin" : ""}`}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div
|
||||||
|
className={`flex items-start gap-2 rounded-md border p-3 text-sm ${
|
||||||
|
feed.live
|
||||||
|
? "border-green-200 bg-green-50 text-green-900 dark:border-green-900 dark:bg-green-950 dark:text-green-100"
|
||||||
|
: "border-amber-200 bg-amber-50 text-amber-900 dark:border-amber-900 dark:bg-amber-950 dark:text-amber-100"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{feed.live ? (
|
||||||
|
<CheckCircle2 className="mt-0.5 h-4 w-4 shrink-0" />
|
||||||
|
) : (
|
||||||
|
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
|
||||||
|
)}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="font-medium">{feed.text}</p>
|
||||||
|
{data?.feed?.rate != null && (
|
||||||
|
<p>Rate in use: {data.feed.rate} ETB per USD</p>
|
||||||
|
)}
|
||||||
|
<p className="opacity-80">
|
||||||
|
Last successful update: {formatTime(data?.feed?.lastSuccessAt ?? null)}
|
||||||
|
</p>
|
||||||
|
{data?.feed?.lastError && (
|
||||||
|
<p className="opacity-80">Last error: {data.feed.lastError}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium" htmlFor="fallback-rate">
|
||||||
|
Fallback rate (ETB per USD)
|
||||||
|
</label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Input
|
||||||
|
id="fallback-rate"
|
||||||
|
type="number"
|
||||||
|
step="0.0001"
|
||||||
|
min={1}
|
||||||
|
max={10000}
|
||||||
|
className="max-w-[220px]"
|
||||||
|
disabled={isLoading}
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => setDraft(e.target.value)}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={!dirty || invalid || setRate.isPending}
|
||||||
|
>
|
||||||
|
<Save className="mr-2 h-4 w-4" />
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{invalid && draft !== "" && (
|
||||||
|
<p className="text-sm text-red-600">
|
||||||
|
Enter a rate between 1 and 10,000.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{data?.fallbackSource === "MANUAL"
|
||||||
|
? "Set manually. The next successful CBE update will replace it."
|
||||||
|
: `Synced automatically from CBE (${formatTime(
|
||||||
|
data?.lastSyncedAt ?? null,
|
||||||
|
)}).`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,10 +5,12 @@ import {
|
|||||||
Alert,
|
Alert,
|
||||||
Badge,
|
Badge,
|
||||||
Button,
|
Button,
|
||||||
|
Card,
|
||||||
Group,
|
Group,
|
||||||
Loader,
|
Loader,
|
||||||
Modal,
|
Modal,
|
||||||
SegmentedControl,
|
SegmentedControl,
|
||||||
|
SimpleGrid,
|
||||||
Stack,
|
Stack,
|
||||||
Table,
|
Table,
|
||||||
Text,
|
Text,
|
||||||
@@ -18,16 +20,23 @@ import {
|
|||||||
Checkbox,
|
Checkbox,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { ChevronDown, ChevronRight, History } from "lucide-react";
|
import { ChevronDown, ChevronRight, History } from "lucide-react";
|
||||||
|
import { DataTable, type ColumnDef } from "@edr/ui-common";
|
||||||
|
|
||||||
import { PageContainer, PageHeader } from "@/components/page";
|
import { PageContainer, PageHeader } from "@/components/page";
|
||||||
|
import ListControls from "@/components/common/ListControls";
|
||||||
import RuleEngineListFooter from "@/components/ruleEngine/RuleEngineListFooter";
|
import RuleEngineListFooter from "@/components/ruleEngine/RuleEngineListFooter";
|
||||||
import { useListControls } from "@/hooks/useListControls";
|
import { OverviewHorizontalBarChart } from "@/components/overview/OverviewHorizontalBarChart";
|
||||||
|
import { OverviewStackedBarChart } from "@/components/overview/OverviewStackedBarChart";
|
||||||
|
import { useListControls, toDayString } from "@/hooks/useListControls";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
import { useWarehouseYards, useWarehouseZones } from "@/hooks/useWarehouses";
|
import { useWarehouseYards, useWarehouseZones } from "@/hooks/useWarehouses";
|
||||||
import { api } from "@/services/api";
|
import { api } from "@/services/api";
|
||||||
import { warehouseService } from "@/services/warehouse.service";
|
import { warehouseService } from "@/services/warehouse.service";
|
||||||
import { importOperationsService } from "@/services/importOperations.service";
|
import { importOperationsService } from "@/services/importOperations.service";
|
||||||
import type { EmptyContainerReturnStatus } from "@/types/importOperations";
|
import type {
|
||||||
|
EmptyContainerReturn,
|
||||||
|
EmptyContainerReturnStatus,
|
||||||
|
} from "@/types/importOperations";
|
||||||
|
|
||||||
type ReturnType = "all" | "edr" | "customer";
|
type ReturnType = "all" | "edr" | "customer";
|
||||||
|
|
||||||
@@ -51,6 +60,13 @@ const RETURN_STATUS_LABEL: Record<EmptyContainerReturnStatus, string> = {
|
|||||||
COMPLETED: "Completed",
|
COMPLETED: "Completed",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Fixed series colors (colors follow the entity, never the rank) — pair
|
||||||
|
// validated for CVD separation + surface contrast.
|
||||||
|
const RETURNED_BY_SERIES = [
|
||||||
|
{ key: "edr", label: "EDR Last Mile", color: "#0d9488" },
|
||||||
|
{ key: "customer", label: "Customer Self-Haul", color: "#b45309" },
|
||||||
|
];
|
||||||
|
|
||||||
interface ContainerReturnRow {
|
interface ContainerReturnRow {
|
||||||
key: string;
|
key: string;
|
||||||
containerNumber: string;
|
containerNumber: string;
|
||||||
@@ -186,10 +202,47 @@ export default function ContainerReturnsPage() {
|
|||||||
enabled: bookingIds.length > 0 && !queueLoading,
|
enabled: bookingIds.length > 0 && !queueLoading,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const [statusFilter, setStatusFilter] = useState<string | null>(null);
|
||||||
|
|
||||||
const filteredReturnedContainers = useMemo(() => {
|
const filteredReturnedContainers = useMemo(() => {
|
||||||
if (filterType === "all") return returnedContainers;
|
let rows = returnedContainers as EmptyContainerReturn[];
|
||||||
return returnedContainers.filter((ret: any) => ret.returnedBy === filterType.toUpperCase());
|
if (filterType !== "all") {
|
||||||
}, [returnedContainers, filterType]);
|
rows = rows.filter((ret) => ret.returnedBy === filterType.toUpperCase());
|
||||||
|
}
|
||||||
|
if (statusFilter) {
|
||||||
|
rows = rows.filter((ret) => ret.status === statusFilter);
|
||||||
|
}
|
||||||
|
return rows;
|
||||||
|
}, [returnedContainers, filterType, statusFilter]);
|
||||||
|
|
||||||
|
const returnedControls = useListControls(filteredReturnedContainers, {
|
||||||
|
dateKey: "returnDate",
|
||||||
|
searchValue: (ret) =>
|
||||||
|
`${ret.containerNumber} ${ret.facility ?? ""} ${ret.yard ?? ""} ${ret.condition ?? ""}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Charts read the filtered set, so the controls above drive them too.
|
||||||
|
const returnsPerDay = useMemo(() => {
|
||||||
|
const byDay = new Map<string, { date: string; edr: number; customer: number }>();
|
||||||
|
for (const ret of returnedControls.filteredRows) {
|
||||||
|
const day = toDayString(ret.returnDate);
|
||||||
|
if (!day) continue;
|
||||||
|
const entry = byDay.get(day) ?? { date: day, edr: 0, customer: 0 };
|
||||||
|
if (ret.returnedBy === "CUSTOMER") entry.customer += 1;
|
||||||
|
else entry.edr += 1;
|
||||||
|
byDay.set(day, entry);
|
||||||
|
}
|
||||||
|
return [...byDay.values()].sort((a, b) => a.date.localeCompare(b.date));
|
||||||
|
}, [returnedControls.filteredRows]);
|
||||||
|
|
||||||
|
const returnsByStatus = useMemo(
|
||||||
|
() =>
|
||||||
|
RETURN_STATUS_ORDER.map((status) => ({
|
||||||
|
label: RETURN_STATUS_LABEL[status],
|
||||||
|
value: returnedControls.filteredRows.filter((ret) => ret.status === status).length,
|
||||||
|
})),
|
||||||
|
[returnedControls.filteredRows],
|
||||||
|
);
|
||||||
|
|
||||||
const allGroups = useMemo(() => containerReturnsQuery.data ?? [], [containerReturnsQuery.data]);
|
const allGroups = useMemo(() => containerReturnsQuery.data ?? [], [containerReturnsQuery.data]);
|
||||||
const filteredGroups = useMemo(() => {
|
const filteredGroups = useMemo(() => {
|
||||||
@@ -271,6 +324,103 @@ export default function ContainerReturnsPage() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const returnedColumns: ColumnDef<EmptyContainerReturn>[] = [
|
||||||
|
{
|
||||||
|
id: "containerNumber",
|
||||||
|
header: "Container Number",
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Text fw={600} size="sm">
|
||||||
|
{row.original.containerNumber}
|
||||||
|
</Text>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "bookingRef",
|
||||||
|
header: "Booking Ref",
|
||||||
|
cell: ({ row }) => (row.original.bookingId ? "Associated" : "—"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "returnedBy",
|
||||||
|
header: "Returned By",
|
||||||
|
cell: ({ row }) =>
|
||||||
|
row.original.returnedBy ? (
|
||||||
|
<Badge size="sm" color={row.original.returnedBy === "EDR" ? "edr-green" : "orange"}>
|
||||||
|
{row.original.returnedBy === "EDR" ? "EDR Last Mile" : "Customer Self-Haul"}
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
"—"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "returnDate",
|
||||||
|
header: "Returned Date",
|
||||||
|
cell: ({ row }) =>
|
||||||
|
row.original.returnDate ? new Date(row.original.returnDate).toLocaleDateString() : "—",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "facility",
|
||||||
|
header: "Facility",
|
||||||
|
cell: ({ row }) => row.original.facility || "—",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "yard",
|
||||||
|
header: "Yard",
|
||||||
|
cell: ({ row }) => row.original.yard || "—",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "condition",
|
||||||
|
header: "Condition",
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Text size="sm" lineClamp={2}>
|
||||||
|
{row.original.condition || "—"}
|
||||||
|
</Text>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "status",
|
||||||
|
header: "Status",
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Badge size="sm">
|
||||||
|
{RETURN_STATUS_LABEL[row.original.status] ?? row.original.status}
|
||||||
|
</Badge>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "action",
|
||||||
|
header: "Action",
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const ret = row.original;
|
||||||
|
const nextStatus = RETURN_STATUS_ORDER[RETURN_STATUS_ORDER.indexOf(ret.status) + 1];
|
||||||
|
return (
|
||||||
|
<Group gap="xs" justify="flex-end" wrap="nowrap">
|
||||||
|
<ActionIcon
|
||||||
|
variant="subtle"
|
||||||
|
color="gray"
|
||||||
|
onClick={() => setHistoryRow(ret)}
|
||||||
|
title="View status history"
|
||||||
|
>
|
||||||
|
<History size={14} />
|
||||||
|
</ActionIcon>
|
||||||
|
{nextStatus ? (
|
||||||
|
<Button
|
||||||
|
size="xs"
|
||||||
|
variant="light"
|
||||||
|
loading={advanceStatusMutation.isPending && advanceStatusMutation.variables === ret.id}
|
||||||
|
onClick={() => advanceStatusMutation.mutate(ret.id)}
|
||||||
|
>
|
||||||
|
Advance to {RETURN_STATUS_LABEL[nextStatus]}
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
Done
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
const activeGroup = activeKey ? (filteredGroups.find((g) => `${g.returnType.toLowerCase()}-${g.bookingId}` === activeKey) ?? null) : null;
|
const activeGroup = activeKey ? (filteredGroups.find((g) => `${g.returnType.toLowerCase()}-${g.bookingId}` === activeKey) ?? null) : null;
|
||||||
|
|
||||||
if (queueLoading || containerReturnsQuery.isLoading) {
|
if (queueLoading || containerReturnsQuery.isLoading) {
|
||||||
@@ -305,73 +455,45 @@ export default function ContainerReturnsPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
{filteredReturnedContainers.length > 0 && (
|
{returnedContainers.length > 0 && (
|
||||||
<>
|
<Card withBorder radius="lg" p="md" mb="lg">
|
||||||
<Text fw={600} mb="xs">Returned Containers</Text>
|
<Stack gap="md">
|
||||||
<Table.ScrollContainer minWidth={1000} mb="lg">
|
<Text fw={600}>Returned Containers</Text>
|
||||||
<Table striped highlightOnHover verticalSpacing="xs">
|
<ListControls
|
||||||
<Table.Thead>
|
search={returnedControls.search}
|
||||||
<Table.Tr>
|
onSearchChange={returnedControls.setSearch}
|
||||||
<Table.Th>Container Number</Table.Th>
|
searchPlaceholder="Search container, facility, condition…"
|
||||||
<Table.Th>Booking Ref</Table.Th>
|
dateFrom={returnedControls.dateFrom}
|
||||||
<Table.Th>Returned By</Table.Th>
|
onDateFromChange={returnedControls.setDateFrom}
|
||||||
<Table.Th>Returned Date</Table.Th>
|
dateTo={returnedControls.dateTo}
|
||||||
<Table.Th>Facility</Table.Th>
|
onDateToChange={returnedControls.setDateTo}
|
||||||
<Table.Th>Yard</Table.Th>
|
dateLabel="Returned"
|
||||||
<Table.Th>Condition</Table.Th>
|
hasFilters={returnedControls.hasFilters || Boolean(statusFilter)}
|
||||||
<Table.Th>Status</Table.Th>
|
onReset={() => {
|
||||||
<Table.Th ta="right">Action</Table.Th>
|
returnedControls.reset();
|
||||||
</Table.Tr>
|
setStatusFilter(null);
|
||||||
</Table.Thead>
|
}}
|
||||||
<Table.Tbody>
|
>
|
||||||
{filteredReturnedContainers.map((ret: any) => {
|
<Select
|
||||||
const nextStatus = RETURN_STATUS_ORDER[RETURN_STATUS_ORDER.indexOf(ret.status) + 1];
|
placeholder="Status"
|
||||||
return (
|
value={statusFilter}
|
||||||
<Table.Tr key={ret.id}>
|
onChange={setStatusFilter}
|
||||||
<Table.Td>{ret.containerNumber}</Table.Td>
|
data={RETURN_STATUS_ORDER.map((status) => ({
|
||||||
<Table.Td>{ret.bookingId ? "Associated" : "—"}</Table.Td>
|
value: status,
|
||||||
<Table.Td>
|
label: RETURN_STATUS_LABEL[status],
|
||||||
{ret.returnedBy ? (
|
}))}
|
||||||
<Badge size="sm" color={ret.returnedBy === "EDR" ? "edr-green" : "blue"}>
|
clearable
|
||||||
{ret.returnedBy === "EDR" ? "EDR Last Mile" : "Customer Self-Haul"}
|
w={200}
|
||||||
</Badge>
|
/>
|
||||||
) : (
|
</ListControls>
|
||||||
"—"
|
<DataTable
|
||||||
)}
|
columns={returnedColumns}
|
||||||
</Table.Td>
|
data={returnedControls.pagedRows}
|
||||||
<Table.Td>{ret.returnDate ? new Date(ret.returnDate).toLocaleDateString() : "—"}</Table.Td>
|
containerClassName="border-0 shadow-none"
|
||||||
<Table.Td>{ret.facility || "—"}</Table.Td>
|
{...returnedControls.tableProps}
|
||||||
<Table.Td>{ret.yard || "—"}</Table.Td>
|
/>
|
||||||
<Table.Td>{ret.condition || "—"}</Table.Td>
|
</Stack>
|
||||||
<Table.Td>
|
</Card>
|
||||||
<Badge size="sm">{RETURN_STATUS_LABEL[ret.status as EmptyContainerReturnStatus] ?? ret.status}</Badge>
|
|
||||||
</Table.Td>
|
|
||||||
<Table.Td ta="right">
|
|
||||||
<Group gap="xs" justify="flex-end" wrap="nowrap">
|
|
||||||
<ActionIcon variant="subtle" color="gray" onClick={() => setHistoryRow(ret)} title="View status history">
|
|
||||||
<History size={14} />
|
|
||||||
</ActionIcon>
|
|
||||||
{nextStatus ? (
|
|
||||||
<Button
|
|
||||||
size="xs"
|
|
||||||
variant="light"
|
|
||||||
loading={advanceStatusMutation.isPending && advanceStatusMutation.variables === ret.id}
|
|
||||||
onClick={() => advanceStatusMutation.mutate(ret.id)}
|
|
||||||
>
|
|
||||||
Advance to {RETURN_STATUS_LABEL[nextStatus]}
|
|
||||||
</Button>
|
|
||||||
) : (
|
|
||||||
<Text size="xs" c="dimmed">Done</Text>
|
|
||||||
)}
|
|
||||||
</Group>
|
|
||||||
</Table.Td>
|
|
||||||
</Table.Tr>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</Table.Tbody>
|
|
||||||
</Table>
|
|
||||||
</Table.ScrollContainer>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{filteredGroups.length === 0 ? (
|
{filteredGroups.length === 0 ? (
|
||||||
@@ -471,6 +593,26 @@ export default function ContainerReturnsPage() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{returnedContainers.length > 0 && (
|
||||||
|
<SimpleGrid cols={{ base: 1, lg: 2 }} spacing="md" mt="lg">
|
||||||
|
<OverviewStackedBarChart
|
||||||
|
title="Returns per day by truck type"
|
||||||
|
data={returnsPerDay}
|
||||||
|
series={RETURNED_BY_SERIES}
|
||||||
|
formatXLabel={(value) =>
|
||||||
|
new Date(value).toLocaleDateString(undefined, { day: "numeric", month: "short" })
|
||||||
|
}
|
||||||
|
emptyMessage="No returns in this range"
|
||||||
|
/>
|
||||||
|
<OverviewHorizontalBarChart
|
||||||
|
title="Returns by status"
|
||||||
|
data={returnsByStatus}
|
||||||
|
valueLabel="Containers"
|
||||||
|
emptyMessage="No returns in this range"
|
||||||
|
/>
|
||||||
|
</SimpleGrid>
|
||||||
|
)}
|
||||||
|
|
||||||
<ContainerReturnModal
|
<ContainerReturnModal
|
||||||
opened={returnModalOpen}
|
opened={returnModalOpen}
|
||||||
onClose={() => setReturnModalOpen(false)}
|
onClose={() => setReturnModalOpen(false)}
|
||||||
|
|||||||
@@ -1605,6 +1605,15 @@ export const api = {
|
|||||||
undefined,
|
undefined,
|
||||||
() => [["routes"]],
|
() => [["routes"]],
|
||||||
),
|
),
|
||||||
|
|
||||||
|
/** Irreversible purge — refused while any train schedule uses the route. */
|
||||||
|
purge: endpoint<string, void>(
|
||||||
|
"routes",
|
||||||
|
"purge",
|
||||||
|
(id) => routesService.purge(id).then(() => undefined),
|
||||||
|
undefined,
|
||||||
|
() => [["routes"]],
|
||||||
|
),
|
||||||
},
|
},
|
||||||
|
|
||||||
stations: {
|
stations: {
|
||||||
@@ -2194,6 +2203,15 @@ export const api = {
|
|||||||
undefined,
|
undefined,
|
||||||
({ slug }) => [QUERY_KEYS.FLEET.list(slug)],
|
({ slug }) => [QUERY_KEYS.FLEET.list(slug)],
|
||||||
),
|
),
|
||||||
|
|
||||||
|
/** Irreversible purge — locomotives and wagons only. */
|
||||||
|
purge: endpoint<{ slug: FleetResourceSlug; id: string }, unknown>(
|
||||||
|
"fleet",
|
||||||
|
"purge",
|
||||||
|
({ slug, id }) => fleetService.purge(slug, id),
|
||||||
|
undefined,
|
||||||
|
({ slug }) => [QUERY_KEYS.FLEET.list(slug)],
|
||||||
|
),
|
||||||
},
|
},
|
||||||
|
|
||||||
truckTypes: {
|
truckTypes: {
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { api as client } from "../auth/http";
|
||||||
|
import { unwrap } from "@/utils/endpoint";
|
||||||
|
import { URL_CONSTANTS } from "@/constants/URLS";
|
||||||
|
import type { ApiResponse } from "@/types/apiResponse";
|
||||||
|
|
||||||
|
const BASE = URL_CONSTANTS.EXCHANGE_SETTINGS.BASE;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where the rate the API last served came from. `live` means CBE answered;
|
||||||
|
* `stored` means it is failing and the fallback is in use.
|
||||||
|
*/
|
||||||
|
export type ExchangeRateSource = "live" | "stored";
|
||||||
|
|
||||||
|
/** Health of the CBE exchange-rate feed. */
|
||||||
|
export interface ExchangeFeedStatus {
|
||||||
|
rate: number | null;
|
||||||
|
source: ExchangeRateSource | null;
|
||||||
|
lastSuccessAt: string | null;
|
||||||
|
lastError: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExchangeSettings {
|
||||||
|
fallbackRate: number;
|
||||||
|
/** `AUTO` when synced from CBE, `MANUAL` when set here. */
|
||||||
|
fallbackSource: "AUTO" | "MANUAL";
|
||||||
|
lastSyncedAt: string | null;
|
||||||
|
updatedById: string | null;
|
||||||
|
feed?: ExchangeFeedStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const exchangeSettingsService = {
|
||||||
|
get: async (): Promise<ExchangeSettings> => {
|
||||||
|
const response = await client.get<ApiResponse<ExchangeSettings>>(BASE);
|
||||||
|
return unwrap(response.data);
|
||||||
|
},
|
||||||
|
|
||||||
|
setFallbackRate: async (fallbackRate: number): Promise<ExchangeSettings> => {
|
||||||
|
const response = await client.patch<ApiResponse<ExchangeSettings>>(BASE, {
|
||||||
|
fallbackRate,
|
||||||
|
});
|
||||||
|
return unwrap(response.data);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -77,6 +77,21 @@ const removeHandlers: Record<FleetResourceSlug, (id: string) => Promise<unknown>
|
|||||||
drivers: (id) => driversService.delete(id),
|
drivers: (id) => driversService.delete(id),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Permanent purge, only for the two slugs that expose it. Everything else stays
|
||||||
|
* soft-delete/decommission only, so there is deliberately no entry here.
|
||||||
|
*/
|
||||||
|
const purgeHandlers: Partial<
|
||||||
|
Record<FleetResourceSlug, (id: string) => Promise<unknown>>
|
||||||
|
> = {
|
||||||
|
locomotives: (id) => locomotivesService.purge(id),
|
||||||
|
wagons: (id) => wagonService.purge(id),
|
||||||
|
};
|
||||||
|
|
||||||
|
/** True when the slug supports an irreversible purge. */
|
||||||
|
export const isFleetPurgeable = (slug: FleetResourceSlug) =>
|
||||||
|
slug in purgeHandlers;
|
||||||
|
|
||||||
export const fleetService = {
|
export const fleetService = {
|
||||||
list: (slug: FleetResourceSlug, filters?: FleetListFilters) => listHandlers[slug](filters),
|
list: (slug: FleetResourceSlug, filters?: FleetListFilters) => listHandlers[slug](filters),
|
||||||
/** Only for slugs in `pagedHandlers` — guard with `isFleetServerPaginated`. */
|
/** Only for slugs in `pagedHandlers` — guard with `isFleetServerPaginated`. */
|
||||||
@@ -89,4 +104,11 @@ export const fleetService = {
|
|||||||
update: (slug: FleetResourceSlug, id: string, data: Record<string, unknown>) =>
|
update: (slug: FleetResourceSlug, id: string, data: Record<string, unknown>) =>
|
||||||
updateHandlers[slug](id, data),
|
updateHandlers[slug](id, data),
|
||||||
remove: (slug: FleetResourceSlug, id: string) => removeHandlers[slug](id),
|
remove: (slug: FleetResourceSlug, id: string) => removeHandlers[slug](id),
|
||||||
|
purge: (slug: FleetResourceSlug, id: string) => {
|
||||||
|
const handler = purgeHandlers[slug];
|
||||||
|
if (!handler) {
|
||||||
|
throw new Error(`Fleet resource "${slug}" cannot be permanently deleted`);
|
||||||
|
}
|
||||||
|
return handler(id);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -85,4 +85,7 @@ export const locomotivesService = {
|
|||||||
update: (id: string, data: Partial<SaveLocomotivePayload>) =>
|
update: (id: string, data: Partial<SaveLocomotivePayload>) =>
|
||||||
apiClient.patch(URL_CONSTANTS.LOCOMOTIVES.BY_ID(id), data),
|
apiClient.patch(URL_CONSTANTS.LOCOMOTIVES.BY_ID(id), data),
|
||||||
decommission: (id: string) => apiClient.post(URL_CONSTANTS.LOCOMOTIVES.DECOMMISSION(id), {}),
|
decommission: (id: string) => apiClient.post(URL_CONSTANTS.LOCOMOTIVES.DECOMMISSION(id), {}),
|
||||||
|
/** Irreversible purge — the API refuses it while any train references the loco. */
|
||||||
|
purge: (id: string) =>
|
||||||
|
apiClient.delete(`${URL_CONSTANTS.LOCOMOTIVES.BY_ID(id)}/permanent`),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -101,6 +101,9 @@ export const routesService = {
|
|||||||
update: (id: string, data: Partial<SaveRoutePayload>) =>
|
update: (id: string, data: Partial<SaveRoutePayload>) =>
|
||||||
apiClient.patch(URL_CONSTANTS.ROUTES.BY_ID(id), data),
|
apiClient.patch(URL_CONSTANTS.ROUTES.BY_ID(id), data),
|
||||||
deactivate: (id: string) => apiClient.delete(URL_CONSTANTS.ROUTES.BY_ID(id)),
|
deactivate: (id: string) => apiClient.delete(URL_CONSTANTS.ROUTES.BY_ID(id)),
|
||||||
|
/** Irreversible purge — the API refuses it while any train schedule uses the route. */
|
||||||
|
purge: (id: string) =>
|
||||||
|
apiClient.delete(`${URL_CONSTANTS.ROUTES.BY_ID(id)}/permanent`),
|
||||||
/** All active yards (page-walked — the yards list API caps pageSize at 100). */
|
/** All active yards (page-walked — the yards list API caps pageSize at 100). */
|
||||||
getYards: async (): Promise<YardRef[]> => {
|
getYards: async (): Promise<YardRef[]> => {
|
||||||
const rows = await ruleEngineService.listAll("yards", { isActive: true });
|
const rows = await ruleEngineService.listAll("yards", { isActive: true });
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ export interface FaydaCompleteResult {
|
|||||||
userDataSaved?: boolean;
|
userDataSaved?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Message posted from the /callback popup back to the opener window. */
|
/** Message posted from the /fayda/callback popup back to the opener window. */
|
||||||
export interface FaydaCallbackMessage {
|
export interface FaydaCallbackMessage {
|
||||||
type: 'fayda-callback';
|
type: 'fayda-callback';
|
||||||
code?: string;
|
code?: string;
|
||||||
|
|||||||
@@ -123,6 +123,8 @@ export const wagonService = {
|
|||||||
create: (data: Partial<Wagon>) => apiClient.post('/wagons', data),
|
create: (data: Partial<Wagon>) => apiClient.post('/wagons', data),
|
||||||
update: (id: string, data: Partial<Wagon>) => apiClient.patch(`/wagons/${id}`, data),
|
update: (id: string, data: Partial<Wagon>) => apiClient.patch(`/wagons/${id}`, data),
|
||||||
delete: (id: string) => apiClient.delete(`/wagons/${id}`),
|
delete: (id: string) => apiClient.delete(`/wagons/${id}`),
|
||||||
|
/** Irreversible purge — the API refuses it when the wagon has any history. */
|
||||||
|
purge: (id: string) => apiClient.delete(`/wagons/${id}/permanent`),
|
||||||
/** Relocate many wagons to one yard in a single call (writes movement ledger). */
|
/** Relocate many wagons to one yard in a single call (writes movement ledger). */
|
||||||
bulkTransfer: (wagonIds: string[], toYardId: string) =>
|
bulkTransfer: (wagonIds: string[], toYardId: string) =>
|
||||||
apiClient.post<{ moved: number }>('/wagons/bulk-transfer', { wagonIds, toYardId }),
|
apiClient.post<{ moved: number }>('/wagons/bulk-transfer', { wagonIds, toYardId }),
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import ExternalPortalCallback from "@/external-portal/components/Registration/Ex
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Single FAYDA OIDC callback entry point.
|
* Single FAYDA OIDC callback entry point.
|
||||||
* Fayda only allows whitelisted redirect URIs (e.g. /callback) — route
|
* Fayda only allows whitelisted redirect URIs (e.g. /fayda/callback) — route
|
||||||
* internally based on the `state` param sent during authorization.
|
* internally based on the `state` param sent during authorization.
|
||||||
*/
|
*/
|
||||||
export default function FaydaCallbackDispatcher() {
|
export default function FaydaCallbackDispatcher() {
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ const PUBLIC_PATHS = [
|
|||||||
"/set-password",
|
"/set-password",
|
||||||
"/verify-otp",
|
"/verify-otp",
|
||||||
"/verification_page",
|
"/verification_page",
|
||||||
"/callback",
|
"/fayda/callback",
|
||||||
"/complaints",
|
"/complaints",
|
||||||
"/complaint-form",
|
"/complaint-form",
|
||||||
"/follow-complaint",
|
"/follow-complaint",
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ const DEFAULT_CODE_CHALLENGE = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM";
|
|||||||
const DEFAULT_NONCE = "g4DEuje5Fx57Vb64dO4oqLHXGT8L8G7g";
|
const DEFAULT_NONCE = "g4DEuje5Fx57Vb64dO4oqLHXGT8L8G7g";
|
||||||
const DEFAULT_STATE = "ptOO76SD";
|
const DEFAULT_STATE = "ptOO76SD";
|
||||||
|
|
||||||
/** OIDC state value that routes the shared /callback to the complaint flow (legacy sign-in). */
|
/** OIDC state value that routes the shared /fayda/callback to the complaint flow (legacy sign-in). */
|
||||||
export const COMPLAINT_FLOW_STATE = "complaint_flow";
|
export const COMPLAINT_FLOW_STATE = "complaint_flow";
|
||||||
|
|
||||||
/** Complaint flow OIDC states — distinguish sign-in vs sign-up endpoints. */
|
/** Complaint flow OIDC states — distinguish sign-in vs sign-up endpoints. */
|
||||||
@@ -66,9 +66,7 @@ export function startExternalPortalFaydaAuth(): void {
|
|||||||
export function generateFaydaAuthorizationUrl(
|
export function generateFaydaAuthorizationUrl(
|
||||||
options: FaydaOidcOptions = {},
|
options: FaydaOidcOptions = {},
|
||||||
): string {
|
): string {
|
||||||
const redirectUri =
|
const redirectUri = options.redirectUri || getDefaultFaydaRedirectUri();
|
||||||
options.redirectUri ||
|
|
||||||
getDefaultFaydaRedirectUri();
|
|
||||||
|
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
client_id: import.meta.env.VITE_CLIENT_ID || "",
|
client_id: import.meta.env.VITE_CLIENT_ID || "",
|
||||||
@@ -98,7 +96,7 @@ export function generateFaydaAuthorizationUrl(
|
|||||||
export function getDefaultFaydaRedirectUri(): string {
|
export function getDefaultFaydaRedirectUri(): string {
|
||||||
return (
|
return (
|
||||||
import.meta.env.VITE_REDIRECT_URI ||
|
import.meta.env.VITE_REDIRECT_URI ||
|
||||||
`${window.location.origin}/callback`
|
`${window.location.origin}/fayda/callback`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,13 +104,12 @@ export function getDefaultFaydaRedirectUri(): string {
|
|||||||
* Returns the redirect URI registered with FAYDA for the complaint flow.
|
* Returns the redirect URI registered with FAYDA for the complaint flow.
|
||||||
*
|
*
|
||||||
* Must exactly match a URI whitelisted in the FAYDA OIDC client — we reuse
|
* Must exactly match a URI whitelisted in the FAYDA OIDC client — we reuse
|
||||||
* the same /callback path as external-portal registration and distinguish
|
* the same /fayda/callback path as external-portal registration and distinguish
|
||||||
* flows via the `state` parameter (see COMPLAINT_FLOW_STATE).
|
* flows via the `state` parameter (see COMPLAINT_FLOW_STATE).
|
||||||
*/
|
*/
|
||||||
export function getComplaintFaydaRedirectUri(): string {
|
export function getComplaintFaydaRedirectUri(): string {
|
||||||
return (
|
return (
|
||||||
import.meta.env.VITE_COMPLAINT_REDIRECT_URI ||
|
import.meta.env.VITE_COMPLAINT_REDIRECT_URI || getDefaultFaydaRedirectUri()
|
||||||
getDefaultFaydaRedirectUri()
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite --port 5173 --clearScreen false",
|
"dev": "vite --port 3000 --clearScreen false",
|
||||||
"build": "tsc -b && vite build",
|
"build": "tsc -b && vite build",
|
||||||
"preview": "vite preview --port 5173",
|
"preview": "vite preview --port 5173",
|
||||||
"lint": "eslint src",
|
"lint": "eslint src",
|
||||||
|
|||||||
@@ -253,113 +253,116 @@ const App = () => {
|
|||||||
{/* Global API error modal — shows the server's actual error message for
|
{/* Global API error modal — shows the server's actual error message for
|
||||||
every failed request (suppressed on onboarding/auth pages). */}
|
every failed request (suppressed on onboarding/auth pages). */}
|
||||||
<ApiErrorModal />
|
<ApiErrorModal />
|
||||||
<Routes>
|
<Routes>
|
||||||
{/* Public routes */}
|
{/* Public routes */}
|
||||||
<Route index element={<LandingRoute />} />
|
<Route index element={<LandingRoute />} />
|
||||||
<Route path="/logout" element={<LogoutHandler />} />
|
<Route path="/logout" element={<LogoutHandler />} />
|
||||||
<Route
|
<Route
|
||||||
path="/booking/check-status/:orderId"
|
path="/booking/check-status/:orderId"
|
||||||
element={<CheckPaymentPage />}
|
element={<CheckPaymentPage />}
|
||||||
/>
|
/>
|
||||||
{/* Payment provider browser redirects (PAYMENT_RETURN_URL / PAYMENT_FAILURE_URL) */}
|
{/* Payment provider browser redirects (PAYMENT_RETURN_URL / PAYMENT_FAILURE_URL) */}
|
||||||
{/* Fayda (eSignet) redirect_uri — runs in the verification popup and
|
{/* Fayda (eSignet) redirect_uri — the whole tab lands here after
|
||||||
relays the code/state back to the form that opened it. */}
|
verification, completes the code/state exchange and navigates back
|
||||||
<Route path="/callback" element={<FaydaCallbackPage />} />
|
to the page that started it. Public on purpose: behind RequireAuth
|
||||||
<Route path="/payment/success" element={<PaymentSuccessPage />} />
|
the onboarding gate would redirect away before the exchange ran. */}
|
||||||
<Route path="/payment/failure" element={<PaymentFailurePage />} />
|
<Route path="/fayda/callback" element={<FaydaCallbackPage />} />
|
||||||
|
<Route path="/callback" element={<FaydaCallbackPage />} />
|
||||||
|
<Route path="/payment/success" element={<PaymentSuccessPage />} />
|
||||||
|
<Route path="/payment/failure" element={<PaymentFailurePage />} />
|
||||||
|
|
||||||
{/* Auth pages — inaccessible once logged in */}
|
{/* Auth pages — inaccessible once logged in */}
|
||||||
<Route element={<RedirectIfAuthed />}>
|
<Route element={<RedirectIfAuthed />}>
|
||||||
<Route path="/login" element={<LoginPage />} />
|
<Route path="/login" element={<LoginPage />} />
|
||||||
<Route path="/signup" element={<SignupPage />} />
|
<Route path="/signup" element={<SignupPage />} />
|
||||||
<Route path="/forgot-password" element={<ForgotPasswordPage />} />
|
<Route path="/forgot-password" element={<ForgotPasswordPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
{/* Staff-issued reset links land here. Deliberately outside
|
{/* Staff-issued reset links land here. Deliberately outside
|
||||||
RedirectIfAuthed: a customer with a stale session still needs the link
|
RedirectIfAuthed: a customer with a stale session still needs the link
|
||||||
to work, and the token — not the session — is what authorises it. */}
|
to work, and the token — not the session — is what authorises it. */}
|
||||||
<Route path="/reset-password" element={<ResetPasswordLinkPage />} />
|
<Route path="/reset-password" element={<ResetPasswordLinkPage />} />
|
||||||
|
|
||||||
{/* Signup-flow pages; reached while a session already exists */}
|
{/* Signup-flow pages; reached while a session already exists */}
|
||||||
<Route path="/otp" element={<VerificationOtpPage />} />
|
<Route path="/otp" element={<VerificationOtpPage />} />
|
||||||
<Route path="/set-password" element={<SetPasswordPage />} />
|
<Route path="/set-password" element={<SetPasswordPage />} />
|
||||||
|
|
||||||
<Route element={<RequireAuth />}>
|
<Route element={<RequireAuth />}>
|
||||||
<Route element={<RequireCompany />}>
|
<Route element={<RequireCompany />}>
|
||||||
<Route
|
<Route
|
||||||
element={
|
element={
|
||||||
<AppLayout
|
<AppLayout
|
||||||
title="EDR Freight"
|
title="EDR Freight"
|
||||||
sidebarItems={sidebarItems}
|
sidebarItems={sidebarItems}
|
||||||
activeHref={location.pathname}
|
activeHref={location.pathname}
|
||||||
onNavigate={navigate}
|
onNavigate={navigate}
|
||||||
enableThemeToggle
|
enableThemeToggle
|
||||||
userName={displayName}
|
userName={displayName}
|
||||||
userEmail={userEmail}
|
userEmail={userEmail}
|
||||||
companyProfiles={companyProfiles}
|
companyProfiles={companyProfiles}
|
||||||
companyType={companyType}
|
companyType={companyType}
|
||||||
onCreateProfile={createProfile}
|
onCreateProfile={createProfile}
|
||||||
onReapplyProfile={reapplyProfile}
|
onReapplyProfile={reapplyProfile}
|
||||||
>
|
>
|
||||||
<OnboardingGate />
|
<OnboardingGate />
|
||||||
</AppLayout>
|
</AppLayout>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Route path="/portal" element={<MyPortalPage />} />
|
<Route path="/portal" element={<MyPortalPage />} />
|
||||||
{/* Bookings are created against a contract, but the full list is
|
{/* Bookings are created against a contract, but the full list is
|
||||||
browsable here. New-booking entry still routes via a contract. */}
|
browsable here. New-booking entry still routes via a contract. */}
|
||||||
<Route path="/bookings" element={<BookingsListPage />} />
|
<Route path="/bookings" element={<BookingsListPage />} />
|
||||||
<Route
|
<Route
|
||||||
path="/bookings/new"
|
path="/bookings/new"
|
||||||
element={<Navigate to="/contracts/new" replace />}
|
element={<Navigate to="/contracts/new" replace />}
|
||||||
/>
|
/>
|
||||||
<Route path="/bookings/:id/edit" element={<EditBookingPage />} />
|
<Route path="/bookings/:id/edit" element={<EditBookingPage />} />
|
||||||
<Route path="/bookings/:id" element={<BookingDetailPage />} />
|
<Route path="/bookings/:id" element={<BookingDetailPage />} />
|
||||||
<Route
|
<Route
|
||||||
path="/bookings/:id/contract"
|
path="/bookings/:id/contract"
|
||||||
element={<BookingContractPage />}
|
element={<BookingContractPage />}
|
||||||
/>
|
/>
|
||||||
<Route path="/contracts" element={<ContractsList />} />
|
<Route path="/contracts" element={<ContractsList />} />
|
||||||
<Route path="/contracts/new" element={<NewContractPage />} />
|
<Route path="/contracts/new" element={<NewContractPage />} />
|
||||||
<Route
|
<Route
|
||||||
path="/contracts/:id/edit"
|
path="/contracts/:id/edit"
|
||||||
element={<NewContractPage mode="edit" />}
|
element={<NewContractPage mode="edit" />}
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/contracts/:id/shipment-requests/new"
|
path="/contracts/:id/shipment-requests/new"
|
||||||
element={<NewShipmentRequestPage />}
|
element={<NewShipmentRequestPage />}
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/contracts/:id/bookings/new"
|
path="/contracts/:id/bookings/new"
|
||||||
element={<NewShipmentPage />}
|
element={<NewShipmentPage />}
|
||||||
/>
|
/>
|
||||||
{/* Completion of an initiated (bare) booking after per-booking
|
{/* Completion of an initiated (bare) booking after per-booking
|
||||||
clearance — same form, submits to the complete endpoint. */}
|
clearance — same form, submits to the complete endpoint. */}
|
||||||
<Route
|
<Route
|
||||||
path="/contracts/:id/bookings/:bookingId/complete"
|
path="/contracts/:id/bookings/:bookingId/complete"
|
||||||
element={<NewShipmentPage />}
|
element={<NewShipmentPage />}
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/contracts/:id/view"
|
path="/contracts/:id/view"
|
||||||
element={<ContractViewPage />}
|
element={<ContractViewPage />}
|
||||||
/>
|
/>
|
||||||
<Route path="/contracts/:id" element={<ContractDetailPage />} />
|
<Route path="/contracts/:id" element={<ContractDetailPage />} />
|
||||||
<Route path="/tracking" element={<TrackingPage />} />
|
<Route path="/tracking" element={<TrackingPage />} />
|
||||||
<Route path="/billing" element={<InvoicesList />} />
|
<Route path="/billing" element={<InvoicesList />} />
|
||||||
<Route path="/billing/:id" element={<InvoiceDetailPage />} />
|
<Route path="/billing/:id" element={<InvoiceDetailPage />} />
|
||||||
{/* Profile was merged into Settings — keep old links working. */}
|
{/* Profile was merged into Settings — keep old links working. */}
|
||||||
<Route
|
<Route
|
||||||
path="/profile"
|
path="/profile"
|
||||||
element={<Navigate to="/settings" replace />}
|
element={<Navigate to="/settings" replace />}
|
||||||
/>
|
/>
|
||||||
<Route path="/signature" element={<MySignaturePage />} />
|
<Route path="/signature" element={<MySignaturePage />} />
|
||||||
<Route path="/settings" element={<SettingsPage />} />
|
<Route path="/settings" element={<SettingsPage />} />
|
||||||
|
</Route>
|
||||||
</Route>
|
</Route>
|
||||||
</Route>
|
</Route>
|
||||||
</Route>
|
|
||||||
|
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useRef, useState, type ReactNode } from "react";
|
import { useState, type ReactNode } from "react";
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
Avatar,
|
Avatar,
|
||||||
@@ -20,9 +20,8 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
stashPendingVerification,
|
||||||
verifaydaService,
|
verifaydaService,
|
||||||
type CompanyIdentityState,
|
|
||||||
type FaydaCallbackMessage,
|
|
||||||
type IdentitySubject,
|
type IdentitySubject,
|
||||||
type IdentityVerificationState,
|
type IdentityVerificationState,
|
||||||
} from "@/services/verifayda.service";
|
} from "@/services/verifayda.service";
|
||||||
@@ -38,8 +37,6 @@ interface FaydaVerifyPanelProps {
|
|||||||
* on it, so the panel says so rather than nagging.
|
* on it, so the panel says so rather than nagging.
|
||||||
*/
|
*/
|
||||||
required: boolean;
|
required: boolean;
|
||||||
/** Called with the fresh company-wide state once a verification lands. */
|
|
||||||
onVerified: (next: CompanyIdentityState) => void;
|
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
/**
|
/**
|
||||||
* True when a fresh verification for this person is already staged in a
|
* True when a fresh verification for this person is already staged in a
|
||||||
@@ -61,115 +58,45 @@ function getInitials(name: string | null): string {
|
|||||||
/**
|
/**
|
||||||
* Verify one of the company's people through Fayda and show what came back.
|
* Verify one of the company's people through Fayda and show what came back.
|
||||||
*
|
*
|
||||||
* The identity is proved in an eSignet popup; that popup lands on /callback,
|
* The identity is proved on eSignet, which the whole tab navigates to — no
|
||||||
* which relays the code+state here by postMessage. This window then completes
|
* popup, because a popup opened after the /start round-trip has lost its user
|
||||||
* the exchange — once, in one place — and the API writes the person's name,
|
* activation and iOS Safari blocks it outright. eSignet redirects back to
|
||||||
* phone, email and address from the verified payload. Nothing on this panel
|
* /fayda/callback, which completes the exchange and returns the user here; the API
|
||||||
* is typed.
|
* writes the person's name, phone, email and address from the verified
|
||||||
|
* payload. Nothing on this panel is typed.
|
||||||
*/
|
*/
|
||||||
export default function FaydaVerifyPanel({
|
export default function FaydaVerifyPanel({
|
||||||
subject,
|
subject,
|
||||||
title,
|
title,
|
||||||
state,
|
state,
|
||||||
required,
|
required,
|
||||||
onVerified,
|
|
||||||
disabled,
|
disabled,
|
||||||
pendingReview,
|
pendingReview,
|
||||||
}: FaydaVerifyPanelProps) {
|
}: FaydaVerifyPanelProps) {
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
// The listener closes over `subject`; keep it in a ref so remounting the
|
|
||||||
// panel between steps can't complete a verification against the wrong person.
|
|
||||||
const subjectRef = useRef(subject);
|
|
||||||
subjectRef.current = subject;
|
|
||||||
// FaydaCallbackPage posts its message from a StrictMode-double-invoked
|
|
||||||
// effect in dev, so the same one-time-use code+state can arrive twice.
|
|
||||||
// Track the last state we've started completing so the resend is a no-op.
|
|
||||||
const handledStateRef = useRef<string | null>(null);
|
|
||||||
// Polls the popup so a manually-closed window (no postMessage ever sent)
|
|
||||||
// still clears `loading` instead of leaving the button spinning forever.
|
|
||||||
const pollRef = useRef<number | null>(null);
|
|
||||||
|
|
||||||
const stopPolling = () => {
|
|
||||||
if (pollRef.current !== null) {
|
|
||||||
window.clearInterval(pollRef.current);
|
|
||||||
pollRef.current = null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const onMessage = async (event: MessageEvent<FaydaCallbackMessage>) => {
|
|
||||||
if (event.origin !== window.location.origin) return;
|
|
||||||
if (event.data?.type !== "fayda-callback") return;
|
|
||||||
|
|
||||||
if (event.data.error) {
|
|
||||||
stopPolling();
|
|
||||||
setLoading(false);
|
|
||||||
setError(event.data.errorDescription ?? event.data.error);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!event.data.code || !event.data.state) return;
|
|
||||||
if (handledStateRef.current === event.data.state) return;
|
|
||||||
handledStateRef.current = event.data.state;
|
|
||||||
stopPolling();
|
|
||||||
|
|
||||||
try {
|
|
||||||
const next = await verifaydaService.completeIdentity(
|
|
||||||
subjectRef.current,
|
|
||||||
event.data.code,
|
|
||||||
event.data.state,
|
|
||||||
);
|
|
||||||
setError(null);
|
|
||||||
onVerified(next);
|
|
||||||
} catch (err) {
|
|
||||||
setError(
|
|
||||||
(err as { response?: { data?: { message?: string } } })?.response?.data
|
|
||||||
?.message ??
|
|
||||||
(err instanceof Error ? err.message : "Verification failed"),
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
window.addEventListener("message", onMessage);
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener("message", onMessage);
|
|
||||||
stopPolling();
|
|
||||||
};
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const startVerification = async () => {
|
const startVerification = async () => {
|
||||||
setError(null);
|
setError(null);
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
handledStateRef.current = null;
|
|
||||||
try {
|
try {
|
||||||
const authorizationUrl = await verifaydaService.start();
|
const authorizationUrl = await verifaydaService.start();
|
||||||
const popup = window.open(
|
// Record who is being verified and where to come back to before the tab
|
||||||
authorizationUrl,
|
// leaves — /fayda/callback has no other way to know either.
|
||||||
"fayda-verify",
|
stashPendingVerification({
|
||||||
"width=480,height=760,noopener=no",
|
subject,
|
||||||
);
|
returnTo:
|
||||||
if (!popup) {
|
window.location.pathname +
|
||||||
setLoading(false);
|
window.location.search +
|
||||||
setError("Pop-up blocked — allow pop-ups for this site and try again.");
|
window.location.hash,
|
||||||
return;
|
});
|
||||||
}
|
window.location.assign(authorizationUrl);
|
||||||
// Loading stays on until the popup posts back — unless the user closes
|
|
||||||
// it by hand, which never sends a message; poll for that and clear
|
|
||||||
// loading ourselves so the button doesn't spin forever.
|
|
||||||
stopPolling();
|
|
||||||
pollRef.current = window.setInterval(() => {
|
|
||||||
if (!popup.closed) return;
|
|
||||||
stopPolling();
|
|
||||||
if (handledStateRef.current === null) setLoading(false);
|
|
||||||
}, 500);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
setError(
|
setError(
|
||||||
(err as { response?: { data?: { message?: string } } })?.response?.data
|
(err as { response?: { data?: { message?: string } } })?.response?.data
|
||||||
?.message ??
|
?.message ??
|
||||||
(err instanceof Error ? err.message : "Could not start verification"),
|
(err instanceof Error ? err.message : "Could not start verification"),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -262,7 +189,13 @@ function DataRow({ icon, value }: { icon: ReactNode; value: string | null }) {
|
|||||||
if (!value) return null;
|
if (!value) return null;
|
||||||
return (
|
return (
|
||||||
<Group gap={6} wrap="nowrap">
|
<Group gap={6} wrap="nowrap">
|
||||||
<span style={{ color: "var(--mantine-color-edr-muted-6)", display: "flex", flexShrink: 0 }}>
|
<span
|
||||||
|
style={{
|
||||||
|
color: "var(--mantine-color-edr-muted-6)",
|
||||||
|
display: "flex",
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
{icon}
|
{icon}
|
||||||
</span>
|
</span>
|
||||||
<Text size="xs" c="edr-muted" truncate>
|
<Text size="xs" c="edr-muted" truncate>
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import { useNavigate } from "react-router-dom";
|
|||||||
|
|
||||||
import type { Freight } from "@edr/types";
|
import type { Freight } from "@edr/types";
|
||||||
|
|
||||||
import { PayNowButton } from "@/pages/bookings/payments/PayNowButton";
|
|
||||||
import { api } from "@/services/api";
|
import { api } from "@/services/api";
|
||||||
import { bookingDocNoun } from "@/pages/bookings/clearance/bookingNextAction";
|
import { bookingDocNoun } from "@/pages/bookings/clearance/bookingNextAction";
|
||||||
import { deriveContractCustomerAction } from "./deriveContractCustomerAction";
|
import { deriveContractCustomerAction } from "./deriveContractCustomerAction";
|
||||||
@@ -50,12 +49,6 @@ export function ContractCustomerAction({
|
|||||||
}
|
}
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
if (action.type === "pay") {
|
|
||||||
return (
|
|
||||||
<PayNowButton booking={action.booking} label={action.label} size={size} />
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (action.type === "initiate") {
|
if (action.type === "initiate") {
|
||||||
return (
|
return (
|
||||||
<InitiateBookingButton
|
<InitiateBookingButton
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import type { Freight } from "@edr/types";
|
import type { Freight } from "@edr/types";
|
||||||
import type { LucideIcon } from "lucide-react";
|
import type { LucideIcon } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
CreditCard,
|
|
||||||
Eye,
|
Eye,
|
||||||
FileSignature,
|
FileSignature,
|
||||||
PackagePlus,
|
PackagePlus,
|
||||||
@@ -26,13 +25,6 @@ export type ContractCustomerAction =
|
|||||||
primary: boolean;
|
primary: boolean;
|
||||||
icon: LucideIcon;
|
icon: LucideIcon;
|
||||||
}
|
}
|
||||||
| {
|
|
||||||
type: "pay";
|
|
||||||
booking: Freight.IBooking;
|
|
||||||
label: string;
|
|
||||||
primary: boolean;
|
|
||||||
icon: LucideIcon;
|
|
||||||
}
|
|
||||||
| {
|
| {
|
||||||
/** One-click bare booking instance (GENERAL non-customs) — mutation, not navigation. */
|
/** One-click bare booking instance (GENERAL non-customs) — mutation, not navigation. */
|
||||||
type: "initiate";
|
type: "initiate";
|
||||||
@@ -42,22 +34,6 @@ export type ContractCustomerAction =
|
|||||||
icon: LucideIcon;
|
icon: LucideIcon;
|
||||||
};
|
};
|
||||||
|
|
||||||
function findPayableBookingForContract(
|
|
||||||
contractId: string,
|
|
||||||
bookings: Freight.IBooking[],
|
|
||||||
): Freight.IBooking | null {
|
|
||||||
return (
|
|
||||||
bookings.find((b) => {
|
|
||||||
if (b.contractId !== contractId) return false;
|
|
||||||
if (b.paymentStatus === "PAID") return false;
|
|
||||||
const isGeneral = b.bookingType === "GENERAL_CONTRACT";
|
|
||||||
return isGeneral
|
|
||||||
? b.status === "FULLY_EXECUTED"
|
|
||||||
: b.status === "SELECTED_FOR_BATCH";
|
|
||||||
}) ?? null
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Single best customer action for a contract row (list / home). */
|
/** Single best customer action for a contract row (list / home). */
|
||||||
export function deriveContractCustomerAction(
|
export function deriveContractCustomerAction(
|
||||||
contract: Freight.IContract,
|
contract: Freight.IContract,
|
||||||
@@ -97,17 +73,8 @@ export function deriveContractCustomerAction(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const payable = findPayableBookingForContract(id, bookings);
|
// Paying happens from the booking row/detail — contract rows never show
|
||||||
if (payable) {
|
// "Pay now" (payable bookings fall through to the next action here).
|
||||||
return {
|
|
||||||
type: "pay",
|
|
||||||
booking: payable,
|
|
||||||
label: "Pay now",
|
|
||||||
primary: true,
|
|
||||||
icon: CreditCard,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
contract.customsClearingEnabled &&
|
contract.customsClearingEnabled &&
|
||||||
["CLEARANCE_READY_FOR_BOOKING", "ACTIVE_SHIPMENT_IN_PROGRESS"].includes(
|
["CLEARANCE_READY_FOR_BOOKING", "ACTIVE_SHIPMENT_IN_PROGRESS"].includes(
|
||||||
|
|||||||
@@ -209,7 +209,20 @@ export default function OnboardingWizardDialog({
|
|||||||
nationality?: CompanyNationality;
|
nationality?: CompanyNationality;
|
||||||
}) => api.companies.startOnboarding.call(vars),
|
}) => api.companies.startOnboarding.call(vars),
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
await refreshInfo();
|
// Nationality drives the server-resolved identity requirements (Fayda vs
|
||||||
|
// passport), the document set and the GM/PoA copy — all read from
|
||||||
|
// onboardingRequirements/profile. Re-entering role selection can change
|
||||||
|
// it, so both must be refetched alongside getInfo or the form step would
|
||||||
|
// keep rendering the previous nationality's requirements.
|
||||||
|
await Promise.all([
|
||||||
|
refreshInfo(),
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: api.companies.onboardingRequirements.queryKey(),
|
||||||
|
}),
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: api.companies.getProfile.queryKey(),
|
||||||
|
}),
|
||||||
|
]);
|
||||||
setPhase("form");
|
setPhase("form");
|
||||||
},
|
},
|
||||||
onError: (err) => setStartError(extractApiError(err).message),
|
onError: (err) => setStartError(extractApiError(err).message),
|
||||||
|
|||||||
@@ -1,51 +1,89 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { Center, Loader, Stack, Text } from "@mantine/core";
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { Button, Center, Loader, Stack, Text } from "@mantine/core";
|
||||||
|
|
||||||
import type { FaydaCallbackMessage } from "@/services/verifayda.service";
|
import {
|
||||||
|
takePendingVerification,
|
||||||
|
verifaydaService,
|
||||||
|
} from "@/services/verifayda.service";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Landing page for the portal's eSignet redirect_uri
|
* Landing page for the portal's eSignet redirect_uri
|
||||||
* (FAYDA_PORTAL_REDIRECT_URI → http://localhost:5173/callback). Runs inside the
|
* (FAYDA_PORTAL_REDIRECT_URI → http://localhost:5173/fayda/callback).
|
||||||
* verification popup: relays ?code&state (or ?error) to the window that opened
|
*
|
||||||
* it via postMessage, then closes itself. The opener performs the completion
|
* The verification is a full-page redirect, so the page that started it no
|
||||||
* call so the single-use session is only consumed once, in one place.
|
* longer exists: this page completes the code+state exchange itself against
|
||||||
|
* the subject FaydaVerifyPanel stashed, then sends the user back where they
|
||||||
|
* were. Everything mounts fresh on the way back, so the verified identity is
|
||||||
|
* fetched rather than pushed.
|
||||||
*/
|
*/
|
||||||
export default function FaydaCallbackPage() {
|
export default function FaydaCallbackPage() {
|
||||||
const [standalone, setStandalone] = useState(false);
|
const navigate = useNavigate();
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [returnTo, setReturnTo] = useState("/");
|
||||||
|
// The code+state are single-use, so StrictMode's double-invoked effect must
|
||||||
|
// not exchange them twice — the second attempt would fail on a spent session.
|
||||||
|
const startedRef = useRef(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const params = new URLSearchParams(window.location.search);
|
if (startedRef.current) return;
|
||||||
const message: FaydaCallbackMessage = {
|
startedRef.current = true;
|
||||||
type: "fayda-callback",
|
|
||||||
code: params.get("code") ?? undefined,
|
|
||||||
state: params.get("state") ?? undefined,
|
|
||||||
error: params.get("error") ?? undefined,
|
|
||||||
errorDescription: params.get("error_description") ?? undefined,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (window.opener && window.opener !== window) {
|
const params = new URLSearchParams(window.location.search);
|
||||||
(window.opener as Window).postMessage(message, window.location.origin);
|
const pending = takePendingVerification();
|
||||||
window.close();
|
if (pending) setReturnTo(pending.returnTo);
|
||||||
} else {
|
|
||||||
// Opened as a full-page redirect instead of a popup — nothing to relay to.
|
const authError = params.get("error");
|
||||||
setStandalone(true);
|
if (authError) {
|
||||||
|
setError(params.get("error_description") ?? authError);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
}, []);
|
|
||||||
|
const code = params.get("code");
|
||||||
|
const state = params.get("state");
|
||||||
|
if (!code || !state) {
|
||||||
|
setError("This verification link is missing its code — start again.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!pending) {
|
||||||
|
// Landed here without the tab that started it — a bookmarked/copied
|
||||||
|
// callback URL, or sessionStorage cleared mid-flow.
|
||||||
|
setError("This verification was started somewhere else — start again.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
verifaydaService
|
||||||
|
.completeIdentity(pending.subject, code, state)
|
||||||
|
.then(() => navigate(pending.returnTo, { replace: true }))
|
||||||
|
.catch((err) =>
|
||||||
|
setError(
|
||||||
|
(err as { response?: { data?: { message?: string } } })?.response
|
||||||
|
?.data?.message ??
|
||||||
|
(err instanceof Error ? err.message : "Verification failed"),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}, [navigate]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Center h="100vh">
|
<Center h="100vh">
|
||||||
<Stack align="center" gap="sm">
|
<Stack align="center" gap="sm">
|
||||||
{standalone ? (
|
{error ? (
|
||||||
<>
|
<>
|
||||||
<Text fw={600}>Verification window lost its parent page</Text>
|
<Text fw={600}>Verification could not be completed</Text>
|
||||||
<Text size="sm" c="dimmed">
|
<Text size="sm" c="edr-muted" ta="center" maw={360}>
|
||||||
Close this tab and start the verification again from the form.
|
{error}
|
||||||
</Text>
|
</Text>
|
||||||
|
<Button
|
||||||
|
variant="light"
|
||||||
|
onClick={() => navigate(returnTo, { replace: true })}
|
||||||
|
>
|
||||||
|
Go back
|
||||||
|
</Button>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Loader size="sm" color="edr-green" />
|
<Loader size="sm" color="edr-green" />
|
||||||
<Text size="sm" c="dimmed">
|
<Text size="sm" c="edr-muted">
|
||||||
Completing Fayda verification…
|
Completing Fayda verification…
|
||||||
</Text>
|
</Text>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -82,6 +82,12 @@ function tabIncomplete(tabId: SettingsTab, profile: ProfileResponse): boolean {
|
|||||||
case "contact":
|
case "contact":
|
||||||
return !profile.contactPersonName || !profile.contactPersonPhone;
|
return !profile.contactPersonName || !profile.contactPersonPhone;
|
||||||
case "gm":
|
case "gm":
|
||||||
|
// The GM is established through Fayda — verified in their own right or
|
||||||
|
// declared the same person as the owner — so the identity answers this,
|
||||||
|
// not the typed columns. A company that may still type them (foreign,
|
||||||
|
// whose manager may hold no Fayda ID) is judged on those instead.
|
||||||
|
if (profile.identity?.gm.verified) return false;
|
||||||
|
if (profile.identity?.faydaRequired) return true;
|
||||||
return (
|
return (
|
||||||
!profile.generalManagerName ||
|
!profile.generalManagerName ||
|
||||||
!profile.generalManagerEmail ||
|
!profile.generalManagerEmail ||
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ import {
|
|||||||
toFormValues,
|
toFormValues,
|
||||||
} from "./companyProfileForm/helpers";
|
} from "./companyProfileForm/helpers";
|
||||||
import FaydaVerifyPanel from "@/components/FaydaVerifyPanel";
|
import FaydaVerifyPanel from "@/components/FaydaVerifyPanel";
|
||||||
|
import { verifaydaService } from "@/services/verifayda.service";
|
||||||
import type { CompanyIdentityState } from "@/services/verifayda.service";
|
import type { CompanyIdentityState } from "@/services/verifayda.service";
|
||||||
import { LinkCheckboxCard } from "./companyProfileForm/LinkCheckboxCard";
|
import { LinkCheckboxCard } from "./companyProfileForm/LinkCheckboxCard";
|
||||||
import ETradeCompanyCard from "./companyProfileForm/ETradeCompanyCard";
|
import ETradeCompanyCard from "./companyProfileForm/ETradeCompanyCard";
|
||||||
@@ -107,7 +108,11 @@ export default function CompanyProfileForm({
|
|||||||
>;
|
>;
|
||||||
/** Fayda verification state for the owner and the PoA (undefined until loaded). */
|
/** Fayda verification state for the owner and the PoA (undefined until loaded). */
|
||||||
identity?: CompanyIdentityState;
|
identity?: CompanyIdentityState;
|
||||||
/** Refetch the profile + requirements once a verification lands. */
|
/**
|
||||||
|
* Refetch the profile + requirements. Only the in-page identity actions need
|
||||||
|
* this — a Fayda verification navigates the whole tab away and comes back to
|
||||||
|
* a freshly booted app, so it has nothing to notify.
|
||||||
|
*/
|
||||||
onIdentityChange?: () => void;
|
onIdentityChange?: () => void;
|
||||||
}) {
|
}) {
|
||||||
const [step, setStep] = useState<CompanyStep>(initialStep ?? "company");
|
const [step, setStep] = useState<CompanyStep>(initialStep ?? "company");
|
||||||
@@ -342,7 +347,11 @@ export default function CompanyProfileForm({
|
|||||||
// "Same as …" links. A checked card prefills the target step's fields from the
|
// "Same as …" links. A checked card prefills the target step's fields from the
|
||||||
// source step and disables them (kept mirrored while linked); unchecking clears
|
// source step and disables them (kept mirrored while linked); unchecking clears
|
||||||
// them and re-enables editing.
|
// them and re-enables editing.
|
||||||
const [gmSameAsOwner, setGmSameAsOwner] = useState(false);
|
// Seeded from the server so a resumed draft reopens with the declaration the
|
||||||
|
// company already made, rather than an unticked box over a linked GM.
|
||||||
|
const [gmSameAsOwner, setGmSameAsOwner] = useState(
|
||||||
|
identity?.gmSameAsOwner ?? false,
|
||||||
|
);
|
||||||
const [contactSameAsGm, setContactSameAsGm] = useState(false);
|
const [contactSameAsGm, setContactSameAsGm] = useState(false);
|
||||||
|
|
||||||
// General Manager source. The company step's email/phone are seeded from
|
// General Manager source. The company step's email/phone are seeded from
|
||||||
@@ -369,6 +378,10 @@ export default function CompanyProfileForm({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!gmSameAsOwner) return;
|
if (!gmSameAsOwner) return;
|
||||||
|
// A verified owner's identity is copied server-side and read back from
|
||||||
|
// `identity.gm`; mirroring it into form fields here would send typed
|
||||||
|
// values for something the API already owns.
|
||||||
|
if (identity?.owner.verified) return;
|
||||||
setValue("generalManagerName", gmSourceName, { shouldValidate: true });
|
setValue("generalManagerName", gmSourceName, { shouldValidate: true });
|
||||||
setValue("generalManagerEmail", gmSourceEmail, { shouldValidate: true });
|
setValue("generalManagerEmail", gmSourceEmail, { shouldValidate: true });
|
||||||
setValue("generalManagerPhone", gmSourcePhone, {
|
setValue("generalManagerPhone", gmSourcePhone, {
|
||||||
@@ -377,18 +390,78 @@ export default function CompanyProfileForm({
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [gmSameAsOwner, gmSourceName, gmSourceEmail, gmSourcePhone]);
|
}, [gmSameAsOwner, gmSourceName, gmSourceEmail, gmSourcePhone]);
|
||||||
|
|
||||||
const toggleGmSameAsOwner = (checked: boolean) => {
|
/**
|
||||||
|
* "Same as owner" has two meanings depending on what backs the owner.
|
||||||
|
*
|
||||||
|
* A Fayda-verified owner is a proven identity, so the declaration is made
|
||||||
|
* server-side: the API copies that identity onto the GM and records what it
|
||||||
|
* did. Anything typed here would arrive wearing a verified badge it hadn't
|
||||||
|
* earned, which is exactly what the verification exists to prevent.
|
||||||
|
*
|
||||||
|
* A foreign company's owner is backed by a typed passport instead, so there
|
||||||
|
* is nothing proven to copy — that stays the local field-mirroring it has
|
||||||
|
* always been.
|
||||||
|
*/
|
||||||
|
const [gmLinkPending, setGmLinkPending] = useState(false);
|
||||||
|
const toggleGmSameAsOwner = async (checked: boolean) => {
|
||||||
setGmSameAsOwner(checked);
|
setGmSameAsOwner(checked);
|
||||||
if (!checked) {
|
if (!identity?.owner.verified) {
|
||||||
setValue("generalManagerName", "");
|
if (!checked) {
|
||||||
setValue("generalManagerEmail", "");
|
setValue("generalManagerName", "");
|
||||||
setValue("generalManagerPhone", "");
|
setValue("generalManagerEmail", "");
|
||||||
|
setValue("generalManagerPhone", "");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setGmLinkPending(true);
|
||||||
|
try {
|
||||||
|
if (checked) await verifaydaService.setGmSameAsOwner();
|
||||||
|
else await verifaydaService.clearGmIdentity();
|
||||||
|
onIdentityChange?.();
|
||||||
|
} catch (err) {
|
||||||
|
setGmSameAsOwner(!checked);
|
||||||
|
setSaveError(
|
||||||
|
(err as { response?: { data?: { message?: string } } })?.response?.data
|
||||||
|
?.message ??
|
||||||
|
(err instanceof Error ? err.message : "Could not update the general manager"),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setGmLinkPending(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const gmName = watch("generalManagerName");
|
// Where the GM's details come from depends on how they were established: a
|
||||||
const gmEmail = watch("generalManagerEmail");
|
// Fayda verification (or a "same as owner" declaration) owns them outright,
|
||||||
const gmPhone = watch("generalManagerPhone");
|
// and only a company that may still type them falls back to form state.
|
||||||
|
const gmVerified = identity?.gm.verified ?? false;
|
||||||
|
const gmName = gmVerified
|
||||||
|
? (identity?.gm.name ?? "")
|
||||||
|
: watch("generalManagerName");
|
||||||
|
const gmEmail = gmVerified
|
||||||
|
? (identity?.gm.email ?? "")
|
||||||
|
: watch("generalManagerEmail");
|
||||||
|
const gmPhone = gmVerified
|
||||||
|
? (identity?.gm.phone ?? "")
|
||||||
|
: watch("generalManagerPhone");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the GM has been established at all — by verification, by the
|
||||||
|
* "same as owner" declaration, or (only where Fayda is optional) by typing.
|
||||||
|
* Fayda is an Ethiopian national ID, so a foreign company's GM may hold none.
|
||||||
|
*/
|
||||||
|
const gmTyped = Boolean(
|
||||||
|
watch("generalManagerName")?.trim() &&
|
||||||
|
watch("generalManagerEmail")?.trim() &&
|
||||||
|
watch("generalManagerPhone")?.trim(),
|
||||||
|
);
|
||||||
|
const gmEstablished =
|
||||||
|
gmVerified || (identity ? !identity.faydaRequired && gmTyped : gmTyped);
|
||||||
|
|
||||||
|
/** Same rule for the representative: verified, or typed where Fayda is optional. */
|
||||||
|
const poaEstablished =
|
||||||
|
(identity?.poa.verified ?? false) ||
|
||||||
|
(identity ? !identity.faydaRequired && Boolean(watch("poaName")?.trim()) : false);
|
||||||
|
|
||||||
// While linked, mirror the source values into the (disabled) target fields so
|
// While linked, mirror the source values into the (disabled) target fields so
|
||||||
// the copy stays current even if the user goes back and edits the source.
|
// the copy stays current even if the user goes back and edits the source.
|
||||||
@@ -535,14 +608,16 @@ export default function CompanyProfileForm({
|
|||||||
const currentIdx = stepOrder.indexOf(step);
|
const currentIdx = stepOrder.indexOf(step);
|
||||||
|
|
||||||
// The DARS delegation paper is what proves the representative was actually
|
// The DARS delegation paper is what proves the representative was actually
|
||||||
// delegated, so it's required the moment a PoA exists — and unconditionally
|
// delegated, so it's required the moment a PoA exists. The API enforces the
|
||||||
// for a freight forwarder, whose PoA itself is mandatory. The API enforces
|
// same rule on save, so skipping it here only costs the customer a
|
||||||
// the same rule on save, so skipping it here only costs the customer a
|
|
||||||
// round-trip.
|
// round-trip.
|
||||||
// A PoA exists exactly when one has been verified — the details are the
|
// A PoA exists exactly when one has been verified — the details are the
|
||||||
// verification's output, so there is nothing else that could stand for one.
|
// verification's output, so there is nothing else that could stand for one.
|
||||||
|
// Until then the upload is hidden: there is no representative for the paper
|
||||||
|
// to authorise, and a freight forwarder is held on the verification gate
|
||||||
|
// below rather than on a file field it cannot yet fill.
|
||||||
const poaProvided = identity?.poa.verified ?? false;
|
const poaProvided = identity?.poa.verified ?? false;
|
||||||
const delegationRequired = requirePoa || poaProvided;
|
const delegationRequired = poaProvided;
|
||||||
const delegationPresent =
|
const delegationPresent =
|
||||||
(uploadedDocumentKeys ?? []).includes(POA_DELEGATION_FILE_KEY) ||
|
(uploadedDocumentKeys ?? []).includes(POA_DELEGATION_FILE_KEY) ||
|
||||||
(() => {
|
(() => {
|
||||||
@@ -628,9 +703,23 @@ export default function CompanyProfileForm({
|
|||||||
setSaveError("Verify the company owner's identity with Fayda before continuing.");
|
setSaveError("Verify the company owner's identity with Fayda before continuing.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (step === "poa" && requirePoa && !identity?.poa.verified) {
|
// The GM is established through Fayda now, so the step gates on the
|
||||||
|
// identity rather than on typed text — same strength as the old required
|
||||||
|
// fields, different evidence. A foreign company's GM may hold no Fayda ID,
|
||||||
|
// so typed details still satisfy it there.
|
||||||
|
if (step === "personnel" && !gmEstablished) {
|
||||||
setSaveError(
|
setSaveError(
|
||||||
"Freight forwarders act on other companies' behalf, so the Power of Attorney's identity must be verified with Fayda.",
|
identity?.faydaRequired
|
||||||
|
? "Verify the general manager with Fayda, or tick “same as owner” if they are the company's owner."
|
||||||
|
: "Add the general manager's details, or verify them with Fayda.",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (step === "poa" && requirePoa && !poaEstablished) {
|
||||||
|
setSaveError(
|
||||||
|
identity?.faydaRequired
|
||||||
|
? "Freight forwarders act on other companies' behalf, so the Power of Attorney's identity must be verified with Fayda."
|
||||||
|
: "Freight forwarders act on other companies' behalf, so a Power of Attorney is required.",
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -714,7 +803,6 @@ export default function CompanyProfileForm({
|
|||||||
title="Owner"
|
title="Owner"
|
||||||
state={identity.owner}
|
state={identity.owner}
|
||||||
required={identity.faydaRequired}
|
required={identity.faydaRequired}
|
||||||
onVerified={() => onIdentityChange?.()}
|
|
||||||
/>
|
/>
|
||||||
{identity.passportRequired && (
|
{identity.passportRequired && (
|
||||||
<TextInput
|
<TextInput
|
||||||
@@ -766,10 +854,11 @@ export default function CompanyProfileForm({
|
|||||||
<Text fw={600} size="sm" c="edr-text">
|
<Text fw={600} size="sm" c="edr-text">
|
||||||
General Manager
|
General Manager
|
||||||
</Text>
|
</Text>
|
||||||
{/* GM is a plain typed role, not the person the Fayda
|
{/* The GM is very often the owner. Where the owner is
|
||||||
verification proves — the owner is (see the Company step).
|
Fayda-verified this reuses that proven identity outright
|
||||||
They're very often the same human, which "same as owner" is
|
rather than making the same human verify twice; where the
|
||||||
for once the owner has verified. */}
|
owner is backed by a typed passport there is nothing proven
|
||||||
|
to copy, so it stays a local prefill. */}
|
||||||
<LinkCheckboxCard
|
<LinkCheckboxCard
|
||||||
checked={gmSameAsOwner}
|
checked={gmSameAsOwner}
|
||||||
onToggle={toggleGmSameAsOwner}
|
onToggle={toggleGmSameAsOwner}
|
||||||
@@ -780,33 +869,53 @@ export default function CompanyProfileForm({
|
|||||||
}
|
}
|
||||||
description={
|
description={
|
||||||
identity?.owner.verified
|
identity?.owner.verified
|
||||||
? "Reuse the Fayda-verified owner's name, email and phone. Uncheck to enter different details."
|
? "Reuse the Fayda-verified owner's identity for the general manager. Uncheck to verify a different person."
|
||||||
: etradeOwner
|
: etradeOwner
|
||||||
? "Reuse the eTrade-registered owner's name, plus the company email and phone as you entered them. Uncheck to enter different details."
|
? "Reuse the eTrade-registered owner's name, plus the company email and phone as you entered them. Uncheck to enter different details."
|
||||||
: "Reuse your account's name and the company email and phone as you entered them. Uncheck to enter different details."
|
: "Reuse your account's name and the company email and phone as you entered them. Uncheck to enter different details."
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<TextInput
|
|
||||||
label="Name"
|
{/* Verifying a second person is only meaningful when the GM is
|
||||||
placeholder="Abebe Bikila"
|
someone other than the owner. */}
|
||||||
error={errors.generalManagerName?.message}
|
{!gmSameAsOwner && identity && (
|
||||||
{...register("generalManagerName")}
|
<FaydaVerifyPanel
|
||||||
/>
|
subject="gm"
|
||||||
<SimpleGrid cols={2} spacing="md">
|
title="General Manager"
|
||||||
<TextInput
|
state={identity.gm}
|
||||||
label="Email"
|
required={identity.faydaRequired}
|
||||||
type="email"
|
disabled={gmLinkPending}
|
||||||
placeholder="gm@company.com"
|
|
||||||
error={errors.generalManagerEmail?.message}
|
|
||||||
{...register("generalManagerEmail")}
|
|
||||||
/>
|
/>
|
||||||
<ControlledPhoneField
|
)}
|
||||||
control={control}
|
|
||||||
name="generalManagerPhone"
|
{/* Typed details survive only where Fayda cannot be required —
|
||||||
label="Phone"
|
a foreign company's manager may hold no Fayda ID. Once
|
||||||
required
|
verified the API owns these fields, so they go away. */}
|
||||||
/>
|
{!gmSameAsOwner && !gmVerified && !identity?.faydaRequired && (
|
||||||
</SimpleGrid>
|
<>
|
||||||
|
<TextInput
|
||||||
|
label="Name"
|
||||||
|
placeholder="Abebe Bikila"
|
||||||
|
error={errors.generalManagerName?.message}
|
||||||
|
{...register("generalManagerName")}
|
||||||
|
/>
|
||||||
|
<SimpleGrid cols={2} spacing="md">
|
||||||
|
<TextInput
|
||||||
|
label="Email"
|
||||||
|
type="email"
|
||||||
|
placeholder="gm@company.com"
|
||||||
|
error={errors.generalManagerEmail?.message}
|
||||||
|
{...register("generalManagerEmail")}
|
||||||
|
/>
|
||||||
|
<ControlledPhoneField
|
||||||
|
control={control}
|
||||||
|
name="generalManagerPhone"
|
||||||
|
label="Phone"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</SimpleGrid>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -872,19 +981,23 @@ export default function CompanyProfileForm({
|
|||||||
title="Power of Attorney"
|
title="Power of Attorney"
|
||||||
state={identity.poa}
|
state={identity.poa}
|
||||||
required={requirePoa}
|
required={requirePoa}
|
||||||
onVerified={() => onIdentityChange?.()}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{/* The city is the one field the Fayda address claim does not
|
{/* The address comes from the Fayda claim along with the name,
|
||||||
reliably decompose into, so it stays typed. */}
|
so it is shown on the panel rather than typed. Only a company
|
||||||
<TextInput
|
whose representative may hold no Fayda ID still types it. */}
|
||||||
label="PoA Location"
|
{!identity?.poa.verified && !identity?.faydaRequired && (
|
||||||
placeholder="City, Country"
|
<TextInput
|
||||||
error={errors.poaLocation?.message}
|
label="PoA Location"
|
||||||
{...register("poaLocation")}
|
placeholder="City, Country"
|
||||||
/>
|
error={errors.poaLocation?.message}
|
||||||
|
{...register("poaLocation")}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{poaDocumentSetting && (
|
{/* The paper authorises the representative the verification
|
||||||
|
named, so it only has meaning once one exists. */}
|
||||||
|
{poaProvided && poaDocumentSetting && (
|
||||||
<>
|
<>
|
||||||
<Divider my="sm" />
|
<Divider my="sm" />
|
||||||
<SmartFileInput
|
<SmartFileInput
|
||||||
|
|||||||
@@ -69,12 +69,24 @@ export const onboardingSchema = z.object({
|
|||||||
.string()
|
.string()
|
||||||
.min(1, "Contact person phone is required")
|
.min(1, "Contact person phone is required")
|
||||||
.refine(isValidPhone, "Enter a valid phone number"),
|
.refine(isValidPhone, "Enter a valid phone number"),
|
||||||
generalManagerName: z.string().min(1, "Manager name is required"),
|
// Optional here, not unrequired: the GM is now established by Fayda — either
|
||||||
generalManagerEmail: z.string().email("Invalid Manager email"),
|
// verified in their own right or declared the same person as the owner — so
|
||||||
|
// for an Ethiopian company these fields are never typed and would fail a
|
||||||
|
// blanket `min(1)`. Presence is gated per nationality in the step's own
|
||||||
|
// check, where the identity state is available; zod only polices format for
|
||||||
|
// the foreign companies that still type them.
|
||||||
|
generalManagerName: z.string().optional(),
|
||||||
|
generalManagerEmail: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.refine(
|
||||||
|
(v) => !v || z.string().email().safeParse(v).success,
|
||||||
|
"Invalid Manager email",
|
||||||
|
),
|
||||||
generalManagerPhone: z
|
generalManagerPhone: z
|
||||||
.string()
|
.string()
|
||||||
.min(1, "Manager phone is required")
|
.optional()
|
||||||
.refine(isValidPhone, "Enter a valid phone number"),
|
.refine((v) => !v || isValidPhone(v), "Enter a valid phone number"),
|
||||||
poaName: z.string().optional(),
|
poaName: z.string().optional(),
|
||||||
poaPhone: z
|
poaPhone: z
|
||||||
.string()
|
.string()
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ import {
|
|||||||
Loader,
|
Loader,
|
||||||
Modal,
|
Modal,
|
||||||
Paper,
|
Paper,
|
||||||
SegmentedControl,
|
|
||||||
Stack,
|
Stack,
|
||||||
Switch,
|
Switch,
|
||||||
Text,
|
Text,
|
||||||
@@ -50,7 +49,11 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
import type { Freight } from "@edr/types";
|
import type { Freight } from "@edr/types";
|
||||||
import { ExportTrainPicker, OperationDatePicker } from "@edr/ui-common";
|
import {
|
||||||
|
CurrencySelector,
|
||||||
|
ExportTrainPicker,
|
||||||
|
OperationDatePicker,
|
||||||
|
} from "@edr/ui-common";
|
||||||
import { api } from "@/services/api";
|
import { api } from "@/services/api";
|
||||||
import {
|
import {
|
||||||
contractsService,
|
contractsService,
|
||||||
@@ -265,8 +268,10 @@ function NewShipmentBookingForm({
|
|||||||
// still flip it per shipment.
|
// still flip it per shipment.
|
||||||
withReturn: contract.equipmentReturn === "WITH_RETURN",
|
withReturn: contract.equipmentReturn === "WITH_RETURN",
|
||||||
// The contract quotes USD; the customer bills this shipment in the
|
// The contract quotes USD; the customer bills this shipment in the
|
||||||
// currency they pick here. Intercity is always ETB.
|
// currency they pick here. Intercity is always ETB, so it is preset;
|
||||||
paymentCurrency: contract.tradeDirection === "DOMESTIC" ? "ETB" : "USD",
|
// everything else starts empty so the customer picks deliberately
|
||||||
|
// instead of silently inheriting USD.
|
||||||
|
paymentCurrency: contract.tradeDirection === "DOMESTIC" ? "ETB" : "",
|
||||||
},
|
},
|
||||||
resolver: zodResolver(
|
resolver: zodResolver(
|
||||||
createShipmentFormSchema({
|
createShipmentFormSchema({
|
||||||
@@ -340,7 +345,11 @@ function NewShipmentBookingForm({
|
|||||||
...(values.contractRouteId
|
...(values.contractRouteId
|
||||||
? { contractRouteId: values.contractRouteId }
|
? { contractRouteId: values.contractRouteId }
|
||||||
: {}),
|
: {}),
|
||||||
paymentCurrency: values.paymentCurrency,
|
// Validation guarantees a currency by here; the guard keeps an empty
|
||||||
|
// value out of the payload rather than tripping the API's @IsIn check.
|
||||||
|
...(values.paymentCurrency
|
||||||
|
? { paymentCurrency: values.paymentCurrency }
|
||||||
|
: {}),
|
||||||
// Intercity bookings carry no date — staff assign a passing train later.
|
// Intercity bookings carry no date — staff assign a passing train later.
|
||||||
...(values.scheduledDate
|
...(values.scheduledDate
|
||||||
? { scheduledDate: new Date(values.scheduledDate).toISOString() }
|
? { scheduledDate: new Date(values.scheduledDate).toISOString() }
|
||||||
@@ -1131,73 +1140,63 @@ function ScheduleStep({
|
|||||||
<Controller
|
<Controller
|
||||||
name="paymentCurrency"
|
name="paymentCurrency"
|
||||||
control={form.control}
|
control={form.control}
|
||||||
render={({ field }) => (
|
render={({ field, fieldState }) => (
|
||||||
<Box mb="lg">
|
<Box mb="lg">
|
||||||
<StepLabel>Billing currency *</StepLabel>
|
<StepLabel>Billing currency *</StepLabel>
|
||||||
<Text fz={12.5} c="dimmed" mt={4} mb={10}>
|
<Text fz={12.5} c="dimmed" mt={4} mb={10}>
|
||||||
Your contract is quoted in USD. Pick the currency this shipment is
|
Your contract is quoted in USD. Pick the currency this shipment is
|
||||||
invoiced in — the total is converted for you.
|
invoiced in — the total is converted for you.
|
||||||
</Text>
|
</Text>
|
||||||
<SegmentedControl
|
<CurrencySelector
|
||||||
value={field.value ?? "USD"}
|
value={field.value || ""}
|
||||||
onChange={(v) => field.onChange(v)}
|
onChange={(v) => field.onChange(v)}
|
||||||
data={[
|
error={fieldState.error?.message}
|
||||||
{ label: "USD", value: "USD" },
|
|
||||||
{ label: "ETB", value: "ETB" },
|
|
||||||
]}
|
|
||||||
color="edr-green"
|
|
||||||
radius={10}
|
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
{cargoQuery === null ? (
|
<Controller
|
||||||
<Alert
|
name="scheduledDate"
|
||||||
color="yellow"
|
control={form.control}
|
||||||
variant="light"
|
render={({ field, fieldState }) => (
|
||||||
radius="md"
|
<Box>
|
||||||
icon={<AlertCircle size={16} />}
|
<StepLabel>Shipment day *</StepLabel>
|
||||||
>
|
<Box mt={10} w="100%">
|
||||||
Enter your cargo details first — available shipment days depend on the
|
{/* Calendar stays visible before cargo is entered — all days
|
||||||
wagons your cargo needs.
|
disabled with a hint, since availability depends on cargo. */}
|
||||||
</Alert>
|
<OperationDatePicker
|
||||||
) : (
|
fullWidth
|
||||||
<Controller
|
availableDays={cargoQuery === null ? [] : (availableDays ?? [])}
|
||||||
name="scheduledDate"
|
isLoading={cargoQuery !== null && isLoading}
|
||||||
control={form.control}
|
emptyMessage={
|
||||||
render={({ field, fieldState }) => (
|
cargoQuery === null
|
||||||
<Box>
|
? "Enter your cargo details first — available shipment days depend on the wagons your cargo needs."
|
||||||
<StepLabel>Shipment day *</StepLabel>
|
: undefined
|
||||||
<Box mt={10} w="100%">
|
}
|
||||||
<OperationDatePicker
|
value={field.value ?? ""}
|
||||||
fullWidth
|
onChange={(d) => {
|
||||||
availableDays={availableDays ?? []}
|
field.onChange(d);
|
||||||
isLoading={isLoading}
|
// A new day invalidates the old train pick.
|
||||||
value={field.value ?? ""}
|
form.setValue("trainScheduleId", "");
|
||||||
onChange={(d) => {
|
}}
|
||||||
field.onChange(d);
|
/>
|
||||||
// A new day invalidates the old train pick.
|
|
||||||
form.setValue("trainScheduleId", "");
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
{fieldState.error?.message && (
|
|
||||||
<Text fz="xs" c="red" mt={6}>
|
|
||||||
{fieldState.error.message}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
{isExportPick && scheduledDate ? (
|
|
||||||
<ExportTrainPicker
|
|
||||||
options={exportTrainsQuery.data ?? []}
|
|
||||||
loading={exportTrainsQuery.isLoading}
|
|
||||||
value={selectedTrainId ?? ""}
|
|
||||||
onChange={(id) => form.setValue("trainScheduleId", id)}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
{fieldState.error?.message && (
|
||||||
/>
|
<Text fz="xs" c="red" mt={6}>
|
||||||
)}
|
{fieldState.error.message}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{isExportPick && scheduledDate ? (
|
||||||
|
<ExportTrainPicker
|
||||||
|
options={exportTrainsQuery.data ?? []}
|
||||||
|
loading={exportTrainsQuery.isLoading}
|
||||||
|
value={selectedTrainId ?? ""}
|
||||||
|
onChange={(id) => form.setValue("trainScheduleId", id)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
</StepCard>
|
</StepCard>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { createShipmentFormSchema, initialShipmentFormValues } from "./schema";
|
||||||
|
|
||||||
|
const schema = createShipmentFormSchema({
|
||||||
|
isContainer: false,
|
||||||
|
isHazardous: false,
|
||||||
|
isReefer: false,
|
||||||
|
requiresDate: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const values = (over: Record<string, unknown> = {}) => ({
|
||||||
|
...initialShipmentFormValues,
|
||||||
|
cargoWeightTons: "10",
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
|
||||||
|
const currencyIssues = (input: Record<string, unknown>) => {
|
||||||
|
const result = schema.safeParse(input);
|
||||||
|
return result.success
|
||||||
|
? []
|
||||||
|
: result.error.issues.filter((i) => i.path[0] === "paymentCurrency");
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("paymentCurrency validation", () => {
|
||||||
|
it("defaults to empty rather than silently picking USD", () => {
|
||||||
|
expect(initialShipmentFormValues.paymentCurrency ?? "").toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a submit with no currency chosen", () => {
|
||||||
|
const issues = currencyIssues(values({ paymentCurrency: "" }));
|
||||||
|
expect(issues).toHaveLength(1);
|
||||||
|
expect(issues[0].message).toBe("Select the billing currency for this shipment.");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts either currency once chosen", () => {
|
||||||
|
expect(currencyIssues(values({ paymentCurrency: "USD" }))).toHaveLength(0);
|
||||||
|
expect(currencyIssues(values({ paymentCurrency: "ETB" }))).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -76,8 +76,9 @@ const shipmentFormBase = z.object({
|
|||||||
// EXPORT rail: the specific train picked for the shipment day (schedule id).
|
// EXPORT rail: the specific train picked for the shipment day (schedule id).
|
||||||
trainScheduleId: z.string().default(""),
|
trainScheduleId: z.string().default(""),
|
||||||
// The contract quotes in USD; the customer picks the billing currency for
|
// The contract quotes in USD; the customer picks the billing currency for
|
||||||
// THIS shipment. Intercity is forced to ETB (server-enforced too).
|
// THIS shipment. Starts empty so the choice is deliberate — validated as
|
||||||
paymentCurrency: z.enum(["USD", "ETB"]).default("USD"),
|
// required below. Intercity is forced to ETB (server-enforced too).
|
||||||
|
paymentCurrency: z.enum(["USD", "ETB", ""]).default(""),
|
||||||
// Container contracts only: return the empty container(s) to EDR after
|
// Container contracts only: return the empty container(s) to EDR after
|
||||||
// unloading. Seeded from the contract's equipment return; bulk ignores it.
|
// unloading. Seeded from the contract's equipment return; bulk ignores it.
|
||||||
withReturn: z.boolean().default(false),
|
withReturn: z.boolean().default(false),
|
||||||
@@ -101,6 +102,15 @@ export function createShipmentFormSchema(ctx: ShipmentValidationContext) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// No default currency — the customer must pick one before submitting.
|
||||||
|
if (!data.paymentCurrency) {
|
||||||
|
refineCtx.addIssue({
|
||||||
|
code: "custom",
|
||||||
|
path: ["paymentCurrency"],
|
||||||
|
message: "Select the billing currency for this shipment.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (ctx.isContainer) {
|
if (ctx.isContainer) {
|
||||||
// Containerized cargo must say WHAT is inside — required per booking.
|
// Containerized cargo must say WHAT is inside — required per booking.
|
||||||
if (!data.cargoDescription.trim()) {
|
if (!data.cargoDescription.trim()) {
|
||||||
@@ -311,6 +321,6 @@ export const shipmentStepFields: Record<
|
|||||||
"bulkReeferQuantity",
|
"bulkReeferQuantity",
|
||||||
"withReturn",
|
"withReturn",
|
||||||
],
|
],
|
||||||
2: ["scheduledDate"],
|
2: ["paymentCurrency", "scheduledDate"],
|
||||||
3: ["notes"],
|
3: ["notes"],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -379,11 +379,6 @@ export default function TabCompanyProfile({
|
|||||||
required={identity.faydaRequired}
|
required={identity.faydaRequired}
|
||||||
disabled={mutation.isPending}
|
disabled={mutation.isPending}
|
||||||
pendingReview={pendingOwnerReview}
|
pendingReview={pendingOwnerReview}
|
||||||
onVerified={() =>
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: api.companies.getProfile.queryKey(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
{identity.passportRequired && (
|
{identity.passportRequired && (
|
||||||
<TextInput
|
<TextInput
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { Briefcase, CheckCircle2, Save, XCircle } from "lucide-react";
|
import { Briefcase, CheckCircle2, Save, XCircle } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
|
Alert,
|
||||||
Card,
|
Card,
|
||||||
Group,
|
Group,
|
||||||
Stack,
|
Stack,
|
||||||
@@ -15,17 +16,29 @@ import {
|
|||||||
Grid,
|
Grid,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { api } from "@/services/api";
|
import { api } from "@/services/api";
|
||||||
|
import { verifaydaService } from "@/services/verifayda.service";
|
||||||
import { ControlledPhoneField, isValidPhone } from "@/components/PhoneField";
|
import { ControlledPhoneField, isValidPhone } from "@/components/PhoneField";
|
||||||
import { LinkCheckboxCard } from "@/pages/accounts/companyProfileForm/LinkCheckboxCard";
|
import { LinkCheckboxCard } from "@/pages/accounts/companyProfileForm/LinkCheckboxCard";
|
||||||
|
import FaydaVerifyPanel from "@/components/FaydaVerifyPanel";
|
||||||
import type { ProfileResponse } from "@/types/profile";
|
import type { ProfileResponse } from "@/types/profile";
|
||||||
|
|
||||||
|
// Optional, not unrequired: an Ethiopian company's GM is established through
|
||||||
|
// Fayda and never types these, so a blanket `min(1)` would fail a form that is
|
||||||
|
// correct. Presence is gated below, where the identity state says which route
|
||||||
|
// applies; zod only polices format for the companies that still type them.
|
||||||
const schema = z.object({
|
const schema = z.object({
|
||||||
generalManagerName: z.string().min(1, "GM name is required"),
|
generalManagerName: z.string().optional(),
|
||||||
generalManagerEmail: z.string().email("Invalid GM email"),
|
generalManagerEmail: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.refine(
|
||||||
|
(v) => !v || z.string().email().safeParse(v).success,
|
||||||
|
"Invalid GM email",
|
||||||
|
),
|
||||||
generalManagerPhone: z
|
generalManagerPhone: z
|
||||||
.string()
|
.string()
|
||||||
.min(1, "GM phone is required")
|
.optional()
|
||||||
.refine(isValidPhone, "Enter a valid phone number"),
|
.refine((v) => !v || isValidPhone(v), "Enter a valid phone number"),
|
||||||
});
|
});
|
||||||
|
|
||||||
type FormData = z.infer<typeof schema>;
|
type FormData = z.infer<typeof schema>;
|
||||||
@@ -37,16 +50,20 @@ interface TabGeneralManagerProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The general manager is a plain typed role, not the person the Fayda
|
* The general manager's identity comes from Fayda: either verified in their
|
||||||
* verification proves — the owner is. They're very often the same human,
|
* own right, or declared to be the owner — very often the same human, which is
|
||||||
* which is what "Same as owner" is for: once the owner has verified, this
|
* what "Same as owner" is for. Typed details survive only for a foreign
|
||||||
* copies their name/email/phone in rather than making the customer re-type
|
* company, whose manager may hold no Fayda ID at all.
|
||||||
* data the company already proved.
|
|
||||||
*/
|
*/
|
||||||
export default function TabGeneralManager({ profile, mode = "edit", onContinue }: TabGeneralManagerProps) {
|
export default function TabGeneralManager({ profile, mode = "edit", onContinue }: TabGeneralManagerProps) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const owner = profile.identity?.owner;
|
const owner = profile.identity?.owner;
|
||||||
const [gmSameAsOwner, setGmSameAsOwner] = useState(false);
|
const identity = profile.identity;
|
||||||
|
const gm = identity?.gm;
|
||||||
|
const faydaRequired = identity?.faydaRequired ?? false;
|
||||||
|
const [gmSameAsOwner, setGmSameAsOwner] = useState(
|
||||||
|
identity?.gmSameAsOwner ?? false,
|
||||||
|
);
|
||||||
|
|
||||||
const defaultValues = useMemo((): FormData => {
|
const defaultValues = useMemo((): FormData => {
|
||||||
return {
|
return {
|
||||||
@@ -68,25 +85,45 @@ export default function TabGeneralManager({ profile, mode = "edit", onContinue }
|
|||||||
values: defaultValues,
|
values: defaultValues,
|
||||||
});
|
});
|
||||||
|
|
||||||
const toggleGmSameAsOwner = (checked: boolean) => {
|
/**
|
||||||
|
* With a Fayda-verified owner the declaration is made server-side — the API
|
||||||
|
* copies the proven identity onto the GM — so nothing is typed here. Without
|
||||||
|
* one (a foreign company, whose owner is backed by a passport) there is
|
||||||
|
* nothing proven to copy and this stays a local prefill.
|
||||||
|
*/
|
||||||
|
const [linkPending, setLinkPending] = useState(false);
|
||||||
|
const [linkError, setLinkError] = useState<string | null>(null);
|
||||||
|
const toggleGmSameAsOwner = async (checked: boolean) => {
|
||||||
setGmSameAsOwner(checked);
|
setGmSameAsOwner(checked);
|
||||||
if (checked && owner) {
|
setLinkError(null);
|
||||||
setValue("generalManagerName", owner.name ?? "", { shouldValidate: true });
|
if (!owner?.verified) {
|
||||||
setValue("generalManagerEmail", owner.email ?? "", { shouldValidate: true });
|
if (checked && owner) {
|
||||||
setValue("generalManagerPhone", owner.phone ?? "", { shouldValidate: true });
|
setValue("generalManagerName", owner.name ?? "", { shouldValidate: true });
|
||||||
|
setValue("generalManagerEmail", owner.email ?? "", { shouldValidate: true });
|
||||||
|
setValue("generalManagerPhone", owner.phone ?? "", { shouldValidate: true });
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLinkPending(true);
|
||||||
|
try {
|
||||||
|
if (checked) await verifaydaService.setGmSameAsOwner();
|
||||||
|
else await verifaydaService.clearGmIdentity();
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: api.companies.getProfile.queryKey(),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
setGmSameAsOwner(!checked);
|
||||||
|
setLinkError(
|
||||||
|
(err as { response?: { data?: { message?: string } } })?.response?.data
|
||||||
|
?.message ??
|
||||||
|
(err instanceof Error ? err.message : "Could not update the general manager"),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setLinkPending(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Keep the copy live while the checkbox is on — e.g. the owner re-verifies
|
|
||||||
// with updated details.
|
|
||||||
useEffect(() => {
|
|
||||||
if (!gmSameAsOwner || !owner) return;
|
|
||||||
setValue("generalManagerName", owner.name ?? "");
|
|
||||||
setValue("generalManagerEmail", owner.email ?? "");
|
|
||||||
setValue("generalManagerPhone", owner.phone ?? "");
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [gmSameAsOwner, owner?.name, owner?.email, owner?.phone]);
|
|
||||||
|
|
||||||
const mutation = useMutation({
|
const mutation = useMutation({
|
||||||
mutationFn: (data: FormData) =>
|
mutationFn: (data: FormData) =>
|
||||||
api.companies.updateProfile.call({
|
api.companies.updateProfile.call({
|
||||||
@@ -102,6 +139,11 @@ export default function TabGeneralManager({ profile, mode = "edit", onContinue }
|
|||||||
|
|
||||||
const onSubmit = (data: FormData) => mutation.mutate(data);
|
const onSubmit = (data: FormData) => mutation.mutate(data);
|
||||||
|
|
||||||
|
// Nothing to save when Fayda owns the details: the verification and the
|
||||||
|
// "same as owner" declaration both write server-side, so the form would be
|
||||||
|
// posting empty strings over a proven identity.
|
||||||
|
const typedFieldsInUse = !gmSameAsOwner && !gm?.verified && !faydaRequired;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card padding="lg">
|
<Card padding="lg">
|
||||||
<Group gap="sm" mb="xs">
|
<Group gap="sm" mb="xs">
|
||||||
@@ -114,40 +156,70 @@ export default function TabGeneralManager({ profile, mode = "edit", onContinue }
|
|||||||
|
|
||||||
<form onSubmit={handleSubmit(onSubmit)}>
|
<form onSubmit={handleSubmit(onSubmit)}>
|
||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
{owner?.verified && (
|
<LinkCheckboxCard
|
||||||
<LinkCheckboxCard
|
checked={gmSameAsOwner}
|
||||||
checked={gmSameAsOwner}
|
onToggle={toggleGmSameAsOwner}
|
||||||
onToggle={toggleGmSameAsOwner}
|
title={
|
||||||
title="Same as verified owner"
|
owner?.verified ? "Same as verified owner" : "Same as business owner"
|
||||||
description="Reuse the Fayda-verified owner's name, email and phone. Uncheck to enter different details."
|
}
|
||||||
/>
|
description={
|
||||||
)}
|
owner?.verified
|
||||||
<TextInput
|
? "Reuse the Fayda-verified owner's identity for the general manager. Uncheck to verify a different person."
|
||||||
label="Full Name"
|
: "Reuse the owner's name, email and phone. Uncheck to enter different details."
|
||||||
placeholder="Abebe Bikila"
|
}
|
||||||
error={errors.generalManagerName?.message}
|
|
||||||
{...register("generalManagerName")}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Grid>
|
{linkError && (
|
||||||
<Grid.Col span={6}>
|
<Alert color="red" variant="light" icon={<XCircle size={18} />}>
|
||||||
|
{linkError}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Verifying a second person only means something when the manager
|
||||||
|
is someone other than the owner. */}
|
||||||
|
{!gmSameAsOwner && gm && (
|
||||||
|
<FaydaVerifyPanel
|
||||||
|
subject="gm"
|
||||||
|
title="General Manager"
|
||||||
|
state={gm}
|
||||||
|
required={faydaRequired}
|
||||||
|
disabled={linkPending || mutation.isPending}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Typed details survive only where Fayda cannot be required — a
|
||||||
|
foreign company's manager may hold no Fayda ID. Once verified the
|
||||||
|
API owns these fields and refuses edits, so they go away. */}
|
||||||
|
{!gmSameAsOwner && !gm?.verified && !faydaRequired && (
|
||||||
|
<>
|
||||||
<TextInput
|
<TextInput
|
||||||
label="Email Address"
|
label="Full Name"
|
||||||
type="email"
|
placeholder="Abebe Bikila"
|
||||||
placeholder="gm@company.com"
|
error={errors.generalManagerName?.message}
|
||||||
error={errors.generalManagerEmail?.message}
|
{...register("generalManagerName")}
|
||||||
{...register("generalManagerEmail")}
|
|
||||||
/>
|
/>
|
||||||
</Grid.Col>
|
|
||||||
<Grid.Col span={6}>
|
<Grid>
|
||||||
<ControlledPhoneField
|
<Grid.Col span={6}>
|
||||||
control={control}
|
<TextInput
|
||||||
name="generalManagerPhone"
|
label="Email Address"
|
||||||
label="Phone Number"
|
type="email"
|
||||||
required
|
placeholder="gm@company.com"
|
||||||
/>
|
error={errors.generalManagerEmail?.message}
|
||||||
</Grid.Col>
|
{...register("generalManagerEmail")}
|
||||||
</Grid>
|
/>
|
||||||
|
</Grid.Col>
|
||||||
|
<Grid.Col span={6}>
|
||||||
|
<ControlledPhoneField
|
||||||
|
control={control}
|
||||||
|
name="generalManagerPhone"
|
||||||
|
label="Phone Number"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</Grid.Col>
|
||||||
|
</Grid>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
<Group
|
<Group
|
||||||
@@ -171,7 +243,7 @@ export default function TabGeneralManager({ profile, mode = "edit", onContinue }
|
|||||||
)}
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
<Group gap="md">
|
<Group gap="md">
|
||||||
{mode === "edit" && (
|
{mode === "edit" && typedFieldsInUse && (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -181,13 +253,26 @@ export default function TabGeneralManager({ profile, mode = "edit", onContinue }
|
|||||||
Reset
|
Reset
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Button
|
{/* Saving only means something while the details are typed: under
|
||||||
type="submit"
|
Fayda both routes write server-side, so a submit would post
|
||||||
leftSection={<Save size={16} />}
|
empty strings at an identity the API owns and refuses to
|
||||||
loading={mutation.isPending}
|
overwrite. Onboarding still needs a way forward, so the button
|
||||||
>
|
becomes a plain Continue rather than disappearing. */}
|
||||||
{mode === "onboarding" ? "Continue" : "Save Changes"}
|
{typedFieldsInUse ? (
|
||||||
</Button>
|
<Button
|
||||||
|
type="submit"
|
||||||
|
leftSection={<Save size={16} />}
|
||||||
|
loading={mutation.isPending}
|
||||||
|
>
|
||||||
|
{mode === "onboarding" ? "Continue" : "Save Changes"}
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
mode === "onboarding" && (
|
||||||
|
<Button type="button" onClick={() => onContinue?.()}>
|
||||||
|
Continue
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -136,7 +136,12 @@ export default function TabPowerOfAttorney({
|
|||||||
// has been verified.
|
// has been verified.
|
||||||
const identity = profile.identity;
|
const identity = profile.identity;
|
||||||
const poaProvided = identity?.poa.verified ?? false;
|
const poaProvided = identity?.poa.verified ?? false;
|
||||||
const letterRequired = requirePoa || poaProvided;
|
// The paper authorises the representative named above, so there is nothing
|
||||||
|
// for it to authorise until one has been verified — the upload is hidden
|
||||||
|
// until then, and requiring it while hidden would block the save on a
|
||||||
|
// control the customer cannot see. A freight forwarder is still held to
|
||||||
|
// having a PoA at all, by the verification gate on the panel and by the API.
|
||||||
|
const letterRequired = poaProvided;
|
||||||
const letterMissing = letterRequired && !hasLetterAfterSave;
|
const letterMissing = letterRequired && !hasLetterAfterSave;
|
||||||
|
|
||||||
const fileDirty = Boolean(pickedFile) || removeIds.length > 0;
|
const fileDirty = Boolean(pickedFile) || removeIds.length > 0;
|
||||||
@@ -253,35 +258,33 @@ export default function TabPowerOfAttorney({
|
|||||||
state={identity.poa}
|
state={identity.poa}
|
||||||
required={requirePoa}
|
required={requirePoa}
|
||||||
disabled={mutation.isPending}
|
disabled={mutation.isPending}
|
||||||
onVerified={() => {
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: api.companies.getProfile.queryKey(),
|
|
||||||
});
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: api.companies.poaDelegation.queryKey(),
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<form onSubmit={handleSubmit(onSubmit)}>
|
<form onSubmit={handleSubmit(onSubmit)}>
|
||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
{/* Name, email, phone and address are all written by the Fayda
|
{/* Name, email, phone and address all come from the Fayda
|
||||||
verification, so only the city — which the address claim does
|
verification and are shown on the panel above. Only a company
|
||||||
not reliably decompose into — is typed. */}
|
whose representative may hold no Fayda ID still types a
|
||||||
<Grid>
|
location. */}
|
||||||
<Grid.Col span={6}>
|
{!poaProvided && !(identity?.faydaRequired ?? false) && (
|
||||||
<TextInput
|
<Grid>
|
||||||
label="PoA Location"
|
<Grid.Col span={6}>
|
||||||
placeholder="City, Country"
|
<TextInput
|
||||||
error={errors.poaLocation?.message}
|
label="PoA Location"
|
||||||
{...register("poaLocation")}
|
placeholder="City, Country"
|
||||||
/>
|
error={errors.poaLocation?.message}
|
||||||
</Grid.Col>
|
{...register("poaLocation")}
|
||||||
</Grid>
|
/>
|
||||||
|
</Grid.Col>
|
||||||
|
</Grid>
|
||||||
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
{/* ------------------------ Delegation letter ------------------------ */}
|
{/* ------------------------ Delegation letter ------------------------ */}
|
||||||
|
{/* The paper authorises the representative the verification named,
|
||||||
|
so it only has meaning once one exists. */}
|
||||||
|
{poaProvided && (
|
||||||
<Stack gap="sm" mt="xl">
|
<Stack gap="sm" mt="xl">
|
||||||
<Group justify="space-between" align="center">
|
<Group justify="space-between" align="center">
|
||||||
<Group gap="sm">
|
<Group gap="sm">
|
||||||
@@ -429,6 +432,7 @@ export default function TabPowerOfAttorney({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
)}
|
||||||
|
|
||||||
<Group
|
<Group
|
||||||
justify="space-between"
|
justify="space-between"
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import {
|
||||||
|
stashPendingVerification,
|
||||||
|
takePendingVerification,
|
||||||
|
} from "./verifayda.service";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The stash is the only thing that survives the full-page handoff to eSignet,
|
||||||
|
* so /fayda/callback completing against the wrong subject — or throwing on junk left
|
||||||
|
* behind by an older build — would either misfile a verified identity or dead-
|
||||||
|
* end the flow.
|
||||||
|
*/
|
||||||
|
describe("pending verification stash", () => {
|
||||||
|
// The suite runs in node, not jsdom — a Map is all these two calls need.
|
||||||
|
beforeEach(() => {
|
||||||
|
const store = new Map<string, string>();
|
||||||
|
vi.stubGlobal("sessionStorage", {
|
||||||
|
getItem: (k: string) => store.get(k) ?? null,
|
||||||
|
setItem: (k: string, v: string) => void store.set(k, v),
|
||||||
|
removeItem: (k: string) => void store.delete(k),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("round-trips and clears, so a spent code can't be replayed", () => {
|
||||||
|
stashPendingVerification({ subject: "poa", returnTo: "/settings?tab=poa" });
|
||||||
|
|
||||||
|
expect(takePendingVerification()).toEqual({
|
||||||
|
subject: "poa",
|
||||||
|
returnTo: "/settings?tab=poa",
|
||||||
|
});
|
||||||
|
expect(takePendingVerification()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null rather than throwing on missing or malformed entries", () => {
|
||||||
|
expect(takePendingVerification()).toBeNull();
|
||||||
|
|
||||||
|
sessionStorage.setItem("fayda-pending-verification", "not json");
|
||||||
|
expect(takePendingVerification()).toBeNull();
|
||||||
|
|
||||||
|
sessionStorage.setItem("fayda-pending-verification", '{"returnTo":"/"}');
|
||||||
|
expect(takePendingVerification()).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,11 +3,12 @@ import { unwrap } from "@/utils/endpoint";
|
|||||||
import type { ApiResponse } from "@/types/apiResponse";
|
import type { ApiResponse } from "@/types/apiResponse";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Which of the company's people a verification is for. The owner is NOT the
|
* Which of the company's people a verification is for. The owner is who the
|
||||||
* general manager — GM is a plain typed role the portal offers a "same as
|
* company is proven through; the PoA and GM are personnel it names. The GM is
|
||||||
* owner" copy for, but only the owner and the PoA are ever Fayda-verified.
|
* very often the owner — "same as owner" reuses that verification rather than
|
||||||
|
* making the same human prove themselves twice.
|
||||||
*/
|
*/
|
||||||
export type IdentitySubject = "owner" | "poa";
|
export type IdentitySubject = "owner" | "poa" | "gm";
|
||||||
|
|
||||||
/** One person's Fayda verification state, as the API reports it. */
|
/** One person's Fayda verification state, as the API reports it. */
|
||||||
export interface IdentityVerificationState {
|
export interface IdentityVerificationState {
|
||||||
@@ -29,29 +30,67 @@ export interface OwnerIdentityState extends IdentityVerificationState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface CompanyIdentityState {
|
export interface CompanyIdentityState {
|
||||||
/** True when Fayda verification of the owner (and PoA) is mandatory — Ethiopian companies only. */
|
/**
|
||||||
|
* True when Fayda verification is mandatory — Ethiopian companies only.
|
||||||
|
* Doubles as "may this person be typed instead": Fayda is an Ethiopian
|
||||||
|
* national ID, so a foreign company's GM and PoA are offered the
|
||||||
|
* verification but fall back to typed details when they hold none.
|
||||||
|
*/
|
||||||
faydaRequired: boolean;
|
faydaRequired: boolean;
|
||||||
/** True when the owner's passport number is mandatory — foreign companies only. */
|
/** True when the owner's passport number is mandatory — foreign companies only. */
|
||||||
passportRequired: boolean;
|
passportRequired: boolean;
|
||||||
owner: OwnerIdentityState;
|
owner: OwnerIdentityState;
|
||||||
poa: IdentityVerificationState;
|
poa: IdentityVerificationState;
|
||||||
|
/**
|
||||||
|
* General manager. `verified` covers both routes: the GM verifying in their
|
||||||
|
* own right, and the company declaring the GM is the owner (in which case
|
||||||
|
* `gmSameAsOwner` is set and the owner's Fayda sub backs it).
|
||||||
|
*/
|
||||||
|
gm: IdentityVerificationState;
|
||||||
|
gmSameAsOwner: boolean;
|
||||||
complete: boolean;
|
complete: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Message posted from the /callback popup back to the opener window. */
|
/**
|
||||||
export interface FaydaCallbackMessage {
|
* What the panel was doing when it handed the tab over to eSignet. The
|
||||||
type: "fayda-callback";
|
* verification is a full-page redirect, so the page that started it is gone by
|
||||||
code?: string;
|
* the time /fayda/callback runs — this is how that page knows whose identity
|
||||||
state?: string;
|
* the code+state belongs to and where to put the user back.
|
||||||
error?: string;
|
*
|
||||||
errorDescription?: string;
|
* sessionStorage, not localStorage: it is scoped to this tab, so two tabs
|
||||||
|
* verifying different people can't overwrite each other, and it dies with the
|
||||||
|
* tab rather than outliving an abandoned verification.
|
||||||
|
*/
|
||||||
|
const PENDING_KEY = "fayda-pending-verification";
|
||||||
|
|
||||||
|
export interface PendingVerification {
|
||||||
|
subject: IdentitySubject;
|
||||||
|
/** Path to return to once the verification completes. */
|
||||||
|
returnTo: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stashPendingVerification(pending: PendingVerification): void {
|
||||||
|
sessionStorage.setItem(PENDING_KEY, JSON.stringify(pending));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read and clear — the code+state are single-use, so a retry needs a fresh start. */
|
||||||
|
export function takePendingVerification(): PendingVerification | null {
|
||||||
|
const raw = sessionStorage.getItem(PENDING_KEY);
|
||||||
|
sessionStorage.removeItem(PENDING_KEY);
|
||||||
|
if (!raw) return null;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw) as PendingVerification;
|
||||||
|
return parsed.subject ? parsed : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const verifaydaService = {
|
export const verifaydaService = {
|
||||||
/**
|
/**
|
||||||
* Returns the eSignet authorize URL to open in a popup. `PORTAL` selects the
|
* Returns the eSignet authorize URL to navigate the tab to. `PORTAL` selects
|
||||||
* portal's own registered redirect_uri — the backoffice and mobile clients
|
* the portal's own registered redirect_uri — the backoffice and mobile
|
||||||
* have their own.
|
* clients have their own.
|
||||||
*/
|
*/
|
||||||
start: async (): Promise<string> => {
|
start: async (): Promise<string> => {
|
||||||
const response = await client.post<
|
const response = await client.post<
|
||||||
@@ -80,6 +119,30 @@ export const verifaydaService = {
|
|||||||
return unwrap(response.data);
|
return unwrap(response.data);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Declare the General Manager is the company's owner, reusing the owner's
|
||||||
|
* verified identity rather than making the same human verify twice. The copy
|
||||||
|
* happens server-side from the stored owner identity — the portal never
|
||||||
|
* supplies the values — and is refused until the owner is verified.
|
||||||
|
*/
|
||||||
|
setGmSameAsOwner: async (): Promise<CompanyIdentityState> => {
|
||||||
|
const response = await client.post<ApiResponse<CompanyIdentityState>>(
|
||||||
|
"/api/companies/identity/gm/same-as-owner",
|
||||||
|
);
|
||||||
|
return unwrap(response.data);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear the GM's identity — the "same as owner" declaration or a verification
|
||||||
|
* of their own — leaving them open to be re-established either way.
|
||||||
|
*/
|
||||||
|
clearGmIdentity: async (): Promise<CompanyIdentityState> => {
|
||||||
|
const response = await client.delete<ApiResponse<CompanyIdentityState>>(
|
||||||
|
"/api/companies/identity/gm",
|
||||||
|
);
|
||||||
|
return unwrap(response.data);
|
||||||
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Drop the Power of Attorney — verified identity, details and delegation
|
* Drop the Power of Attorney — verified identity, details and delegation
|
||||||
* paper together. A verified person's fields are locked, so blanking the form
|
* paper together. A verified person's fields are locked, so blanking the form
|
||||||
|
|||||||
@@ -36,11 +36,15 @@ export interface ProfileResponse {
|
|||||||
generalManagerEmail: string | null;
|
generalManagerEmail: string | null;
|
||||||
generalManagerPhone: string | null;
|
generalManagerPhone: string | null;
|
||||||
/**
|
/**
|
||||||
* Fayda verification state for the owner and the PoA — not the general
|
* Fayda verification state for the owner, the PoA and the general manager.
|
||||||
* manager, which stays a plain typed role. `identity.faydaRequired` /
|
* `identity.faydaRequired` / `identity.passportRequired` is the
|
||||||
* `identity.passportRequired` is the Ethiopian/foreign switch: an Ethiopian
|
* Ethiopian/foreign switch: an Ethiopian company verifies all three with
|
||||||
* company verifies the owner (and PoA) with Fayda; a foreign one requires a
|
* Fayda, while a foreign one proves its owner with a typed passport number
|
||||||
* typed passport number for the owner instead.
|
* and may type its GM and PoA, whose holders may have no Fayda ID.
|
||||||
|
*
|
||||||
|
* The `generalManager*` fields above are the same person's details written
|
||||||
|
* flat — a verification keeps them in step, since the booking, contract and
|
||||||
|
* train-scheduling notifiers mail `generalManagerEmail` directly.
|
||||||
*/
|
*/
|
||||||
identity: CompanyIdentityState;
|
identity: CompanyIdentityState;
|
||||||
poaName: string | null;
|
poaName: string | null;
|
||||||
|
|||||||
@@ -235,8 +235,8 @@ services:
|
|||||||
PAYMENT_API_URL: http://payment-mock-e2e:4500
|
PAYMENT_API_URL: http://payment-mock-e2e:4500
|
||||||
FAYDA_TOKEN_ENDPOINT: http://fayda-mock-e2e:4400/token
|
FAYDA_TOKEN_ENDPOINT: http://fayda-mock-e2e:4400/token
|
||||||
FAYDA_USERINFO_ENDPOINT: http://fayda-mock-e2e:4400/userinfo
|
FAYDA_USERINFO_ENDPOINT: http://fayda-mock-e2e:4400/userinfo
|
||||||
FAYDA_REDIRECT_URI: http://localhost:${E2E_PORTAL_PORT:-5373}/callback
|
FAYDA_REDIRECT_URI: http://localhost:${E2E_PORTAL_PORT:-5373}/fayda/callback
|
||||||
FAYDA_PORTAL_REDIRECT_URI: http://localhost:${E2E_PORTAL_PORT:-5373}/callback
|
FAYDA_PORTAL_REDIRECT_URI: http://localhost:${E2E_PORTAL_PORT:-5373}/fayda/callback
|
||||||
# Throwaway e2e-only RSA JWK (client_assertion signing) — the mock
|
# Throwaway e2e-only RSA JWK (client_assertion signing) — the mock
|
||||||
# never verifies the signature, this just has to be well-formed.
|
# never verifies the signature, this just has to be well-formed.
|
||||||
# Generated fresh per launch by e2e.mjs (fakeFaydaPrivateKeyBase64),
|
# Generated fresh per launch by e2e.mjs (fakeFaydaPrivateKeyBase64),
|
||||||
|
|||||||
@@ -1,12 +1,24 @@
|
|||||||
# EDR Freight — API integration stack (headless).
|
# EDR Freight — API integration stack (headless, sharded).
|
||||||
#
|
#
|
||||||
# Overlay on docker-compose.e2e.yaml. Same base services (postgres, minio,
|
# Overlay on docker-compose.e2e.yaml, plus a GENERATED third file holding the
|
||||||
# mocks, freight-api), except the payment microservice is REAL here instead of
|
# per-shard services (integration/.it-shards.yaml, written by gen-shards.mjs).
|
||||||
# `payment-mock-e2e`, and only the bank gateways are stubbed:
|
|
||||||
#
|
#
|
||||||
# freight-api-it ──HTTP──> payment-api-it ──HTTP──> gateway-mock-it
|
# The freight API is NOT a container here — it boots inside each vitest worker,
|
||||||
# ^ │
|
# on the host, so a code change needs no image rebuild and a breakpoint works.
|
||||||
# └────── RabbitMQ ───────┘ (outbox → payment.events → consumer)
|
# Each worker is a full shard of the topology; nothing mutable is shared:
|
||||||
|
#
|
||||||
|
# shard i:
|
||||||
|
# freight app (in vitest worker, host :3111+i)
|
||||||
|
# │ ▲
|
||||||
|
# │ └──── HTTP ─── payment-api-it-{i} :3131+i (inbound CBE bill query,
|
||||||
|
# │ │ via host.docker.internal)
|
||||||
|
# └── HTTP ──────────────► │ ──HTTP──> gateway-mock-it-{i} :4600+i
|
||||||
|
# ▲ │
|
||||||
|
# └──── RabbitMQ vhost payment_s{i} ──┘ (outbox → payment.events → consumer)
|
||||||
|
#
|
||||||
|
# Shared by every shard: postgres (one container, one database per shard cloned
|
||||||
|
# from a seeded template), rabbitmq (one container, one vhost per shard), minio,
|
||||||
|
# and the one-shot migration.
|
||||||
#
|
#
|
||||||
# Its own compose project (`name:` below overrides the base) and its own host
|
# Its own compose project (`name:` below overrides the base) and its own host
|
||||||
# ports, so it can run side by side with the Cypress e2e stack.
|
# ports, so it can run side by side with the Cypress e2e stack.
|
||||||
@@ -15,13 +27,16 @@
|
|||||||
#
|
#
|
||||||
# Never start it with plain `docker compose -f docker-compose.it.yaml` — it is
|
# Never start it with plain `docker compose -f docker-compose.it.yaml` — it is
|
||||||
# an OVERLAY and needs the base file first:
|
# an OVERLAY and needs the base file first:
|
||||||
# docker compose -f docker-compose.e2e.yaml -f docker-compose.it.yaml ...
|
# docker compose -f docker-compose.e2e.yaml -f docker-compose.it.yaml \
|
||||||
|
# -f integration/.it-shards.yaml ...
|
||||||
name: edr-freight-it
|
name: edr-freight-it
|
||||||
|
|
||||||
services:
|
services:
|
||||||
# Outbox transport. The payment API publishes payment.succeeded/failed here
|
# Outbox transport. The payment API publishes payment.succeeded/failed here
|
||||||
# and freight consumes it — the production path. Copied from the passenger
|
# and freight consumes it — the production path. Copied from the passenger
|
||||||
# harness (e2e/docker-compose.yml).
|
# harness (e2e/docker-compose.yml). One broker, one vhost per shard
|
||||||
|
# (payment_s0, payment_s1, …) created by it.mjs — a shared vhost would let one
|
||||||
|
# shard's freight consumer eat another shard's settlement event.
|
||||||
rabbitmq-it:
|
rabbitmq-it:
|
||||||
image: rabbitmq:3-management
|
image: rabbitmq:3-management
|
||||||
environment:
|
environment:
|
||||||
@@ -32,139 +47,14 @@ services:
|
|||||||
- "${IT_RABBIT_PORT:-5772}:5672"
|
- "${IT_RABBIT_PORT:-5772}:5672"
|
||||||
- "${IT_RABBIT_UI_PORT:-15772}:15672"
|
- "${IT_RABBIT_UI_PORT:-15772}:15672"
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "rabbitmq-diagnostics", "-q", "ping"]
|
# check_running, NOT ping: ping only proves the Erlang node answers, and
|
||||||
|
# it.mjs runs `rabbitmqctl add_vhost` the moment this goes healthy — which
|
||||||
|
# on a cold boot failed with "this command requires the 'rabbit' app to be
|
||||||
|
# running on the target node". check_running waits for the application.
|
||||||
|
test: ["CMD", "rabbitmq-diagnostics", "-q", "check_running"]
|
||||||
interval: 5s
|
interval: 5s
|
||||||
timeout: 5s
|
timeout: 10s
|
||||||
retries: 20
|
retries: 20
|
||||||
|
# The per-shard `gateway-mock-it-{i}` and `payment-api-it-{i}` services live in
|
||||||
# Stand-in for every bank/wallet gateway the payment API talks to, plus a
|
# integration/.it-shards.yaml. The base file's `freight-api-e2e` is never
|
||||||
# control plane the tests drive (force a provider to fail/hang, fire a
|
# started — the app runs in-process (integration/src/app.ts).
|
||||||
# correctly-signed webhook, read back what was called). See
|
|
||||||
# integration/gateway-mock/server.js.
|
|
||||||
gateway-mock-it:
|
|
||||||
image: node:20-alpine
|
|
||||||
volumes:
|
|
||||||
- ./integration/gateway-mock:/app:ro
|
|
||||||
working_dir: /app
|
|
||||||
environment:
|
|
||||||
PORT: "4600"
|
|
||||||
# Same secrets the payment API gets — so webhooks the mock signs pass the
|
|
||||||
# API's REAL signature verification instead of bypassing it.
|
|
||||||
CBE_SECRET_KEY: it-cbe-secret
|
|
||||||
CBE_MERCHANT_ID: it-cbe-merchant
|
|
||||||
PAYMENT_API_URL: http://payment-api-it:3003
|
|
||||||
command: ["node", "server.js"]
|
|
||||||
ports:
|
|
||||||
- "${IT_GATEWAY_PORT:-4600}:4600"
|
|
||||||
healthcheck:
|
|
||||||
test:
|
|
||||||
[
|
|
||||||
"CMD",
|
|
||||||
"node",
|
|
||||||
"-e",
|
|
||||||
"fetch('http://localhost:4600/__control/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))",
|
|
||||||
]
|
|
||||||
interval: 3s
|
|
||||||
timeout: 3s
|
|
||||||
retries: 10
|
|
||||||
|
|
||||||
payment-api-it:
|
|
||||||
build:
|
|
||||||
context: .
|
|
||||||
dockerfile: apps/edr-payment-api/Dockerfile
|
|
||||||
secrets:
|
|
||||||
- npmrc
|
|
||||||
depends_on:
|
|
||||||
postgres-freight-e2e:
|
|
||||||
condition: service_healthy
|
|
||||||
rabbitmq-it:
|
|
||||||
condition: service_healthy
|
|
||||||
gateway-mock-it:
|
|
||||||
condition: service_healthy
|
|
||||||
environment:
|
|
||||||
PORT: "3003"
|
|
||||||
NODE_ENV: test
|
|
||||||
# Payment tables live in their own schema of the same throwaway DB;
|
|
||||||
# main.ts ensurePaymentSchema() creates it, migrationsRun does the rest.
|
|
||||||
DB_HOST: postgres-freight-e2e
|
|
||||||
DB_PORT: "5432"
|
|
||||||
DB_USER: edr_e2e
|
|
||||||
DB_PASSWORD: edr_e2e
|
|
||||||
DB_NAME: edr_freight_e2e
|
|
||||||
DB_SCHEMA: edr_payment
|
|
||||||
# Same token freight already uses in the base stack.
|
|
||||||
SERVICE_AUTH_TOKEN: e2e-service-token
|
|
||||||
PUBLISHER_TRANSPORT: rabbitmq
|
|
||||||
PAYMENT_RABBITMQ_URL: amqp://edr:edr_secret@rabbitmq-it:5672/payment
|
|
||||||
# HTTP fallback targets (only used with PUBLISHER_TRANSPORT=http).
|
|
||||||
PAYMENT_NOTIFY_FREIGHT_URL: http://freight-api-e2e:3001/api/internal/payments/mark-paid
|
|
||||||
# Fast relay + sweep so retry/reconciliation are observable inside a test
|
|
||||||
# rather than a minute later.
|
|
||||||
OUTBOX_RELAY_INTERVAL_MS: "1000"
|
|
||||||
RECONCILE_STALE_AFTER_MS: "5000"
|
|
||||||
# Every gateway points at the one mock. Paths are per-provider prefixes.
|
|
||||||
CBE_BASE_URL: http://gateway-mock-it:4600/cbe-birr
|
|
||||||
CBE_MERCHANT_ID: it-cbe-merchant
|
|
||||||
CBE_SECRET_KEY: it-cbe-secret
|
|
||||||
CBE_NOTIFY_URL: http://payment-api-it:3003/webhooks/cbe-birr
|
|
||||||
CBE_RETURN_URL: http://localhost/return
|
|
||||||
TELEBIRR_BASE_URL: http://gateway-mock-it:4600/telebirr
|
|
||||||
TELEBIRR_WEB_BASE_URL: http://gateway-mock-it:4600/telebirr/web
|
|
||||||
TELEBIRR_FABRIC_APP_ID: it-fabric
|
|
||||||
TELEBIRR_APP_SECRET: it-secret
|
|
||||||
TELEBIRR_MERCHANT_APP_ID: it-merchant-app
|
|
||||||
TELEBIRR_MERCHANT_CODE: "999999"
|
|
||||||
TELEBIRR_NOTIFY_URL: http://payment-api-it:3003/webhooks/telebirr
|
|
||||||
# Telebirr PSS-signs every request object — a throwaway key generated per
|
|
||||||
# launch by it.mjs (nothing key-shaped lives in git).
|
|
||||||
TELEBIRR_PRIVATE_KEY: ${IT_TELEBIRR_PRIVATE_KEY}
|
|
||||||
EBIRR_BASE_URL: http://gateway-mock-it:4600/ebirr
|
|
||||||
DMONEY_BASE_URL: http://gateway-mock-it:4600/dmoney
|
|
||||||
CARD_BASE_URL: http://gateway-mock-it:4600/card
|
|
||||||
WAAFI_BASE_URL: http://gateway-mock-it:4600/waafi
|
|
||||||
CAC_BASE_URL: http://gateway-mock-it:4600/cac
|
|
||||||
CAC_USERNAME: it-cac
|
|
||||||
CAC_PASSWORD: it-cac
|
|
||||||
CAC_APP_KEY: it-cac-key
|
|
||||||
CAC_API_KEY: it-cac-api
|
|
||||||
CAC_COMPANY_SERVICES_ID: "1"
|
|
||||||
# Inbound CBE Unified Bill — we are the biller; bill-query hops back into
|
|
||||||
# the freight API, so this direction runs real code on both sides.
|
|
||||||
CBE_BILL_ENABLED: "true"
|
|
||||||
CBE_BILL_CLIENT_ID: it-cbe-bill
|
|
||||||
CBE_BILL_CLIENT_SECRET: it-cbe-bill-secret
|
|
||||||
CBE_BILL_JWT_SECRET: it-cbe-bill-jwt
|
|
||||||
FREIGHT_API_BASE_URL: http://freight-api-e2e:3001/api
|
|
||||||
ports:
|
|
||||||
- "${IT_PAYMENT_PORT:-3113}:3003"
|
|
||||||
healthcheck:
|
|
||||||
test:
|
|
||||||
[
|
|
||||||
"CMD",
|
|
||||||
"node",
|
|
||||||
"-e",
|
|
||||||
"fetch('http://localhost:3003/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))",
|
|
||||||
]
|
|
||||||
interval: 5s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 12
|
|
||||||
start_period: 40s
|
|
||||||
|
|
||||||
# Base-stack service, re-pointed at the real payment API.
|
|
||||||
freight-api-e2e:
|
|
||||||
depends_on:
|
|
||||||
payment-api-it:
|
|
||||||
condition: service_healthy
|
|
||||||
environment:
|
|
||||||
PAYMENT_API_URL: http://payment-api-it:3003
|
|
||||||
# Freight's payment module skips RabbitMQModule entirely when this is
|
|
||||||
# unset (payment.module.ts) — without it, outbox events never arrive.
|
|
||||||
PAYMENT_RABBITMQ_URL: amqp://edr:edr_secret@rabbitmq-it:5672/payment
|
|
||||||
# RABBITMQ_ENABLED stays "false" (base stack) — it only gates the SMS/email
|
|
||||||
# clients, which must remain off. The payment consumer is wired by
|
|
||||||
# PAYMENT_RABBITMQ_URL alone.
|
|
||||||
# Drain tail on every pay window. Production defaults to 5 minutes; a
|
|
||||||
# reservation here lives ~60s, so 5 would push every natural expiry past
|
|
||||||
# the suite's 180s timeouts. One minute keeps the tail real and observable
|
|
||||||
# (src/expired-invoice-late-settle.it.ts asserts both sides of it).
|
|
||||||
FREIGHT_PAYMENT_DRAIN_MINUTES: "1"
|
|
||||||
|
|||||||
@@ -42,21 +42,44 @@ function latestOnboardJourney() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Fill a labelled Mantine input (label[for] → input id). */
|
/**
|
||||||
|
* Fill a labelled Mantine input (label[for] → input id).
|
||||||
|
*
|
||||||
|
* The input is resolved fresh for every action rather than captured once.
|
||||||
|
* Each wizard step persists and re-seeds asynchronously, and when a field
|
||||||
|
* remounts Mantine mints a NEW generated id — so both a subject and an id
|
||||||
|
* captured a command earlier can be stale by the time the next command runs.
|
||||||
|
* Going label → for → element each time always addresses what's on the page
|
||||||
|
* now.
|
||||||
|
*/
|
||||||
function fill(label: string | RegExp, value: string) {
|
function fill(label: string | RegExp, value: string) {
|
||||||
cy.contains("label", label)
|
const input = () =>
|
||||||
.invoke("attr", "for")
|
cy
|
||||||
.then((id) => {
|
.contains("label", label)
|
||||||
cy.get(`[id="${id}"]`).clear({ force: true }).type(value, { force: true });
|
.invoke("attr", "for")
|
||||||
});
|
.then((id) => cy.get(`[id="${id}"]`));
|
||||||
|
|
||||||
|
input().clear({ force: true });
|
||||||
|
input().type(value, { force: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fill an input that has no <label> — the wizard's company step renders its
|
||||||
|
* fields inside StepSection cards (the heading is the card's title, not a
|
||||||
|
* label), so they're reachable only by aria-label. Both TIN and VAT share the
|
||||||
|
* "0012345678" placeholder, which is why this matches on aria-label instead.
|
||||||
|
*/
|
||||||
|
function fillAria(ariaSelector: string, value: string) {
|
||||||
|
const selector = `.mantine-Modal-content ${ariaSelector}`;
|
||||||
|
cy.get(selector).clear({ force: true });
|
||||||
|
cy.get(selector).type(value, { force: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The wizard's phone inputs (react-phone-number-input, type=tel). */
|
/** The wizard's phone inputs (react-phone-number-input, type=tel). */
|
||||||
function fillPhone(index: number, national: string) {
|
function fillPhone(index: number, national: string) {
|
||||||
cy.get('.mantine-Modal-content input[type="tel"]')
|
const selector = '.mantine-Modal-content input[type="tel"]';
|
||||||
.eq(index)
|
cy.get(selector).eq(index).clear({ force: true });
|
||||||
.clear({ force: true })
|
cy.get(selector).eq(index).type(national, { force: true });
|
||||||
.type(national, { force: true });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("customer onboarding journey", { retries: 0 }, () => {
|
describe("customer onboarding journey", { retries: 0 }, () => {
|
||||||
@@ -86,20 +109,48 @@ describe("customer onboarding journey", { retries: 0 }, () => {
|
|||||||
cy.get(".mantine-Modal-content").contains("button", "Continue").click();
|
cy.get(".mantine-Modal-content").contains("button", "Continue").click();
|
||||||
|
|
||||||
// Ethiopian companies gate the "Owner identity" step on Fayda
|
// Ethiopian companies gate the "Owner identity" step on Fayda
|
||||||
// verification — a real popup + SMS OTP flow that can't run in e2e.
|
// verification — a real eSignet redirect + SMS OTP flow that can't run in
|
||||||
// Complete it via the API against fayda-mock-e2e (the profile this
|
// e2e. Complete it via the API against fayda-mock-e2e (the profile this
|
||||||
// attaches to was just created by the nationality/role step above).
|
// attaches to was just created by the nationality/role step above).
|
||||||
// The wizard already fetched `identity` once when this step mounted —
|
// The wizard already fetched `identity` once when this step mounted, and
|
||||||
// completing verification out-of-band (no popup, so no onVerified
|
// completing verification out-of-band skips the redirect that would
|
||||||
// callback fires) leaves that fetch stale, so reload to force a fresh
|
// normally remount everything — so reload to force a fresh fetch. Wizard
|
||||||
// one. Wizard progress resumes server-side, so this doesn't lose the
|
// progress resumes server-side, so this doesn't lose the nationality/role
|
||||||
// nationality/role step just completed.
|
// step just completed.
|
||||||
completeFaydaVerification("owner");
|
completeFaydaVerification("owner");
|
||||||
cy.reload();
|
cy.reload();
|
||||||
cy.contains("Confirm your VAT number", { timeout: 20000 }).should(
|
cy.contains("Confirm your VAT number", { timeout: 20000 }).should(
|
||||||
"be.visible",
|
"be.visible",
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// The owner's identity is the verification's output, never typed: the
|
||||||
|
// panel must show it verified and render the name/phone/email that came
|
||||||
|
// back from fayda-mock-e2e. Asserting the mock's own values is the only
|
||||||
|
// way to prove the payload travelled Fayda → API → UI rather than the
|
||||||
|
// panel simply flipping a "verified" flag.
|
||||||
|
cy.get(".mantine-Modal-content").within(() => {
|
||||||
|
cy.contains("Fayda verified").should("be.visible");
|
||||||
|
cy.contains("Abebe Bekele").should("be.visible");
|
||||||
|
cy.contains("+251911223344").should("be.visible");
|
||||||
|
cy.contains("abebe.bekele@example.com").should("be.visible");
|
||||||
|
});
|
||||||
|
|
||||||
|
// The verified sub is what locks the owner's fields server-side, so check
|
||||||
|
// it actually landed on the company rather than trusting the panel alone.
|
||||||
|
cy.task<{ rows: Array<{ owner_fayda_sub: string | null }> }>("db:query", {
|
||||||
|
sql: `SELECT c.attributes->>'ownerFaydaSub' AS owner_fayda_sub
|
||||||
|
FROM freight.companies c
|
||||||
|
JOIN freight.external_profiles ep ON ep.company_id = c.id
|
||||||
|
JOIN iam.users u ON u.id = ep.user_id
|
||||||
|
WHERE u.email = $1`,
|
||||||
|
params: [email],
|
||||||
|
}).then(({ rows }) => {
|
||||||
|
expect(rows, "company row").to.have.length(1);
|
||||||
|
expect(rows[0].owner_fayda_sub, "owner Fayda sub").to.eq(
|
||||||
|
"e2e-fayda-sub-0001",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
// Company step. TIN auto-triggers the eTrade lookup once it's a full 10
|
// Company step. TIN auto-triggers the eTrade lookup once it's a full 10
|
||||||
// digits (mocked in e2e — see docker-compose.e2e.yaml's etrade-mock-e2e).
|
// digits (mocked in e2e — see docker-compose.e2e.yaml's etrade-mock-e2e).
|
||||||
// A successful lookup locks Company Name/Region/Zone/Woreda/Kebele/House
|
// A successful lookup locks Company Name/Region/Zone/Woreda/Kebele/House
|
||||||
@@ -108,7 +159,7 @@ describe("customer onboarding journey", { retries: 0 }, () => {
|
|||||||
// Fayda-verified owner supplies contact details now). By label, not
|
// Fayda-verified owner supplies contact details now). By label, not
|
||||||
// placeholder: the VAT Number field on this same step shares the TIN
|
// placeholder: the VAT Number field on this same step shares the TIN
|
||||||
// field's "0012345678" placeholder, so a placeholder selector matches 2.
|
// field's "0012345678" placeholder, so a placeholder selector matches 2.
|
||||||
fill(/^TIN Number/, tin);
|
fillAria('[aria-label^="TIN Number"]', tin);
|
||||||
cy.contains("Verified with eTrade", { timeout: 15000 }).should(
|
cy.contains("Verified with eTrade", { timeout: 15000 }).should(
|
||||||
"be.visible",
|
"be.visible",
|
||||||
);
|
);
|
||||||
@@ -117,7 +168,7 @@ describe("customer onboarding journey", { retries: 0 }, () => {
|
|||||||
// after the badge appears. Typing into VAT immediately raced one of
|
// after the badge appears. Typing into VAT immediately raced one of
|
||||||
// those and detached mid-type; let it finish before touching the form.
|
// those and detached mid-type; let it finish before touching the form.
|
||||||
cy.wait(500);
|
cy.wait(500);
|
||||||
fill(/^VAT Number/, vat);
|
fillAria('[aria-label="VAT Number"]', vat);
|
||||||
cy.get(".mantine-Modal-content").contains("button", "Continue").click();
|
cy.get(".mantine-Modal-content").contains("button", "Continue").click();
|
||||||
|
|
||||||
// Personnel (general manager).
|
// Personnel (general manager).
|
||||||
@@ -131,7 +182,16 @@ describe("customer onboarding journey", { retries: 0 }, () => {
|
|||||||
fillPhone(0, "911234569");
|
fillPhone(0, "911234569");
|
||||||
cy.get(".mantine-Modal-content").contains("button", "Continue").click();
|
cy.get(".mantine-Modal-content").contains("button", "Continue").click();
|
||||||
|
|
||||||
// PoA — optional for an importer.
|
// PoA — optional for an importer, and left unverified here. The DARS
|
||||||
|
// delegation paper authorises the representative the verification names,
|
||||||
|
// so with no verified PoA there is nothing for it to authorise: the
|
||||||
|
// upload must not be offered, and the step must not block on it. Asserted
|
||||||
|
// as "no file input on this step" rather than by label, so a reworded
|
||||||
|
// document setting doesn't turn a real regression into a passing test.
|
||||||
|
cy.get(".mantine-Modal-content")
|
||||||
|
.contains("Power of Attorney")
|
||||||
|
.should("be.visible");
|
||||||
|
cy.get('.mantine-Modal-content input[type="file"]').should("not.exist");
|
||||||
cy.get(".mantine-Modal-content").contains("button", "Continue").click();
|
cy.get(".mantine-Modal-content").contains("button", "Continue").click();
|
||||||
|
|
||||||
// Documents: no company docs are configured in e2e, but every role needs
|
// Documents: no company docs are configured in e2e, but every role needs
|
||||||
|
|||||||
@@ -3,29 +3,77 @@
|
|||||||
Headless, API-level tests for the freight API running against the **real**
|
Headless, API-level tests for the freight API running against the **real**
|
||||||
`edr-payment-api`. Only the bank/wallet gateways are stubbed.
|
`edr-payment-api`. Only the bank/wallet gateways are stubbed.
|
||||||
|
|
||||||
|
The freight API is **not containerized** here — it boots inside each vitest
|
||||||
|
worker from `apps/edr-freight-api/dist`, which `it:test` rebuilds every run. A
|
||||||
|
code change needs no image rebuild, and a breakpoint in the API is hit by the
|
||||||
|
test that provoked it.
|
||||||
|
|
||||||
```
|
```
|
||||||
pnpm it:up # build + start the stack (first run ~5 min)
|
pnpm it:up # build + start the stack (first run ~5 min)
|
||||||
pnpm it:test # vitest run (auto-ups the stack if needed)
|
pnpm it:test # vitest run (auto-ups the stack if needed)
|
||||||
pnpm it:test -- --reporter=basic src/payment-happy.it.ts
|
pnpm it:test -- --reporter=basic src/payment-happy.it.ts
|
||||||
pnpm it:logs payment-api-it
|
pnpm it:test --no-reset -- src/payment-happy.it.ts # skip the DB re-clone
|
||||||
pnpm it:down # -v, wipes the throwaway DB
|
pnpm it:logs payment-api-it-0
|
||||||
|
pnpm it:down # -v, wipes the throwaway DBs and the generated shard file
|
||||||
|
|
||||||
|
IT_SHARDS=1 pnpm it:up # 1 shard when RAM is tight or output must be readable
|
||||||
```
|
```
|
||||||
|
|
||||||
## Stack
|
## Stack
|
||||||
|
|
||||||
`docker-compose.it.yaml` is an **overlay** on `docker-compose.e2e.yaml` — same
|
`docker-compose.it.yaml` is an **overlay** on `docker-compose.e2e.yaml`, plus a
|
||||||
freight API, Postgres (tmpfs), MinIO, Fayda/eTrade mocks; plus RabbitMQ, the
|
third **generated** file (`integration/.it-shards.yaml`, written by
|
||||||
real payment API, and one gateway mock. It is a separate compose project
|
`scripts/gen-shards.mjs`, gitignored) holding the per-shard services. It is a
|
||||||
(`edr-freight-it`) on offset ports, so the Cypress e2e stack can run alongside.
|
separate compose project (`edr-freight-it`) on offset ports, so the Cypress e2e
|
||||||
|
stack can run alongside.
|
||||||
|
|
||||||
|
Each vitest worker is a whole shard of the topology. Nothing mutable is shared
|
||||||
|
between shards, so spec files run in parallel:
|
||||||
|
|
||||||
```
|
```
|
||||||
freight-api-e2e :3111 ──HTTP──> payment-api-it :3113 ──HTTP──> gateway-mock-it :4600
|
shard i (vitest worker i, VITEST_POOL_ID)
|
||||||
^ │
|
|
||||||
└────────── RabbitMQ :5772 ─────┘ (outbox → payment.events → consumer)
|
freight app in-process, host :3111+i
|
||||||
|
│ ▲
|
||||||
|
│ └──── HTTP ──── payment-api-it-{i} :3131+i inbound CBE bill query,
|
||||||
|
│ │ via host.docker.internal
|
||||||
|
└── HTTP ──────────────► │ ──HTTP──> gateway-mock-it-{i} :4600+i
|
||||||
|
▲ │
|
||||||
|
└── RabbitMQ vhost payment_s{i} ──┘ (outbox → payment.events → consumer)
|
||||||
|
|
||||||
|
database edr_it_s{i} ← CREATE DATABASE … TEMPLATE edr_freight_e2e
|
||||||
|
freight tables AND the payment API's edr_payment
|
||||||
|
schema live in it, so `db()` and `paymentDb()` are
|
||||||
|
one connection
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Shared by every shard: Postgres (one container, one database each), RabbitMQ
|
||||||
|
(one container, one vhost each), MinIO, and the one-shot migration.
|
||||||
|
|
||||||
|
`IT_SHARDS` defaults to **4**. RAM is the ceiling, not cores — a shard is an
|
||||||
|
in-process Nest app plus two containers.
|
||||||
|
|
||||||
Never `docker compose -f docker-compose.it.yaml` on its own — it needs the base
|
Never `docker compose -f docker-compose.it.yaml` on its own — it needs the base
|
||||||
file first. Use `it.mjs`.
|
file first, and the generated shard file last. Use `it.mjs`.
|
||||||
|
|
||||||
|
## Setup order
|
||||||
|
|
||||||
|
The sequence is load-bearing, which is why `scripts/prepare-shards.mjs` exists
|
||||||
|
rather than vitest's `globalSetup`:
|
||||||
|
|
||||||
|
1. `freight-migration-e2e` migrates the **template** database `edr_freight_e2e`.
|
||||||
|
2. `prepare-shards.mjs` boots the freight app once against the template and
|
||||||
|
calls `app.init()` — that fires `onApplicationBootstrap`, the always-on
|
||||||
|
seeders (org, units, positions, permissions).
|
||||||
|
3. …then applies the SQL fixtures, which declare exactly those as prerequisites
|
||||||
|
(`seed-users.sql`: *"created by the API's always-on boot seeders"*).
|
||||||
|
4. `CREATE DATABASE edr_it_s{i} TEMPLATE edr_freight_e2e` per shard — a file
|
||||||
|
copy on the tmpfs Postgres, not a re-run of migrations and five seeders.
|
||||||
|
5. The per-shard payment APIs start; each creates its own `edr_payment` schema
|
||||||
|
inside its shard database.
|
||||||
|
|
||||||
|
`it:test` re-runs step 4 on **every run** (stop payment APIs → drop → clone →
|
||||||
|
start), so each run is hermetic. `--no-reset` skips it for a quick rerun.
|
||||||
|
|
||||||
## Gateway mock
|
## Gateway mock
|
||||||
|
|
||||||
@@ -46,7 +94,15 @@ negative test. The suite drives **CBE Birr** end to end (plain HMAC, no key
|
|||||||
material); other providers answer a generic stub until a scenario needs them.
|
material); other providers answer a generic stub until a scenario needs them.
|
||||||
|
|
||||||
Editing `server.js` needs a container restart (`docker compose … restart
|
Editing `server.js` needs a container restart (`docker compose … restart
|
||||||
gateway-mock-it`) — the code is a read-only mount, not baked into an image.
|
gateway-mock-it-0`) — the code is a read-only mount, not baked into an image.
|
||||||
|
|
||||||
|
**One mock per shard.** `modes`, `calls` and `orders` are process-global in
|
||||||
|
`server.js`, and 20 of 25 specs call `POST /__control/reset` in `beforeAll`. On a
|
||||||
|
shared mock every starting spec would wipe every running spec's live orders —
|
||||||
|
and that fails as a wrong *assertion*, not an error: an unknown order still gets
|
||||||
|
a correctly-signed webhook, just carrying the mock's default amount. Per-shard
|
||||||
|
mocks are why the suite can run `payment-failure` (which forces providers into
|
||||||
|
`fail`/`timeout`) beside a spec that settles normally.
|
||||||
|
|
||||||
## Files
|
## Files
|
||||||
|
|
||||||
@@ -151,21 +207,34 @@ what it actually does and says so in a comment, so a fix fails loudly:
|
|||||||
ignores `deleted_at`, so a soft-unlinked booking can never be re-batched).
|
ignores `deleted_at`, so a soft-unlinked booking can never be re-batched).
|
||||||
- **Arrange is slow.** Contract → booking → clearance → ops accept → batch is
|
- **Arrange is slow.** Contract → booking → clearance → ops accept → batch is
|
||||||
30–60s of real API work per booking, so files share one schedule day.
|
30–60s of real API work per booking, so files share one schedule day.
|
||||||
- Files run sequentially (one DB); concurrency is exercised inside a test with
|
- **Files run in parallel across shards, sequentially within one.** A shard owns
|
||||||
`Promise.all`.
|
its whole topology, so two files never contend; two files on the *same* shard
|
||||||
|
still run one after the other, which is what the day-offset partitioning and
|
||||||
|
the per-file `releaseUnpaidHolds()` / `resetCorridorDay()` hygiene still
|
||||||
|
assume. Concurrency inside a single test is exercised with `Promise.all`.
|
||||||
|
- **Day offsets are the de-facto partition key** (`departureAt(n)` per file).
|
||||||
|
`payment-happy` and `expired-invoice-late-settle` both claim day 4 — harmless
|
||||||
|
now that a shard has its own database, but do not read the offsets as unique.
|
||||||
- **A retired fixture keeps its shipment day at its peril.**
|
- **A retired fixture keeps its shipment day at its peril.**
|
||||||
`rescueStrandedPaidForDay` sweeps every unlinked booking whose
|
`rescueStrandedPaidForDay` sweeps every unlinked booking whose
|
||||||
`payment_status` is PAID and whose `scheduled_date` falls on the day being
|
`payment_status` is PAID and whose `scheduled_date` falls on the day being
|
||||||
filled, and re-places it on the fresh train. A previous run's paid bookings
|
filled, and re-places it on the fresh train. Within a run this still bites
|
||||||
therefore climb back aboard — 18 stowaway wagons on a 28-wagon day, until
|
across files on one shard; *between* runs it no longer can — `it:test`
|
||||||
`releaseUnpaidHolds` / `resetCorridorDay` started nulling `scheduled_date`.
|
re-clones every shard database from the template first.
|
||||||
- **Only a FULL train rests at DONE.** An under-filled day CONCLUDES and
|
- **Only a FULL train rests at DONE.** An under-filled day CONCLUDES and
|
||||||
REOPENS (`window_phase` back to OPEN, `booking_cycle_no` 2), so waiting for
|
REOPENS (`window_phase` back to OPEN, `booking_cycle_no` 2), so waiting for
|
||||||
DONE there waits forever — use `pollCycleConcluded`.
|
DONE there waits forever — use `pollCycleConcluded`.
|
||||||
- **Wagon stock is finite and shared.** Paid bookings keep their wagons, so
|
- **Wagon stock is finite and shared** *within a shard*. Paid bookings keep
|
||||||
`releaseUnpaidHolds()` also frees every earlier `CTR-IT-%` allocation —
|
their wagons, so `releaseUnpaidHolds()` also frees every earlier `CTR-IT-%`
|
||||||
without it the fifth or sixth file on a warm stack silently gets a short
|
allocation — without it the fifth or sixth file on one shard silently gets a
|
||||||
consist.
|
short consist.
|
||||||
|
- **`poll()` samples every 250ms, but keeps the caller's deadline.** Callers pass
|
||||||
|
`attempts × intervalMs`; that product is the timeout, and only the sampling
|
||||||
|
rate changed. Override with `IT_POLL_INTERVAL_MS`.
|
||||||
|
- **The tick cadence is env-driven.** `BOOKING_WINDOW_TICK_CRON` is `*/1` here
|
||||||
|
and `*/10` in production. Every window phase, allocation and expiry the suite
|
||||||
|
waits on lands on that tick. A spec that proves sensitive to it should pin its
|
||||||
|
own value rather than the whole suite reverting.
|
||||||
- **One tenant per booking.** `seedTenantContracts` mints a company per booking
|
- **One tenant per booking.** `seedTenantContracts` mints a company per booking
|
||||||
because a company may hold only one unpaid reservation at a time; staff book
|
because a company may hold only one unpaid reservation at a time; staff book
|
||||||
and pay on their behalf, which is also the real Path B flow.
|
and pay on their behalf, which is also the real Path B flow.
|
||||||
|
|||||||
182
integration/scripts/gen-shards.mjs
Normal file
182
integration/scripts/gen-shards.mjs
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Generate `integration/.it-shards.yaml` — one payment API + one gateway mock
|
||||||
|
* per shard. Compose has no loops, so the per-shard services are written out.
|
||||||
|
*
|
||||||
|
* Every shard is the real topology in miniature; nothing crosses between them:
|
||||||
|
*
|
||||||
|
* gateway-mock-it-{i} its own `modes`/`calls`/`orders` — the mock keeps those
|
||||||
|
* process-global, and 20 of 25 specs call
|
||||||
|
* POST /__control/reset in beforeAll, so a shared mock
|
||||||
|
* would have each starting spec wipe every running spec's
|
||||||
|
* live orders (which fails as a wrong *assertion*: an
|
||||||
|
* unknown order still gets a correctly-signed webhook,
|
||||||
|
* just with the mock's default amount).
|
||||||
|
* payment-api-it-{i} its own DB_NAME (the shard's database, `edr_payment`
|
||||||
|
* schema inside it), its own broker vhost, and its own
|
||||||
|
* FREIGHT_API_BASE_URL — the inbound CBE-bill query has
|
||||||
|
* to land on THIS shard's in-process app, and a single
|
||||||
|
* container could only ever point at one of them.
|
||||||
|
*
|
||||||
|
* Written by it.mjs, gitignored. Do not edit by hand.
|
||||||
|
*/
|
||||||
|
import { writeFileSync } from "node:fs";
|
||||||
|
import { dirname, join, resolve } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
const itDir = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* All shards share one build and one image tag, so the payment API image is
|
||||||
|
* built once and reused rather than N times.
|
||||||
|
*/
|
||||||
|
const PAYMENT_IMAGE = "edr-payment-api-it:local";
|
||||||
|
|
||||||
|
export function renderShards({
|
||||||
|
shards,
|
||||||
|
apiPortBase,
|
||||||
|
paymentPortBase,
|
||||||
|
gatewayPortBase,
|
||||||
|
dbPrefix,
|
||||||
|
telebirrKey,
|
||||||
|
}) {
|
||||||
|
const services = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < shards; i++) {
|
||||||
|
const gw = `gateway-mock-it-${i}`;
|
||||||
|
const pay = `payment-api-it-${i}`;
|
||||||
|
const db = `${dbPrefix}${i}`;
|
||||||
|
const vhost = `payment_s${i}`;
|
||||||
|
// The freight app for this shard runs on the HOST, inside the vitest worker.
|
||||||
|
const freightBase = `http://host.docker.internal:${apiPortBase + i}/api`;
|
||||||
|
|
||||||
|
services.push(`
|
||||||
|
${gw}:
|
||||||
|
image: node:20-alpine
|
||||||
|
volumes:
|
||||||
|
- ./integration/gateway-mock:/app:ro
|
||||||
|
working_dir: /app
|
||||||
|
environment:
|
||||||
|
PORT: "4600"
|
||||||
|
# Same secrets the payment API gets — so webhooks the mock signs pass the
|
||||||
|
# API's REAL signature verification instead of bypassing it.
|
||||||
|
CBE_SECRET_KEY: it-cbe-secret
|
||||||
|
CBE_MERCHANT_ID: it-cbe-merchant
|
||||||
|
PAYMENT_API_URL: http://${pay}:3003
|
||||||
|
command: ["node", "server.js"]
|
||||||
|
ports:
|
||||||
|
- "${gatewayPortBase + i}:4600"
|
||||||
|
healthcheck:
|
||||||
|
test:
|
||||||
|
[
|
||||||
|
"CMD",
|
||||||
|
"node",
|
||||||
|
"-e",
|
||||||
|
"fetch('http://localhost:4600/__control/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))",
|
||||||
|
]
|
||||||
|
interval: 3s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 10
|
||||||
|
|
||||||
|
${pay}:
|
||||||
|
image: ${PAYMENT_IMAGE}
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: apps/edr-payment-api/Dockerfile
|
||||||
|
secrets:
|
||||||
|
- npmrc
|
||||||
|
depends_on:
|
||||||
|
postgres-freight-e2e:
|
||||||
|
condition: service_healthy
|
||||||
|
rabbitmq-it:
|
||||||
|
condition: service_healthy
|
||||||
|
${gw}:
|
||||||
|
condition: service_healthy
|
||||||
|
# The freight app is on the host now, not in this network.
|
||||||
|
extra_hosts:
|
||||||
|
- "host.docker.internal:host-gateway"
|
||||||
|
environment:
|
||||||
|
PORT: "3003"
|
||||||
|
NODE_ENV: test
|
||||||
|
# Payment tables live in their own schema of THIS SHARD's database;
|
||||||
|
# main.ts ensurePaymentSchema() creates it, migrationsRun does the rest.
|
||||||
|
# Same database as freight, so every raw edr_payment.* query in the specs
|
||||||
|
# reads through the one connection.
|
||||||
|
DB_HOST: postgres-freight-e2e
|
||||||
|
DB_PORT: "5432"
|
||||||
|
DB_USER: edr_e2e
|
||||||
|
DB_PASSWORD: edr_e2e
|
||||||
|
DB_NAME: ${db}
|
||||||
|
DB_SCHEMA: edr_payment
|
||||||
|
SERVICE_AUTH_TOKEN: e2e-service-token
|
||||||
|
PUBLISHER_TRANSPORT: rabbitmq
|
||||||
|
PAYMENT_RABBITMQ_URL: amqp://edr:edr_secret@rabbitmq-it:5672/${vhost}
|
||||||
|
PAYMENT_NOTIFY_FREIGHT_URL: ${freightBase}/internal/payments/mark-paid
|
||||||
|
# Fast relay + sweep so retry/reconciliation are observable inside a test
|
||||||
|
# rather than a minute later.
|
||||||
|
OUTBOX_RELAY_INTERVAL_MS: "1000"
|
||||||
|
RECONCILE_STALE_AFTER_MS: "5000"
|
||||||
|
# Every gateway points at this shard's mock. Paths are per-provider prefixes.
|
||||||
|
CBE_BASE_URL: http://${gw}:4600/cbe-birr
|
||||||
|
CBE_MERCHANT_ID: it-cbe-merchant
|
||||||
|
CBE_SECRET_KEY: it-cbe-secret
|
||||||
|
CBE_NOTIFY_URL: http://${pay}:3003/webhooks/cbe-birr
|
||||||
|
CBE_RETURN_URL: http://localhost/return
|
||||||
|
TELEBIRR_BASE_URL: http://${gw}:4600/telebirr
|
||||||
|
TELEBIRR_WEB_BASE_URL: http://${gw}:4600/telebirr/web
|
||||||
|
TELEBIRR_FABRIC_APP_ID: it-fabric
|
||||||
|
TELEBIRR_APP_SECRET: it-secret
|
||||||
|
TELEBIRR_MERCHANT_APP_ID: it-merchant-app
|
||||||
|
TELEBIRR_MERCHANT_CODE: "999999"
|
||||||
|
TELEBIRR_NOTIFY_URL: http://${pay}:3003/webhooks/telebirr
|
||||||
|
# Telebirr PSS-signs every request object — a throwaway key generated per
|
||||||
|
# launch by it.mjs (nothing key-shaped lives in git).
|
||||||
|
TELEBIRR_PRIVATE_KEY: ${JSON.stringify(telebirrKey)}
|
||||||
|
EBIRR_BASE_URL: http://${gw}:4600/ebirr
|
||||||
|
DMONEY_BASE_URL: http://${gw}:4600/dmoney
|
||||||
|
CARD_BASE_URL: http://${gw}:4600/card
|
||||||
|
WAAFI_BASE_URL: http://${gw}:4600/waafi
|
||||||
|
CAC_BASE_URL: http://${gw}:4600/cac
|
||||||
|
CAC_USERNAME: it-cac
|
||||||
|
CAC_PASSWORD: it-cac
|
||||||
|
CAC_APP_KEY: it-cac-key
|
||||||
|
CAC_API_KEY: it-cac-api
|
||||||
|
CAC_COMPANY_SERVICES_ID: "1"
|
||||||
|
# Inbound CBE Unified Bill — we are the biller; bill-query hops back into
|
||||||
|
# the freight API, so this direction runs real code on both sides.
|
||||||
|
CBE_BILL_ENABLED: "true"
|
||||||
|
CBE_BILL_CLIENT_ID: it-cbe-bill
|
||||||
|
CBE_BILL_CLIENT_SECRET: it-cbe-bill-secret
|
||||||
|
CBE_BILL_JWT_SECRET: it-cbe-bill-jwt
|
||||||
|
FREIGHT_API_BASE_URL: ${freightBase}
|
||||||
|
ports:
|
||||||
|
- "${paymentPortBase + i}:3003"
|
||||||
|
healthcheck:
|
||||||
|
test:
|
||||||
|
[
|
||||||
|
"CMD",
|
||||||
|
"node",
|
||||||
|
"-e",
|
||||||
|
"fetch('http://localhost:3003/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))",
|
||||||
|
]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 12
|
||||||
|
start_period: 40s`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return `# GENERATED by integration/scripts/gen-shards.mjs — do not edit.
|
||||||
|
# ${shards} shard(s). Overlaid on docker-compose.e2e.yaml + docker-compose.it.yaml.
|
||||||
|
services:${services.join("\n")}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SHARD_FILE = join(itDir, ".it-shards.yaml");
|
||||||
|
|
||||||
|
export function writeShards(opts) {
|
||||||
|
writeFileSync(SHARD_FILE, renderShards(opts));
|
||||||
|
return SHARD_FILE;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const shardServices = (shards) =>
|
||||||
|
Array.from({ length: shards }, (_, i) => [`gateway-mock-it-${i}`, `payment-api-it-${i}`]).flat();
|
||||||
@@ -3,10 +3,13 @@
|
|||||||
* Freight integration-suite launcher.
|
* Freight integration-suite launcher.
|
||||||
*
|
*
|
||||||
* node integration/scripts/it.mjs <up|test|down|logs> [vitest args...]
|
* node integration/scripts/it.mjs <up|test|down|logs> [vitest args...]
|
||||||
|
* IT_SHARDS=4 node integration/scripts/it.mjs test
|
||||||
|
* node integration/scripts/it.mjs test --no-reset -- src/payment-happy.it.ts
|
||||||
*
|
*
|
||||||
* Overlays docker-compose.it.yaml on docker-compose.e2e.yaml: same freight
|
* The freight API is NOT containerized here: it boots inside each vitest worker
|
||||||
* stack, but the payment microservice is real and only the bank gateways are
|
* from `apps/edr-freight-api/dist`, which `test` rebuilds every run. Each worker
|
||||||
* stubbed. Web/Cypress containers are never started — this suite is HTTP only.
|
* is a whole shard — own database, own payment API, own gateway mock, own broker
|
||||||
|
* vhost — so spec files run in parallel without sharing anything mutable.
|
||||||
*
|
*
|
||||||
* Ports are fixed (and distinct from the Cypress e2e defaults) so both stacks
|
* Ports are fixed (and distinct from the Cypress e2e defaults) so both stacks
|
||||||
* can be up at once; they are separate compose projects.
|
* can be up at once; they are separate compose projects.
|
||||||
@@ -16,21 +19,24 @@
|
|||||||
|
|
||||||
import { execFileSync, spawnSync } from "node:child_process";
|
import { execFileSync, spawnSync } from "node:child_process";
|
||||||
import { generateKeyPairSync } from "node:crypto";
|
import { generateKeyPairSync } from "node:crypto";
|
||||||
import { existsSync } from "node:fs";
|
import { existsSync, rmSync } from "node:fs";
|
||||||
import { dirname, join, resolve } from "node:path";
|
import { dirname, join, resolve } from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
import { SHARD_FILE, shardServices, writeShards } from "./gen-shards.mjs";
|
||||||
|
|
||||||
const itDir = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
const itDir = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
||||||
const repoRoot = resolve(itDir, "..");
|
const repoRoot = resolve(itDir, "..");
|
||||||
const composeBase = [
|
|
||||||
"compose",
|
|
||||||
"-f",
|
|
||||||
join(repoRoot, "docker-compose.e2e.yaml"),
|
|
||||||
"-f",
|
|
||||||
join(repoRoot, "docker-compose.it.yaml"),
|
|
||||||
];
|
|
||||||
|
|
||||||
/** Deliberately offset from the Cypress stack's defaults (3101/5533/9310…). */
|
/**
|
||||||
|
* Deliberately offset from the Cypress stack's defaults (3101/5533/9310…).
|
||||||
|
*
|
||||||
|
* Each per-shard service takes a CONTIGUOUS BLOCK of `IT_SHARDS` ports starting
|
||||||
|
* at its base (freight :3111+i, payment :3131+i, gateway :4600+i), so the bases
|
||||||
|
* must be at least MAX_SHARDS apart. They were originally 3111 and 3113 — two
|
||||||
|
* apart — which silently worked at 1–2 shards and then had shard 2's freight app
|
||||||
|
* try to bind :3113, the port payment-api-it-0 was already published on.
|
||||||
|
*/
|
||||||
const PORTS = {
|
const PORTS = {
|
||||||
E2E_API_PORT: 3111,
|
E2E_API_PORT: 3111,
|
||||||
E2E_DB_PORT: 5543,
|
E2E_DB_PORT: 5543,
|
||||||
@@ -39,32 +45,39 @@ const PORTS = {
|
|||||||
// Unused here (no web containers) but referenced by the base file's build args.
|
// Unused here (no web containers) but referenced by the base file's build args.
|
||||||
E2E_PORTAL_PORT: 5393,
|
E2E_PORTAL_PORT: 5393,
|
||||||
E2E_BACKOFFICE_PORT: 5394,
|
E2E_BACKOFFICE_PORT: 5394,
|
||||||
IT_PAYMENT_PORT: 3113,
|
IT_PAYMENT_PORT: 3131,
|
||||||
IT_GATEWAY_PORT: 4600,
|
IT_GATEWAY_PORT: 4600,
|
||||||
IT_RABBIT_PORT: 5772,
|
IT_RABBIT_PORT: 5772,
|
||||||
IT_RABBIT_UI_PORT: 15772,
|
IT_RABBIT_UI_PORT: 15772,
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Everything the suite needs up — web + cypress are deliberately absent. */
|
/** Beyond this the freight block (3111+) would run into the payment block (3131+). */
|
||||||
const SERVICES = [
|
const MAX_SHARDS = 16;
|
||||||
"postgres-freight-e2e",
|
|
||||||
"minio-e2e",
|
|
||||||
"minio-init-e2e",
|
|
||||||
"freight-migration-e2e",
|
|
||||||
"fayda-mock-e2e",
|
|
||||||
"etrade-mock-e2e",
|
|
||||||
// Still a base-stack dependency of freight-api-e2e (reconcile-before-expire
|
|
||||||
// has its own client); cheap to run alongside the real payment API.
|
|
||||||
"payment-mock-e2e",
|
|
||||||
"gateway-mock-it",
|
|
||||||
"rabbitmq-it",
|
|
||||||
"payment-api-it",
|
|
||||||
"freight-api-e2e",
|
|
||||||
];
|
|
||||||
|
|
||||||
const RUNNING = SERVICES.filter(
|
/**
|
||||||
(s) => !["minio-init-e2e", "freight-migration-e2e"].includes(s),
|
* How many shards. RAM is the ceiling, not cores: each shard is an in-process
|
||||||
);
|
* Nest app plus two containers.
|
||||||
|
*/
|
||||||
|
const SHARDS = Math.max(1, Number(process.env.IT_SHARDS ?? 4));
|
||||||
|
if (SHARDS > MAX_SHARDS) {
|
||||||
|
console.error(
|
||||||
|
`\nit: IT_SHARDS=${SHARDS} exceeds ${MAX_SHARDS} — the per-service port blocks would overlap.` +
|
||||||
|
`\n Raise IT_PAYMENT_PORT/IT_GATEWAY_PORT in it.mjs first.`,
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
const TEMPLATE_DB = "edr_freight_e2e";
|
||||||
|
const DB_PREFIX = "edr_it_s";
|
||||||
|
|
||||||
|
/** Long-running services shared by every shard. Web + Cypress are absent. */
|
||||||
|
const INFRA = ["postgres-freight-e2e", "minio-e2e", "rabbitmq-it"];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One-shots: they run, then exit 0. They cannot go in the `up --wait` set —
|
||||||
|
* compose reports an exited container as a failed wait, so a healthy stack
|
||||||
|
* looks broken. Run them with `compose run`, which returns their exit code.
|
||||||
|
*/
|
||||||
|
const ONE_SHOT = ["minio-init-e2e", "freight-migration-e2e"];
|
||||||
|
|
||||||
function fail(msg) {
|
function fail(msg) {
|
||||||
console.error(`\nit: ${msg}`);
|
console.error(`\nit: ${msg}`);
|
||||||
@@ -97,64 +110,271 @@ function fakeFaydaPrivateKeyBase64() {
|
|||||||
return Buffer.from(JSON.stringify(jwk)).toString("base64");
|
return Buffer.from(JSON.stringify(jwk)).toString("base64");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const telebirrKey = process.env.IT_TELEBIRR_PRIVATE_KEY ?? fakeTelebirrPrivateKey();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The freight app's own environment. This is docker-compose.e2e.yaml's
|
||||||
|
* `freight-api-e2e` block with container hostnames swapped for published host
|
||||||
|
* ports — the app runs on the host now. Per-shard values (DB_NAME, PORT,
|
||||||
|
* PAYMENT_API_URL, PAYMENT_RABBITMQ_URL) are derived per worker in src/app.ts.
|
||||||
|
*/
|
||||||
|
const freightEnv = {
|
||||||
|
NODE_ENV: "test",
|
||||||
|
DB_HOST: "localhost",
|
||||||
|
DB_PORT: String(PORTS.E2E_DB_PORT),
|
||||||
|
DB_USER: "edr_e2e",
|
||||||
|
DB_PASSWORD: "edr_e2e",
|
||||||
|
DB_NAME: TEMPLATE_DB,
|
||||||
|
|
||||||
|
// e2e-only secrets — never reuse outside this stack
|
||||||
|
JWT_SECRET: "e2e-jwt-secret",
|
||||||
|
JWT_ACCESS_TOKEN_SECRET: "e2e-access-secret",
|
||||||
|
JWT_REFRESH_TOKEN_SECRET: "e2e-refresh-secret",
|
||||||
|
JWT_EXPIRES_IN: "1d",
|
||||||
|
JWT_ACCESS_TOKEN_EXPIRES: "1d",
|
||||||
|
JWT_REFRESH_TOKEN_EXPIRES: "7d",
|
||||||
|
// Mandatory: ServiceAuthGuard returns TRUE when this is unset, and
|
||||||
|
// authz.it.ts asserts that the internal surface rejects an unsigned caller.
|
||||||
|
SERVICE_AUTH_TOKEN: "e2e-service-token",
|
||||||
|
|
||||||
|
SEED_EDR_ORG: "true",
|
||||||
|
SUPER_ADMIN_EMAIL: "superadmin@tria.com",
|
||||||
|
SUPER_ADMIN_PHONE: "+251900000000",
|
||||||
|
|
||||||
|
MINIO_ENDPOINT: "localhost",
|
||||||
|
MINIO_PORT: String(PORTS.E2E_MINIO_PORT),
|
||||||
|
MINIO_USE_SSL: "false",
|
||||||
|
MINIO_ACCESS_KEY: "e2e-minio",
|
||||||
|
MINIO_SECRET_KEY: "e2e-minio-secret",
|
||||||
|
MINIO_REGION: "us-east-1",
|
||||||
|
|
||||||
|
// Only gates the SMS/email clients. The payment consumer is wired by
|
||||||
|
// PAYMENT_RABBITMQ_URL alone (payment.module.ts).
|
||||||
|
RABBITMQ_ENABLED: "false",
|
||||||
|
// fayda.config.ts THROWS at load if this is "true" without the full var set,
|
||||||
|
// and the fayda/etrade mocks publish no host ports. No IT spec touches them.
|
||||||
|
FAYDA_ENABLED: "false",
|
||||||
|
// SMS strategy has no kill switch and defaults to a real dev endpoint.
|
||||||
|
OZIKING_SMS_URL: "http://127.0.0.1:9/sms",
|
||||||
|
// app.config.ts otherwise live-scrapes https://ethio.forex on first booking.
|
||||||
|
CBE_EXCHANGE_SCRAPE_URL: "http://127.0.0.1:9/fx",
|
||||||
|
CBE_EXCHANGE_API_URL: "http://127.0.0.1:9/fx",
|
||||||
|
CBE_EXCHANGE_FALLBACK_RATE: "130",
|
||||||
|
FREIGHT_PORTAL_URL: `http://localhost:${PORTS.E2E_PORTAL_PORT}`,
|
||||||
|
|
||||||
|
// Drain tail on every pay window. Production defaults to 5 minutes; a
|
||||||
|
// reservation here lives ~60s, so 5 would push every natural expiry past the
|
||||||
|
// suite's timeouts. One minute keeps the tail real and observable
|
||||||
|
// (src/expired-invoice-late-settle.it.ts asserts both sides of it, and
|
||||||
|
// hardcodes DRAIN_MS = 60_000 to match).
|
||||||
|
FREIGHT_PAYMENT_DRAIN_MINUTES: "1",
|
||||||
|
// The suite's pacing floor: every window phase, allocation and expiry it waits
|
||||||
|
// on lands on this tick. Production stays at */10. Overridable from the
|
||||||
|
// environment so a cadence-sensitive spec (or a bisect) can pin it back.
|
||||||
|
BOOKING_WINDOW_TICK_CRON: process.env.BOOKING_WINDOW_TICK_CRON ?? "*/1 * * * * *",
|
||||||
|
};
|
||||||
|
|
||||||
const env = {
|
const env = {
|
||||||
...process.env,
|
...process.env,
|
||||||
...Object.fromEntries(Object.entries(PORTS).map(([k, v]) => [k, String(v)])),
|
...Object.fromEntries(Object.entries(PORTS).map(([k, v]) => [k, String(v)])),
|
||||||
IT_API_URL: `http://localhost:${PORTS.E2E_API_PORT}`,
|
...freightEnv,
|
||||||
IT_PAYMENT_URL: `http://localhost:${PORTS.IT_PAYMENT_PORT}`,
|
IT_SHARDS: String(SHARDS),
|
||||||
IT_GATEWAY_URL: `http://localhost:${PORTS.IT_GATEWAY_PORT}`,
|
IT_TEMPLATE_DB: TEMPLATE_DB,
|
||||||
IT_DB_URL: `postgres://edr_e2e:edr_e2e@localhost:${PORTS.E2E_DB_PORT}/edr_freight_e2e`,
|
IT_DB_PREFIX: DB_PREFIX,
|
||||||
|
IT_API_PORT_BASE: String(PORTS.E2E_API_PORT),
|
||||||
|
IT_PAYMENT_PORT_BASE: String(PORTS.IT_PAYMENT_PORT),
|
||||||
|
IT_GATEWAY_PORT_BASE: String(PORTS.IT_GATEWAY_PORT),
|
||||||
FAYDA_PRIVATE_KEY_BASE64:
|
FAYDA_PRIVATE_KEY_BASE64:
|
||||||
process.env.FAYDA_PRIVATE_KEY_BASE64 ?? fakeFaydaPrivateKeyBase64(),
|
process.env.FAYDA_PRIVATE_KEY_BASE64 ?? fakeFaydaPrivateKeyBase64(),
|
||||||
IT_TELEBIRR_PRIVATE_KEY:
|
IT_TELEBIRR_PRIVATE_KEY: telebirrKey,
|
||||||
process.env.IT_TELEBIRR_PRIVATE_KEY ?? fakeTelebirrPrivateKey(),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function generateShardFile() {
|
||||||
|
writeShards({
|
||||||
|
shards: SHARDS,
|
||||||
|
apiPortBase: PORTS.E2E_API_PORT,
|
||||||
|
paymentPortBase: PORTS.IT_PAYMENT_PORT,
|
||||||
|
gatewayPortBase: PORTS.IT_GATEWAY_PORT,
|
||||||
|
dbPrefix: DB_PREFIX,
|
||||||
|
telebirrKey,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The shard file must exist before compose is invoked — it is one of the -f's. */
|
||||||
|
function composeBase() {
|
||||||
|
if (!existsSync(SHARD_FILE)) generateShardFile();
|
||||||
|
return [
|
||||||
|
"compose",
|
||||||
|
"-f",
|
||||||
|
join(repoRoot, "docker-compose.e2e.yaml"),
|
||||||
|
"-f",
|
||||||
|
join(repoRoot, "docker-compose.it.yaml"),
|
||||||
|
"-f",
|
||||||
|
SHARD_FILE,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
function compose(args) {
|
function compose(args) {
|
||||||
const { status } = spawnSync("docker", [...composeBase, ...args], { stdio: "inherit", env });
|
const { status } = spawnSync("docker", [...composeBase(), ...args], {
|
||||||
|
stdio: "inherit",
|
||||||
|
env,
|
||||||
|
});
|
||||||
return status ?? 1;
|
return status ?? 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function composeQuiet(args) {
|
||||||
|
return spawnSync("docker", [...composeBase(), ...args], {
|
||||||
|
encoding: "utf8",
|
||||||
|
env,
|
||||||
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function stackRunning() {
|
function stackRunning() {
|
||||||
try {
|
try {
|
||||||
const out = execFileSync("docker", [...composeBase, "ps", "--services", "--status", "running"], {
|
const out = execFileSync(
|
||||||
encoding: "utf8",
|
"docker",
|
||||||
env,
|
[...composeBase(), "ps", "--services", "--status", "running"],
|
||||||
stdio: ["ignore", "pipe", "ignore"],
|
{ encoding: "utf8", env, stdio: ["ignore", "pipe", "ignore"] },
|
||||||
});
|
);
|
||||||
const running = new Set(out.split("\n").filter(Boolean));
|
const running = new Set(out.split("\n").filter(Boolean));
|
||||||
return RUNNING.every((s) => running.has(s));
|
const want = [...INFRA, ...shardServices(SHARDS)];
|
||||||
|
return want.every((s) => running.has(s));
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function node(script, args = []) {
|
||||||
|
const { status } = spawnSync("node", [join(itDir, "scripts", script), ...args], {
|
||||||
|
cwd: repoRoot,
|
||||||
|
stdio: "inherit",
|
||||||
|
env,
|
||||||
|
});
|
||||||
|
return status ?? 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One broker, one vhost per shard — a shared vhost would let one shard's freight
|
||||||
|
* consumer eat another shard's settlement event.
|
||||||
|
*
|
||||||
|
* Retried: the healthcheck now waits for the rabbit *application*, but a cold
|
||||||
|
* boot can still land between "healthy" and "accepting rabbitmqctl", and a
|
||||||
|
* half-provisioned broker fails later as an unexplained missing settlement.
|
||||||
|
*/
|
||||||
|
function createVhosts() {
|
||||||
|
for (let i = 0; i < SHARDS; i++) {
|
||||||
|
const vhost = `payment_s${i}`;
|
||||||
|
let last = "";
|
||||||
|
let ok = false;
|
||||||
|
for (let attempt = 1; attempt <= 10 && !ok; attempt++) {
|
||||||
|
// Idempotent: add_vhost on an existing vhost exits non-zero, which is fine.
|
||||||
|
composeQuiet(["exec", "-T", "rabbitmq-it", "rabbitmqctl", "add_vhost", vhost]);
|
||||||
|
const perm = composeQuiet([
|
||||||
|
"exec", "-T", "rabbitmq-it",
|
||||||
|
"rabbitmqctl", "set_permissions", "-p", vhost, "edr", ".*", ".*", ".*",
|
||||||
|
]);
|
||||||
|
ok = perm.status === 0;
|
||||||
|
last = perm.stderr ?? "";
|
||||||
|
if (!ok) execFileSync("sleep", ["3"]);
|
||||||
|
}
|
||||||
|
if (!ok) fail(`could not grant on rabbit vhost ${vhost} after 10 tries:\n${last}`);
|
||||||
|
}
|
||||||
|
console.log(`it: rabbit vhosts payment_s0…payment_s${SHARDS - 1} ready`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildFreight() {
|
||||||
|
console.log("it: building @edr/freight-api (the suite runs dist/, never stale)");
|
||||||
|
const { status } = spawnSync("pnpm", ["--filter", "@edr/freight-api", "run", "build"], {
|
||||||
|
cwd: repoRoot,
|
||||||
|
stdio: "inherit",
|
||||||
|
env,
|
||||||
|
});
|
||||||
|
if (status !== 0) fail("freight API build failed — fix it before running the suite.");
|
||||||
|
}
|
||||||
|
|
||||||
function up() {
|
function up() {
|
||||||
preflight();
|
preflight();
|
||||||
|
generateShardFile();
|
||||||
console.log(
|
console.log(
|
||||||
`it: starting stack — freight :${PORTS.E2E_API_PORT} payment :${PORTS.IT_PAYMENT_PORT} ` +
|
`it: starting ${SHARDS} shard(s) — freight :${PORTS.E2E_API_PORT}..${
|
||||||
`gateway :${PORTS.IT_GATEWAY_PORT} db :${PORTS.E2E_DB_PORT}`,
|
PORTS.E2E_API_PORT + SHARDS - 1
|
||||||
|
} (in-process) payment :${PORTS.IT_PAYMENT_PORT}.. gateway :${
|
||||||
|
PORTS.IT_GATEWAY_PORT
|
||||||
|
}.. db :${PORTS.E2E_DB_PORT}`,
|
||||||
);
|
);
|
||||||
if (compose(["up", "-d", "--build", "--wait", ...SERVICES]) !== 0) {
|
|
||||||
|
// 1. Shared infrastructure.
|
||||||
|
if (compose(["up", "-d", "--build", "--wait", "--remove-orphans", ...INFRA]) !== 0) {
|
||||||
fail(
|
fail(
|
||||||
"stack failed to become healthy. Inspect with:\n" +
|
"shared services failed to become healthy. Inspect with:\n" +
|
||||||
" node integration/scripts/it.mjs logs payment-api-it",
|
" node integration/scripts/it.mjs logs postgres-freight-e2e",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
createVhosts();
|
||||||
|
|
||||||
|
// 1b. One-shots: the MinIO bucket, then freight's migrations into the
|
||||||
|
// TEMPLATE database every shard is cloned from.
|
||||||
|
for (const svc of ONE_SHOT) {
|
||||||
|
if (compose(["run", "--rm", "--no-deps", "--build", svc]) !== 0) {
|
||||||
|
fail(`${svc} failed — inspect with:\n node integration/scripts/it.mjs logs ${svc}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. The app must exist as dist/ before we can boot it to seed the template.
|
||||||
|
buildFreight();
|
||||||
|
|
||||||
|
// 3. Seed the template (boot seeders + SQL fixtures) and clone it per shard.
|
||||||
|
// Must precede the payment APIs: they connect to the shard databases.
|
||||||
|
if (node("prepare-shards.mjs") !== 0) fail("template seed / shard clone failed.");
|
||||||
|
|
||||||
|
// 4. Per-shard payment API + gateway mock.
|
||||||
|
if (compose(["up", "-d", "--build", "--wait", ...shardServices(SHARDS)]) !== 0) {
|
||||||
|
fail(
|
||||||
|
"shard services failed to become healthy. Inspect with:\n" +
|
||||||
|
" node integration/scripts/it.mjs logs payment-api-it-0",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const [cmd, ...rawExtra] = process.argv.slice(2);
|
/**
|
||||||
|
* Re-clone every shard database from the template so each run is hermetic. The
|
||||||
|
* payment APIs hold connections to those databases, so they come down first —
|
||||||
|
* their `edr_payment` schema is recreated by their own boot migrations.
|
||||||
|
*
|
||||||
|
* This is what retires the warm-stack failure modes the suite used to document:
|
||||||
|
* stowaway paid bookings climbing back aboard, a short consist on the fifth
|
||||||
|
* file, invoice numbers continuing from a previous run.
|
||||||
|
*/
|
||||||
|
function resetShardDbs() {
|
||||||
|
const svc = shardServices(SHARDS);
|
||||||
|
console.log("it: re-cloning shard databases from the template");
|
||||||
|
compose(["stop", ...svc]);
|
||||||
|
if (node("prepare-shards.mjs", ["--clone"]) !== 0) fail("shard database reset failed.");
|
||||||
|
if (compose(["up", "-d", "--wait", ...svc]) !== 0) {
|
||||||
|
fail("shard services failed to become healthy after the reset.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const [cmd, ...rawArgs] = process.argv.slice(2);
|
||||||
|
const noReset = rawArgs.includes("--no-reset");
|
||||||
// `pnpm it:test -- src/foo.it.ts` hands us a literal "--" first. Forwarding it
|
// `pnpm it:test -- src/foo.it.ts` hands us a literal "--" first. Forwarding it
|
||||||
// makes vitest treat everything after it as CLI options and ignore the file
|
// makes vitest treat everything after it as CLI options and ignore the file
|
||||||
// filter — the "one file" run silently becomes the whole suite.
|
// filter — the "one file" run silently becomes the whole suite.
|
||||||
const extra = rawExtra[0] === "--" ? rawExtra.slice(1) : rawExtra;
|
const passthrough = rawArgs.filter((a) => a !== "--no-reset");
|
||||||
|
const extra = passthrough[0] === "--" ? passthrough.slice(1) : passthrough;
|
||||||
|
|
||||||
switch (cmd) {
|
switch (cmd) {
|
||||||
case "up":
|
case "up":
|
||||||
up();
|
up();
|
||||||
break;
|
break;
|
||||||
case "test": {
|
case "test": {
|
||||||
if (!stackRunning()) up();
|
if (!stackRunning()) {
|
||||||
|
up();
|
||||||
|
} else {
|
||||||
|
buildFreight();
|
||||||
|
if (!noReset) resetShardDbs();
|
||||||
|
}
|
||||||
const { status } = spawnSync(
|
const { status } = spawnSync(
|
||||||
"pnpm",
|
"pnpm",
|
||||||
["--filter", "@edr/freight-integration", "run", "test", ...extra],
|
["--filter", "@edr/freight-integration", "run", "test", ...extra],
|
||||||
@@ -162,12 +382,14 @@ switch (cmd) {
|
|||||||
);
|
);
|
||||||
process.exit(status ?? 1);
|
process.exit(status ?? 1);
|
||||||
}
|
}
|
||||||
case "logs":
|
case "logs": {
|
||||||
process.exit(compose(["logs", "--tail", "200", ...extra]));
|
process.exit(compose(["logs", "--tail", "200", ...extra]));
|
||||||
break;
|
}
|
||||||
case "down":
|
case "down": {
|
||||||
process.exit(compose(["down", "-v", "--remove-orphans"]));
|
const status = compose(["down", "-v", "--remove-orphans"]);
|
||||||
break;
|
rmSync(SHARD_FILE, { force: true });
|
||||||
|
process.exit(status);
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
fail(`unknown command "${cmd ?? ""}" — use up | test | logs | down`);
|
fail(`unknown command "${cmd ?? ""}" — use up | test | logs | down`);
|
||||||
}
|
}
|
||||||
|
|||||||
134
integration/scripts/prepare-shards.mjs
Normal file
134
integration/scripts/prepare-shards.mjs
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Build the seeded template database, then clone it once per shard.
|
||||||
|
*
|
||||||
|
* node integration/scripts/prepare-shards.mjs # seed template + clone
|
||||||
|
* node integration/scripts/prepare-shards.mjs --clone # clone only (per-run reset)
|
||||||
|
*
|
||||||
|
* Seeding has to happen here, not in vitest's globalSetup, because the order is
|
||||||
|
* load-bearing and half of it is the app itself:
|
||||||
|
*
|
||||||
|
* 1. freight migrations (freight-migration-e2e container, before us)
|
||||||
|
* 2. the app's always-on boot seeders — org, units, positions, permissions
|
||||||
|
* (app.module.ts onApplicationBootstrap)
|
||||||
|
* 3. the SQL fixtures, which declare those as prerequisites
|
||||||
|
* (seed-users.sql: "created by the API's always-on boot seeders")
|
||||||
|
*
|
||||||
|
* Step 2 needs a real Nest boot, and the app now lives inside the vitest workers
|
||||||
|
* — which globalSetup runs in a different process from. So we boot it once here
|
||||||
|
* against the template and every shard is a `CREATE DATABASE … TEMPLATE` copy of
|
||||||
|
* the result: a file copy on the tmpfs postgres, versus re-running migrations
|
||||||
|
* and five seeders per shard.
|
||||||
|
*
|
||||||
|
* Run by it.mjs, which owns the env. No dependencies beyond `pg`.
|
||||||
|
*/
|
||||||
|
import { createRequire } from "node:module";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { dirname, join, resolve } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { Client } from "pg";
|
||||||
|
|
||||||
|
const itDir = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
||||||
|
const repoRoot = resolve(itDir, "..");
|
||||||
|
const freightDir = join(repoRoot, "apps", "edr-freight-api");
|
||||||
|
|
||||||
|
const TEMPLATE = process.env.IT_TEMPLATE_DB ?? "edr_freight_e2e";
|
||||||
|
const PREFIX = process.env.IT_DB_PREFIX ?? "edr_it_s";
|
||||||
|
const SHARDS = Number(process.env.IT_SHARDS ?? 1);
|
||||||
|
|
||||||
|
const admin = () =>
|
||||||
|
new Client({
|
||||||
|
host: process.env.DB_HOST ?? "localhost",
|
||||||
|
port: Number(process.env.DB_PORT ?? 5543),
|
||||||
|
user: process.env.DB_USER ?? "edr_e2e",
|
||||||
|
password: process.env.DB_PASSWORD ?? "edr_e2e",
|
||||||
|
// Never the template itself: CREATE DATABASE … TEMPLATE refuses while any
|
||||||
|
// session is connected to the source.
|
||||||
|
database: "postgres",
|
||||||
|
});
|
||||||
|
|
||||||
|
const CYPRESS_FIXTURES = join(repoRoot, "e2e", "freight", "cypress", "fixtures");
|
||||||
|
const OWN_FIXTURES = join(itDir, "sql");
|
||||||
|
|
||||||
|
/** Order matters — company needs the users, everything needs the corridor. */
|
||||||
|
const SEEDS = [
|
||||||
|
[CYPRESS_FIXTURES, "seed-users.sql"],
|
||||||
|
[CYPRESS_FIXTURES, "seed-company.sql"],
|
||||||
|
[CYPRESS_FIXTURES, "seed-import-corridor.sql"],
|
||||||
|
[CYPRESS_FIXTURES, "seed-bulk-items.sql"],
|
||||||
|
[CYPRESS_FIXTURES, "seed-g1-train.sql"],
|
||||||
|
[CYPRESS_FIXTURES, "seed-g2-weight.sql"],
|
||||||
|
[CYPRESS_FIXTURES, "seed-government.sql"],
|
||||||
|
[OWN_FIXTURES, "seed-company-b.sql"],
|
||||||
|
[OWN_FIXTURES, "seed-customs-service-type.sql"],
|
||||||
|
];
|
||||||
|
|
||||||
|
async function seedTemplate() {
|
||||||
|
console.log(`it: seeding template ${TEMPLATE}`);
|
||||||
|
|
||||||
|
process.env.DB_NAME = TEMPLATE;
|
||||||
|
// No broker for the template boot: payment.module.ts skips RabbitMQModule
|
||||||
|
// entirely when this is unset, and nothing here publishes.
|
||||||
|
delete process.env.PAYMENT_RABBITMQ_URL;
|
||||||
|
|
||||||
|
const { createFreightApp } = createRequire(join(freightDir, "package.json"))("./dist/main.js");
|
||||||
|
|
||||||
|
// createFreightApp() only builds the graph. `init()` is what fires
|
||||||
|
// onApplicationBootstrap — the seeders we are here for. We never listen.
|
||||||
|
const app = await createFreightApp();
|
||||||
|
try {
|
||||||
|
await app.init();
|
||||||
|
console.log("it: boot seeders done (org, units, positions, permissions)");
|
||||||
|
} finally {
|
||||||
|
await app.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = new Client({
|
||||||
|
host: process.env.DB_HOST ?? "localhost",
|
||||||
|
port: Number(process.env.DB_PORT ?? 5543),
|
||||||
|
user: process.env.DB_USER ?? "edr_e2e",
|
||||||
|
password: process.env.DB_PASSWORD ?? "edr_e2e",
|
||||||
|
database: TEMPLATE,
|
||||||
|
});
|
||||||
|
await client.connect();
|
||||||
|
try {
|
||||||
|
for (const [dir, file] of SEEDS) {
|
||||||
|
await client.query(readFileSync(join(dir, file), "utf8"));
|
||||||
|
console.log(`it: seeded ${file}`);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await client.end();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cloneShards() {
|
||||||
|
const pg = admin();
|
||||||
|
await pg.connect();
|
||||||
|
try {
|
||||||
|
// The app's pool and any stray psql hold the template open; without this the
|
||||||
|
// CREATE below fails with "source database is being accessed by other users".
|
||||||
|
await pg.query(
|
||||||
|
`SELECT pg_terminate_backend(pid) FROM pg_stat_activity
|
||||||
|
WHERE datname = $1 AND pid <> pg_backend_pid()`,
|
||||||
|
[TEMPLATE],
|
||||||
|
);
|
||||||
|
|
||||||
|
for (let i = 0; i < SHARDS; i++) {
|
||||||
|
const db = `${PREFIX}${i}`;
|
||||||
|
await pg.query(
|
||||||
|
`SELECT pg_terminate_backend(pid) FROM pg_stat_activity
|
||||||
|
WHERE datname = $1 AND pid <> pg_backend_pid()`,
|
||||||
|
[db],
|
||||||
|
);
|
||||||
|
await pg.query(`DROP DATABASE IF EXISTS ${db}`);
|
||||||
|
await pg.query(`CREATE DATABASE ${db} TEMPLATE ${TEMPLATE}`);
|
||||||
|
console.log(`it: shard ${i} → ${db}`);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await pg.end();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const cloneOnly = process.argv.includes("--clone");
|
||||||
|
if (!cloneOnly) await seedTemplate();
|
||||||
|
await cloneShards();
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user