Merge pull request #1110 from Tria-plc/dev

Dev to staging
This commit is contained in:
marshal
2026-08-05 10:45:27 +03:00
committed by GitHub
113 changed files with 5049 additions and 1410 deletions

3
.gitignore vendored
View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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 |

View File

@@ -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,

View File

@@ -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),
}, },
})); }));

View File

@@ -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: [],
}); });

View File

@@ -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

View File

@@ -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');

View File

@@ -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',

View File

@@ -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),

View File

@@ -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>

View File

@@ -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();
}

View File

@@ -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);
}
}

View File

@@ -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: [

View File

@@ -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:

View File

@@ -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();
});
}); });

View File

@@ -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

View File

@@ -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,
};
} }

View File

@@ -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",

View File

@@ -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: [

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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),
}),
});
}

View File

@@ -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,
};
}
}

View File

@@ -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 {}

View File

@@ -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();
}
}

View File

@@ -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);
}
} }

View File

@@ -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 });
}
} }

View File

@@ -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;
} }
} }

View File

@@ -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,

View 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();
});
});

View File

@@ -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' })

View File

@@ -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

View File

@@ -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;

View File

@@ -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()

View 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();
});
});

View File

@@ -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' })

View File

@@ -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

View File

@@ -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,

View File

@@ -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 {

View File

@@ -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',

View File

@@ -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++;
} }

View File

@@ -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>
} }

View File

@@ -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"
); );
} }

View File

@@ -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>

View File

@@ -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,

View File

@@ -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>

View File

@@ -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;

View File

@@ -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>
); );

View File

@@ -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) => ({

View File

@@ -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}`,

View File

@@ -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,
});
};

View File

@@ -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);
} }

View File

@@ -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.

View File

@@ -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">

View File

@@ -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" />

View File

@@ -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&nbsp;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("");
});
});

View File

@@ -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> = {
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
};
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 (&amp;, &nbsp;) 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");
}

View File

@@ -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={() => {

View File

@@ -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)}

View File

@@ -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)}

View File

@@ -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>
);
}

View File

@@ -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)}

View File

@@ -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: {

View File

@@ -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);
},
};

View File

@@ -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);
},
}; };

View File

@@ -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`),
}; };

View File

@@ -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 });

View File

@@ -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;

View File

@@ -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 }),

View File

@@ -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() {

View File

@@ -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",

View File

@@ -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()
); );
} }

View File

@@ -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",

View File

@@ -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>
</> </>
); );
}; };

View File

@@ -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>

View File

@@ -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

View File

@@ -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(

View File

@@ -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),

View File

@@ -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>
</> </>

View File

@@ -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 ||

View File

@@ -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

View File

@@ -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()

View File

@@ -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>
); );
} }

View File

@@ -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);
});
});

View File

@@ -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"],
}; };

View File

@@ -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

View File

@@ -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>

View File

@@ -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"

View File

@@ -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();
});
});

View File

@@ -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

View File

@@ -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;

View File

@@ -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),

View File

@@ -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"

View File

@@ -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

View File

@@ -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
3060s of real API work per booking, so files share one schedule day. 3060s 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.

View 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();

View File

@@ -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 12 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`);
} }

View 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