From 7888dc771ede40323ae35acaaa19ef87f66b982d Mon Sep 17 00:00:00 2001 From: Abubeker Yasin Date: Fri, 7 Aug 2026 10:47:55 +0300 Subject: [PATCH 001/276] fix: (passenger): block payment initiation too close to the booking deadline --- .../common/utils/payment-deadline.utils.ts | 30 ++++++-- .../src/modules/payments/payments.dto.ts | 4 ++ .../modules/payments/payments.service.spec.ts | 68 +++++++++++++++++++ .../src/modules/payments/payments.service.ts | 48 +++++++++++-- .../src/providers/dmoney/dmoney.provider.ts | 2 +- 5 files changed, 141 insertions(+), 11 deletions(-) diff --git a/apps/edr-passenger-api/src/common/utils/payment-deadline.utils.ts b/apps/edr-passenger-api/src/common/utils/payment-deadline.utils.ts index e125bb0bf..8c391aef1 100644 --- a/apps/edr-passenger-api/src/common/utils/payment-deadline.utils.ts +++ b/apps/edr-passenger-api/src/common/utils/payment-deadline.utils.ts @@ -13,11 +13,17 @@ export const MAX_PAYMENT_HOURS = 2; export const CUTOFF_MINUTES = 30; /** - * payment_deadline = MIN(booking_time + MAX_PAYMENT_HOURS, segment_departure - checkinMinutes) - * - * checkinMinutes defaults to CUTOFF_MINUTES but callers should pass the route-level - * checkinMinutesBefore so that each route's own window is respected. + * How long a passenger is given to finish one provider payment session, once opened. + * 5 minutes of actual paying (redirect → PIN/OTP → provider callback) + 1 minute of slack. */ +export const PAYMENT_SESSION_MINUTES = 6; + + +export const MIN_PAYMENT_WINDOW_MINUTES = 7; + +export const PAYMENT_SETTLE_MARGIN_SECONDS = 60; + + export function computePaymentDeadline( createdAt: Date, departureAt: Date, @@ -27,3 +33,19 @@ export function computePaymentDeadline( const cutoffDeadline = new Date(departureAt.getTime() - checkinMinutes * 60 * 1000); return maxDeadline < cutoffDeadline ? maxDeadline : cutoffDeadline; } + + +export function canOpenPaymentSession( + paymentDeadline: Date, + now: Date = new Date(), +): boolean { + return paymentDeadline.getTime() - now.getTime() >= MIN_PAYMENT_WINDOW_MINUTES * 60 * 1000; +} + +export function computePaymentSessionExpiry( + paymentDeadline: Date, + now: Date = new Date(), +): Date { + const sessionEnd = new Date(now.getTime() + PAYMENT_SESSION_MINUTES * 60 * 1000); + return sessionEnd < paymentDeadline ? sessionEnd : paymentDeadline; +} diff --git a/apps/edr-passenger-api/src/modules/payments/payments.dto.ts b/apps/edr-passenger-api/src/modules/payments/payments.dto.ts index dc389e5a1..1d033e5ff 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.dto.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.dto.ts @@ -154,6 +154,10 @@ export class InitiateResponseDto { @ApiPropertyOptional({ type: ClientActionDto }) clientAction?: ClientActionDto; @ApiPropertyOptional() merchantOrderId?: string; + /** When this payment session stops being offered — PAYMENT_SESSION_MINUTES from initiation, capped at paymentDeadline. Drives the client-side countdown. */ + @ApiPropertyOptional() sessionExpiresAt?: string; + /** The booking's payment deadline: after it, the booking is auto-cancelled. */ + @ApiPropertyOptional() paymentDeadline?: string; } export class IntentStatusDto { diff --git a/apps/edr-passenger-api/src/modules/payments/payments.service.spec.ts b/apps/edr-passenger-api/src/modules/payments/payments.service.spec.ts index 8c4edf083..186383335 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.service.spec.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.service.spec.ts @@ -16,6 +16,11 @@ import { ProviderMethod, ProviderPaymentStatus, } from "@edr/types"; +import { + MAX_PAYMENT_HOURS, + MIN_PAYMENT_WINDOW_MINUTES, + PAYMENT_SESSION_MINUTES, +} from "../../common/utils/payment-deadline.utils"; describe("PaymentsService", () => { let service: PaymentsService; @@ -163,6 +168,69 @@ describe("PaymentsService", () => { ).rejects.toThrow(BadRequestException); }); + /** + * A booking whose payment deadline lands exactly `minutesLeft` from now: the deadline is + * MIN(createdAt + MAX_PAYMENT_HOURS, departure - checkin), so back-date createdAt and keep + * departure far away. Derived from MAX_PAYMENT_HOURS so the test survives changes to it. + */ + const bookingWithDeadlineIn = (minutesLeft: number) => ({ + ...mockBooking, + createdAt: new Date( + Date.now() - (MAX_PAYMENT_HOURS * 60 - minutesLeft) * 60 * 1000, + ), + originStationId: null, + schedule: { + departureAt: new Date(Date.now() + 10 * 60 * 60 * 1000), + stopTimes: [], + route: null, + }, + }); + + it("should refuse to open a provider session that cannot finish before auto-cancel", async () => { + // 2 minutes left — the real incident: the session was opened, the provider captured the + // money, and the auto-cancel cron had already cancelled the booking by then. + mockPrisma.booking.findUnique.mockResolvedValue(bookingWithDeadlineIn(2)); + + await expect( + service.initiatePayment({ + bookingId: "booking-1", + method: "TELEBIRR" as any, + }), + ).rejects.toThrow(BadRequestException); + + // Nothing may reach the provider — no session, no capture, no orphan payment. + expect(mockPaymentClient.initiate).not.toHaveBeenCalled(); + }); + + it("should open a session and report its expiry when the window is wide enough", async () => { + const minutesLeft = MIN_PAYMENT_WINDOW_MINUTES + 3; + mockPrisma.booking.findUnique.mockResolvedValue( + bookingWithDeadlineIn(minutesLeft), + ); + mockPaymentClient.initiate.mockResolvedValue( + requiresActionSnapshot(ProviderMethod.TELEBIRR), + ); + mockPrisma.paymentIntent.upsert.mockResolvedValue({ + id: "intent-1", + status: PaymentIntentStatus.REQUIRES_ACTION, + merchantOrderId: "PSG-MERCH-123", + }); + + const result = await service.initiatePayment({ + bookingId: "booking-1", + method: "TELEBIRR" as any, + }); + + expect(mockPaymentClient.initiate).toHaveBeenCalled(); + // Session ends PAYMENT_SESSION_MINUTES from now — before the deadline, not at it. + const sessionMs = + new Date(result.sessionExpiresAt!).getTime() - Date.now(); + expect(sessionMs).toBeLessThanOrEqual(PAYMENT_SESSION_MINUTES * 60 * 1000); + expect(new Date(result.sessionExpiresAt!).getTime()).toBeLessThan( + new Date(result.paymentDeadline!).getTime(), + ); + }); + it("should initiate a provider payment through the payment microservice", async () => { mockPrisma.booking.findUnique.mockResolvedValue(mockBooking); mockPaymentClient.initiate.mockResolvedValue( diff --git a/apps/edr-passenger-api/src/modules/payments/payments.service.ts b/apps/edr-passenger-api/src/modules/payments/payments.service.ts index 14e48dce5..2b1b14cf1 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.service.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.service.ts @@ -29,7 +29,13 @@ import { MarkPaidResponseDto, BillQueryResponseDto, } from "./internal-payments.dto"; -import { computePaymentDeadline } from "../../common/utils/payment-deadline.utils"; +import { + computePaymentDeadline, + computePaymentSessionExpiry, + canOpenPaymentSession, + MIN_PAYMENT_WINDOW_MINUTES, + PAYMENT_SETTLE_MARGIN_SECONDS, +} from "../../common/utils/payment-deadline.utils"; import { PaymentClientService, PaymentDiagnostic, @@ -260,6 +266,27 @@ export class PaymentsService { return this.initiateWalletPayment(booking); } + // Refuse to open a provider session that cannot finish before auto-cancel. Everything below + // this point hands the passenger off to an external provider (redirect/HPP/OTP), which takes + // minutes; TasksService cancels the booking the first cron tick after its payment deadline. + // Opening a session with less than MIN_PAYMENT_WINDOW_MINUTES left produces the worst possible + // outcome — the provider captures the money and the booking is already CANCELLED when the + // capture lands. WALLET is exempt (returned above): it is an instant internal balance debit. + const paymentDeadline = await this.computeBookingPaymentDeadline(booking.id); + const sessionExpiresAt = paymentDeadline + ? computePaymentSessionExpiry(paymentDeadline) + : undefined; + if (paymentDeadline && !canOpenPaymentSession(paymentDeadline)) { + const remainingMs = paymentDeadline.getTime() - Date.now(); + throw new BadRequestException( + remainingMs <= 0 + ? "The payment window for this booking has expired. Please make a new booking." + : `Too little time is left to start a payment (${Math.ceil(remainingMs / 60000)} minute(s) ` + + `until this booking expires; at least ${MIN_PAYMENT_WINDOW_MINUTES} are required). ` + + `Please make a new booking.`, + ); + } + // Free method changes: no reuse/blocking. Every initiate opens a fresh provider session; the // single passenger projection row (upserted by bookingId below) tracks the latest session. // Confirm-once is enforced when a payment succeeds (finalizePaymentSuccess), not here. @@ -317,9 +344,7 @@ export class PaymentsService { payerName = booking.seats.find((s) => s.leg === 1)?.passengerName ?? booking.seats[0]?.passengerName; - expiresAt = ( - await this.computeBookingPaymentDeadline(booking.id) - )?.toISOString(); + expiresAt = paymentDeadline?.toISOString(); } const snapshot = await this.paymentClient.initiate({ @@ -350,7 +375,11 @@ export class PaymentsService { where: { id: intent.id }, }); } - return this.formatIntentResponse(intent); + return { + ...this.formatIntentResponse(intent), + sessionExpiresAt: sessionExpiresAt?.toISOString(), + paymentDeadline: paymentDeadline?.toISOString(), + }; } /** @@ -436,8 +465,15 @@ export class PaymentsService { if (booking.status !== "PENDING_PAYMENT") { return { ...base, stillPayable: false, reason: "NOT_PAYABLE" }; } + // A CBE debit confirmed now lands in seconds, so this doesn't need the full + // MIN_PAYMENT_WINDOW_MINUTES that opening a session does — but it must not be confirmed so + // close to the deadline that the auto-cancel cron cancels the booking before the capture is + // registered. Refusing here is what keeps CBE from debiting a passenger for a dead booking. const deadline = await this.computeBookingPaymentDeadline(booking.id); - if (deadline && deadline.getTime() < Date.now()) { + if ( + deadline && + deadline.getTime() - PAYMENT_SETTLE_MARGIN_SECONDS * 1000 < Date.now() + ) { return { ...base, stillPayable: false, reason: "EXPIRED" }; } return { ...base, stillPayable: true, reason: null }; diff --git a/packages/payment-providers/src/providers/dmoney/dmoney.provider.ts b/packages/payment-providers/src/providers/dmoney/dmoney.provider.ts index 51e90381b..3af351e32 100644 --- a/packages/payment-providers/src/providers/dmoney/dmoney.provider.ts +++ b/packages/payment-providers/src/providers/dmoney/dmoney.provider.ts @@ -353,7 +353,7 @@ export class DMoneyProvider implements PaymentProvider { return this.config.get("dmoney.returnUrl") ?? ""; } private get timeoutExpress(): string { - return this.config.get("dmoney.timeoutExpress") ?? "120m"; + return this.config.get("dmoney.timeoutExpress") ?? "5m"; } private get language(): string { return this.config.get("dmoney.language") ?? "en"; From f51ee7cf598f838ca1373ca8b75d6c90179bba30 Mon Sep 17 00:00:00 2001 From: Abubeker Yasin Date: Fri, 7 Aug 2026 13:48:58 +0300 Subject: [PATCH 002/276] feat: (payment) add telebirr mini-app in-app payment flow --- .../src/modules/payment/payments.dto.ts | 28 +++- .../modules/payments/payments.controller.ts | 11 +- .../src/modules/payments/payments.dto.ts | 38 ++++- .../payments/supplementary-charges.service.ts | 3 +- .../portal/src/app/booking/payment/page.tsx | 133 +++++++++++++++++- .../portal/src/lib/telebirr-bridge.ts | 106 ++++++++++++++ .../intents/dto/initiate-payment.dto.ts | 9 +- .../providers/telebirr/telebirr.provider.ts | 81 +++++++++-- packages/types/src/common/payments.ts | 13 +- 9 files changed, 393 insertions(+), 29 deletions(-) create mode 100644 apps/edr-passenger-web/portal/src/lib/telebirr-bridge.ts diff --git a/apps/edr-freight-api/src/modules/payment/payments.dto.ts b/apps/edr-freight-api/src/modules/payment/payments.dto.ts index 255da0ea2..ee7b703d2 100644 --- a/apps/edr-freight-api/src/modules/payment/payments.dto.ts +++ b/apps/edr-freight-api/src/modules/payment/payments.dto.ts @@ -60,14 +60,38 @@ export class RefundDto { } export class ClientActionDto { + // INVOKE_BRIDGE (SuperApp mini-app payload) is part of the shared ClientAction union and so + // must be assignable here, but freight never requests platform=inapp and therefore never + // receives one. Passenger owns that flow — see docs/telebirr-miniapp/. @ApiProperty({ - enum: ["REDIRECT", "LAUNCH_APP", "COLLECT_OTP", "SHOW_BILL_REFERENCE"], + enum: [ + "REDIRECT", + "LAUNCH_APP", + "INVOKE_BRIDGE", + "COLLECT_OTP", + "SHOW_BILL_REFERENCE", + ], }) - type!: "REDIRECT" | "LAUNCH_APP" | "COLLECT_OTP" | "SHOW_BILL_REFERENCE"; + type!: + | "REDIRECT" + | "LAUNCH_APP" + | "INVOKE_BRIDGE" + | "COLLECT_OTP" + | "SHOW_BILL_REFERENCE"; @ApiPropertyOptional({ description: "Set when type=REDIRECT (web flow)" }) url?: string; + @ApiPropertyOptional({ + description: "Set when type=INVOKE_BRIDGE (SuperApp mini app) — not used by freight", + }) + bridge?: "TELEBIRR"; + + @ApiPropertyOptional({ + description: "Set when type=INVOKE_BRIDGE (SuperApp mini app) — not used by freight", + }) + rawRequest?: string; + @ApiPropertyOptional({ description: "Set when type=LAUNCH_APP (mobile flow)" }) appId?: string; diff --git a/apps/edr-passenger-api/src/modules/payments/payments.controller.ts b/apps/edr-passenger-api/src/modules/payments/payments.controller.ts index 7da37df33..9c1bfa65d 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.controller.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.controller.ts @@ -56,7 +56,7 @@ class WaiveSupplementaryChargeDto { class PaySupplementaryChargeDto { @ApiProperty({ enum: PaymentMethodTypeEnum, example: 'TELEBIRR' }) @IsEnum(PaymentMethodTypeEnum) method: PaymentMethodTypeEnum; - @ApiPropertyOptional({ enum: ['web', 'mobile'], default: 'web' }) @IsOptional() @IsIn(['web', 'mobile']) platform?: 'web' | 'mobile'; + @ApiPropertyOptional({ enum: ['web', 'mobile', 'inapp'], default: 'web' }) @IsOptional() @IsIn(['web', 'mobile', 'inapp']) platform?: PaymentPlatformDto; } @ApiTags("Payment") @@ -307,7 +307,14 @@ export class PaymentsController { }) @ApiQuery({ name: "bookingId", required: true }) @ApiQuery({ name: "method", enum: PaymentMethodTypeEnum, required: true }) - @ApiQuery({ name: "platform", enum: ["web", "mobile"], required: false }) + @ApiQuery({ + name: "platform", + enum: ["web", "mobile"], + required: false, + description: + "Browser-only endpoint — `inapp` is not offered here. A mini-app payer has no browser " + + "to redirect and must go through POST /payments/initiate for the bridge payload.", + }) @ApiProduces("text/html") async checkout( @Query("bookingId") bookingId: string, diff --git a/apps/edr-passenger-api/src/modules/payments/payments.dto.ts b/apps/edr-passenger-api/src/modules/payments/payments.dto.ts index 1d033e5ff..448aa4e7b 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.dto.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.dto.ts @@ -28,7 +28,8 @@ export enum PaymentMethodTypeEnum { CBE_BILL = "CBE_BILL", // Ethiopia (pay at any CBE channel by bill number) } -export type PaymentPlatformDto = "web" | "mobile"; +/** Mirrors `PaymentPlatform` in @edr/types — see there for what each surface means. */ +export type PaymentPlatformDto = "web" | "mobile" | "inapp"; export class InitiatePaymentDto { @ApiProperty({ example: "booking-uuid" }) @IsString() bookingId: string; @@ -45,12 +46,14 @@ export class InitiatePaymentDto { @IsString() paymentMethodId?: string; @ApiPropertyOptional({ - enum: ["web", "mobile"], + enum: ["web", "mobile", "inapp"], default: "web", - description: "Payment platform (web or mobile)", + description: + "Payer surface. `inapp` = the portal is running inside a SuperApp mini-app WebView " + + "(Telebirr), which cannot follow redirect flows and gets a bridge payload instead.", }) @IsOptional() - @IsIn(["web", "mobile"]) + @IsIn(["web", "mobile", "inapp"]) platform?: PaymentPlatformDto; @ApiPropertyOptional({ description: @@ -117,9 +120,20 @@ export class SupportedPaymentMethodDto { export class ClientActionDto { @ApiProperty({ - enum: ["REDIRECT", "LAUNCH_APP", "COLLECT_OTP", "SHOW_BILL_REFERENCE"], + enum: [ + "REDIRECT", + "LAUNCH_APP", + "INVOKE_BRIDGE", + "COLLECT_OTP", + "SHOW_BILL_REFERENCE", + ], }) - type: "REDIRECT" | "LAUNCH_APP" | "COLLECT_OTP" | "SHOW_BILL_REFERENCE"; + type: + | "REDIRECT" + | "LAUNCH_APP" + | "INVOKE_BRIDGE" + | "COLLECT_OTP" + | "SHOW_BILL_REFERENCE"; @ApiPropertyOptional({ description: "Set when type=REDIRECT (web flow)" }) url?: string; @ApiPropertyOptional({ @@ -134,6 +148,18 @@ export class ClientActionDto { description: "Set when type=LAUNCH_APP (mobile flow)", }) shortCode?: string; + @ApiPropertyOptional({ + description: + "Set when type=INVOKE_BRIDGE (telebirr mini app) — which SuperApp host bridge to call", + enum: ["TELEBIRR"], + }) + bridge?: "TELEBIRR"; + @ApiPropertyOptional({ + description: + "Set when type=INVOKE_BRIDGE (telebirr mini app). Signed query string handed verbatim " + + "to the host bridge (js_fun_start_pay). NOT a URL — never navigate to it.", + }) + rawRequest?: string; @ApiPropertyOptional({ description: "Set when type=COLLECT_OTP (e.g. CAC Bank)" }) providerOrderId?: string; @ApiPropertyOptional({ description: "Set when type=COLLECT_OTP" }) diff --git a/apps/edr-passenger-api/src/modules/payments/supplementary-charges.service.ts b/apps/edr-passenger-api/src/modules/payments/supplementary-charges.service.ts index f8c316d87..079792366 100644 --- a/apps/edr-passenger-api/src/modules/payments/supplementary-charges.service.ts +++ b/apps/edr-passenger-api/src/modules/payments/supplementary-charges.service.ts @@ -5,6 +5,7 @@ import { SmsClientService } from '../notifications/sms-client.service'; import { EmailClientService } from '../notifications/email-client.service'; import { PaymentClientService } from './payment-client.service'; import { PaymentReferenceType, PaymentService as PaymentServiceEnum, ProviderMethod } from '@edr/types'; +import { PaymentPlatformDto } from './payments.dto'; const CHARGE_TTL_MS = 72 * 60 * 60 * 1000; // 72 hours @@ -119,7 +120,7 @@ export class SupplementaryChargesService { async pay( token: string, method: string, - platform?: 'web' | 'mobile', + platform?: PaymentPlatformDto, requestOrigin?: string | null, ) { const charge = await this.getByToken(token); // validates status/expiry diff --git a/apps/edr-passenger-web/portal/src/app/booking/payment/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/payment/page.tsx index 685ba5ee2..166b1c799 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/payment/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/payment/page.tsx @@ -6,7 +6,12 @@ import { usePaymentStore } from "@/lib/payment-store"; import { useMutation, useQuery } from "@tanstack/react-query"; import { apiClient } from "@/lib/api-client"; import { resolvePaymentRedirectUrl } from "@/lib/payment-redirect"; -import { useState, useEffect } from "react"; +import { + isTelebirrMiniApp, + onTelebirrPayResult, + startTelebirrPay, +} from "@/lib/telebirr-bridge"; +import { useState, useEffect, useCallback, useMemo } from "react"; import { PaymentMethod } from "@/types"; import { format } from "date-fns"; import { formatTime, getTimePeriod, toZonedDate } from '@/utils/format'; @@ -55,6 +60,17 @@ export default function PaymentPage() { } | null>(null); const [billCopied, setBillCopied] = useState(false); + // Telebirr mini app: the SuperApp payment sheet is open (or just closed) and we're + // polling our own status endpoint for the webhook-backed outcome. + const [verifyingPayment, setVerifyingPayment] = useState(false); + + // Resolved once on mount — SSR has no `window`, so this must not be read during render + // of the first (server) pass. + const [inMiniApp, setInMiniApp] = useState(false); + useEffect(() => { + setInMiniApp(isTelebirrMiniApp()); + }, []); + const isRoundTrip = searchCriteria?.tripType === 'ROUND_TRIP'; // Use the same display currency as the review page — stored on the schedule at search time. @@ -72,8 +88,26 @@ export default function PaymentPage() { }, }); + // Inside the telebirr SuperApp only telebirr can complete: every other method is a + // redirect/HPP flow, and the mini-app WebView cannot follow the scheme handoffs those + // gateways use. Offering them would strand the payer on a dead page. + // Memoised: this feeds an effect's dep array, and a fresh array identity every render + // would re-run that effect on every render. + const availableMethods = useMemo( + () => paymentMethods.filter((m) => m.enabled && (!inMiniApp || m.type === 'TELEBIRR')), + [paymentMethods, inMiniApp], + ); + const selectedPaymentMethod = paymentMethods.find(m => m.type === selectedMethod) || null; + // A method chosen before the container was known (or carried over in state) may no longer + // be offerable — drop it rather than letting Pay fire against a hidden method. + useEffect(() => { + if (selectedMethod && !availableMethods.some((m) => m.type === selectedMethod)) { + setSelectedMethod(null); + } + }, [selectedMethod, availableMethods]); + // Derive charge currency directly from the selected method — no separate state that can lag. const amountCurrency = (selectedPaymentMethod?.currency || 'ETB').toUpperCase(); @@ -128,6 +162,70 @@ export default function PaymentPage() { } }, [selectedMethod, dataReady, bookingAmountData, reviewedTotal, displayCurrency, setCurrency, setPaidAmount]); + /** + * Poll our own status endpoint until the payment reaches a terminal state. + * + * Used by the telebirr mini-app flow, where nothing navigates and therefore no return page + * ever runs. The bridge callback only tells us the sheet closed; the authoritative outcome + * is the webhook-backed status the API reports here. + */ + const pollPaymentStatus = useCallback( + async (attemptsLeft: number): Promise => { + if (!bookingId) return; + try { + const res: any = await apiClient.get(`/payments/status/${bookingId}`); + if (res?.status === 'SUCCEEDED') { + setVerifyingPayment(false); + updateStatus("SUCCEEDED"); + router.push("/booking/confirmation"); + return; + } + if (res?.status === 'FAILED' || res?.status === 'CANCELLED') { + setVerifyingPayment(false); + setIsProcessing(false); + updateStatus("FAILED"); + setPaymentError(res?.failureMessage || "Payment was not completed. Please try again."); + return; + } + } catch { + // Transient read failure — keep polling; the attempt budget bounds it. + } + + if (attemptsLeft <= 0) { + // Don't call it failed: telebirr may have taken the money and the webhook is simply + // still in flight. Stop spinning, tell the truth, and let the payer re-check. + setVerifyingPayment(false); + setIsProcessing(false); + setPaymentError( + "We haven't received confirmation yet. If you completed the payment, your booking " + + "will be confirmed shortly — check My Bookings in a moment before paying again.", + ); + return; + } + setTimeout(() => void pollPaymentStatus(attemptsLeft - 1), 1500); + }, + [bookingId, router, updateStatus], + ); + + /** + * Telebirr mini app reports the sheet outcome on a global callback rather than a redirect. + * Registered on mount — the SuperApp can call back the moment the sheet closes, so it must + * already be installed before the bridge is invoked. + */ + useEffect(() => { + return onTelebirrPayResult((succeeded) => { + if (!succeeded) { + setVerifyingPayment(false); + setIsProcessing(false); + updateStatus("FAILED"); + setPaymentError("Payment was cancelled or declined. Please try again."); + return; + } + setVerifyingPayment(true); + void pollPaymentStatus(15); + }); + }, [pollPaymentStatus, updateStatus]); + const paymentMutation = useMutation({ mutationFn: async (data: any) => { return await apiClient.post("/payments/initiate", { @@ -135,7 +233,7 @@ export default function PaymentPage() { method: data.method, paymentMethodId: data.paymentMethodId, payerAccount: data.payerAccount, - platform: 'web', + platform: isTelebirrMiniApp() ? 'inapp' : 'web', }); }, onSuccess: async (data: any) => { @@ -163,6 +261,23 @@ export default function PaymentPage() { return; } + // Telebirr mini app: hand the signed rawRequest to the SuperApp bridge. Nothing + // navigates — telebirr draws its payment sheet over the WebView and reports back on + // the global callback registered above, which starts the status polling. + if (data?.clientAction?.type === 'INVOKE_BRIDGE') { + setPaymentIntent(data.intentId); + updateStatus("REQUIRES_ACTION"); + if (!startTelebirrPay(data.clientAction.rawRequest)) { + setIsProcessing(false); + updateStatus("FAILED"); + setPaymentError( + "Couldn't open the telebirr payment sheet. Please reopen this page from the " + + "telebirr app and try again.", + ); + } + return; + } + if ((selectedMethod === 'TELEBIRR' || selectedMethod === 'WAAFI' || selectedMethod === 'DMONEY') && data?.clientAction?.type === 'REDIRECT') { setPaymentIntent(data.intentId); updateStatus("REQUIRES_ACTION"); @@ -505,7 +620,15 @@ export default function PaymentPage() { {isProcessing && (
- {paymentMutation.isSuccess ? ( + {verifyingPayment ? ( + <> + +

Confirming payment

+

+ Checking with telebirr — this only takes a moment. +

+ + ) : paymentMutation.isSuccess ? ( <>

Loading...

@@ -688,13 +811,13 @@ export default function PaymentPage() {

Failed to load payment methods. Please refresh.

- ) : paymentMethods.length === 0 ? ( + ) : availableMethods.length === 0 ? (

No payment methods available at the moment.

) : (
- {paymentMethods.filter(m => m.enabled).map((method) => { + {availableMethods.map((method) => { const Icon = getIconForMethod(method.type); const isSelected = selectedMethod === method.type; return ( diff --git a/apps/edr-passenger-web/portal/src/lib/telebirr-bridge.ts b/apps/edr-passenger-web/portal/src/lib/telebirr-bridge.ts new file mode 100644 index 000000000..fd2a9ccea --- /dev/null +++ b/apps/edr-passenger-web/portal/src/lib/telebirr-bridge.ts @@ -0,0 +1,106 @@ +/** + * Telebirr SuperApp mini-app bridge. + * + * When the portal runs inside the telebirr SuperApp, the ordinary web checkout is unusable: + * the H5 paygate page hands off to the native wallet with a custom scheme + * (`kcbconsumer://h5checkout?...`) that the SuperApp's WebView cannot resolve, so the payer + * only ever sees `net::ERR_UNKNOWN_URL_SCHEME`. + * + * The in-app flow never navigates. The API returns a signed `rawRequest` string + * (clientAction.type === "INVOKE_BRIDGE") which is handed to the host's JS bridge; telebirr + * renders its own payment sheet over the WebView and reports the outcome on a global callback. + * + * See docs/telebirr-miniapp/inapp-payment-plan.md. + */ + +/** Name of the global the SuperApp calls back into. Must be a property of `window`. */ +export const TELEBIRR_PAY_CALLBACK = "handleEdrPaymentCallback"; + +type ConsumerApp = { evaluate: (payload: string) => void }; + +declare global { + interface Window { + /** Injected by the telebirr SuperApp WebView. Absent everywhere else. */ + consumerapp?: ConsumerApp; + [TELEBIRR_PAY_CALLBACK]?: (response: unknown) => void; + } +} + +/** + * True only when the telebirr host bridge is actually present. + * + * Deliberately does NOT sniff the user agent. A UA match without `window.consumerapp` would + * make us request `platform: "inapp"` and get back a bare rawRequest we have no way to use — + * there is no navigating our way out of that, because by then the server has already committed + * to the bridge payload. Gating on the bridge object keeps the decision and the capability in + * sync: if we can't call it, we don't ask for it. + */ +export function isTelebirrMiniApp(): boolean { + return typeof window !== "undefined" && typeof window.consumerapp?.evaluate === "function"; +} + +/** + * Hand a signed rawRequest to the SuperApp to open its payment sheet. + * + * Register the callback (see `onTelebirrPayResult`) BEFORE calling this — the host may invoke + * it as soon as the sheet closes. Returns false when the bridge is missing or throws, so the + * caller can surface an error instead of leaving the payer on a dead spinner. + */ +export function startTelebirrPay(rawRequest: string): boolean { + if (!isTelebirrMiniApp()) return false; + try { + window.consumerapp!.evaluate( + JSON.stringify({ + functionName: "js_fun_start_pay", + params: { + rawRequest, + functionCallBackName: TELEBIRR_PAY_CALLBACK, + }, + }), + ); + return true; + } catch (err) { + console.error("[telebirr] bridge evaluate failed:", err); + return false; + } +} + +/** + * Install the global result callback; returns a disposer for effect cleanup. + * + * The result is a TRIGGER TO VERIFY, never proof of payment — the payer can close the sheet, + * the host can report success before settlement lands, and the payload shape is not a contract. + * Confirmation always comes from polling our own payment status (webhook-backed). + */ +export function onTelebirrPayResult(handler: (succeeded: boolean) => void): () => void { + if (typeof window === "undefined") return () => {}; + window[TELEBIRR_PAY_CALLBACK] = (response: unknown) => { + handler(isSuccessResponse(response)); + }; + return () => { + delete window[TELEBIRR_PAY_CALLBACK]; + }; +} + +/** + * Telebirr reports `code: 0` (number or string) for success. The payload arrives as either a + * JSON string or an object depending on host version, and an unparseable payload is treated as + * success on purpose: polling is what decides the outcome, and a false "failed" would strand a + * payer who actually paid. + */ +function isSuccessResponse(response: unknown): boolean { + let parsed: unknown = response; + if (typeof response === "string") { + try { + parsed = JSON.parse(response); + } catch { + return true; + } + } + if (parsed && typeof parsed === "object" && "code" in parsed) { + const code = (parsed as { code: unknown }).code; + if (code === undefined || code === null) return true; + return code === 0 || code === "0"; + } + return true; +} diff --git a/apps/edr-payment-api/src/modules/intents/dto/initiate-payment.dto.ts b/apps/edr-payment-api/src/modules/intents/dto/initiate-payment.dto.ts index 6d602b28a..fa7587b2e 100644 --- a/apps/edr-payment-api/src/modules/intents/dto/initiate-payment.dto.ts +++ b/apps/edr-payment-api/src/modules/intents/dto/initiate-payment.dto.ts @@ -62,9 +62,14 @@ export class InitiatePaymentRequestDto implements InitiatePaymentRequest { @IsEnum(ProviderMethod) provider!: ProviderMethod; - @ApiPropertyOptional({ enum: ["web", "mobile"] }) + @ApiPropertyOptional({ + enum: ["web", "mobile", "inapp"], + description: + "Payer surface. `inapp` = running inside a SuperApp mini-app WebView (Telebirr), " + + "which cannot follow redirect/HPP flows and gets a bridge payload instead.", + }) @IsOptional() - @IsIn(["web", "mobile"]) + @IsIn(["web", "mobile", "inapp"]) platform?: PaymentPlatform; @ApiPropertyOptional({ diff --git a/packages/payment-providers/src/providers/telebirr/telebirr.provider.ts b/packages/payment-providers/src/providers/telebirr/telebirr.provider.ts index d732a51e3..32bf859b1 100644 --- a/packages/payment-providers/src/providers/telebirr/telebirr.provider.ts +++ b/packages/payment-providers/src/providers/telebirr/telebirr.provider.ts @@ -2,6 +2,8 @@ import { Injectable, Logger } from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; import { HttpService } from "@nestjs/axios"; import { + ClientAction, + PaymentPlatform, PaymentProvider, ProviderInitiationInput, ProviderInitiationResult, @@ -67,15 +69,7 @@ export class TelebirrProvider implements PaymentProvider { requestBody.biz_content.timeout_express, ); const platform = input.platform ?? "web"; - const clientAction = - platform === "mobile" - ? { - type: "LAUNCH_APP" as const, - appId: this.merchantAppId, - receiveCode: response.biz_content?.receiveCode, - shortCode: this.merchantCode, - } - : { type: "REDIRECT" as const, url: this.buildCheckoutUrl(prepayId) }; + const clientAction = this.buildClientAction(platform, prepayId, response); return { providerOrderId: prepayId, @@ -199,6 +193,11 @@ export class TelebirrProvider implements PaymentProvider { input: ProviderInitiationInput, ): CreateOrderRequest { const totalAmount = String(input.amountMinor); + // In-app pays inside the SuperApp overlay and never navigates, so there is no browser + // to send back — telebirr's own in-app integration omits redirect_url entirely. Keep it + // absent rather than undefined: a signed-but-unsent field is what produced the earlier + // "verify sign failed" (see docs/payment-service + telebirr.crypto skip-undefined). + const wantsRedirect = input.platform !== "inapp" && !!input.redirectUrl; const req = { timestamp: createTimestamp(), nonce_str: createNonceStr(), @@ -214,7 +213,7 @@ export class TelebirrProvider implements PaymentProvider { total_amount: totalAmount, trans_currency: input.currency, timeout_express: this.timeoutExpress, - ...(input.redirectUrl ? { redirect_url: input.redirectUrl } : {}), + ...(wantsRedirect ? { redirect_url: input.redirectUrl! } : {}), }, }; const sign = signRequestObject( @@ -245,6 +244,68 @@ export class TelebirrProvider implements PaymentProvider { return { ...req, sign, sign_type: "SHA256WithRSA" }; } + /** + * Telebirr exposes the same pre-order three ways; only the launch payload differs. + * + * - `mobile` — native app hands off to the wallet app with a receiveCode. + * - `inapp` — the portal is running inside the telebirr SuperApp mini-app WebView. The + * H5 checkout page is unusable there: it deep-links to `kcbconsumer://…`, + * which the WebView cannot resolve (`net::ERR_UNKNOWN_URL_SCHEME`). The + * signed rawRequest goes to the host JS bridge instead — no navigation. + * - `web` — ordinary browser; redirect to the H5 checkout page. + */ + private buildClientAction( + platform: PaymentPlatform, + prepayId: string, + response: CreateOrderResponse, + ): ClientAction { + switch (platform) { + case "mobile": + return { + type: "LAUNCH_APP", + appId: this.merchantAppId, + receiveCode: response.biz_content?.receiveCode, + shortCode: this.merchantCode, + }; + case "inapp": + return { + type: "INVOKE_BRIDGE", + bridge: "TELEBIRR", + rawRequest: this.buildInAppRawRequest(prepayId), + }; + default: + return { type: "REDIRECT", url: this.buildCheckoutUrl(prepayId) }; + } + } + + /** + * Signed request handed verbatim to the SuperApp bridge (`js_fun_start_pay`). + * + * Emits `appid, merch_code, nonce_str, prepay_id, timestamp, sign_type, sign` in that + * order — no `webBaseUrl` prefix and no `version`/`trade_type` tail, because the bridge + * takes the bare query string rather than a URL. + * + * `sign_type` sits in the map purely so it lands in the output in the right position; + * `buildCanonicalString` excludes it (as does telebirr's own reference implementation), + * so the signature covers the same five fields as the web checkout URL. + * + * Kept separate from `buildCheckoutUrl` rather than sharing a builder: the two payloads + * are consumed by different validators, and the web flow is live. + */ + private buildInAppRawRequest(prepayId: string): string { + const map: Record = { + appid: this.merchantAppId, + merch_code: this.merchantCode, + nonce_str: createNonceStr(), + prepay_id: prepayId, + timestamp: createTimestamp(), + sign_type: "SHA256WithRSA", + }; + const sign = signRequestObject(map, this.privateKey); + const fields = Object.entries(map).map(([k, v]) => `${k}=${v}`); + return [...fields, `sign=${sign}`].join("&"); + } + private buildCheckoutUrl(prepayId: string): string { const map: Record = { appid: this.merchantAppId, diff --git a/packages/types/src/common/payments.ts b/packages/types/src/common/payments.ts index 04849f54d..05be21682 100644 --- a/packages/types/src/common/payments.ts +++ b/packages/types/src/common/payments.ts @@ -32,7 +32,8 @@ export enum ProviderMethod { CBE_BILL = "CBE_BILL", } -export type PaymentPlatform = "web" | "mobile"; + +export type PaymentPlatform = "web" | "mobile" | "inapp"; export type ClientAction = | { type: "REDIRECT"; url: string } @@ -42,6 +43,16 @@ export type ClientAction = receiveCode?: string; shortCode: string; } + | { + type: "INVOKE_BRIDGE"; + /** Which SuperApp host bridge the payload targets. */ + bridge: "TELEBIRR"; + /** + * Signed query string handed verbatim to the host bridge (`js_fun_start_pay`). + * NOT a URL — it has no scheme or host and must never be navigated to. + */ + rawRequest: string; + } | { type: "COLLECT_OTP"; providerOrderId: string; From c77f5200a3051801f5c2f6ac426424e29918183f Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Fri, 7 Aug 2026 11:43:18 +0000 Subject: [PATCH 003/276] feat(last-mile): default the approval advance to the live rate The chief's typed advance was mandatory, so the rule-based estimate shown in the approve dialog had to be retyped and could silently diverge from it. advanceAmount is now optional: the advance defaults to the live last-mile rate estimate (km x rate) and the typed value is only an override. When no rate covers the job the request is rejected with a message telling the chief to enter the amount manually, rather than approving a zero advance. The advance invoice now bills in the rate's currency from the snapshotted contract summary, falling back to the booking payment currency only when the amount came from a manual override. Co-Authored-By: Claude Opus 5 --- .../dto/approve-last-mile-request.dto.ts | 20 +++++++++----- .../last-mile-requests.controller.ts | 2 +- .../last-mile-requests.service.ts | 27 ++++++++++++++----- 3 files changed, 35 insertions(+), 14 deletions(-) diff --git a/apps/edr-freight-api/src/modules/last-mile-requests/dto/approve-last-mile-request.dto.ts b/apps/edr-freight-api/src/modules/last-mile-requests/dto/approve-last-mile-request.dto.ts index 56be0c545..0fa7457a8 100644 --- a/apps/edr-freight-api/src/modules/last-mile-requests/dto/approve-last-mile-request.dto.ts +++ b/apps/edr-freight-api/src/modules/last-mile-requests/dto/approve-last-mile-request.dto.ts @@ -1,13 +1,19 @@ -import { ApiProperty } from '@nestjs/swagger'; +import { ApiPropertyOptional } from '@nestjs/swagger'; import { Transform } from 'class-transformer'; -import { IsNumber, Min } from 'class-validator'; +import { IsNumber, IsOptional, Min } from 'class-validator'; export class ApproveLastMileRequestDto { - // The approve dialog prefills this from GET :id/price-estimate (rule-based), - // but the chief can still override — the typed value is what's invoiced. - @ApiProperty({ description: 'Advance amount the customer must pay before execution proceeds', example: 3000 }) - @Transform(({ value }) => Number(value)) + // Omitted = the rule-based last-mile rate estimate is the advance. The chief + // can still override with an explicit amount (required when no rate covers + // the job). + @ApiPropertyOptional({ + description: + 'Advance override. Omitted = the amount comes from the live last-mile rates (km × rate).', + example: 3000, + }) + @IsOptional() + @Transform(({ value }) => (value === null || value === undefined || value === '' ? undefined : Number(value))) @IsNumber() @Min(0.01) - advanceAmount!: number; + advanceAmount?: number; } diff --git a/apps/edr-freight-api/src/modules/last-mile-requests/last-mile-requests.controller.ts b/apps/edr-freight-api/src/modules/last-mile-requests/last-mile-requests.controller.ts index 4c4ac88e5..6f96d12c6 100644 --- a/apps/edr-freight-api/src/modules/last-mile-requests/last-mile-requests.controller.ts +++ b/apps/edr-freight-api/src/modules/last-mile-requests/last-mile-requests.controller.ts @@ -110,7 +110,7 @@ export class LastMileRequestsController { @Post(':id/approve') @BookingStaff(FREIGHT_PERMS.lastMile.requestApprove) - @ApiOperation({ summary: 'Truck & Machinery chief approves the request — LM contract becomes signable; the advance invoice follows the customer signature' }) + @ApiOperation({ summary: 'Truck & Machinery chief approves the request — the advance defaults to the live last-mile rate; LM contract becomes signable and the advance invoice follows the customer signature' }) approve( @Param('id', ParseUUIDPipe) id: string, @Body() dto: ApproveLastMileRequestDto, diff --git a/apps/edr-freight-api/src/modules/last-mile-requests/last-mile-requests.service.ts b/apps/edr-freight-api/src/modules/last-mile-requests/last-mile-requests.service.ts index 901e7b9b5..b89f06479 100644 --- a/apps/edr-freight-api/src/modules/last-mile-requests/last-mile-requests.service.ts +++ b/apps/edr-freight-api/src/modules/last-mile-requests/last-mile-requests.service.ts @@ -299,7 +299,11 @@ export class LastMileRequestsService { return this.findById(id); } - async approve(id: string, staffId: string | null, advanceAmount: number): Promise { + async approve( + id: string, + staffId: string | null, + advanceOverride?: number | null, + ): Promise { const request = await this.findById(id); if (request.status !== LastMileRequestStatus.Submitted) { throw new BadRequestException(`Only a submitted request can be approved (current status: ${request.status})`); @@ -307,6 +311,18 @@ export class LastMileRequestsService { const booking = request.booking ?? (await this.bookingsRepository.findById(request.bookingId)); if (!booking) throw new NotFoundException(`Booking ${request.bookingId} not found`); + // The live last-mile rates are the authority on the advance (km × rate); + // the chief's typed amount is only an override — and the only path when no + // rate covers the job. Snapshotted so the contract and invoice stay immune + // to later rate edits. + const estimate = await this.priceEstimate(id); + const advanceAmount = advanceOverride ?? estimate.total; + if (!advanceAmount || advanceAmount <= 0) { + throw new BadRequestException( + 'No live last-mile rate covers this job — enter the advance amount manually.', + ); + } + // Idempotent per booking — reuses the record if one already exists. const lastMile = await this.lastMileService.create({ bookingId: request.bookingId, @@ -316,10 +332,6 @@ export class LastMileRequestsService { // No invoice yet: the advance is invoiced by LastMileContractService.sign() // once the customer has signed the LM contract — doc first, then payment. - // Snapshot the rate estimate now so the contract shows the numbers the - // chief actually approved against, immune to later rate edits. - const estimate = await this.priceEstimate(id); - await this.requestsRepository.update(id, { status: LastMileRequestStatus.Approved, reviewedByStaffId: staffId, @@ -364,7 +376,10 @@ export class LastMileRequestsService { type: 'LAST_MILE_ADVANCE', companyId: booking.companyId, companyProfileId: booking.companyProfileId || '', - currency: booking.paymentCurrency || 'ETB', + // The advance is priced by the last-mile rate, so it bills in that + // rate's currency (birr for domestic trucking) — the booking's payment + // currency is only the fallback when the amount was a manual override. + currency: request.contractSummary?.currency || booking.paymentCurrency || 'ETB', lines: [ { chargeType: 'LAST_MILE_ADVANCE', From 116b479bb02e74ce50e20a949a7f60ff8ab7c71a Mon Sep 17 00:00:00 2001 From: Nathnael Date: Fri, 7 Aug 2026 12:42:33 +0000 Subject: [PATCH 004/276] feat: scope notification to permission actions --- apps/edr-freight-api/src/app.module.ts | 13 ++ .../modules/backoffice/backoffice.service.ts | 72 ++++++++-- ...booking-lifecycle-notifier.service.spec.ts | 37 +++++ .../booking-lifecycle-notifier.service.ts | 23 +++- .../booking-wagon-cancellation.service.ts | 6 +- .../companies/company-notifier.service.ts | 12 +- .../contracts/contract-expiry.service.spec.ts | 15 ++ .../contracts/contract-expiry.service.ts | 7 +- .../contracts/contract-notifier.service.ts | 26 +++- .../maintenance/maintenance-due-alert.spec.ts | 5 + .../maintenance/maintenance.service.ts | 5 +- .../notification-recipients.service.ts | 16 +-- .../priority-rule-change-requests.service.ts | 5 +- .../services/rate-change-requests.service.ts | 5 +- .../warehouses/warehouse-fee.service.ts | 7 +- ...freight-notification-permissions.seeder.ts | 103 ++++++++++++++ .../seed/freight-permissions.registry.spec.ts | 39 +++++- .../src/seed/freight-permissions.registry.ts | 128 ++++++++++++++++++ packages/types/src/freight/notifications.ts | 13 +- 19 files changed, 486 insertions(+), 51 deletions(-) create mode 100644 apps/edr-freight-api/src/seed/freight-notification-permissions.seeder.ts diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index c98990d25..c4c1514ec 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -76,6 +76,7 @@ import { FileUploadSettingsSeeder } from "./seed/file-upload-settings.seeder"; // import { ExportDjiboutiInterchangeDemoSeeder } from "./seed/export-djibouti-interchange-demo.seeder"; // import { MarshallingDemoTrainsSeeder } from "./seed/marshalling-demo-trains.seeder"; import { FreightPermissionKeyMigrationSeeder } from "./seed/freight-permission-key-migration.seeder"; +import { FreightNotificationPermissionsSeeder } from "./seed/freight-notification-permissions.seeder"; // import { DemoFreightDataSeeder } from "./seed/demo-freight-data.seeder"; // import { GovCompaniesSeeder } from "./seed/gov-companies.seeder"; import { ApprovedFirstLastMileDemoBookingsSeeder } from "./seed/approved-first-lastmile-demo-bookings.seeder"; @@ -257,6 +258,7 @@ if (!process.env.APPLICATION_NAME) { FileUploadSettingsSeeder, // YardFacilitiesSeeder, FreightPermissionKeyMigrationSeeder, + FreightNotificationPermissionsSeeder, // Disabled seeds — providers commented out (imports/injection/run too): // DemoUsersSeeder, // FreightStaffUsersSeeder, @@ -287,6 +289,7 @@ export class AppModule implements OnApplicationBootstrap { private readonly fileUploadSettingsSeeder: FileUploadSettingsSeeder, // private readonly yardFacilitiesSeeder: YardFacilitiesSeeder, private readonly freightPermissionKeyMigrationSeeder: FreightPermissionKeyMigrationSeeder, + private readonly freightNotificationPermissionsSeeder: FreightNotificationPermissionsSeeder, // Disabled seeds — injections commented out (imports/provider/run too): // private readonly demoUsersSeeder: DemoUsersSeeder, // private readonly freightStaffUsersSeeder: FreightStaffUsersSeeder, @@ -328,6 +331,16 @@ export class AppModule implements OnApplicationBootstrap { await this.edrOrgSeeder.run(); await this.iamBaselineSeeder.run(); await this.freightPositionsSeeder.run(); + // freightNotificationPermissions → seeds the :get_notification + // keys and backfills them onto whoever + // already holds each desk's anchor + // permission. Runs LAST in this block so + // it sees a freshly-seeded catalog and + // freshly-seeded positions. Unlike the + // seeders above it is NOT gated behind + // SEED_EDR_ORG — without it every staff + // notification resolves to no one. + await this.freightNotificationPermissionsSeeder.run(); // File upload settings — keep enabled. await this.fileUploadSettingsSeeder.run(); diff --git a/apps/edr-freight-api/src/modules/backoffice/backoffice.service.ts b/apps/edr-freight-api/src/modules/backoffice/backoffice.service.ts index 49384922e..f47b5e8b1 100644 --- a/apps/edr-freight-api/src/modules/backoffice/backoffice.service.ts +++ b/apps/edr-freight-api/src/modules/backoffice/backoffice.service.ts @@ -46,7 +46,8 @@ export class BackofficeService { /** * IAM user ids of every current employee across all organizations — used by - * the notification recipients resolver's `allBackoffice` selector. + * support chat for staff room membership. Notifications deliberately do NOT + * use this: they target a desk via `getEmployeeUserIdsByPermission`. */ async getAllCurrentEmployeeUserIds(): Promise { const employees = await this.employeeRepository.find({ @@ -63,24 +64,67 @@ export class BackofficeService { * IAM user ids of current employees (any org) holding ANY of the given * permission keys — used by the notification recipients resolver's * `permissionKeys` selector for department/role-scoped targeting. + * + * This MUST agree with the request-time guard (`hasFreightPermission`, + * common/freight-permission.util.ts), which counts four grant carriers plus + * the super_admin bypass. Counting fewer silently drops legitimate + * recipients: an earlier version joined only direct position permissions, on + * which `bookings:view` resolved to 2 users — against 21 through position + * TYPES, which is where admin-created positions actually keep their grants. + * + * Raw SQL rather than QueryBuilder because `Position.positionTypePermissions` + * declares its inverse side against PositionType, so a relation join emits + * `ptp.position_type_id = position.id` and silently matches nothing. Same + * approach as FreightMeService's position-type lookups. */ async getEmployeeUserIdsByPermission( permissionKeys: string[], ): Promise { if (!permissionKeys.length) return []; - const rows: { userId: string | null }[] = await this.employeeRepository - .createQueryBuilder("employee") - .innerJoin("employee.employeePositions", "employeePosition") - .innerJoin("employeePosition.position", "position") - .innerJoin("position.positionPermission", "positionPermission") - .innerJoin("positionPermission.permission", "permission") - .where("employee.isCurrent = :isCurrent", { isCurrent: true }) - .andWhere("permission.key IN (:...permissionKeys)", { permissionKeys }) - .select("DISTINCT employee.user_id", "userId") - .getRawMany(); - return rows - .map((r) => r.userId) - .filter((id): id is string => Boolean(id)); + const rows: { userId: string }[] = await this.dataSource.query( + `WITH target AS (SELECT id FROM iam.permissions WHERE key = ANY($1)) + -- 1. IAM role grants (user_roles -> role_permissions). + SELECT e.user_id AS "userId" + FROM iam.employees e + JOIN iam.user_roles ur ON ur.user_id = e.user_id + JOIN iam.role_permissions rp ON rp.role_id = ur.role_id + WHERE e.is_current AND e.user_id IS NOT NULL + AND rp.permission_id IN (SELECT id FROM target) + UNION + -- 2. Direct position grants. A delegate keeps their own position AND + -- gains the one they stand in for, so both columns count. + SELECT e.user_id + FROM iam.employees e + JOIN iam.employee_positions ep + ON ep.employee_id = e.id AND ep.is_current + JOIN iam.position_permissions pp + ON pp.position_id IN (ep.position_id, ep.delegatee_position_id) + WHERE e.is_current AND e.user_id IS NOT NULL + AND pp.permission_id IN (SELECT id FROM target) + UNION + -- 3. Position TYPE grants — where admin-created positions keep theirs. + SELECT e.user_id + FROM iam.employees e + JOIN iam.employee_positions ep + ON ep.employee_id = e.id AND ep.is_current + JOIN iam.positions p + ON p.id IN (ep.position_id, ep.delegatee_position_id) + JOIN iam.position_type_permissions ptp + ON ptp.position_type_id = p.position_type_id + WHERE e.is_current AND e.user_id IS NOT NULL + AND ptp.permission_id IN (SELECT id FROM target) + UNION + -- 4. super_admin passes every freight permission check, so mirror that + -- here or admins go blind on desks nobody else has been granted yet. + SELECT e.user_id + FROM iam.employees e + JOIN iam.user_roles ur ON ur.user_id = e.user_id + JOIN iam.roles r ON r.id = ur.role_id + WHERE e.is_current AND e.user_id IS NOT NULL + AND r.key = 'super_admin'`, + [permissionKeys], + ); + return rows.map((r) => r.userId); } async createOrganizationUser( diff --git a/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.spec.ts b/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.spec.ts index 2c21d804c..9a5960295 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.spec.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.spec.ts @@ -1,5 +1,6 @@ import { BookingLifecycleNotifierService } from './booking-lifecycle-notifier.service'; import type { Booking } from './entities/booking.entity'; +import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; /** * Who hears "Operations wants changes" depends on who owns the booking. A @@ -74,3 +75,39 @@ describe('BookingLifecycleNotifierService — operation changes requested', () = expect(inbox.notify.mock.calls[0][0].recipients).toEqual({ companyId: 'co-1' }); }); }); + +/** + * Staff notifications used to go to every employee in every organization. They + * now target a desk — and the two desks are disjoint: the GL presets hold no + * bookings:view and no intake keys, so intake pings would be noise they cannot + * act on. Both branches run through the same `inAppStaff` helper, which is the + * easy place to lose the distinction again. + */ +describe('BookingLifecycleNotifierService — staff desk targeting', () => { + const booking = () => + ({ id: 'b-1', reference: 'BKG-0001', companyId: 'co-1' }) as Booking; + + let inbox: { notify: jest.Mock }; + let service: BookingLifecycleNotifierService; + + beforeEach(() => { + inbox = { notify: jest.fn().mockResolvedValue(undefined) }; + service = new BookingLifecycleNotifierService( + { directSend: jest.fn().mockResolvedValue(undefined) } as never, + inbox as never, + { query: jest.fn().mockResolvedValue([]) } as never, + ); + }); + + it('routes intake items to the booking desk and clearance items to the clearance desk', () => { + service.submittedToStaff(booking()); + service.clearanceDocsUploadedToStaff(booking()); + + expect(inbox.notify.mock.calls[0][0].recipients).toEqual({ + permissionKeys: [FREIGHT_PERMS.bookings.getNotification], + }); + expect(inbox.notify.mock.calls[1][0].recipients).toEqual({ + permissionKeys: [FREIGHT_PERMS.bookings.clearanceGetNotification], + }); + }); +}); diff --git a/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.ts index 6245195eb..90b020472 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.ts @@ -11,6 +11,16 @@ import { Booking } from './entities/booking.entity'; import { NotificationsService } from '../notifications/notifications.service'; import { NotificationInboxService } from '../notification-inbox/notification-inbox.service'; import { resolveCompanyNotifyPhone } from '../notifications/resolve-company-phone.util'; +import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; + +/** + * Clearance items are worked by the GL desks, which hold no bookings:view and + * no intake keys — so they take their own selector rather than the booking + * desk's. Every override using this deep-links to a clearance page. + */ +const CLEARANCE_DESK = { + permissionKeys: [FREIGHT_PERMS.bookings.clearanceGetNotification], +}; /** * Customer + staff notifications for the booking lifecycle: review, clearance @@ -89,7 +99,11 @@ export class BookingLifecycleNotifierService { }); } - /** Persist + push an in-app item to every backoffice staff user. */ + /** + * Persist + push an in-app item to the booking desk — staff holding + * `bookings:get_notification`. Callers whose item belongs to a different desk + * override `recipients` (see {@link CLEARANCE_DESK}). + */ private inAppStaff( b: Booking, title: string, @@ -97,7 +111,7 @@ export class BookingLifecycleNotifierService { overrides: Partial = {}, ): void { void this.inbox.notify({ - recipients: { allBackoffice: true }, + recipients: { permissionKeys: [FREIGHT_PERMS.bookings.getNotification] }, audience: NotificationAudience.BACKOFFICE, type: NotificationType.REQUEST_SUBMITTED, title, @@ -274,6 +288,7 @@ export class BookingLifecycleNotifierService { `the customs declaration can be filed.${note ? ` Note: "${note}"` : ''}`; this.logger.log(`TRANSIT ASSIGNEE REQUESTED — ${this.ref(b)}`); this.inAppStaff(b, `Transit assignee needed — ${b.reference}`, msg, { + recipients: CLEARANCE_DESK, type: NotificationType.CLEARANCE_REVIEW, link: `/dashboard/gl-djibouti/clearance/${b.id}`, }); @@ -288,6 +303,7 @@ export class BookingLifecycleNotifierService { `The customs declaration can now be filed.`; this.logger.log(`TRANSIT ASSIGNEE ASSIGNED — ${this.ref(b)}`); this.inAppStaff(b, `Transit assignee set — ${b.reference}`, msg, { + recipients: CLEARANCE_DESK, type: NotificationType.CLEARANCE_REVIEW, link: `/dashboard/bookings/${b.id}/clearance`, }); @@ -395,6 +411,7 @@ export class BookingLifecycleNotifierService { 'Clearance documents uploaded', `Customer uploaded clearance documents for booking ${this.ref(b)} — review them in the clearance queue.`, { + recipients: CLEARANCE_DESK, type: NotificationType.CLEARANCE_REVIEW, link: `/dashboard/bookings/${b.id}/clearance`, }, @@ -422,6 +439,7 @@ export class BookingLifecycleNotifierService { `The customer requested a change to the draft declaration on booking ${this.ref(b)}: ` + `"${note}". Send a corrected draft from the clearance page.`; this.inAppStaff(b, `Draft declaration change requested — ${this.ref(b)}`, msg, { + recipients: CLEARANCE_DESK, type: NotificationType.CLEARANCE_REVIEW, link: `/dashboard/bookings/${b.id}/clearance`, }); @@ -440,6 +458,7 @@ export class BookingLifecycleNotifierService { 'Payment slip uploaded', `Customer uploaded the ${label} payment slip for booking ${this.ref(b)}.`, { + recipients: CLEARANCE_DESK, type: NotificationType.PAYMENT_RECEIVED, link: `/dashboard/bookings/${b.id}/clearance`, }, diff --git a/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.ts index c576f5a43..198342cc8 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.ts @@ -1112,12 +1112,14 @@ export class BookingWagonCancellationService { private notifyStaff(booking: Booking, title: string, body: string): void { void this.inbox.notify({ - recipients: { allBackoffice: true }, + recipients: { permissionKeys: [FREIGHT_PERMS.bookings.getNotification] }, audience: NotificationAudience.BACKOFFICE, type: NotificationType.BOOKING_STATUS, title, body, - link: `/bookings/${booking.id}`, + // The portal path `/bookings/:id` used to be sent here, which 404s in the + // dashboard. The staff view of these lives on the queue page. + link: '/dashboard/wagon-cancellations', data: { bookingId: booking.id, reference: booking.reference }, }); } diff --git a/apps/edr-freight-api/src/modules/companies/company-notifier.service.ts b/apps/edr-freight-api/src/modules/companies/company-notifier.service.ts index 66f88e9f8..178ace492 100644 --- a/apps/edr-freight-api/src/modules/companies/company-notifier.service.ts +++ b/apps/edr-freight-api/src/modules/companies/company-notifier.service.ts @@ -11,6 +11,7 @@ import { Company, CompanyStatus } from "./entities/company.entity"; import { NotificationsService } from "../notifications/notifications.service"; import { NotificationInboxService } from "../notification-inbox/notification-inbox.service"; import { resolveCompanyNotifyPhone } from "../notifications/resolve-company-phone.util"; +import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry"; /** Account statuses that lock the customer out and therefore must be told to them. */ const PUNITIVE_STATUSES: readonly CompanyStatus[] = [ @@ -175,12 +176,9 @@ export class CompanyNotifierService { // ── Backoffice-facing: work has arrived back in the review queue ──────────── /** - * Persist + push an in-app item to every backoffice staff user, deep-linked to - * the customer's detail page. - * - * The recipient resolver has no role/permission targeting (see - * `notification-recipients.service.ts`) — `allBackoffice` is the narrowest - * selector available, so marketing is reached by notifying all staff. + * Persist + push an in-app item to the customer desk — staff holding + * `customers:get_notification` — deep-linked to the customer's detail page, + * which is itself gated on `customers:view`. */ private notifyStaff( company: Company, @@ -189,7 +187,7 @@ export class CompanyNotifierService { data: Record = {}, ): void { void this.inbox.notify({ - recipients: { allBackoffice: true }, + recipients: { permissionKeys: [FREIGHT_PERMS.customers.getNotification] }, audience: NotificationAudience.BACKOFFICE, type: NotificationType.REQUEST_SUBMITTED, title, diff --git a/apps/edr-freight-api/src/modules/contracts/contract-expiry.service.spec.ts b/apps/edr-freight-api/src/modules/contracts/contract-expiry.service.spec.ts index 0551cfc7a..27cfa5fa1 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-expiry.service.spec.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-expiry.service.spec.ts @@ -1,5 +1,6 @@ import { ContractExpiryService } from './contract-expiry.service'; import type { Contract } from './entities/contract.entity'; +import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; /** * The reminder must warn each customer once, ten days out, and must never let a @@ -60,4 +61,18 @@ describe('ContractExpiryService — expiry reminder', () => { inbox.notify.mockRejectedValue(new Error('inbox down')); await expect(service.remindExpiringContracts()).resolves.toBeUndefined(); }); + + // The sweep-failure alert is staff-facing. It used to go to every employee; + // it belongs to the people who would notice expired contracts still listed + // as active, i.e. the contract desk. + it('alerts the contract desk when the sweep itself fails', async () => { + repo.expireLapsedContracts.mockRejectedValue(new Error('deadlock')); + + await service.expireLapsedContracts(); + + expect(inbox.notify).toHaveBeenCalledTimes(1); + expect(inbox.notify.mock.calls[0][0].recipients).toEqual({ + permissionKeys: [FREIGHT_PERMS.contracts.getNotification], + }); + }); }); diff --git a/apps/edr-freight-api/src/modules/contracts/contract-expiry.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-expiry.service.ts index 1ef84c141..c570fbc0a 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-expiry.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-expiry.service.ts @@ -4,6 +4,7 @@ import { NotificationAudience, NotificationType } from '@edr/types'; import { NotificationInboxService } from '../notification-inbox/notification-inbox.service'; import { ContractsRepository } from './contracts.repository'; +import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; /** * How many days before a contract lapses the customer is reminded. Mirrored by @@ -79,7 +80,11 @@ export class ContractExpiryService { ); try { await this.inbox.notify({ - recipients: { allBackoffice: true }, + // The people who would notice expired contracts still listed as + // active are the ones working the contract desk. + recipients: { + permissionKeys: [FREIGHT_PERMS.contracts.getNotification], + }, audience: NotificationAudience.BACKOFFICE, type: NotificationType.GENERIC, title: 'Contract expiry sweep failed', diff --git a/apps/edr-freight-api/src/modules/contracts/contract-notifier.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-notifier.service.ts index 92a313569..e3c79a742 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-notifier.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-notifier.service.ts @@ -11,6 +11,16 @@ import { Contract } from './entities/contract.entity'; import { NotificationsService } from '../notifications/notifications.service'; import { NotificationInboxService } from '../notification-inbox/notification-inbox.service'; import { resolveCompanyNotifyPhone } from '../notifications/resolve-company-phone.util'; +import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; + +/** + * Clearance items are worked by the GL desks, which hold no intake keys — so + * they take their own selector rather than the contract desk's. Every override + * using this deep-links to a clearance or shipment-request page. + */ +const CLEARANCE_DESK = { + permissionKeys: [FREIGHT_PERMS.contracts.clearanceGetNotification], +}; /** * Customer + staff notifications for the contract lifecycle. Every customer @@ -86,7 +96,11 @@ export class ContractNotifierService { }); } - /** Persist + push an in-app item to every backoffice staff user. */ + /** + * Persist + push an in-app item to the contract desk — staff holding + * `contracts:get_notification`. Callers whose item belongs to a different + * desk override `recipients` (see {@link CLEARANCE_DESK}). + */ private inAppStaff( c: Contract, title: string, @@ -94,7 +108,7 @@ export class ContractNotifierService { overrides: Partial = {}, ): void { void this.inbox.notify({ - recipients: { allBackoffice: true }, + recipients: { permissionKeys: [FREIGHT_PERMS.contracts.getNotification] }, audience: NotificationAudience.BACKOFFICE, type: NotificationType.REQUEST_SUBMITTED, title, @@ -230,6 +244,7 @@ export class ContractNotifierService { `the customs declaration can be filed.${note ? ` Note: "${note}"` : ''}`; this.logger.log(`TRANSIT ASSIGNEE REQUESTED — ${c.reference}`); this.inAppStaff(c, `Transit assignee needed — ${c.reference}`, msg, { + recipients: CLEARANCE_DESK, type: NotificationType.CLEARANCE_REVIEW, link: `/dashboard/gl-djibouti/clearance/${c.id}`, }); @@ -248,6 +263,7 @@ export class ContractNotifierService { `The customs declaration can now be filed.`; this.logger.log(`TRANSIT ASSIGNEE ASSIGNED — ${c.reference}`); this.inAppStaff(c, `Transit assignee set — ${c.reference}`, msg, { + recipients: CLEARANCE_DESK, type: NotificationType.CLEARANCE_REVIEW, link: `/dashboard/contracts/clearance/${c.id}`, }); @@ -264,6 +280,7 @@ export class ContractNotifierService { `"${note}". Review and re-advise the amount on the clearance page.`; this.logger.log(`DUTY DISPUTED — ${c.reference}`); this.inAppStaff(c, `Duty disputed on ${c.reference}`, msg, { + recipients: CLEARANCE_DESK, type: NotificationType.CLEARANCE_REVIEW, link: `/dashboard/contracts/clearance/${c.id}`, }); @@ -321,6 +338,7 @@ export class ContractNotifierService { 'Clearance documents uploaded', `Customer uploaded clearance documents for contract ${this.ref(c)} — review them in the clearance queue.`, { + recipients: CLEARANCE_DESK, type: NotificationType.CLEARANCE_REVIEW, link: `/dashboard/contracts/clearance/${c.id}`, }, @@ -334,6 +352,7 @@ export class ContractNotifierService { 'Duty slip uploaded', `Customer uploaded the duty & tax payment slip for contract ${this.ref(c)}.`, { + recipients: CLEARANCE_DESK, type: NotificationType.PAYMENT_RECEIVED, link: `/dashboard/contracts/clearance/${c.id}`, }, @@ -347,6 +366,9 @@ export class ContractNotifierService { 'New shipment request', `Shipment request ${requestRef} was filed under contract ${this.ref(c)} and awaits GL review.`, { + // GL reviews these, and the shipment-requests page is gated on + // contracts:create_booking — a key only the GL Ethiopia preset holds. + recipients: CLEARANCE_DESK, link: `/dashboard/shipment-requests/${requestId}`, data: { contractId: c.id, requestId, reference: requestRef }, }, diff --git a/apps/edr-freight-api/src/modules/maintenance/maintenance-due-alert.spec.ts b/apps/edr-freight-api/src/modules/maintenance/maintenance-due-alert.spec.ts index 7aac5e863..5726123cf 100644 --- a/apps/edr-freight-api/src/modules/maintenance/maintenance-due-alert.spec.ts +++ b/apps/edr-freight-api/src/modules/maintenance/maintenance-due-alert.spec.ts @@ -1,6 +1,7 @@ import { NotificationAudience } from '@edr/types'; import { MaintenanceService } from './maintenance.service'; +import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; /** * The daily due-alert: a SCHEDULED item that crossed its km or date threshold @@ -37,6 +38,10 @@ describe('MaintenanceService.sendDueAlerts', () => { expect(notify).toHaveBeenCalledWith( expect.objectContaining({ audience: NotificationAudience.BACKOFFICE, + // The fleet desk, not every employee in the company. + recipients: { + permissionKeys: [FREIGHT_PERMS.maintenance.getNotification], + }, title: 'Maintenance due — ET-9875', body: expect.stringContaining('driven 50200 km (due at 50000 km)'), }), diff --git a/apps/edr-freight-api/src/modules/maintenance/maintenance.service.ts b/apps/edr-freight-api/src/modules/maintenance/maintenance.service.ts index dd9da55a8..54aab6e36 100644 --- a/apps/edr-freight-api/src/modules/maintenance/maintenance.service.ts +++ b/apps/edr-freight-api/src/modules/maintenance/maintenance.service.ts @@ -15,6 +15,7 @@ import { UpsertMaintenanceIntervalDto, } from './dto/create-maintenance.dto'; import { NotificationInboxService } from '../notification-inbox/notification-inbox.service'; +import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; @Injectable() export class MaintenanceService { @@ -53,7 +54,9 @@ export class MaintenanceService { ? `driven ${item.currentKm} km (due at ${item.nextDueKm} km)` : `due ${new Date(item.nextDueDate as Date).toLocaleDateString()}`; await this.inbox.notify({ - recipients: { allBackoffice: true }, + recipients: { + permissionKeys: [FREIGHT_PERMS.maintenance.getNotification], + }, audience: NotificationAudience.BACKOFFICE, type: NotificationType.GENERIC, title: `Maintenance due — ${item.plateNumber}`, diff --git a/apps/edr-freight-api/src/modules/notification-inbox/notification-recipients.service.ts b/apps/edr-freight-api/src/modules/notification-inbox/notification-recipients.service.ts index e3e711d19..d8e338dbf 100644 --- a/apps/edr-freight-api/src/modules/notification-inbox/notification-recipients.service.ts +++ b/apps/edr-freight-api/src/modules/notification-inbox/notification-recipients.service.ts @@ -14,7 +14,9 @@ import { ExternalProfileRepository } from "../companies/external-profile.reposit * - `companyProfileId` → resolved to its company, then to that company's users. * - `organizationId` → all current employees of the org (backoffice staff). * - `permissionKeys` → current employees (any org) holding any of these - * permission keys (e.g. department/role-scoped targeting). + * permission keys — how every staff-facing notification is targeted. There is + * deliberately no "all backoffice" selector: staff notifications belong to a + * desk, and the `:get_notification` keys name which one. */ @Injectable() export class NotificationRecipientsService { @@ -69,18 +71,6 @@ export class NotificationRecipientsService { } } - if (recipients.allBackoffice) { - try { - for (const uid of await this.backoffice.getAllCurrentEmployeeUserIds()) { - ids.add(uid); - } - } catch (err) { - this.logger.warn( - `Failed to resolve allBackoffice recipients: ${(err as Error).message}`, - ); - } - } - if (recipients.permissionKeys?.length) { try { for (const uid of await this.backoffice.getEmployeeUserIdsByPermission( diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/priority-rule-change-requests.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/priority-rule-change-requests.service.ts index a80221c68..1526769d3 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/priority-rule-change-requests.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/priority-rule-change-requests.service.ts @@ -22,6 +22,7 @@ import { PriorityRuleChangeStatus, } from '../entities/priority-rule-change-request.entity'; import { PriorityConfigsService } from './priority-configs.service'; +import { FREIGHT_PERMS } from '../../../seed/freight-permissions.registry'; /** Backoffice rule-engine page — where both queue and rules live. */ const RULES_LINK = '/dashboard/rules/priority-configs'; @@ -221,7 +222,9 @@ export class PriorityRuleChangeRequestsService { ): void { void this.inbox .notify({ - recipients: { allBackoffice: true }, + recipients: { + permissionKeys: [FREIGHT_PERMS.ruleEngine.getNotification], + }, audience: NotificationAudience.BACKOFFICE, type: NotificationType.REQUEST_SUBMITTED, title, diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/rate-change-requests.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/rate-change-requests.service.ts index 6dde40d7d..8cc97f343 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/rate-change-requests.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/rate-change-requests.service.ts @@ -19,6 +19,7 @@ import { } from '../entities/rate-change-request.entity'; import { Rate } from '../entities/rate.entity'; import { RatesService } from './rates.service'; +import { FREIGHT_PERMS } from '../../../seed/freight-permissions.registry'; /** Backoffice page where both the queue and the rates live. */ const RATES_LINK = '/dashboard/rules/rates'; @@ -234,7 +235,9 @@ export class RateChangeRequestsService { private notifyTeam(title: string, body: string, request: RateChangeRequest): void { void this.inbox .notify({ - recipients: { allBackoffice: true }, + recipients: { + permissionKeys: [FREIGHT_PERMS.ruleEngine.getNotification], + }, audience: NotificationAudience.BACKOFFICE, type: NotificationType.REQUEST_SUBMITTED, title, diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-fee.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-fee.service.ts index aa30b31e7..576933c01 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-fee.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-fee.service.ts @@ -8,6 +8,7 @@ import { NotificationInboxService } from '../notification-inbox/notification-inb import { CreateFeeRuleDto, UpdateFeeRuleDto } from './dto/fee-rule.dto'; import { FeeRuleBasis, FeeRuleType, WarehouseFeeRule, WarehouseFeeTier } from './entities/warehouse-fee-rule.entity'; import { WarehouseFeeRuleRepository } from './warehouse-fee-rule.repository'; +import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; interface ItemAttributes { arrivedAt: Date | null; @@ -178,7 +179,11 @@ export class WarehouseFeeService { const total = alerts.reduce((sum, r) => sum + r.accruedAmount, 0); try { await this.inbox.notify({ - recipients: { allBackoffice: true }, + recipients: { + permissionKeys: [ + FREIGHT_PERMS.warehouseFeeInvoices.getNotification, + ], + }, audience: NotificationAudience.BACKOFFICE, type: NotificationType.BOOKING_STATUS, title: 'Warehouse fee accruals need attention', diff --git a/apps/edr-freight-api/src/seed/freight-notification-permissions.seeder.ts b/apps/edr-freight-api/src/seed/freight-notification-permissions.seeder.ts new file mode 100644 index 000000000..0bea4ad95 --- /dev/null +++ b/apps/edr-freight-api/src/seed/freight-notification-permissions.seeder.ts @@ -0,0 +1,103 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { Application, Permission } from '@tria-plc/iamapi-common'; +import { DataSource } from 'typeorm'; + +import { + NOTIFICATION_PERMISSIONS, + NOTIFICATION_PERMISSION_ANCHORS, +} from './freight-permissions.registry'; + +const EDR_FREIGHT_APP_KEY = 'edr_freight_app'; + +/** + * The three tables a permission grant can arrive on, with the column naming + * the grantee. These are compile-time constants — the only values interpolated + * into the SQL below. + */ +const CARRIERS = [ + ['iam.position_permissions', 'position_id'], + ['iam.position_type_permissions', 'position_type_id'], + ['iam.role_permissions', 'role_id'], +] as const; + +/** + * Backfills the `:get_notification` keys. + * + * Those keys are new, so on deploy nobody holds them and every staff + * notification would resolve to zero recipients — silently, because + * `NotificationInboxService.notify` logs an empty resolve at debug level. This + * grants each new key to whoever already holds that desk's anchor key (the + * permission gating the page the notification links to), across all three + * carriers. + * + * Deliberately NOT gated behind SEED_EDR_ORG: that flag is off everywhere + * except e2e/integration, and this has to run wherever the notifications do. + * For the same reason it inserts the permission rows itself rather than + * trusting EdrOrgSeeder. Idempotent — safe on every boot, and safe to leave in + * place permanently. + */ +@Injectable() +export class FreightNotificationPermissionsSeeder { + private readonly logger = new Logger(FreightNotificationPermissionsSeeder.name); + + constructor(private readonly dataSource: DataSource) {} + + async run(): Promise { + const application = await this.dataSource + .getRepository(Application) + .findOne({ where: { key: EDR_FREIGHT_APP_KEY }, select: { id: true } }); + + if (!application?.id) { + this.logger.warn( + `Application '${EDR_FREIGHT_APP_KEY}' not found — notification permission backfill skipped`, + ); + return; + } + + // Ids are left to the column default and never sent: iam.permissions has + // two unique columns (PK id, UQ key) and ON CONFLICT can only target one, + // so a hand-minted id that some retired key already owns would slip past + // ON CONFLICT (key) and die on the PK. Same reasoning as EdrOrgSeeder. + await this.dataSource + .getRepository(Permission) + .createQueryBuilder() + .insert() + .values( + NOTIFICATION_PERMISSIONS.map((permission) => ({ + key: permission.key, + name: { ...permission.name }, + applicationId: application.id as string, + })), + ) + .orIgnore() + .execute(); + + let granted = 0; + for (const [key, anchors] of Object.entries( + NOTIFICATION_PERMISSION_ANCHORS, + )) { + for (const [table, granteeColumn] of CARRIERS) { + const result: unknown = await this.dataSource.query( + `INSERT INTO ${table} (${granteeColumn}, permission_id) + SELECT DISTINCT g.${granteeColumn}, target.id + FROM ${table} g + JOIN iam.permissions anchor + ON anchor.id = g.permission_id AND anchor.key = ANY($1) + JOIN iam.permissions target ON target.key = $2 + WHERE NOT EXISTS ( + SELECT 1 + FROM ${table} existing + WHERE existing.${granteeColumn} = g.${granteeColumn} + AND existing.permission_id = target.id)`, + [anchors, key], + ); + // node-postgres returns [rows, rowCount] for a bare INSERT. + granted += (Array.isArray(result) ? (result[1] as number) : 0) ?? 0; + } + } + + this.logger.log( + `Ensured ${NOTIFICATION_PERMISSIONS.length} notification permissions, backfilled ${granted} grant(s)`, + ); + } +} diff --git a/apps/edr-freight-api/src/seed/freight-permissions.registry.spec.ts b/apps/edr-freight-api/src/seed/freight-permissions.registry.spec.ts index b69dcff71..d134a7986 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.spec.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.spec.ts @@ -1,5 +1,9 @@ import { EDR_FREIGHT_PERMISSIONS } from './edr-freight.seed'; -import { readTwinOf } from './freight-permissions.registry'; +import { + NOTIFICATION_PERMISSIONS, + NOTIFICATION_PERMISSION_ANCHORS, + readTwinOf, +} from './freight-permissions.registry'; describe('EDR_FREIGHT_PERMISSIONS', () => { // The seeder inserts the whole catalog in one ON CONFLICT (key) DO UPDATE @@ -48,3 +52,36 @@ describe('EDR_FREIGHT_PERMISSIONS', () => { } }); }); + +describe('NOTIFICATION_PERMISSION_ANCHORS', () => { + const catalog = new Set(EDR_FREIGHT_PERMISSIONS.map((p) => p.key)); + + // FreightNotificationPermissionsSeeder backfills each get_notification key + // onto whoever holds its anchor. A typo'd anchor matches no permission row, + // so the key is granted to nobody and every notification on that desk + // silently resolves to zero recipients — notify() logs that at debug level. + it('anchors every notification key on keys that exist', () => { + const missing = Object.values(NOTIFICATION_PERMISSION_ANCHORS) + .flat() + .filter((key) => !catalog.has(key)); + + expect(missing).toEqual([]); + }); + + it('seeds every notification key into the catalog', () => { + const missing = NOTIFICATION_PERMISSIONS.map((p) => p.key).filter( + (key) => !catalog.has(key), + ); + + expect(missing).toEqual([]); + }); + + it('gives every notification key an anchor to backfill from', () => { + const anchored = new Set(Object.keys(NOTIFICATION_PERMISSION_ANCHORS)); + const unanchored = NOTIFICATION_PERMISSIONS.map((p) => p.key).filter( + (key) => !anchored.has(key), + ); + + expect(unanchored).toEqual([]); + }); +}); diff --git a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts index cbe2fcf11..5d249f2be 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts @@ -1335,6 +1335,54 @@ export const AUDIENCE_GAP_PERMISSIONS: FreightPermissionSeed[] = [ ), ]; +// O. Notification recipient selectors. NOT route guards — these are never +// passed to FreightPermissionGuard/assertFreightPermission and never appear in +// the frontend's RequirePermission. They exist so ops can tune who gets pinged +// WITHOUT changing who can open the page. Before them every staff notification +// used the `allBackoffice` selector, i.e. every current employee in every org. +export const NOTIFICATION_PERMISSIONS: FreightPermissionSeed[] = [ + perm( + "f3a00001-0001-4000-8000-000000000001", + "edr_freight_app:bookings:get_notification", + "Receive booking desk notifications", + ), + perm( + "f3a00001-0001-4000-8000-000000000002", + "edr_freight_app:bookings:clearance_get_notification", + "Receive booking clearance notifications", + ), + perm( + "f3a00001-0001-4000-8000-000000000003", + "edr_freight_app:contracts:get_notification", + "Receive contract desk notifications", + ), + perm( + "f3a00001-0001-4000-8000-000000000004", + "edr_freight_app:contracts:clearance_get_notification", + "Receive contract clearance notifications", + ), + perm( + "f3a00001-0001-4000-8000-000000000005", + "edr_freight_app:customers:get_notification", + "Receive customer desk notifications", + ), + perm( + "f3a00001-0001-4000-8000-000000000006", + "edr_freight_app:maintenance:get_notification", + "Receive maintenance due notifications", + ), + perm( + "f3a00001-0001-4000-8000-000000000007", + "edr_freight_app:rule_engine:get_notification", + "Receive rule-change approval notifications", + ), + perm( + "f3a00001-0001-4000-8000-000000000008", + "edr_freight_app:warehouse_fee_invoices:get_notification", + "Receive warehouse fee accrual notifications", + ), +]; + export const ADVANCED_BACKOFFICE_PERMISSIONS: FreightPermissionSeed[] = [ ...CUSTOMER_PERMISSIONS, ...FINANCE_PERMISSIONS, @@ -1348,6 +1396,7 @@ export const ADVANCED_BACKOFFICE_PERMISSIONS: FreightPermissionSeed[] = [ ...STAFF_IAM_PERMISSIONS, ...AUDIENCE_GAP_PERMISSIONS, ...GRANULAR_SPLIT_PERMISSIONS, + ...NOTIFICATION_PERMISSIONS, ]; export const BOOKING_RULE_ENGINE_PERMISSIONS = [ @@ -1439,6 +1488,10 @@ export const FREIGHT_PERMS = { wagonCancellationVoid: "edr_freight_app:bookings:wagon_cancellation_void", wagonCancellationRebook: "edr_freight_app:bookings:wagon_cancellation_rebook", + // Notification selectors, not route guards — see NOTIFICATION_PERMISSIONS. + getNotification: "edr_freight_app:bookings:get_notification", + clearanceGetNotification: + "edr_freight_app:bookings:clearance_get_notification", }, contracts: { view: "edr_freight_app:contracts:view", @@ -1475,6 +1528,10 @@ export const FREIGHT_PERMS = { editDocument: "edr_freight_app:contracts:edit_document", finalInvoiceRaise: "edr_freight_app:contracts:final_invoice_raise", finalInvoiceConfirm: "edr_freight_app:contracts:final_invoice_confirm", + // Notification selectors, not route guards — see NOTIFICATION_PERMISSIONS. + getNotification: "edr_freight_app:contracts:get_notification", + clearanceGetNotification: + "edr_freight_app:contracts:clearance_get_notification", }, trainScheduling: { view: "edr_freight_app:train_scheduling:view", @@ -1503,6 +1560,10 @@ export const FREIGHT_PERMS = { `edr_freight_app:rule_engine:${slugToResourceKey(slug)}:delete`, approve: (slug: RuleEngineApprovableSlug) => `edr_freight_app:rule_engine:${slugToResourceKey(slug)}:approve`, + // Notification selector, not a route guard — see NOTIFICATION_PERMISSIONS. + // One key for the whole rules desk: every preset grants the rule-engine + // view keys as a block, and both producers link to /dashboard/rules/*. + getNotification: "edr_freight_app:rule_engine:get_notification", }, allocation: { manage: "edr_freight_app:allocation:manage", @@ -1514,6 +1575,8 @@ export const FREIGHT_PERMS = { deactivate: "edr_freight_app:customers:deactivate", verify: "edr_freight_app:customers:verify", resetPassword: "edr_freight_app:customers:reset-password", + // Notification selector, not a route guard — see NOTIFICATION_PERMISSIONS. + getNotification: "edr_freight_app:customers:get_notification", }, payments: { view: "edr_freight_app:payments:view", @@ -1637,6 +1700,8 @@ export const FREIGHT_PERMS = { create: "edr_freight_app:maintenance:create", update: "edr_freight_app:maintenance:update", delete: "edr_freight_app:maintenance:delete", + // Notification selector, not a route guard — see NOTIFICATION_PERMISSIONS. + getNotification: "edr_freight_app:maintenance:get_notification", }, fleetReports: { view: "edr_freight_app:fleet_reports:view", @@ -1706,6 +1771,8 @@ export const FREIGHT_PERMS = { generate: "edr_freight_app:warehouse_fee_invoices:generate", cancel: "edr_freight_app:warehouse_fee_invoices:cancel", pay: "edr_freight_app:warehouse_fee_invoices:pay", + // Notification selector, not a route guard — see NOTIFICATION_PERMISSIONS. + getNotification: "edr_freight_app:warehouse_fee_invoices:get_notification", }, settings: { fileUpload: { @@ -1810,6 +1877,40 @@ export const FREIGHT_PERMS = { }, } as const; +/** + * Backfill sources for the `:get_notification` keys: a new key is + * granted to whoever already holds ANY of its anchors. The anchor is the key + * that gates the page the notification deep-links to — if you cannot open the + * page, you were never the intended recipient. The rule-engine desk has no page + * of its own, so it anchors on the keys that let you ACT on a filed change. + * + * Consumed only by FreightNotificationPermissionsSeeder. Keeping it here means + * the registry spec can assert every anchor still exists in the catalog — a + * typo'd anchor backfills nobody, silently. + */ +export const NOTIFICATION_PERMISSION_ANCHORS: Record = { + [FREIGHT_PERMS.bookings.getNotification]: [FREIGHT_PERMS.bookings.view], + [FREIGHT_PERMS.bookings.clearanceGetNotification]: [ + FREIGHT_PERMS.bookings.clearanceView, + ], + [FREIGHT_PERMS.contracts.getNotification]: [FREIGHT_PERMS.contracts.view], + [FREIGHT_PERMS.contracts.clearanceGetNotification]: [ + FREIGHT_PERMS.contracts.clearanceReview, + FREIGHT_PERMS.contracts.clearanceEtActions, + FREIGHT_PERMS.contracts.clearanceDjActions, + FREIGHT_PERMS.contracts.opsClearanceReview, + ], + [FREIGHT_PERMS.customers.getNotification]: [FREIGHT_PERMS.customers.view], + [FREIGHT_PERMS.maintenance.getNotification]: [FREIGHT_PERMS.maintenance.view], + [FREIGHT_PERMS.ruleEngine.getNotification]: [ + FREIGHT_PERMS.ruleEngine.approve("rates"), + FREIGHT_PERMS.ruleEngine.update("priority-configs"), + ], + [FREIGHT_PERMS.warehouseFeeInvoices.getNotification]: [ + FREIGHT_PERMS.warehouseFeeInvoices.view, + ], +}; + /** Both arms of a freight-type-split permission (for one-of route guards). */ export const bothFreightTypes = (p: { bulk: string; @@ -1867,6 +1968,19 @@ const STAFF_DASHBOARD_KEYS: string[] = [ FREIGHT_PERMS.reports.view, ]; +// Notification desks — recipient selectors, not access. A preset gets a desk +// key only where it actually works that queue, which is why the GL presets take +// the clearance pair and nothing else: they hold no bookings:view and no intake +// keys, so intake pings would only be noise they cannot act on. +const BOOKING_DESK_NOTIFICATION_KEYS: string[] = [ + FREIGHT_PERMS.bookings.getNotification, + FREIGHT_PERMS.contracts.getNotification, +]; +const CLEARANCE_DESK_NOTIFICATION_KEYS: string[] = [ + FREIGHT_PERMS.bookings.clearanceGetNotification, + FREIGHT_PERMS.contracts.clearanceGetNotification, +]; + export const ROLE_PERMISSION_PRESETS = { // Marketing / line staff: drives a booking from intake through line-staff // approval and contract generation/signing — i.e. until the contract is ready @@ -1889,6 +2003,8 @@ export const ROLE_PERMISSION_PRESETS = { FREIGHT_PERMS.contracts.approveLineStaff, FREIGHT_PERMS.contracts.editDocument, ...allRuleEngineViewKeys(), + ...BOOKING_DESK_NOTIFICATION_KEYS, + FREIGHT_PERMS.ruleEngine.getNotification, ], // Operations Officer: train scheduling + wagon allocation + transit/complete // + fleet management (wagons, trains, locomotives, routes, containers, cargo). @@ -1921,6 +2037,11 @@ export const ROLE_PERMISSION_PRESETS = { FREIGHT_PERMS.bookings.reviewDocuments, FREIGHT_PERMS.bookings.finalizeClearance, ...allRuleEngineViewKeys(), + ...BOOKING_DESK_NOTIFICATION_KEYS, + // They run Path A clearance review from the booking detail, so the booking + // clearance desk is theirs too — but not the contract one, which is GL's. + FREIGHT_PERMS.bookings.clearanceGetNotification, + FREIGHT_PERMS.ruleEngine.getNotification, ], director: [ ...STAFF_DASHBOARD_KEYS, @@ -1932,6 +2053,7 @@ export const ROLE_PERMISSION_PRESETS = { FREIGHT_PERMS.contracts.approveDirector, FREIGHT_PERMS.contracts.generateContract, ...allRuleEngineViewKeys(), + ...BOOKING_DESK_NOTIFICATION_KEYS, ], ceo: [ ...STAFF_DASHBOARD_KEYS, @@ -1941,6 +2063,7 @@ export const ROLE_PERMISSION_PRESETS = { FREIGHT_PERMS.contracts.view, FREIGHT_PERMS.contracts.approveCeo, ...allRuleEngineViewKeys(), + ...BOOKING_DESK_NOTIFICATION_KEYS, ], finance: [ ...STAFF_DASHBOARD_KEYS, @@ -1972,6 +2095,7 @@ export const ROLE_PERMISSION_PRESETS = { FREIGHT_PERMS.bookings.uploadClearanceOutput, FREIGHT_PERMS.bookings.finalizeClearance, FREIGHT_PERMS.bookings.operations, + ...CLEARANCE_DESK_NOTIFICATION_KEYS, ], // GL Djibouti (edr_gl_djibouti): DO/RO collection, gatepass, loading milestones, // damage reports. Read-only on the contract; no booking creation. @@ -1983,6 +2107,7 @@ export const ROLE_PERMISSION_PRESETS = { FREIGHT_PERMS.bookings.clearanceView, FREIGHT_PERMS.bookings.uploadClearanceOutput, FREIGHT_PERMS.bookings.operations, + ...CLEARANCE_DESK_NOTIFICATION_KEYS, ], // Marketing handles intake through contract (same as line staff here) and, // for non-customs bookings, reviews/finalizes the customer's clearance @@ -2012,6 +2137,7 @@ export const ROLE_PERMISSION_PRESETS = { ...bothFreightTypes(FREIGHT_PERMS.contracts.signStaff), FREIGHT_PERMS.contracts.suspend, FREIGHT_PERMS.contracts.editDocument, + ...BOOKING_DESK_NOTIFICATION_KEYS, ], orgManager: [...BOOKING_RULE_ENGINE_PERMISSION_KEYS], } as const; @@ -2038,6 +2164,8 @@ export const POSITION_PERMISSION_PRESETS = { FREIGHT_PERMS.customers.view, FREIGHT_PERMS.customers.verify, FREIGHT_PERMS.customers.deactivate, + // …and therefore the profile-review pings company-notifier emits. + FREIGHT_PERMS.customers.getNotification, // The chief also owns the customer-facing support inbox. FREIGHT_PERMS.support.agentView, FREIGHT_PERMS.support.agentSend, diff --git a/packages/types/src/freight/notifications.ts b/packages/types/src/freight/notifications.ts index e3ca5b219..d51006c7f 100644 --- a/packages/types/src/freight/notifications.ts +++ b/packages/types/src/freight/notifications.ts @@ -90,13 +90,16 @@ export interface NotificationRecipients { companyProfileId?: string; /** Backoffice: all current employees of this organization. */ organizationId?: string; - /** Backoffice: every current employee across all organizations. */ - allBackoffice?: boolean; /** * Backoffice: current employees (any org) who hold ANY of these permission - * keys — e.g. notify only marketing, not every employee. Super/org admins - * are not implicitly included; add `allBackoffice`/explicit userIds too if - * admins should also see it. + * keys. Resolution mirrors the request-time guard `hasFreightPermission` — + * IAM role grants, direct position grants, position TYPE grants, delegated + * positions, and the `super_admin` bypass. `organization_admin` is NOT + * implicitly included (it only bypasses approval steps, not permission + * checks); grant it a key explicitly if it should be notified. + * + * Prefer the dedicated `:get_notification` keys over reusing a domain + * key: they let ops tune who gets pinged without touching who has access. */ permissionKeys?: string[]; } From f330f486e53909f09dd1c7abe8ed28c3d94f7a40 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Fri, 7 Aug 2026 12:53:19 +0000 Subject: [PATCH 005/276] feat: add notification to intercity user --- .../booking-window.service.ts | 150 +++++++++++++++++- .../intercity-corridor-notify.spec.ts | 110 +++++++++++++ 2 files changed, 259 insertions(+), 1 deletion(-) create mode 100644 apps/edr-freight-api/src/modules/train-scheduling/intercity-corridor-notify.spec.ts diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts index 1a3baa6b4..9eb6c0bf0 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts @@ -313,6 +313,8 @@ export class BookingWindowService implements OnModuleInit { // Fire-and-forget: a slow SMS/email gateway must not stall the tick loop // (the `ticking` guard would otherwise delay every schedule's transition). void this.notifyWindowOpened(schedule); + // Intercity rides along whatever train passes, export included. + void this.notifyIntercityCorridorScheduled(schedule); this.logger.log(`Export booking window opened for schedule ${schedule.id}`); return true; } @@ -365,7 +367,10 @@ export class BookingWindowService implements OnModuleInit { } // Only announce the first opening of the day; reopen cycles don't re-notify. // Fire-and-forget so a slow SMS/email gateway never stalls the tick loop. - if (schedule.bookingCycleNo === 1) void this.notifyWindowOpened(schedule); + if (schedule.bookingCycleNo === 1) { + void this.notifyWindowOpened(schedule); + void this.notifyIntercityCorridorScheduled(schedule); + } this.logger.log( `[WINDOW] ${schedule.id} PRE_WINDOW→OPEN — booking window opened ` + `(cycle ${schedule.bookingCycleNo})`, @@ -710,6 +715,149 @@ export class BookingWindowService implements OnModuleInit { } } + /** + * SMS + email + inbox the owner of every waiting intercity booking whose + * corridor lies on this schedule's route. + * + * Intercity (DOMESTIC) bookings carry no date — the customer books a corridor + * and the cargo waits in a pool until staff ride it along a passing + * import/export train (see IntercityService). Until now that wait was silent: + * `notifyWindowOpened` only reaches companies holding an ACTIVE contract whose + * `contract_routes` match the train's exact origin→destination, and an + * intercity booking is neither contracted nor necessarily end-to-end. + * + * Corridor match mirrors `IntercityService.corridorOnRoute` exactly — both + * yards on the route with origin strictly before destination, falling back to + * the train's own origin/destination when the route has fewer than two + * milestones — so nobody is told about a train they can never be placed on. + */ + private async notifyIntercityCorridorScheduled( + schedule: TrainSchedule, + ): Promise { + try { + const rows: Array<{ + bookingId: string; + companyId: string; + phone: string | null; + email: string | null; + corridor: string; + }> = await this.dataSource.query( + // `stops` is the schedule's stop list, with the two-stop + // origin→destination pseudo-route as the legacy fallback — the same + // shape IntercityService.milestoneSequenceOf builds in TypeScript. + `WITH ms AS ( + SELECT yard_id, sequence_no + FROM freight.route_milestones + WHERE route_id = $1 AND deleted_at IS NULL + ), + stops AS ( + SELECT yard_id, sequence_no FROM ms WHERE (SELECT count(*) FROM ms) >= 2 + UNION ALL + SELECT v.yard_id, v.seq + FROM (VALUES ($2::uuid, 1), ($3::uuid, 2)) AS v(yard_id, seq) + WHERE (SELECT count(*) FROM ms) < 2 + ) + SELECT DISTINCT + b.id AS "bookingId", + b.company_id AS "companyId", + ${companyNotifyPhoneExpr('co')} AS phone, + COALESCE(co.email, co.general_manager_email) AS email, + COALESCE(oy.label, oy.code) || ' to ' || + COALESCE(dy.label, dy.code) AS corridor + FROM freight.bookings b + JOIN stops o ON o.yard_id = b.origin_yard_id + JOIN stops d ON d.yard_id = b.destination_yard_id + AND d.sequence_no > o.sequence_no + JOIN freight.companies co ON co.id = b.company_id AND co.deleted_at IS NULL + JOIN freight.yards oy ON oy.id = b.origin_yard_id + JOIN freight.yards dy ON dy.id = b.destination_yard_id + ${primaryContactUserJoin('co')} + WHERE b.deleted_at IS NULL + AND b.trade_direction = 'DOMESTIC' + AND b.train_schedule_id IS NULL + -- Same waiting pool IntercityService.findWaitingIntercityBookings + -- draws candidates from: commercial paid/executed, government approved. + AND ((b.is_government = false AND b.status IN ('FULLY_EXECUTED', 'PAID')) + OR (b.is_government = true AND b.status = 'APPROVED')) + -- Once per booking, not once per train. A booking can sit in the + -- pool for weeks while several trains open a window on its corridor, + -- and "trains run your corridor, you are queued" is the same message + -- every time. The inbox row written below is the marker. + -- ponytail: unindexed jsonb probe over freight.notifications; add a + -- partial index on (data->>'intercityCorridorBookingId') if the + -- table grows enough for this to show up in the tick loop. + AND NOT EXISTS ( + SELECT 1 FROM freight.notifications n + WHERE n.data->>'intercityCorridorBookingId' = b.id::text)`, + [schedule.routeId, schedule.originStationId, schedule.destinationStationId], + ); + if (!rows.length) return; + + const depart = schedule.scheduledDepartureDate.toLocaleDateString('en-GB', { + timeZone: BATCH_TIMEZONE, + }); + const msgFor = (corridors: string[]) => + `A train is scheduled on your intercity corridor ${corridors.join(', ')}, ` + + `departing ${depart}. EDR will confirm once your cargo is placed on a train.`; + + // One inbox item per booking (its `data` is the once-per-booking marker + // the query above reads), but one SMS/email per company — a customer with + // three waiting bookings gets one message naming all three corridors. + const byCompany = new Map< + string, + { phone: string | null; email: string | null; corridors: string[] } + >(); + for (const row of rows) { + const entry = byCompany.get(row.companyId) ?? { + phone: row.phone, + email: row.email, + corridors: [], + }; + if (!entry.corridors.includes(row.corridor)) entry.corridors.push(row.corridor); + byCompany.set(row.companyId, entry); + + await this.inbox.notify({ + recipients: { companyId: row.companyId }, + audience: NotificationAudience.PORTAL, + type: NotificationType.SCHEDULE_UPDATE, + title: 'Train scheduled on your corridor', + body: msgFor([row.corridor]), + link: `/bookings/${row.bookingId}`, + data: { + intercityCorridorBookingId: row.bookingId, + trainScheduleId: schedule.id, + }, + }); + } + + for (const [companyId, entry] of byCompany) { + const msg = msgFor(entry.corridors); + if (entry.phone) { + await this.notifications + .directSend('sms', entry.phone, msg) + .catch((e) => + this.logger.warn(`Intercity corridor SMS failed: ${(e as Error).message}`), + ); + } + if (entry.email) { + await this.notifications + .directSend('email', entry.email, msg) + .catch((e) => + this.logger.warn(`Intercity corridor email failed: ${(e as Error).message}`), + ); + } + this.logger.log( + `Notified company ${companyId} of ${entry.corridors.length} intercity ` + + `corridor(s) served by schedule ${schedule.id}`, + ); + } + } catch (err) { + this.logger.warn( + `notifyIntercityCorridorScheduled failed for ${schedule.id}: ${(err as Error).message}`, + ); + } + } + private async setPhase( schedule: TrainSchedule, patch: Partial< diff --git a/apps/edr-freight-api/src/modules/train-scheduling/intercity-corridor-notify.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/intercity-corridor-notify.spec.ts new file mode 100644 index 000000000..3b6648c19 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/intercity-corridor-notify.spec.ts @@ -0,0 +1,110 @@ +import { BookingWindowService } from './booking-window.service'; +import type { TrainSchedule } from '../train-schedules/entities/train-schedule.entity'; + +/** + * Intercity corridor announcement: when a train's booking window opens, every + * customer with a waiting intercity booking on that corridor is told over SMS, + * email and the portal inbox. + * + * The corridor SQL itself is EXPLAIN-validated against the dev database; what + * this covers is the fan-out shape around it — one inbox row per booking + * (that row's `data` is the once-per-booking marker the query dedupes on) but + * one SMS/email per company, naming every corridor at once. + */ +describe('BookingWindowService — intercity corridor announcement', () => { + const schedule = { + id: 'sched-1', + routeId: 'route-1', + originStationId: 'yard-o', + destinationStationId: 'yard-d', + scheduledDepartureDate: new Date('2026-08-01T06:00:00.000Z'), + } as unknown as TrainSchedule; + + const build = (rows: unknown[]) => { + const query = jest.fn().mockResolvedValue(rows); + const directSend = jest.fn().mockResolvedValue(undefined); + const notify = jest.fn().mockResolvedValue(undefined); + const service = new BookingWindowService( + { query, getRepository: () => ({ update: jest.fn() }) } as never, + { findById: jest.fn(), findAll: jest.fn() } as never, + {} as never, + {} as never, + { directSend } as never, + { notify } as never, + { emitPhase: jest.fn() } as never, + ); + const run = (): Promise => + ( + service as unknown as { + notifyIntercityCorridorScheduled: (s: TrainSchedule) => Promise; + } + ).notifyIntercityCorridorScheduled(schedule); + return { run, query, directSend, notify }; + }; + + it('sends one inbox item per booking and one SMS/email per company', async () => { + const { run, directSend, notify } = build([ + { + bookingId: 'bk-1', + companyId: 'co-1', + phone: '+251900000001', + email: 'ops@co1.example', + corridor: 'Dire Dawa to Adama', + }, + { + bookingId: 'bk-2', + companyId: 'co-1', + phone: '+251900000001', + email: 'ops@co1.example', + corridor: 'Adama to Mojo', + }, + ]); + + await run(); + + // Per booking: the marker keeps the next train on this corridor from + // re-announcing the same thing to the same booking. + expect(notify).toHaveBeenCalledTimes(2); + expect(notify.mock.calls.map((c) => c[0].data.intercityCorridorBookingId)).toEqual([ + 'bk-1', + 'bk-2', + ]); + expect(notify.mock.calls[0][0].recipients).toEqual({ companyId: 'co-1' }); + expect(notify.mock.calls[0][0].link).toBe('/bookings/bk-1'); + + // Per company: two bookings, one SMS and one email, both corridors named. + expect(directSend).toHaveBeenCalledTimes(2); + const [smsChannel, smsTo, smsBody] = directSend.mock.calls[0]; + expect([smsChannel, smsTo]).toEqual(['sms', '+251900000001']); + expect(smsBody).toContain('Dire Dawa to Adama, Adama to Mojo'); + expect(smsBody).toContain('01/08/2026'); + expect(directSend.mock.calls[1][0]).toBe('email'); + }); + + it('sends nothing when no waiting booking rides this corridor', async () => { + const { run, directSend, notify } = build([]); + + await run(); + + expect(notify).not.toHaveBeenCalled(); + expect(directSend).not.toHaveBeenCalled(); + }); + + it('skips the channels a company has no contact for', async () => { + const { run, directSend, notify } = build([ + { + bookingId: 'bk-3', + companyId: 'co-2', + phone: null, + email: 'ops@co2.example', + corridor: 'Dire Dawa to Adama', + }, + ]); + + await run(); + + expect(notify).toHaveBeenCalledTimes(1); + expect(directSend).toHaveBeenCalledTimes(1); + expect(directSend.mock.calls[0][0]).toBe('email'); + }); +}); From c9bb105e9420ec68b2d2a86058646a13f55b631e Mon Sep 17 00:00:00 2001 From: Nathnael Date: Fri, 7 Aug 2026 12:54:00 +0000 Subject: [PATCH 006/276] feat: add nationality indicator to the customer --- .../src/components/customers/badges.tsx | 26 +++++++++++++++++++ .../src/components/customers/index.ts | 1 + .../pages/customers/CustomerDetailPage.tsx | 8 ++++++ .../src/pages/customers/CustomersPage.tsx | 2 ++ .../backoffice/src/types/customer.ts | 4 +++ 5 files changed, 41 insertions(+) diff --git a/apps/edr-freight-web/backoffice/src/components/customers/badges.tsx b/apps/edr-freight-web/backoffice/src/components/customers/badges.tsx index 046493b0f..edcc4a517 100644 --- a/apps/edr-freight-web/backoffice/src/components/customers/badges.tsx +++ b/apps/edr-freight-web/backoffice/src/components/customers/badges.tsx @@ -16,6 +16,7 @@ import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions"; import { api } from "@/services/api"; import type { + CompanyNationality, CompanyProfile, CompanyStatus, CompanyType, @@ -88,6 +89,31 @@ export function CompanyTypeBadge({ type }: { type: CompanyType }) { ); } +const NATIONALITY_COLOR: Record = { + ethiopian: "edr-green", + foreign: "blue", +}; + +export function CompanyNationalityBadge({ + nationality, +}: { + nationality?: CompanyNationality | null; +}) { + if (!nationality) return null; + return ( + + {humanize(nationality)} + + ); +} + /** * Profile chips for a company row: one chip per role (Importer / Exporter / …) * carrying its reference code. Caps at three (a company has at most three diff --git a/apps/edr-freight-web/backoffice/src/components/customers/index.ts b/apps/edr-freight-web/backoffice/src/components/customers/index.ts index 1ccd29f41..81f25fcb2 100644 --- a/apps/edr-freight-web/backoffice/src/components/customers/index.ts +++ b/apps/edr-freight-web/backoffice/src/components/customers/index.ts @@ -1,5 +1,6 @@ export { BookingStatusBadge, + CompanyNationalityBadge, CompanyStatusBadge, CompanyTypeBadge, InvoiceStatusBadge, diff --git a/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx index a9e56a6d8..f31ce02e9 100644 --- a/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx @@ -812,6 +812,14 @@ export default function CustomerDetailPage() { } /> + diff --git a/apps/edr-freight-web/backoffice/src/pages/customers/CustomersPage.tsx b/apps/edr-freight-web/backoffice/src/pages/customers/CustomersPage.tsx index 34f90ffff..815615a1b 100644 --- a/apps/edr-freight-web/backoffice/src/pages/customers/CustomersPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/customers/CustomersPage.tsx @@ -31,6 +31,7 @@ import { useMemo, useState } from "react"; import { useNavigate } from "react-router-dom"; import { + CompanyNationalityBadge, CompanyStatusBadge, ProfileChips, formatDate, @@ -149,6 +150,7 @@ export default function CustomersPage() { {c.name} + TIN {c.tin} diff --git a/apps/edr-freight-web/backoffice/src/types/customer.ts b/apps/edr-freight-web/backoffice/src/types/customer.ts index ed77307f9..cd2d67842 100644 --- a/apps/edr-freight-web/backoffice/src/types/customer.ts +++ b/apps/edr-freight-web/backoffice/src/types/customer.ts @@ -21,6 +21,9 @@ export type CompanyStatus = "active" | "pending" | "suspended" | "blacklisted"; /** Mirrors backend `CompanyKind` — commercial customer vs. government entity. */ export type CompanyKind = "commercial" | "government"; +/** Mirrors backend `CompanyNationality`. */ +export type CompanyNationality = "ethiopian" | "foreign"; + /** Mirrors backend `ProfileType` (the role a company plays). */ export type ProfileType = | "importer" @@ -207,6 +210,7 @@ export interface Company { vatNumber?: string | null; fanNumber?: string | null; country: string; + nationality?: CompanyNationality | null; address?: string | null; phone?: string | null; email?: string | null; From 2644d5e52d3437ccdd015a03ed3ce435b4ce3cf1 Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Fri, 7 Aug 2026 11:42:29 +0000 Subject: [PATCH 007/276] feat(eims): add invoice mapper and signed EIMS transport Map EDR invoices onto the MoR EIMS /v1/register document and add the cryptographic transport needed to talk to core.mor.gov.et. Mapper: DTOs mirror the supplied Postman collection section by section. Tax is resolved per line via a caller-supplied resolver and throws when unresolved -- the app models no tax at all (invoice.taxAmount is always 0, invoice_lines and the rate catalogue carry no fiscal columns), so a zero-rated default would assert a tax position the codebase cannot support. Seller identity, document number, counters and previous IRN are passed in explicitly; the mapper stays pure. Transport: config, credential loading, RSA-SHA512 signing and /auth/login with an in-memory token cache. Signing reproduces the process that produced a working live token -- compact JSON of the inner request only, exact UTF-8 bytes, base64 signature, and base64 of the certificate file's exact bytes with no parsing or re-encoding. Concurrent callers share one login via an in-flight promise. Refresh is deliberately unimplemented: the collection shows an unsigned refresh body but also ships unsigned examples of calls that do require signing, so an expired token re-logs in instead. Errors normalise to EimsApiException carrying only the gateway's own error fields; secrets, signature, certificate and tokens never reach logs. Key and certificate file patterns are gitignored. Nothing calls EIMS automatically and no invoice entity, migration or UI is touched. Co-Authored-By: Claude Opus 5 --- .gitignore | 9 + apps/edr-freight-api/.env.example | 53 +++ apps/edr-freight-api/package.json | 3 +- apps/edr-freight-api/src/app.module.ts | 4 + .../edr-freight-api/src/config/eims.config.ts | 80 ++++ .../billing/eims-invoice.mapper.spec.ts | 214 +++++++++++ .../modules/billing/eims-invoice.mapper.ts | 362 ++++++++++++++++++ .../modules/eims/eims-auth.service.spec.ts | 190 +++++++++ .../src/modules/eims/eims-auth.service.ts | 116 ++++++ .../src/modules/eims/eims-client.service.ts | 67 ++++ .../modules/eims/eims-credentials.provider.ts | 77 ++++ .../modules/eims/eims-signer.service.spec.ts | 118 ++++++ .../src/modules/eims/eims-signer.service.ts | 37 ++ .../src/modules/eims/eims.errors.ts | 89 +++++ .../src/modules/eims/eims.module.ts | 19 + .../src/modules/eims/eims.types.ts | 46 +++ .../edr-freight-api/src/scripts/eims-login.ts | 40 ++ 17 files changed, 1523 insertions(+), 1 deletion(-) create mode 100644 apps/edr-freight-api/src/config/eims.config.ts create mode 100644 apps/edr-freight-api/src/modules/billing/eims-invoice.mapper.spec.ts create mode 100644 apps/edr-freight-api/src/modules/billing/eims-invoice.mapper.ts create mode 100644 apps/edr-freight-api/src/modules/eims/eims-auth.service.spec.ts create mode 100644 apps/edr-freight-api/src/modules/eims/eims-auth.service.ts create mode 100644 apps/edr-freight-api/src/modules/eims/eims-client.service.ts create mode 100644 apps/edr-freight-api/src/modules/eims/eims-credentials.provider.ts create mode 100644 apps/edr-freight-api/src/modules/eims/eims-signer.service.spec.ts create mode 100644 apps/edr-freight-api/src/modules/eims/eims-signer.service.ts create mode 100644 apps/edr-freight-api/src/modules/eims/eims.errors.ts create mode 100644 apps/edr-freight-api/src/modules/eims/eims.module.ts create mode 100644 apps/edr-freight-api/src/modules/eims/eims.types.ts create mode 100644 apps/edr-freight-api/src/scripts/eims-login.ts diff --git a/.gitignore b/.gitignore index 63784b865..8fa8bca90 100644 --- a/.gitignore +++ b/.gitignore @@ -53,3 +53,12 @@ RUNNING_LOCALLY.md # Generated per-shard compose file for the integration suite (it.mjs). integration/.it-shards.yaml +# private keys / certificates (EIMS INSA credentials and anything like them) — never commit +*.key +*.pem +*.pem.txt +*.p12 +*.pfx +*.crt +secrets/ +certs/ diff --git a/apps/edr-freight-api/.env.example b/apps/edr-freight-api/.env.example index 96af26034..a11b98520 100644 --- a/apps/edr-freight-api/.env.example +++ b/apps/edr-freight-api/.env.example @@ -126,3 +126,56 @@ EMAIL_QUEUE=email_queue # Shared secret for service-to-service calls (payment microservice <-> freight). # Required at boot; set ALLOW_UNAUTH_INTERNAL=true instead ONLY for local dev. SERVICE_AUTH_TOKEN=change-me + +# ── MoR EIMS e-invoicing (core.mor.gov.et) ───────────────────────────────── +# Disabled by default; every EIMS call fails fast with EIMS_NOT_CONFIGURED until enabled. +EIMS_ENABLED=false +EIMS_BASE_URL=https://core.mor.gov.et +EIMS_CLIENT_ID= +EIMS_CLIENT_SECRET= +EIMS_API_KEY= +EIMS_TIN= +# MoR-issued source-system identifiers (used once invoice registration lands) +EIMS_SYSTEM_NUMBER= +EIMS_SYSTEM_TYPE= +# Absolute paths to the INSA-issued credentials. Keep them OUTSIDE the repo; the file +# patterns are gitignored, but a path outside the working tree is safer still. +# The certificate is transmitted as base64 of this file's exact bytes — do not convert it. +EIMS_PRIVATE_KEY_PATH= +EIMS_CERTIFICATE_PATH= +# Optional tuning +EIMS_HTTP_TIMEOUT_MS=30000 +EIMS_TOKEN_SKEW_SECONDS=45 + +# ── EIMS invoice registration (required only to register invoices) ───────── +# Seller identity: EDR's own legal details are not modelled anywhere in the DB. +# Region and Wereda are MoR *codes* (e.g. 13 / 574), not names. +EIMS_SELLER_LEGAL_NAME= +EIMS_SELLER_VAT_NUMBER= +EIMS_SELLER_PHONE= +EIMS_SELLER_EMAIL= +EIMS_SELLER_REGION= +EIMS_SELLER_WEREDA= +# Optional seller address parts; sent as null when unset. +EIMS_SELLER_CITY= +EIMS_SELLER_SUBCITY= +EIMS_SELLER_HOUSE_NUMBER= +EIMS_SELLER_LOCALITY= +# Tax treatment — REQUIRES FINANCE SIGN-OFF. The application models no tax at all +# (invoice.taxAmount is always 0), so nothing here is defaulted: registration fails +# locally, naming the missing variables, until these are set. +EIMS_TAX_CODE= +EIMS_TAX_RATE_PERCENT= +EIMS_EXCISE_TAX_VALUE=0 +EIMS_INCOME_WITHHOLD_VALUE=0 +EIMS_TRANSACTION_WITHHOLD_VALUE=0 +# Document classification and payment presentation. +EIMS_TRANSACTION_TYPE=B2B +EIMS_NATURE_OF_SUPPLIES=Service +EIMS_PAYMENT_MODE=CASH +EIMS_PAYMENT_TERM=IMMIDIATE +EIMS_UNIT_DEFAULT=PCS +# MoR numeric country code for the buyer; our companies store the country name. +EIMS_BUYER_COUNTRY_CODE= +EIMS_CASHIER_NAME= +EIMS_SALESPERSON_NAME= diff --git a/apps/edr-freight-api/package.json b/apps/edr-freight-api/package.json index c4837c8a7..fb6a0bd31 100644 --- a/apps/edr-freight-api/package.json +++ b/apps/edr-freight-api/package.json @@ -37,7 +37,8 @@ "iam:migration:revert": "pnpm run iam:typeorm:cli migration:revert", "iam:migration:show": "pnpm run iam:typeorm:cli migration:show", "migration:run": "nest build && node dist/scripts/migrate.js", - "script": "ts-node -r tsconfig-paths/register src/scripts/main.ts" + "script": "ts-node -r tsconfig-paths/register src/scripts/main.ts", + "eims:login": "ts-node -r tsconfig-paths/register src/scripts/eims-login.ts" }, "dependencies": { "@edr/api-common": "workspace:*", diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index c4c1514ec..50dde1034 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -23,6 +23,7 @@ import databaseConfig from "./config/database.config"; import telebirrConfig from "./config/telebirr.config"; import rabbitmqConfig from "./config/rabbitmq.config"; import faydaConfig from "./config/fayda.config"; +import eimsConfig from "./config/eims.config"; import { BookingsModule } from "./modules/bookings/bookings.module"; import { ContractsModule } from "./modules/contracts/contracts.module"; @@ -84,6 +85,7 @@ import { PaidImportExportMileDemoSeeder } from "./seed/paid-import-export-mile-d //New Trains, Wagons, Container and Cargo management modules import { TrainsModule } from "./modules/trains/trains.module"; import { VerifaydaModule } from "./modules/verifayda/verifayda.module"; +import { EimsModule } from "./modules/eims/eims.module"; import { FleetHistoryModule } from "./modules/fleet-history/fleet-history.module"; import { WagonsModule } from "./modules/wagons/wagons.module"; import { ContainersModule } from "./modules/container-management/containers.module"; @@ -127,6 +129,7 @@ if (!process.env.APPLICATION_NAME) { telebirrConfig, rabbitmqConfig, faydaConfig, + eimsConfig, ], }), ScheduleModule.forRoot(), @@ -248,6 +251,7 @@ if (!process.env.APPLICATION_NAME) { InterchangeDocumentsModule, ImportOperationsModule, VerifaydaModule, + EimsModule, FleetHistoryModule, AiModule, AuditModule, diff --git a/apps/edr-freight-api/src/config/eims.config.ts b/apps/edr-freight-api/src/config/eims.config.ts new file mode 100644 index 000000000..37cbbc84b --- /dev/null +++ b/apps/edr-freight-api/src/config/eims.config.ts @@ -0,0 +1,80 @@ +import { registerAs } from "@nestjs/config"; + +/** + * Ethiopian MoR EIMS e-invoicing gateway. + * + * Disabled by default: with `EIMS_ENABLED=false` the config resolves to a stub and every EIMS + * service throws a clear error on use, so a deployment without credentials still boots. + * + * Secrets (client secret, API key) and the credential file paths live only here and are never + * logged — validation reports missing variable *names*, never their values. + */ +export interface EimsConfig { + enabled: boolean; + baseUrl: string; + clientId: string; + clientSecret: string; + apiKey: string; + tin: string; + /** MoR-issued source-system identifiers; unused until invoice registration lands. */ + systemNumber: string; + systemType: string; + /** Filesystem path to the INSA-issued RSA private key (PEM). Never leaves the server. */ + privateKeyPath: string; + /** Filesystem path to the INSA-issued certificate bundle; sent as base64 of its exact bytes. */ + certificatePath: string; + httpTimeoutMs: number; + /** Re-authenticate this many ms before the access token actually expires. */ + tokenSkewMs: number; +} + +const REQUIRED_VARS = [ + "EIMS_CLIENT_ID", + "EIMS_CLIENT_SECRET", + "EIMS_API_KEY", + "EIMS_TIN", + "EIMS_PRIVATE_KEY_PATH", + "EIMS_CERTIFICATE_PATH", +] as const; + +const positiveInt = (raw: string | undefined, fallback: number, name: string): number => { + if (raw === undefined || raw === "") return fallback; + const value = Number.parseInt(raw, 10); + if (Number.isNaN(value) || value <= 0) { + throw new Error(`${name} must be a positive integer`); + } + return value; +}; + +export default registerAs("eims", (): EimsConfig => { + const enabled = (process.env.EIMS_ENABLED ?? "false").toLowerCase() === "true"; + const baseUrl = (process.env.EIMS_BASE_URL ?? "https://core.mor.gov.et").replace(/\/+$/, ""); + const httpTimeoutMs = positiveInt(process.env.EIMS_HTTP_TIMEOUT_MS, 30_000, "EIMS_HTTP_TIMEOUT_MS"); + const tokenSkewMs = + positiveInt(process.env.EIMS_TOKEN_SKEW_SECONDS, 45, "EIMS_TOKEN_SKEW_SECONDS") * 1000; + + const base: EimsConfig = { + enabled, + baseUrl, + clientId: process.env.EIMS_CLIENT_ID ?? "", + clientSecret: process.env.EIMS_CLIENT_SECRET ?? "", + apiKey: process.env.EIMS_API_KEY ?? "", + tin: process.env.EIMS_TIN ?? "", + systemNumber: process.env.EIMS_SYSTEM_NUMBER ?? "", + systemType: process.env.EIMS_SYSTEM_TYPE ?? "", + privateKeyPath: process.env.EIMS_PRIVATE_KEY_PATH ?? "", + certificatePath: process.env.EIMS_CERTIFICATE_PATH ?? "", + httpTimeoutMs, + tokenSkewMs, + }; + + if (!enabled) return base; + + const missing = REQUIRED_VARS.filter((name) => !process.env[name]); + if (missing.length > 0) { + throw new Error( + `EIMS integration is enabled (EIMS_ENABLED=true) but the following env vars are missing: ${missing.join(", ")}`, + ); + } + return base; +}); diff --git a/apps/edr-freight-api/src/modules/billing/eims-invoice.mapper.spec.ts b/apps/edr-freight-api/src/modules/billing/eims-invoice.mapper.spec.ts new file mode 100644 index 000000000..aef4ac163 --- /dev/null +++ b/apps/edr-freight-api/src/modules/billing/eims-invoice.mapper.spec.ts @@ -0,0 +1,214 @@ +import { + EimsMapperContext, + EimsMapperInvoice, + EimsSellerDetails, + formatEimsDate, + toEimsInvoice, +} from "./eims-invoice.mapper"; + +const seller: EimsSellerDetails = { + City: null, + Email: "finance@edr.et", + HouseNumber: null, + LegalName: "Ethio-Djibouti Railway S.C.", + Locality: null, + Phone: "0911223344", + Region: "13", + SubCity: null, + Tin: "0016324478", + VatNumber: "3215840010", + Wereda: "574", +}; + +const invoice = (over: Partial = {}): EimsMapperInvoice => ({ + invoiceNumber: "INV-20260807-00042", + currency: "ETB", + issuedAt: new Date(2026, 7, 7, 9, 5, 3), + totalAmount: "11000.00", + company: { + name: "ABC Trading PLC", + tin: "0999930000", + vatNumber: "123475885858", + phone: "0912345678", + email: "buyer@abc.et", + region: "13", + zone: "SHA", + woreda: "574", + kebele: "03", + houseNo: "NEW", + country: "Ethiopia", + }, + lines: [ + { chargeType: "RAIL_FREIGHT", description: "Addis → Djibouti", quantity: "1.00", unitRate: "10000.00", amount: "10000.00" }, + { chargeType: "HAZARD_SURCHARGE", description: null, quantity: "2.00", unitRate: "500.00", amount: "1000.00", metadata: { unit: "CTR" } }, + ], + ...over, +}); + +const context = (over: Partial = {}): EimsMapperContext => ({ + systemNumber: "B0360154BA", + systemType: "SYS", + documentNumber: "24", + invoiceCounter: 7, + previousIrn: "", + cashierName: null, + salesPersonName: null, + transactionType: "B2B", + payment: { mode: "CASH", term: "IMMIDIATE" }, + taxForLine: () => ({ code: "VAT15", ratePercent: 15, exciseTaxValue: 0 }), + natureOfSupplies: "Service", + unitDefault: "PCS", + incomeWithholdValue: 0, + transactionWithholdValue: 0, + ...over, +}); + +describe("toEimsInvoice", () => { + it("emits the ten EIMS sections with the collection's field names", () => { + const doc = toEimsInvoice(invoice(), seller, context()); + + expect(Object.keys(doc)).toEqual([ + "BuyerDetails", + "DocumentDetails", + "ItemList", + "PaymentDetails", + "ReferenceDetails", + "SellerDetails", + "SourceSystem", + "TransactionType", + "ValueDetails", + "Version", + ]); + expect(doc.Version).toBe("1"); + expect(doc.DocumentDetails).toEqual({ DocumentNumber: "24", Date: "07-08-2026T09:05:03", Type: "INV" }); + expect(doc.SourceSystem.InvoiceCounter).toBe(7); + expect(doc.SellerDetails).toBe(seller); + }); + + it("maps the buyer from the company row and leaves unmodelled fields null", () => { + const doc = toEimsInvoice(invoice(), seller, context()); + + expect(doc.BuyerDetails).toEqual({ + City: null, + Email: "buyer@abc.et", + HouseNumber: "NEW", + IdNumber: null, + IdType: null, + Tin: "0999930000", + LegalName: "ABC Trading PLC", + Phone: "0912345678", + Region: "13", + Country: null, + Zone: "SHA", + Kebele: "03", + VatNumber: "123475885858", + Wereda: "574", + }); + }); + + it("applies per-line tax and totals it into ValueDetails", () => { + const doc = toEimsInvoice( + invoice(), + seller, + context({ + taxForLine: (line) => + line.chargeType === "RAIL_FREIGHT" + ? { code: "VAT15", ratePercent: 15, exciseTaxValue: 0 } + : { code: "EXEMPT", ratePercent: 0, exciseTaxValue: 50 }, + }), + ); + + expect(doc.ItemList[0]).toMatchObject({ + LineNumber: 1, + ItemCode: "RAIL_FREIGHT", + ProductDescription: "Addis → Djibouti", + Quantity: 1, + UnitPrice: 10000, + PreTaxValue: 10000, + TaxCode: "VAT15", + TaxAmount: 1500, + ExciseTaxValue: 0, + TotalLineAmount: 11500, + Unit: "PCS", + NatureOfSupplies: "Service", + HarmonizationCode: null, + }); + expect(doc.ItemList[1]).toMatchObject({ + LineNumber: 2, + ProductDescription: "HAZARD_SURCHARGE", + TaxCode: "EXEMPT", + TaxAmount: 0, + ExciseTaxValue: 50, + TotalLineAmount: 1050, + Unit: "CTR", + }); + expect(doc.ValueDetails).toEqual({ + Discount: null, + ExciseValue: 50, + IncomeWithholdValue: 0, + TaxValue: 1500, + TotalValue: 12550, + TransactionWithholdValue: 0, + InvoiceCurrency: "ETB", + }); + }); + + it("passes PreviousIrn through verbatim and defaults RelatedDocument to null", () => { + expect(toEimsInvoice(invoice(), seller, context()).ReferenceDetails).toEqual({ + PreviousIrn: "", + RelatedDocument: null, + }); + expect( + toEimsInvoice(invoice(), seller, context({ previousIrn: null, relatedDocument: "CN-9" })) + .ReferenceDetails, + ).toEqual({ PreviousIrn: null, RelatedDocument: "CN-9" }); + }); + + it("emits ExchangeRate only when supplied", () => { + expect(toEimsInvoice(invoice(), seller, context()).ValueDetails.ExchangeRate).toBeUndefined(); + + const usd = toEimsInvoice( + invoice({ currency: "USD" }), + seller, + context({ exchangeRate: 132.5 }), + ); + expect(usd.ValueDetails).toMatchObject({ InvoiceCurrency: "USD", ExchangeRate: 132.5 }); + }); + + it("honours a caller-supplied date formatter", () => { + const doc = toEimsInvoice(invoice(), seller, context({ formatDate: () => "2026-08-07T09:05:03Z" })); + expect(doc.DocumentDetails.Date).toBe("2026-08-07T09:05:03Z"); + }); + + it("throws when tax treatment cannot be resolved for a line", () => { + expect(() => + toEimsInvoice( + invoice(), + seller, + context({ taxForLine: () => ({ code: "", ratePercent: 15, exciseTaxValue: 0 }) }), + ), + ).toThrow(/unresolved tax treatment for line 1/); + }); + + it("throws on a missing buyer TIN, no lines, or an unissued invoice", () => { + expect(() => toEimsInvoice(invoice({ company: null }), seller, context())).toThrow(/buyer company TIN/); + expect(() => toEimsInvoice(invoice({ lines: [] }), seller, context())).toThrow(/has no lines/); + expect(() => toEimsInvoice(invoice({ issuedAt: null }), seller, context())).toThrow(/not issued/); + }); + + it("throws when the lines do not sum to the invoice total", () => { + expect(() => toEimsInvoice(invoice({ totalAmount: "9000.00" }), seller, context())).toThrow( + /lines sum to 11000 but the invoice total is 9000/, + ); + }); + + it("throws on a non-ETB invoice with no exchange rate", () => { + expect(() => toEimsInvoice(invoice({ currency: "USD" }), seller, context())).toThrow(/needs an exchangeRate/); + }); +}); + +describe("formatEimsDate", () => { + it("renders the observed dd-MM-yyyyTHH:mm:ss shape with zero padding", () => { + expect(formatEimsDate(new Date(2025, 2, 21, 0, 0, 0))).toBe("21-03-2025T00:00:00"); + }); +}); diff --git a/apps/edr-freight-api/src/modules/billing/eims-invoice.mapper.ts b/apps/edr-freight-api/src/modules/billing/eims-invoice.mapper.ts new file mode 100644 index 000000000..864d9f829 --- /dev/null +++ b/apps/edr-freight-api/src/modules/billing/eims-invoice.mapper.ts @@ -0,0 +1,362 @@ +/** + * Pure mapper from an EDR invoice onto the Ethiopian MoR EIMS registration document + * (`POST https://core.mor.gov.et/v1/register`). + * + * Field names, casing and section layout are taken verbatim from the supplied + * `EimsCoreApiMockCollection2.postman_collection.json`. Note the payload spells the district + * `Wereda` even though the collection *variable* is named `sellerWoreda`. + * + * Scope: mapping only — no HTTP, no signing, no persistence, no counter allocation. Everything + * that does not live on the invoice (document number, counters, previous IRN, seller identity, + * tax treatment) is supplied by the caller and is never guessed here. + * + * Values that the collection only *demonstrates* by example — the date format, the meaning of an + * empty `PreviousIrn`, the `SystemType` enum, `PaymentTerm` values — are treated as observed, not + * authoritative: they are passed through or overridable rather than validated against a fixed set. + */ + +import { round2 } from "./invoice-settlement.util"; + +/** Only proven-required constant: the 400 SCHEMA ERROR sample rejects a payload without it. */ +const EIMS_VERSION = "1"; + +/** The only `DocumentDetails.Type` observed in the supplied material. */ +const EIMS_DOCUMENT_TYPE = "INV"; + +export interface EimsBuyerDetails { + City: string | null; + Email: string | null; + HouseNumber: string | null; + IdNumber: string | null; + IdType: string | null; + Tin: string; + LegalName: string; + Phone: string | null; + Region: string | null; + Country: string | null; + Zone: string | null; + Kebele: string | null; + VatNumber: string | null; + Wereda: string | null; +} + +export interface EimsSellerDetails { + City: string | null; + Email: string | null; + HouseNumber: string | null; + LegalName: string; + Locality: string | null; + Phone: string | null; + /** MoR region *code* (e.g. "13"), not a region name. */ + Region: string | null; + SubCity: string | null; + Tin: string; + VatNumber: string | null; + /** MoR wereda *code* (e.g. "574"). */ + Wereda: string | null; +} + +export interface EimsDocumentDetails { + DocumentNumber: string; + /** Observed format `dd-MM-yyyyTHH:mm:ss`. Rule seen in the collection: within 3 days of now. */ + Date: string; + Type: string; +} + +export interface EimsInvoiceItem { + Discount: number; + ExciseTaxValue: number; + HarmonizationCode: string | null; + NatureOfSupplies: string; + ItemCode: string; + ProductDescription: string; + PreTaxValue: number; + Quantity: number; + LineNumber: number; + TaxAmount: number; + TaxCode: string; + TotalLineAmount: number; + Unit: string; + UnitPrice: number; +} + +export interface EimsPaymentDetails { + Mode: string; + PaymentTerm: string; +} + +export interface EimsReferenceDetails { + PreviousIrn: string | null; + RelatedDocument: string | null; +} + +export interface EimsSourceSystem { + CashierName: string | null; + InvoiceCounter: number; + SalesPersonName: string | null; + SystemNumber: string; + SystemType: string; +} + +export interface EimsValueDetails { + Discount: number | null; + ExciseValue: number; + IncomeWithholdValue: number; + TaxValue: number; + TotalValue: number; + TransactionWithholdValue: number; + InvoiceCurrency: string; + /** Absent from the register sample, present on the verify response. Emitted only when supplied. */ + ExchangeRate?: number; +} + +export interface EimsInvoiceRequest { + BuyerDetails: EimsBuyerDetails; + DocumentDetails: EimsDocumentDetails; + ItemList: EimsInvoiceItem[]; + PaymentDetails: EimsPaymentDetails; + ReferenceDetails: EimsReferenceDetails; + SellerDetails: EimsSellerDetails; + SourceSystem: EimsSourceSystem; + TransactionType: string; + ValueDetails: EimsValueDetails; + Version: string; +} + +/** `body` of a successful `POST /v1/register`, as observed in the collection. */ +export interface EimsRegisterResponseBody { + irn: string; + ackDate: string; + signedQR: string; + signedInvoice: string; + status: string; + documentNumber: string; + errorMessage: string | null; +} + +/** Numeric columns arrive from pg as strings; every money field is normalised through `num`. */ +export interface EimsMapperLine { + chargeType: string; + description?: string | null; + quantity: number | string; + unitRate: number | string; + amount: number | string; + metadata?: Record | null; +} + +export interface EimsMapperCompany { + name: string; + tin: string; + vatNumber?: string | null; + phone?: string | null; + email?: string | null; + region?: string | null; + zone?: string | null; + woreda?: string | null; + kebele?: string | null; + houseNo?: string | null; + country?: string | null; +} + +/** + * Structurally what `BillingService.findById` returns — the only read path that loads the header, + * the buyer company and the lines together. + */ +export interface EimsMapperInvoice { + invoiceNumber: string; + currency: string; + issuedAt?: Date | string | null; + totalAmount: number | string; + company?: EimsMapperCompany | null; + lines: EimsMapperLine[]; +} + +/** + * Tax treatment for a single line. EIMS models `TaxCode`/`TaxAmount`/`ExciseTaxValue` per item, and + * different charge types may eventually be treated differently, so this is resolved per line. + * + * Nothing in this repo can supply it: `Invoice.taxAmount` is hardcoded to 0 with no caller ever + * setting it, `invoice_lines` has no tax column, and the rate catalogue has no fiscal field. That + * is the absence of a tax model, not evidence of zero-rating — hence no default here. + */ +export interface EimsLineTax { + code: string; + ratePercent: number; + exciseTaxValue: number; +} + +export interface EimsMapperContext { + systemNumber: string; + /** Observed values: POS, MAN, CRM, EFD, SYS (the collection prose also mentions ERP). */ + systemType: string; + /** Caller decides the source — our own `invoiceNumber` or a dedicated EIMS sequence. */ + documentNumber: string; + invoiceCounter: number; + /** Passed through verbatim; the collection shows `""` used for an unchained document. */ + previousIrn: string | null; + cashierName: string | null; + salesPersonName: string | null; + /** B2B / B2C — a tax classification, so the caller states it. */ + transactionType: string; + payment: { mode: string; term: string }; + /** Must return a treatment for every line, or throw. */ + taxForLine: (line: EimsMapperLine, lineNumber: number) => EimsLineTax; + natureOfSupplies: string; + /** Used when a line carries no `metadata.unit`. */ + unitDefault: string; + incomeWithholdValue: number; + transactionWithholdValue: number; + /** Null for an ordinary invoice; set only for a real related-document case. */ + relatedDocument?: string | null; + /** MoR numeric country code for the buyer; our DB stores the country name. */ + buyerCountryCode?: string | null; + buyerIdType?: string | null; + buyerIdNumber?: string | null; + buyerCity?: string | null; + /** Required when the invoice currency is not ETB. */ + exchangeRate?: number | null; + invoiceDiscount?: number | null; + /** Override while the observed `dd-MM-yyyyTHH:mm:ss` format is unconfirmed by MoR. */ + formatDate?: (issuedAt: Date) => string; +} + +const num = (v: number | string): number => { + const n = Number(v); + if (!Number.isFinite(n)) throw new Error(`EIMS mapping: expected a numeric value, got ${String(v)}`); + return n; +}; + +const pad = (n: number, width = 2): string => String(n).padStart(width, "0"); + +/** Observed EIMS document-date format: `dd-MM-yyyyTHH:mm:ss`, no timezone marker. */ +export const formatEimsDate = (issuedAt: Date): string => + `${pad(issuedAt.getDate())}-${pad(issuedAt.getMonth() + 1)}-${issuedAt.getFullYear()}` + + `T${pad(issuedAt.getHours())}:${pad(issuedAt.getMinutes())}:${pad(issuedAt.getSeconds())}`; + +/** + * Map one loaded invoice onto an EIMS registration document. + * + * Throws rather than emitting a payload EIMS would reject opaquely: missing buyer TIN, no lines, + * an unissued invoice, unresolved line tax, a line/total mismatch, or a non-ETB invoice with no + * exchange rate. + */ +export function toEimsInvoice( + invoice: EimsMapperInvoice, + seller: EimsSellerDetails, + context: EimsMapperContext, +): EimsInvoiceRequest { + const company = invoice.company; + if (!company || !company.tin?.trim()) { + throw new Error(`EIMS mapping: invoice ${invoice.invoiceNumber} has no buyer company TIN`); + } + if (!invoice.lines?.length) { + throw new Error(`EIMS mapping: invoice ${invoice.invoiceNumber} has no lines`); + } + if (!invoice.issuedAt) { + throw new Error(`EIMS mapping: invoice ${invoice.invoiceNumber} is not issued (issuedAt is null)`); + } + if (invoice.currency !== "ETB" && context.exchangeRate == null) { + throw new Error( + `EIMS mapping: invoice ${invoice.invoiceNumber} is in ${invoice.currency} and needs an exchangeRate`, + ); + } + + const issuedAt = invoice.issuedAt instanceof Date ? invoice.issuedAt : new Date(invoice.issuedAt); + if (Number.isNaN(issuedAt.getTime())) { + throw new Error(`EIMS mapping: invoice ${invoice.invoiceNumber} has an unparseable issuedAt`); + } + + const ItemList: EimsInvoiceItem[] = invoice.lines.map((line, index) => { + const lineNumber = index + 1; + const tax = context.taxForLine(line, lineNumber); + if (!tax || !tax.code || !Number.isFinite(tax.ratePercent) || !Number.isFinite(tax.exciseTaxValue)) { + throw new Error( + `EIMS mapping: unresolved tax treatment for line ${lineNumber} (${line.chargeType}) ` + + `on invoice ${invoice.invoiceNumber}`, + ); + } + + const PreTaxValue = round2(num(line.amount)); + const TaxAmount = round2((PreTaxValue * tax.ratePercent) / 100); + const ExciseTaxValue = round2(tax.exciseTaxValue); + const unit = typeof line.metadata?.unit === "string" ? line.metadata.unit : context.unitDefault; + + return { + Discount: 0, + ExciseTaxValue, + HarmonizationCode: null, + NatureOfSupplies: context.natureOfSupplies, + ItemCode: line.chargeType, + ProductDescription: line.description?.trim() || line.chargeType, + PreTaxValue, + Quantity: round2(num(line.quantity)), + LineNumber: lineNumber, + TaxAmount, + TaxCode: tax.code, + TotalLineAmount: round2(PreTaxValue + TaxAmount + ExciseTaxValue), + Unit: unit, + UnitPrice: round2(num(line.unitRate)), + }; + }); + + const preTaxTotal = round2(ItemList.reduce((sum, item) => sum + item.PreTaxValue, 0)); + const invoiceTotal = round2(num(invoice.totalAmount)); + if (Math.abs(preTaxTotal - invoiceTotal) > 0.01) { + throw new Error( + `EIMS mapping: invoice ${invoice.invoiceNumber} lines sum to ${preTaxTotal} ` + + `but the invoice total is ${invoiceTotal}`, + ); + } + + const ValueDetails: EimsValueDetails = { + Discount: context.invoiceDiscount ?? null, + ExciseValue: round2(ItemList.reduce((sum, item) => sum + item.ExciseTaxValue, 0)), + IncomeWithholdValue: context.incomeWithholdValue, + TaxValue: round2(ItemList.reduce((sum, item) => sum + item.TaxAmount, 0)), + TotalValue: round2(ItemList.reduce((sum, item) => sum + item.TotalLineAmount, 0)), + TransactionWithholdValue: context.transactionWithholdValue, + InvoiceCurrency: invoice.currency, + }; + if (context.exchangeRate != null) ValueDetails.ExchangeRate = context.exchangeRate; + + return { + BuyerDetails: { + City: context.buyerCity ?? null, + Email: company.email ?? null, + HouseNumber: company.houseNo ?? null, + IdNumber: context.buyerIdNumber ?? null, + IdType: context.buyerIdType ?? null, + Tin: company.tin, + LegalName: company.name, + Phone: company.phone ?? null, + Region: company.region ?? null, + Country: context.buyerCountryCode ?? null, + Zone: company.zone ?? null, + Kebele: company.kebele ?? null, + VatNumber: company.vatNumber ?? null, + Wereda: company.woreda ?? null, + }, + DocumentDetails: { + DocumentNumber: context.documentNumber, + Date: (context.formatDate ?? formatEimsDate)(issuedAt), + Type: EIMS_DOCUMENT_TYPE, + }, + ItemList, + PaymentDetails: { Mode: context.payment.mode, PaymentTerm: context.payment.term }, + ReferenceDetails: { + PreviousIrn: context.previousIrn, + RelatedDocument: context.relatedDocument ?? null, + }, + SellerDetails: seller, + SourceSystem: { + CashierName: context.cashierName, + InvoiceCounter: context.invoiceCounter, + SalesPersonName: context.salesPersonName, + SystemNumber: context.systemNumber, + SystemType: context.systemType, + }, + TransactionType: context.transactionType, + ValueDetails, + Version: EIMS_VERSION, + }; +} diff --git a/apps/edr-freight-api/src/modules/eims/eims-auth.service.spec.ts b/apps/edr-freight-api/src/modules/eims/eims-auth.service.spec.ts new file mode 100644 index 000000000..1ff0d0463 --- /dev/null +++ b/apps/edr-freight-api/src/modules/eims/eims-auth.service.spec.ts @@ -0,0 +1,190 @@ +import { HttpService } from "@nestjs/axios"; +import { ConfigService } from "@nestjs/config"; +import { AxiosError, AxiosHeaders } from "axios"; +import { of, throwError } from "rxjs"; +import { EimsConfig } from "../../config/eims.config"; +import { EimsAuthService } from "./eims-auth.service"; +import { EimsSignerService } from "./eims-signer.service"; + +const CLIENT_SECRET = "super-secret-value"; +const API_KEY = "super-secret-apikey"; + +const cfg = (over: Partial = {}): EimsConfig => ({ + enabled: true, + baseUrl: "https://core.mor.gov.et", + clientId: "cid", + clientSecret: CLIENT_SECRET, + apiKey: API_KEY, + tin: "0000034558", + systemNumber: "B0360154BA", + systemType: "SYS", + privateKeyPath: "/dev/null", + certificatePath: "/dev/null", + httpTimeoutMs: 30_000, + tokenSkewMs: 45_000, + ...over, +}); + +const loginBody = (accessToken: string, expiresIn = 3600) => ({ + data: { accessToken, refreshToken: "refresh-1", encryptionKey: null, expiresIn }, + status: "SUCCESS", +}); + +/** Stub signer: the real signing path has its own spec and needs no key material here. */ +const signer = { + signRequest: (request: T) => ({ request, signature: "SIGNATURE", certificate: "CERTIFICATE" }), +} as unknown as EimsSignerService; + +const build = (post: jest.Mock, config: EimsConfig = cfg()) => + new EimsAuthService( + { post } as unknown as HttpService, + { get: () => config } as unknown as ConfigService, + signer, + ); + +const axiosErr = (status: number, data: unknown) => + new AxiosError("Request failed", undefined, undefined, undefined, { + status, + statusText: "", + data, + headers: new AxiosHeaders(), + config: { headers: new AxiosHeaders() }, + }); + +describe("EimsAuthService.getValidAccessToken", () => { + it("posts the signed login envelope to /auth/login with no Authorization header", async () => { + const post = jest.fn().mockReturnValue(of({ data: loginBody("token-1") })); + + await build(post).getValidAccessToken(); + + expect(post).toHaveBeenCalledTimes(1); + const [url, body, options] = post.mock.calls[0]; + expect(url).toBe("https://core.mor.gov.et/auth/login"); + expect(options.headers).toEqual({ "Content-Type": "application/json" }); + expect(options.headers.Authorization).toBeUndefined(); + + expect(typeof body).toBe("string"); + expect(JSON.parse(body)).toEqual({ + request: { clientId: "cid", clientSecret: CLIENT_SECRET, apikey: API_KEY, tin: "0000034558" }, + signature: "SIGNATURE", + certificate: "CERTIFICATE", + }); + }); + + it("returns the access token from data.accessToken", async () => { + const post = jest.fn().mockReturnValue(of({ data: loginBody("token-1") })); + await expect(build(post).getValidAccessToken()).resolves.toBe("token-1"); + }); + + it("reuses a cached token instead of logging in again", async () => { + const post = jest.fn().mockReturnValue(of({ data: loginBody("token-1") })); + const auth = build(post); + + await auth.getValidAccessToken(); + await expect(auth.getValidAccessToken()).resolves.toBe("token-1"); + expect(post).toHaveBeenCalledTimes(1); + }); + + it("re-authenticates a skew-window before the token actually expires", async () => { + const post = jest + .fn() + .mockReturnValueOnce(of({ data: loginBody("token-1", 100) })) // 100s ttl, 45s skew ⇒ usable 55s + .mockReturnValueOnce(of({ data: loginBody("token-2") })); + const auth = build(post); + const start = Date.now(); + const clock = jest.spyOn(Date, "now"); + + try { + clock.mockReturnValue(start); + await expect(auth.getValidAccessToken()).resolves.toBe("token-1"); + + clock.mockReturnValue(start + 50_000); // inside the window: still cached + await expect(auth.getValidAccessToken()).resolves.toBe("token-1"); + expect(post).toHaveBeenCalledTimes(1); + + clock.mockReturnValue(start + 56_000); // past ttl-minus-skew, before the real 100s expiry + await expect(auth.getValidAccessToken()).resolves.toBe("token-2"); + expect(post).toHaveBeenCalledTimes(2); + } finally { + clock.mockRestore(); + } + }); + + it("logs in again after invalidate()", async () => { + const post = jest + .fn() + .mockReturnValueOnce(of({ data: loginBody("token-1") })) + .mockReturnValueOnce(of({ data: loginBody("token-2") })); + const auth = build(post); + + await auth.getValidAccessToken(); + auth.invalidate(); + await expect(auth.getValidAccessToken()).resolves.toBe("token-2"); + expect(post).toHaveBeenCalledTimes(2); + }); + + it("performs exactly one login for many concurrent callers", async () => { + const post = jest.fn().mockReturnValue(of({ data: loginBody("token-1") })); + const auth = build(post); + + const tokens = await Promise.all(Array.from({ length: 20 }, () => auth.getValidAccessToken())); + + expect(post).toHaveBeenCalledTimes(1); + expect(new Set(tokens)).toEqual(new Set(["token-1"])); + }); + + it("refuses to call the gateway when EIMS is disabled", async () => { + const post = jest.fn(); + await expect(build(post, cfg({ enabled: false })).getValidAccessToken()).rejects.toThrow( + /EIMS integration is disabled/, + ); + expect(post).not.toHaveBeenCalled(); + }); + + it("rejects a 200 response that carries no access token", async () => { + const post = jest.fn().mockReturnValue(of({ data: { data: {}, status: "SUCCESS" } })); + await expect(build(post).getValidAccessToken()).rejects.toThrow(/returned no accessToken/); + }); + + it("surfaces gateway errors without leaking credentials or the envelope", async () => { + const post = jest.fn().mockReturnValue( + throwError(() => + axiosErr(401, { + message: "GATEWAY ERROR", + statusCode: 401, + code: "4400", + details: [{ errorMessage: "Invalid Credentials" }], + // Fields the gateway must never echo back into our logs or exceptions: + signature: "SIGNATURE", + certificate: "CERTIFICATE", + accessToken: "leaked-token", + }), + ), + ); + + const error = (await build(post) + .getValidAccessToken() + .catch((e: Error) => e)) as Error & { response?: unknown }; + const serialized = JSON.stringify({ message: error.message, response: error.response }); + + expect(error.message).toContain("EIMS login failed (401)"); + expect(error.message).toContain("Invalid Credentials"); + for (const secret of [CLIENT_SECRET, API_KEY, "SIGNATURE", "CERTIFICATE", "leaked-token"]) { + expect(serialized).not.toContain(secret); + } + }); + + it("maps a timeout to a TIMEOUT failure without a status", async () => { + const timeout = new AxiosError("timeout of 30000ms exceeded", "ECONNABORTED"); + const post = jest.fn().mockReturnValue(throwError(() => timeout)); + + await expect(build(post).getValidAccessToken()).rejects.toThrow(/EIMS login timed out/); + }); + + it("maps an unreachable gateway to a NETWORK failure", async () => { + const refused = new AxiosError("connect ECONNREFUSED", "ECONNREFUSED"); + const post = jest.fn().mockReturnValue(throwError(() => refused)); + + await expect(build(post).getValidAccessToken()).rejects.toThrow(/could not reach the gateway/); + }); +}); diff --git a/apps/edr-freight-api/src/modules/eims/eims-auth.service.ts b/apps/edr-freight-api/src/modules/eims/eims-auth.service.ts new file mode 100644 index 000000000..99af70c57 --- /dev/null +++ b/apps/edr-freight-api/src/modules/eims/eims-auth.service.ts @@ -0,0 +1,116 @@ +import { HttpService } from "@nestjs/axios"; +import { Injectable, Logger } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { firstValueFrom } from "rxjs"; +import { EimsConfig } from "../../config/eims.config"; +import { EimsSignerService, toSignedBody } from "./eims-signer.service"; +import { EimsApiException, EimsConfigException, toEimsApiException } from "./eims.errors"; +import { EimsLoginRequest, EimsLoginResponse } from "./eims.types"; + +interface TokenCache { + accessToken: string; + /** Epoch ms, already reduced by the configured skew. */ + expiresAt: number; +} + +/** Used when the gateway omits `expiresIn`; the observed value is 3600. */ +const FALLBACK_EXPIRES_IN_SECONDS = 3600; + +/** + * EIMS authentication: signed `POST /auth/login`, plus an in-memory access-token cache. + * + * Login is the one EIMS call that carries no bearer token, which is why it lives here rather than + * in the generic client. Tokens are held in memory only — never persisted, never logged, never + * returned to a frontend. + */ +@Injectable() +export class EimsAuthService { + private readonly logger = new Logger(EimsAuthService.name); + private cache: TokenCache | null = null; + private loginInFlight: Promise | null = null; + + constructor( + private readonly http: HttpService, + private readonly config: ConfigService, + private readonly signer: EimsSignerService, + ) {} + + private get cfg(): EimsConfig { + return this.config.get("eims")!; + } + + /** + * A non-expired access token, logging in if needed. Concurrent callers share one login: the + * first caller stores the in-flight promise and everyone else awaits it. + */ + async getValidAccessToken(): Promise { + if (this.cache && Date.now() < this.cache.expiresAt) { + return this.cache.accessToken; + } + if (this.loginInFlight) return this.loginInFlight; + + this.loginInFlight = this.login(); + try { + return await this.loginInFlight; + } finally { + this.loginInFlight = null; + } + } + + /** Drop the cached token — called after a 401 so the next request re-authenticates. */ + invalidate(): void { + this.cache = null; + } + + private async login(): Promise { + const cfg = this.cfg; + if (!cfg.enabled) { + throw new EimsConfigException("EIMS integration is disabled; set EIMS_ENABLED=true to use it"); + } + + const request: EimsLoginRequest = { + clientId: cfg.clientId, + clientSecret: cfg.clientSecret, + apikey: cfg.apiKey, + tin: cfg.tin, + }; + const body = toSignedBody(this.signer.signRequest(request)); + + let response: EimsLoginResponse; + try { + const res = await firstValueFrom( + this.http.post(`${cfg.baseUrl}/auth/login`, body, { + headers: { "Content-Type": "application/json" }, + timeout: cfg.httpTimeoutMs, + }), + ); + response = res.data; + } catch (err) { + const mapped = toEimsApiException(err, "login"); + this.logger.error(mapped.message); + throw mapped; + } + + const accessToken = response?.data?.accessToken; + if (!accessToken) { + throw new EimsApiException("UNKNOWN", "EIMS login returned no accessToken"); + } + + const expiresIn = + Number.isFinite(response.data.expiresIn) && response.data.expiresIn > 0 + ? response.data.expiresIn + : FALLBACK_EXPIRES_IN_SECONDS; + + // TODO: implement `POST /auth/refresh-token` and hold `response.data.refreshToken`. The + // collection shows a bare `{refreshToken}` body with no envelope, but it also carries unsigned + // examples of calls that do require signing, so whether refresh must be signed is unconfirmed. + // Until MoR confirms it, an expired token just triggers a fresh login — `expiresIn` is 3600s, + // so that is one extra call an hour. + this.cache = { + accessToken, + expiresAt: Date.now() + Math.max(expiresIn * 1000 - cfg.tokenSkewMs, 1000), + }; + this.logger.log(`EIMS login succeeded; token cached for ~${expiresIn}s`); + return accessToken; + } +} diff --git a/apps/edr-freight-api/src/modules/eims/eims-client.service.ts b/apps/edr-freight-api/src/modules/eims/eims-client.service.ts new file mode 100644 index 000000000..04418cf52 --- /dev/null +++ b/apps/edr-freight-api/src/modules/eims/eims-client.service.ts @@ -0,0 +1,67 @@ +import { HttpService } from "@nestjs/axios"; +import { Injectable, Logger } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { firstValueFrom } from "rxjs"; +import { EimsConfig } from "../../config/eims.config"; +import { EimsAuthService } from "./eims-auth.service"; +import { EimsSignerService, toSignedBody } from "./eims-signer.service"; +import { toEimsApiException } from "./eims.errors"; + +/** + * Foundation for EIMS's bearer-authenticated endpoints (`/v1/register`, `/v1/verify`, …). + * + * Login is not routed through here: `/auth/login` carries no bearer token and lives in + * `EimsAuthService`. Nothing calls `postSigned` yet — invoice registration is a later phase. + */ +@Injectable() +export class EimsClientService { + private readonly logger = new Logger(EimsClientService.name); + + constructor( + private readonly http: HttpService, + private readonly config: ConfigService, + private readonly auth: EimsAuthService, + private readonly signer: EimsSignerService, + ) {} + + private get cfg(): EimsConfig { + return this.config.get("eims")!; + } + + /** + * Sign `request`, POST it to `path` with a valid bearer token, and return the parsed response. + * A 401 invalidates the cached token and retries exactly once. + */ + async postSigned(path: string, request: TRequest): Promise { + return this.send(path, request, false); + } + + private async send( + path: string, + request: TRequest, + isRetry: boolean, + ): Promise { + const cfg = this.cfg; + const token = await this.auth.getValidAccessToken(); + const body = toSignedBody(this.signer.signRequest(request)); + + try { + const res = await firstValueFrom( + this.http.post(`${cfg.baseUrl}${path}`, body, { + headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` }, + timeout: cfg.httpTimeoutMs, + }), + ); + return res.data; + } catch (err) { + const mapped = toEimsApiException(err, `POST ${path}`); + if (mapped.kind === "AUTH" && !isRetry) { + this.logger.warn(`EIMS rejected the token on ${path}; re-authenticating once`); + this.auth.invalidate(); + return this.send(path, request, true); + } + this.logger.error(mapped.message); + throw mapped; + } + } +} diff --git a/apps/edr-freight-api/src/modules/eims/eims-credentials.provider.ts b/apps/edr-freight-api/src/modules/eims/eims-credentials.provider.ts new file mode 100644 index 000000000..b68a4a32f --- /dev/null +++ b/apps/edr-freight-api/src/modules/eims/eims-credentials.provider.ts @@ -0,0 +1,77 @@ +import { readFileSync } from "node:fs"; +import { KeyObject, createPrivateKey } from "node:crypto"; +import { Injectable, Logger } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { EimsConfig } from "../../config/eims.config"; +import { EimsConfigException } from "./eims.errors"; + +/** + * Loads the INSA-issued EIMS credentials from disk, once, and keeps them in memory. + * + * The certificate is sent as base64 of the **exact bytes of the issued file** — it is deliberately + * never parsed, re-encoded or re-exported, because that is what produced a working live login. + * The private key never leaves this process: it is only ever used to produce a signature. + */ +@Injectable() +export class EimsCredentialsProvider { + private readonly logger = new Logger(EimsCredentialsProvider.name); + private privateKey: KeyObject | null = null; + private certificateBase64: string | null = null; + + constructor(private readonly config: ConfigService) {} + + private get cfg(): EimsConfig { + return this.config.get("eims")!; + } + + /** RSA private key, parsed once. Throws a config error if the path is missing or unusable. */ + getPrivateKey(): KeyObject { + if (this.privateKey) return this.privateKey; + + const path = this.cfg.privateKeyPath; + if (!path) throw new EimsConfigException("EIMS_PRIVATE_KEY_PATH is not set"); + + let key: KeyObject; + try { + key = createPrivateKey(readFileSync(path)); + } catch (err) { + // The path is operational information, not a secret; the key material never appears. + throw new EimsConfigException( + `EIMS private key at ${path} could not be read or parsed: ${(err as Error).message}`, + ); + } + if (key.asymmetricKeyType !== "rsa") { + throw new EimsConfigException( + `EIMS private key at ${path} is ${key.asymmetricKeyType ?? "of unknown type"}; EIMS requires RSA`, + ); + } + + this.privateKey = key; + this.logger.log(`EIMS private key loaded (RSA-${key.asymmetricKeyDetails?.modulusLength ?? "?"})`); + return key; + } + + /** Base64 of the certificate file's exact bytes. No parsing, no re-encoding. */ + getCertificateBase64(): string { + if (this.certificateBase64) return this.certificateBase64; + + const path = this.cfg.certificatePath; + if (!path) throw new EimsConfigException("EIMS_CERTIFICATE_PATH is not set"); + + let bytes: Buffer; + try { + bytes = readFileSync(path); + } catch (err) { + throw new EimsConfigException( + `EIMS certificate at ${path} could not be read: ${(err as Error).message}`, + ); + } + if (bytes.length === 0) { + throw new EimsConfigException(`EIMS certificate at ${path} is empty`); + } + + this.certificateBase64 = bytes.toString("base64"); + this.logger.log(`EIMS certificate bundle loaded (${bytes.length} bytes)`); + return this.certificateBase64; + } +} diff --git a/apps/edr-freight-api/src/modules/eims/eims-signer.service.spec.ts b/apps/edr-freight-api/src/modules/eims/eims-signer.service.spec.ts new file mode 100644 index 000000000..5e408ddf7 --- /dev/null +++ b/apps/edr-freight-api/src/modules/eims/eims-signer.service.spec.ts @@ -0,0 +1,118 @@ +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createVerify, generateKeyPairSync } from "node:crypto"; +import { ConfigService } from "@nestjs/config"; +import { EimsCredentialsProvider } from "./eims-credentials.provider"; +import { EimsSignerService, toSignedBody } from "./eims-signer.service"; + +/** + * Test-only key material: generated per run, never a production key. The "certificate" fixture is + * an arbitrary byte blob — the point is that its exact bytes survive base64 round-tripping, not + * that it is a valid X.509 chain. + */ +const CERTIFICATE_FIXTURE = "Subject: CN=TEST\n-----BEGIN CERTIFICATE-----\nZm9vYmFy\n-----END CERTIFICATE-----\n"; + +let dir: string; +let keyPath: string; +let certPath: string; +let publicKeyPem: string; +let signer: EimsSignerService; + +beforeAll(() => { + dir = mkdtempSync(join(tmpdir(), "eims-signer-")); + keyPath = join(dir, "private_key.key"); + certPath = join(dir, "certificate.pem.txt"); + + const { privateKey, publicKey } = generateKeyPairSync("rsa", { modulusLength: 2048 }); + writeFileSync(keyPath, privateKey.export({ type: "pkcs8", format: "pem" })); + writeFileSync(certPath, CERTIFICATE_FIXTURE, "utf8"); + publicKeyPem = publicKey.export({ type: "spki", format: "pem" }).toString(); + + const config = { + get: () => ({ privateKeyPath: keyPath, certificatePath: certPath }), + } as unknown as ConfigService; + signer = new EimsSignerService(new EimsCredentialsProvider(config)); +}); + +afterAll(() => rmSync(dir, { recursive: true, force: true })); + +const login = () => ({ clientId: "cid", clientSecret: "secret", apikey: "key", tin: "0000000000" }); + +const verify = (payload: string, signature: string): boolean => + createVerify("RSA-SHA512").update(payload, "utf8").verify(publicKeyPem, signature, "base64"); + +describe("EimsSignerService", () => { + it("produces a signature that verifies against the matching public key", () => { + const signed = signer.signRequest(login()); + expect(verify(JSON.stringify(signed.request), signed.signature)).toBe(true); + }); + + it("fails verification when a single request field changes", () => { + const signed = signer.signRequest(login()); + const tampered = JSON.stringify({ ...signed.request, tin: "9999999999" }); + expect(verify(tampered, signed.signature)).toBe(false); + }); + + it("emits a 256-byte signature for an RSA-2048 key", () => { + const signed = signer.signRequest(login()); + expect(Buffer.from(signed.signature, "base64")).toHaveLength(256); + }); + + it("sends the certificate as base64 of the file's exact bytes", () => { + const signed = signer.signRequest(login()); + expect(signed.certificate).toBe(readFileSync(certPath).toString("base64")); + expect(Buffer.from(signed.certificate, "base64").equals(readFileSync(certPath))).toBe(true); + }); + + it("signs the inner request only, and the wire body carries those exact bytes", () => { + const signed = signer.signRequest(login()); + const body = toSignedBody(signed); + + // The signed string appears verbatim inside the transmitted envelope. + expect(body).toContain(`"request":${JSON.stringify(signed.request)}`); + // Compact, never pretty-printed. + expect(body).not.toMatch(/\n/); + expect(JSON.parse(body)).toEqual({ + request: login(), + signature: signed.signature, + certificate: signed.certificate, + }); + }); + + it("does not mutate the request object", () => { + const request = login(); + const signed = signer.signRequest(request); + expect(signed.request).toBe(request); + expect(request).toEqual(login()); + }); + + it("reuses the loaded key and certificate across calls", () => { + const first = signer.signRequest(login()); + const second = signer.signRequest(login()); + // PKCS#1 v1.5 is deterministic: same key + same payload ⇒ identical signature. + expect(second.signature).toBe(first.signature); + expect(second.certificate).toBe(first.certificate); + }); +}); + +describe("EimsCredentialsProvider", () => { + const providerFor = (paths: { privateKeyPath?: string; certificatePath?: string }) => + new EimsCredentialsProvider({ get: () => paths } as unknown as ConfigService); + + it("fails clearly when the key path is unset", () => { + expect(() => providerFor({}).getPrivateKey()).toThrow(/EIMS_PRIVATE_KEY_PATH is not set/); + }); + + it("fails clearly when the key file is missing", () => { + expect(() => providerFor({ privateKeyPath: join(dir, "nope.key") }).getPrivateKey()).toThrow( + /could not be read or parsed/, + ); + }); + + it("fails clearly when the certificate file is empty", () => { + const emptyPath = join(dir, "empty.txt"); + writeFileSync(emptyPath, ""); + expect(() => providerFor({ certificatePath: emptyPath }).getCertificateBase64()).toThrow(/is empty/); + }); +}); diff --git a/apps/edr-freight-api/src/modules/eims/eims-signer.service.ts b/apps/edr-freight-api/src/modules/eims/eims-signer.service.ts new file mode 100644 index 000000000..babec6b44 --- /dev/null +++ b/apps/edr-freight-api/src/modules/eims/eims-signer.service.ts @@ -0,0 +1,37 @@ +import { createSign } from "node:crypto"; +import { Injectable } from "@nestjs/common"; +import { EimsCredentialsProvider } from "./eims-credentials.provider"; +import { EimsSignedRequest } from "./eims.types"; + +/** + * Signs EIMS request objects, reproducing the process that produced a working live access token: + * + * 1. compact `JSON.stringify` of the **inner** request object only, + * 2. those exact UTF-8 bytes, + * 3. RSA + SHA-512 (`SHA512withRSA`, PKCS#1 v1.5 — Node's default RSA padding), + * 4. base64 of the raw signature bytes (256 bytes for an RSA-2048 key), + * 5. base64 of the certificate file's exact bytes. + * + * The outer `{request, signature, certificate}` envelope is never itself signed, and the request + * object is never mutated after serialization. + */ +@Injectable() +export class EimsSignerService { + constructor(private readonly credentials: EimsCredentialsProvider) {} + + signRequest(request: T): EimsSignedRequest { + const payload = JSON.stringify(request); + const signature = createSign("RSA-SHA512") + .update(payload, "utf8") + .sign(this.credentials.getPrivateKey(), "base64"); + + return { request, signature, certificate: this.credentials.getCertificateBase64() }; + } +} + +/** + * Exact wire body for a signed envelope. Serializing here (rather than handing axios an object) + * keeps one serializer in play: the `request` segment of this string is byte-identical to the + * string that was signed. + */ +export const toSignedBody = (signed: EimsSignedRequest): string => JSON.stringify(signed); diff --git a/apps/edr-freight-api/src/modules/eims/eims.errors.ts b/apps/edr-freight-api/src/modules/eims/eims.errors.ts new file mode 100644 index 000000000..3be21fdd3 --- /dev/null +++ b/apps/edr-freight-api/src/modules/eims/eims.errors.ts @@ -0,0 +1,89 @@ +import { BadGatewayException, ServiceUnavailableException } from "@nestjs/common"; +import { AxiosError } from "axios"; +import { EimsErrorResponse } from "./eims.types"; + +export type EimsFailureKind = + | "NETWORK" + | "TIMEOUT" + | "SCHEMA_VALIDATION" + | "AUTH" + | "FORBIDDEN" + | "RULE_VALIDATION" + | "SERVER" + | "UNKNOWN"; + +/** Raised when EIMS is disabled or its credential files are unusable. */ +export class EimsConfigException extends ServiceUnavailableException { + constructor(message: string) { + super({ code: "EIMS_NOT_CONFIGURED", message }); + } +} + +/** + * A failed EIMS call. Carries only the gateway's own error reporting — never the request body, + * signature, certificate, bearer token or any configured secret. + */ +export class EimsApiException extends BadGatewayException { + constructor( + readonly kind: EimsFailureKind, + message: string, + readonly httpStatus?: number, + readonly details?: EimsErrorResponse, + ) { + super({ code: `EIMS_${kind}`, message }); + } +} + +const SAFE_KEYS = ["message", "statusCode", "code", "details", "body"] as const; + +/** + * Keep only the gateway's error-reporting fields. Anything else a response might carry — an echoed + * request, a token, a signature — is dropped before it can reach a log or an exception payload. + */ +export function redactEimsBody(data: unknown): EimsErrorResponse | undefined { + if (!data || typeof data !== "object") return undefined; + const source = data as Record; + const safe: Record = {}; + for (const key of SAFE_KEYS) { + if (source[key] !== undefined) safe[key] = source[key]; + } + return Object.keys(safe).length > 0 ? (safe as EimsErrorResponse) : undefined; +} + +const kindFor = (status: number): EimsFailureKind => { + if (status === 400) return "SCHEMA_VALIDATION"; + if (status === 401) return "AUTH"; + if (status === 403) return "FORBIDDEN"; + if (status === 406) return "RULE_VALIDATION"; + if (status >= 500) return "SERVER"; + return "UNKNOWN"; +}; + +/** First error line the gateway gives us, whichever shape it used. */ +const describe = (body: EimsErrorResponse | undefined): string => { + if (!body) return "no error body"; + const detail = body.details?.find((d) => d.errorMessage)?.errorMessage; + return [body.message, body.code && `code=${body.code}`, detail].filter(Boolean).join(" ") || "no error body"; +}; + +/** + * Normalise anything thrown by an EIMS HTTP call into an `EimsApiException`. `operation` is a + * short label such as `"login"` or `"POST /v1/register"` — never a payload. + */ +export function toEimsApiException(err: unknown, operation: string): EimsApiException { + if (err instanceof EimsApiException) return err; + + if (err instanceof AxiosError) { + if (err.code === "ECONNABORTED" || err.code === "ETIMEDOUT") { + return new EimsApiException("TIMEOUT", `EIMS ${operation} timed out`); + } + if (!err.response) { + return new EimsApiException("NETWORK", `EIMS ${operation} could not reach the gateway (${err.code ?? "no code"})`); + } + const status = err.response.status; + const body = redactEimsBody(err.response.data); + return new EimsApiException(kindFor(status), `EIMS ${operation} failed (${status}): ${describe(body)}`, status, body); + } + + return new EimsApiException("UNKNOWN", `EIMS ${operation} failed: ${(err as Error)?.message ?? "unknown error"}`); +} diff --git a/apps/edr-freight-api/src/modules/eims/eims.module.ts b/apps/edr-freight-api/src/modules/eims/eims.module.ts new file mode 100644 index 000000000..4c953c14e --- /dev/null +++ b/apps/edr-freight-api/src/modules/eims/eims.module.ts @@ -0,0 +1,19 @@ +import { HttpModule } from "@nestjs/axios"; +import { Module } from "@nestjs/common"; +import { EimsAuthService } from "./eims-auth.service"; +import { EimsClientService } from "./eims-client.service"; +import { EimsCredentialsProvider } from "./eims-credentials.provider"; +import { EimsSignerService } from "./eims-signer.service"; + +/** + * MoR EIMS e-invoicing transport. Exports only what other modules will consume; the credential + * loader and signer stay internal so the private key has exactly one user. + */ +@Module({ + imports: [ + HttpModule.register({ timeout: Number(process.env.EIMS_HTTP_TIMEOUT_MS) || 30_000 }), + ], + providers: [EimsCredentialsProvider, EimsSignerService, EimsAuthService, EimsClientService], + exports: [EimsAuthService, EimsClientService], +}) +export class EimsModule {} diff --git a/apps/edr-freight-api/src/modules/eims/eims.types.ts b/apps/edr-freight-api/src/modules/eims/eims.types.ts new file mode 100644 index 000000000..6e74604bd --- /dev/null +++ b/apps/edr-freight-api/src/modules/eims/eims.types.ts @@ -0,0 +1,46 @@ +/** + * Wire types for the MoR EIMS gateway, taken from the supplied Postman collection. + * + * Every protected payload is the same envelope: the business object under `request`, a base64 + * RSA-SHA512 signature over the *inner* object only, and the base64 certificate bundle. + */ +export interface EimsSignedRequest { + request: T; + signature: string; + certificate: string; +} + +/** Inner request of `POST /auth/login`. Note the lowercase `apikey` — that is the wire name. */ +export interface EimsLoginRequest { + clientId: string; + clientSecret: string; + apikey: string; + tin: string; +} + +export interface EimsLoginData { + accessToken: string; + refreshToken: string; + /** Observed as a UUID on login and `null` on refresh; unused today. */ + encryptionKey: string | null; + /** Seconds. Observed value: 3600. */ + expiresIn: number; +} + +export interface EimsLoginResponse { + data: EimsLoginData; + status: string; +} + +/** + * Error bodies differ per failure mode: gateway errors carry `message`/`code`/`details`, + * schema errors carry a JSON-Schema violation array under `body`, rule errors carry + * `[{portion, errorMessage[]}]` under `body`. Only these fields are ever surfaced or logged. + */ +export interface EimsErrorResponse { + message?: string; + statusCode?: number; + code?: string; + details?: { errorMessage?: string; field?: string }[]; + body?: unknown; +} diff --git a/apps/edr-freight-api/src/scripts/eims-login.ts b/apps/edr-freight-api/src/scripts/eims-login.ts new file mode 100644 index 000000000..f9cc60d27 --- /dev/null +++ b/apps/edr-freight-api/src/scripts/eims-login.ts @@ -0,0 +1,40 @@ +import "dotenv/config"; +import axios from "axios"; +import { HttpService } from "@nestjs/axios"; +import { ConfigService } from "@nestjs/config"; +import eimsConfig, { EimsConfig } from "../config/eims.config"; +import { EimsAuthService } from "../modules/eims/eims-auth.service"; +import { EimsCredentialsProvider } from "../modules/eims/eims-credentials.provider"; +import { EimsSignerService } from "../modules/eims/eims-signer.service"; + +/** + * Manual, developer-run live check of EIMS authentication. + * + * Run explicitly: pnpm --filter @edr/freight-api eims:login + * + * Reads credentials from the local .env only. Never runs at boot, never runs in the test suite, + * and prints no token, secret, signature or certificate — only whether login succeeded. + */ +async function main(): Promise { + const config = eimsConfig() as EimsConfig; + if (!config.enabled) { + throw new Error("EIMS_ENABLED is not true — set it in .env before running this check"); + } + + const configService = { get: () => config } as unknown as ConfigService; + const http = new HttpService(axios.create()); + const credentials = new EimsCredentialsProvider(configService); + const auth = new EimsAuthService(http, configService, new EimsSignerService(credentials)); + + console.log(`POST ${config.baseUrl}/auth/login (tin=${config.tin})`); + const token = await auth.getValidAccessToken(); + console.log(`✔ login succeeded — access token received (${token.length} chars, not printed)`); + + const cached = await auth.getValidAccessToken(); + console.log(`✔ second call served from cache: ${cached === token}`); +} + +main().catch((err: Error) => { + console.error(`✘ ${err.message}`); + process.exitCode = 1; +}); From 75730190381474fb59039b0c6975e486c6329fb8 Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Fri, 7 Aug 2026 13:22:46 +0000 Subject: [PATCH 008/276] feat(eims): register invoices with MoR EIMS and persist the outcome Add manual single-invoice registration, verification and reconciliation. Nothing submits automatically; invoice creation is untouched. Sequencing uses a durable reservation. The counter is consumed and the holder recorded in a committed transaction before the request leaves the process, and the HTTP call runs outside every transaction. A counter is therefore never reused once an attempt begins, a crash mid-flight leaves the reservation standing instead of inviting a blind resubmission, and an ambiguous result blocks the whole system number rather than one invoice -- PreviousIrn is unknown, so any later document would chain to a stale IRN. Deterministic rejections (400/406/401/403) mark the invoice FAILED and clear the block. Timeouts and 5xx mark it UNKNOWN and keep it. Since /v1/verify takes an IRN we never received in that case, POST :id/eims/resolve is the exit: record the IRN confirmed in the MoR portal, or discard. A recorded IRN is verified against the gateway first and refused unless EIMS reports it against this invoice's document number. Business and tax configuration is validated locally before anything is locked, allocated or sent, so a missing tax code fails naming the exact environment variables instead of at the gateway. No tax value is defaulted. Filing gets its own permission (invoices:eims_register) rather than riding on invoices:export -- registration is irreversible at MoR and must not follow from the right to download a PDF. Co-Authored-By: Claude Opus 5 --- .../edr-freight-api/src/config/eims.config.ts | 77 +++ .../3300000000000-EimsInvoiceRegistration.ts | 73 +++ .../billing/entities/invoice.entity.ts | 24 + .../eims/dto/resolve-eims-registration.dto.ts | 25 + .../modules/eims/eims-auth.service.spec.ts | 31 +- .../src/modules/eims/eims-client.service.ts | 19 +- .../src/modules/eims/eims-invoice-context.ts | 114 ++++ .../eims-invoice-registration.service.spec.ts | 507 ++++++++++++++++++ .../eims/eims-invoice-registration.service.ts | 472 ++++++++++++++++ .../modules/eims/eims-invoice.controller.ts | 60 +++ .../modules/eims/eims-registration.types.ts | 87 +++ .../src/modules/eims/eims.module.ts | 24 +- .../eims/entities/eims-system-state.entity.ts | 42 ++ .../src/seed/freight-permissions.registry.ts | 8 + 14 files changed, 1555 insertions(+), 8 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/3300000000000-EimsInvoiceRegistration.ts create mode 100644 apps/edr-freight-api/src/modules/eims/dto/resolve-eims-registration.dto.ts create mode 100644 apps/edr-freight-api/src/modules/eims/eims-invoice-context.ts create mode 100644 apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.spec.ts create mode 100644 apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.ts create mode 100644 apps/edr-freight-api/src/modules/eims/eims-invoice.controller.ts create mode 100644 apps/edr-freight-api/src/modules/eims/eims-registration.types.ts create mode 100644 apps/edr-freight-api/src/modules/eims/entities/eims-system-state.entity.ts diff --git a/apps/edr-freight-api/src/config/eims.config.ts b/apps/edr-freight-api/src/config/eims.config.ts index 37cbbc84b..4e8684a5b 100644 --- a/apps/edr-freight-api/src/config/eims.config.ts +++ b/apps/edr-freight-api/src/config/eims.config.ts @@ -26,6 +26,44 @@ export interface EimsConfig { httpTimeoutMs: number; /** Re-authenticate this many ms before the access token actually expires. */ tokenSkewMs: number; + /** + * Seller identity and tax/business treatment for the invoice document. + * + * None of this is derivable from the database: EDR's own legal identity exists nowhere in the + * codebase, and the app models no tax at all. Values are required at registration time and are + * validated there rather than at boot, so a deployment can run with EIMS enabled for + * authentication before finance has signed off on the tax treatment. + */ + invoice: EimsInvoiceConfig; +} + +export interface EimsInvoiceConfig { + sellerLegalName: string; + sellerVatNumber: string; + sellerPhone: string; + sellerEmail: string; + /** MoR *codes*, not names (e.g. "13" for Addis Ababa, "574"). */ + sellerRegion: string; + sellerWereda: string; + sellerCity: string | null; + sellerSubCity: string | null; + sellerHouseNumber: string | null; + sellerLocality: string | null; + /** REQUIRES_BUSINESS_CONFIRMATION — no tax model exists in this application. */ + taxCode: string; + taxRatePercent: number | null; + exciseTaxValue: number | null; + incomeWithholdValue: number | null; + transactionWithholdValue: number | null; + /** B2B / B2C — a tax classification, so it is configured, not inferred. */ + transactionType: string; + natureOfSupplies: string; + paymentMode: string; + paymentTerm: string; + unitDefault: string; + buyerCountryCode: string | null; + cashierName: string | null; + salesPersonName: string | null; } const REQUIRED_VARS = [ @@ -46,6 +84,14 @@ const positiveInt = (raw: string | undefined, fallback: number, name: string): n return value; }; +/** Unset stays null so the registration-time check can name it; a set-but-bogus value throws. */ +const optionalNumber = (raw: string | undefined, name: string): number | null => { + if (raw === undefined || raw === "") return null; + const value = Number(raw); + if (!Number.isFinite(value)) throw new Error(`${name} must be a number`); + return value; +}; + export default registerAs("eims", (): EimsConfig => { const enabled = (process.env.EIMS_ENABLED ?? "false").toLowerCase() === "true"; const baseUrl = (process.env.EIMS_BASE_URL ?? "https://core.mor.gov.et").replace(/\/+$/, ""); @@ -66,6 +112,37 @@ export default registerAs("eims", (): EimsConfig => { certificatePath: process.env.EIMS_CERTIFICATE_PATH ?? "", httpTimeoutMs, tokenSkewMs, + invoice: { + sellerLegalName: process.env.EIMS_SELLER_LEGAL_NAME ?? "", + sellerVatNumber: process.env.EIMS_SELLER_VAT_NUMBER ?? "", + sellerPhone: process.env.EIMS_SELLER_PHONE ?? "", + sellerEmail: process.env.EIMS_SELLER_EMAIL ?? "", + sellerRegion: process.env.EIMS_SELLER_REGION ?? "", + sellerWereda: process.env.EIMS_SELLER_WEREDA ?? "", + sellerCity: process.env.EIMS_SELLER_CITY || null, + sellerSubCity: process.env.EIMS_SELLER_SUBCITY || null, + sellerHouseNumber: process.env.EIMS_SELLER_HOUSE_NUMBER || null, + sellerLocality: process.env.EIMS_SELLER_LOCALITY || null, + taxCode: process.env.EIMS_TAX_CODE ?? "", + taxRatePercent: optionalNumber(process.env.EIMS_TAX_RATE_PERCENT, "EIMS_TAX_RATE_PERCENT"), + exciseTaxValue: optionalNumber(process.env.EIMS_EXCISE_TAX_VALUE, "EIMS_EXCISE_TAX_VALUE"), + incomeWithholdValue: optionalNumber( + process.env.EIMS_INCOME_WITHHOLD_VALUE, + "EIMS_INCOME_WITHHOLD_VALUE", + ), + transactionWithholdValue: optionalNumber( + process.env.EIMS_TRANSACTION_WITHHOLD_VALUE, + "EIMS_TRANSACTION_WITHHOLD_VALUE", + ), + transactionType: process.env.EIMS_TRANSACTION_TYPE ?? "", + natureOfSupplies: process.env.EIMS_NATURE_OF_SUPPLIES ?? "", + paymentMode: process.env.EIMS_PAYMENT_MODE ?? "", + paymentTerm: process.env.EIMS_PAYMENT_TERM ?? "", + unitDefault: process.env.EIMS_UNIT_DEFAULT ?? "", + buyerCountryCode: process.env.EIMS_BUYER_COUNTRY_CODE || null, + cashierName: process.env.EIMS_CASHIER_NAME || null, + salesPersonName: process.env.EIMS_SALESPERSON_NAME || null, + }, }; if (!enabled) return base; diff --git a/apps/edr-freight-api/src/migrations/3300000000000-EimsInvoiceRegistration.ts b/apps/edr-freight-api/src/migrations/3300000000000-EimsInvoiceRegistration.ts new file mode 100644 index 000000000..c80dfcd1e --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3300000000000-EimsInvoiceRegistration.ts @@ -0,0 +1,73 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * EIMS registration state. + * + * `freight.invoices` gains the per-invoice registration outcome: which EIMS counter the invoice + * consumed, the returned IRN, and the last failure. The partial unique index on `eims_irn` is the + * database-level guarantee that one IRN can never be recorded against two invoices, independent of + * application logic. + * + * `freight.eims_system_state` is a single row per MoR system number holding the sequence the + * gateway expects: the next `SourceSystem.InvoiceCounter` and the `ReferenceDetails.PreviousIrn` + * of the last successful registration. Registration takes `FOR UPDATE` on this row, so the counter + * and the IRN chain stay consistent under concurrent submissions. + * + * The `in_flight_*` columns make a submission a *durable reservation*: the counter is consumed and + * the holder recorded in a committed transaction before the HTTP call, so a crash mid-flight leaves + * evidence instead of silently freeing the slot for a blind resubmission. `blocked_reason` is set + * when a submission ends ambiguously (timeout, network, 5xx) — the IRN is unknown, so every later + * document for this system number would chain to a stale `PreviousIrn` and registration stops until + * a human resolves it. + * + * `eims_ack_date` is varchar, not timestamptz: EIMS returns a Java ZonedDateTime string + * ("2025-03-21T08:33:32.707753413Z[Etc/UTC]") that no JS date parser accepts. It is stored + * verbatim so a compliance value is never mangled by a parse. + */ +export class EimsInvoiceRegistration3300000000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.invoices + ADD COLUMN IF NOT EXISTS eims_status varchar(20) NOT NULL DEFAULT 'NOT_SUBMITTED', + ADD COLUMN IF NOT EXISTS eims_irn varchar(64), + ADD COLUMN IF NOT EXISTS eims_invoice_counter bigint, + ADD COLUMN IF NOT EXISTS eims_submitted_at timestamptz, + ADD COLUMN IF NOT EXISTS eims_ack_date varchar(64), + ADD COLUMN IF NOT EXISTS eims_last_error jsonb + `); + + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS ux_invoices_eims_irn + ON freight.invoices (eims_irn) WHERE eims_irn IS NOT NULL + `); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.eims_system_state ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + system_number varchar(32) NOT NULL UNIQUE, + next_invoice_counter bigint NOT NULL DEFAULT 1, + previous_irn varchar(64), + in_flight_invoice_id uuid, + in_flight_counter bigint, + blocked_reason text, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz + ) + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS freight.eims_system_state`); + await queryRunner.query(`DROP INDEX IF EXISTS freight.ux_invoices_eims_irn`); + await queryRunner.query(` + ALTER TABLE freight.invoices + DROP COLUMN IF EXISTS eims_status, + DROP COLUMN IF EXISTS eims_irn, + DROP COLUMN IF EXISTS eims_invoice_counter, + DROP COLUMN IF EXISTS eims_submitted_at, + DROP COLUMN IF EXISTS eims_ack_date, + DROP COLUMN IF EXISTS eims_last_error + `); + } +} diff --git a/apps/edr-freight-api/src/modules/billing/entities/invoice.entity.ts b/apps/edr-freight-api/src/modules/billing/entities/invoice.entity.ts index 23c332f80..c5c000943 100644 --- a/apps/edr-freight-api/src/modules/billing/entities/invoice.entity.ts +++ b/apps/edr-freight-api/src/modules/billing/entities/invoice.entity.ts @@ -1,6 +1,7 @@ import { BaseEntity } from "@edr/api-common"; import { Freight } from "@edr/types"; import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm"; +import type { EimsInvoiceError, EimsInvoiceStatus } from "../../eims/eims-registration.types"; import { PaymentEntity } from "../../payment/entities/payment.entity"; import { Company } from "../../companies/entities/company.entity"; import { CompanyProfile } from "../../companies/entities/company-profile.entity"; @@ -105,4 +106,27 @@ export class Invoice extends BaseEntity { @Column({ name: "due_at", type: "timestamptz" }) dueAt!: Date; + + /** MoR EIMS registration state. Set only by the EIMS module; billing never writes these. */ + @Column({ name: "eims_status", type: "varchar", length: 20, default: "NOT_SUBMITTED" }) + eimsStatus!: EimsInvoiceStatus; + + /** Invoice Reference Number returned by EIMS. Unique across invoices (partial index). */ + @Column({ name: "eims_irn", type: "varchar", length: 64, nullable: true }) + eimsIrn?: string | null; + + /** The `SourceSystem.InvoiceCounter` this invoice consumed. */ + @Column({ name: "eims_invoice_counter", type: "bigint", nullable: true }) + eimsInvoiceCounter?: number | null; + + @Column({ name: "eims_submitted_at", type: "timestamptz", nullable: true }) + eimsSubmittedAt?: Date | null; + + /** EIMS acknowledgement timestamp, stored verbatim — it is a Java ZonedDateTime string. */ + @Column({ name: "eims_ack_date", type: "varchar", length: 64, nullable: true }) + eimsAckDate?: string | null; + + /** Sanitized last failure: the gateway's own error fields only, never our signed envelope. */ + @Column({ name: "eims_last_error", type: "jsonb", nullable: true }) + eimsLastError?: EimsInvoiceError | null; } diff --git a/apps/edr-freight-api/src/modules/eims/dto/resolve-eims-registration.dto.ts b/apps/edr-freight-api/src/modules/eims/dto/resolve-eims-registration.dto.ts new file mode 100644 index 000000000..66896fd18 --- /dev/null +++ b/apps/edr-freight-api/src/modules/eims/dto/resolve-eims-registration.dto.ts @@ -0,0 +1,25 @@ +import { ApiPropertyOptional } from "@nestjs/swagger"; +import { IsBoolean, IsOptional, IsString, Length } from "class-validator"; + +/** + * Manual reconciliation of a submission that was never acknowledged. Exactly one of the two is + * meaningful: supply the IRN confirmed with MoR, or discard the attempt. + */ +export class ResolveEimsRegistrationDto { + @ApiPropertyOptional({ + description: "IRN confirmed in the MoR portal. Records the registration and resumes the chain.", + example: "9fe9bbbece6ab76c112b617534e6aac7aa8b819d5be79f4d3d088ed2e887b2e0", + }) + @IsOptional() + @IsString() + @Length(1, 64) + irn?: string; + + @ApiPropertyOptional({ + description: "Abandon the submission: the invoice is marked FAILED and the chain is unchanged.", + example: true, + }) + @IsOptional() + @IsBoolean() + discard?: boolean; +} diff --git a/apps/edr-freight-api/src/modules/eims/eims-auth.service.spec.ts b/apps/edr-freight-api/src/modules/eims/eims-auth.service.spec.ts index 1ff0d0463..75a702e94 100644 --- a/apps/edr-freight-api/src/modules/eims/eims-auth.service.spec.ts +++ b/apps/edr-freight-api/src/modules/eims/eims-auth.service.spec.ts @@ -2,7 +2,7 @@ import { HttpService } from "@nestjs/axios"; import { ConfigService } from "@nestjs/config"; import { AxiosError, AxiosHeaders } from "axios"; import { of, throwError } from "rxjs"; -import { EimsConfig } from "../../config/eims.config"; +import { EimsConfig, EimsInvoiceConfig } from "../../config/eims.config"; import { EimsAuthService } from "./eims-auth.service"; import { EimsSignerService } from "./eims-signer.service"; @@ -22,6 +22,35 @@ const cfg = (over: Partial = {}): EimsConfig => ({ certificatePath: "/dev/null", httpTimeoutMs: 30_000, tokenSkewMs: 45_000, + invoice: eimsInvoiceConfig(), + ...over, +}); + +/** Authentication never reads these; they exist so the fixture satisfies EimsConfig. */ +export const eimsInvoiceConfig = (over: Partial = {}): EimsInvoiceConfig => ({ + sellerLegalName: "Ethio-Djibouti Railway S.C.", + sellerVatNumber: "0000000000", + sellerPhone: "0911223344", + sellerEmail: "finance@example.et", + sellerRegion: "13", + sellerWereda: "574", + sellerCity: null, + sellerSubCity: null, + sellerHouseNumber: null, + sellerLocality: null, + taxCode: "VAT15", + taxRatePercent: 15, + exciseTaxValue: 0, + incomeWithholdValue: 0, + transactionWithholdValue: 0, + transactionType: "B2B", + natureOfSupplies: "Service", + paymentMode: "CASH", + paymentTerm: "IMMIDIATE", + unitDefault: "PCS", + buyerCountryCode: null, + cashierName: null, + salesPersonName: null, ...over, }); diff --git a/apps/edr-freight-api/src/modules/eims/eims-client.service.ts b/apps/edr-freight-api/src/modules/eims/eims-client.service.ts index 04418cf52..610647b1a 100644 --- a/apps/edr-freight-api/src/modules/eims/eims-client.service.ts +++ b/apps/edr-freight-api/src/modules/eims/eims-client.service.ts @@ -33,17 +33,30 @@ export class EimsClientService { * A 401 invalidates the cached token and retries exactly once. */ async postSigned(path: string, request: TRequest): Promise { - return this.send(path, request, false); + return this.send(path, request, false, true); + } + + /** + * POST `request` verbatim — bearer-authenticated but **not** wrapped in a signed envelope. + * + * `/v1/verify` is the only endpoint observed to work this way: the supplied collection sends a + * raw `{"irn":"…"}` body with no `signature`/`certificate` siblings. Kept as its own entry point + * so that if the live gateway turns out to require signing after all, exactly one call site + * changes — `postSigned` is already the alternative. + */ + async postBearer(path: string, request: TRequest): Promise { + return this.send(path, request, false, false); } private async send( path: string, request: TRequest, isRetry: boolean, + signed: boolean, ): Promise { const cfg = this.cfg; const token = await this.auth.getValidAccessToken(); - const body = toSignedBody(this.signer.signRequest(request)); + const body = signed ? toSignedBody(this.signer.signRequest(request)) : request; try { const res = await firstValueFrom( @@ -58,7 +71,7 @@ export class EimsClientService { if (mapped.kind === "AUTH" && !isRetry) { this.logger.warn(`EIMS rejected the token on ${path}; re-authenticating once`); this.auth.invalidate(); - return this.send(path, request, true); + return this.send(path, request, true, signed); } this.logger.error(mapped.message); throw mapped; diff --git a/apps/edr-freight-api/src/modules/eims/eims-invoice-context.ts b/apps/edr-freight-api/src/modules/eims/eims-invoice-context.ts new file mode 100644 index 000000000..1a68f2ce4 --- /dev/null +++ b/apps/edr-freight-api/src/modules/eims/eims-invoice-context.ts @@ -0,0 +1,114 @@ +import { BadRequestException } from "@nestjs/common"; +import { EimsConfig } from "../../config/eims.config"; +import { + EimsMapperContext, + EimsMapperLine, + EimsSellerDetails, +} from "../billing/eims-invoice.mapper"; + +/** + * Turns configuration into the seller identity and mapper context that `toEimsInvoice` requires. + * + * Everything here is unavailable from the database by construction: EDR's own legal identity is not + * modelled anywhere, and the application has no tax model at all (`invoice.taxAmount` is always 0, + * `invoice_lines` and the rate catalogue carry no fiscal columns). Rather than defaulting any of it, + * a missing value fails **here** — locally, before a single byte reaches the gateway — naming the + * exact environment variables to set. + */ + +interface RequiredSpec { + env: string; + value: string | number | null | undefined; +} + +const REQUIRED = (invoice: EimsConfig["invoice"], tin: string, systemNumber: string, systemType: string): RequiredSpec[] => [ + { env: "EIMS_TIN", value: tin }, + { env: "EIMS_SYSTEM_NUMBER", value: systemNumber }, + { env: "EIMS_SYSTEM_TYPE", value: systemType }, + { env: "EIMS_SELLER_LEGAL_NAME", value: invoice.sellerLegalName }, + { env: "EIMS_SELLER_VAT_NUMBER", value: invoice.sellerVatNumber }, + { env: "EIMS_SELLER_PHONE", value: invoice.sellerPhone }, + { env: "EIMS_SELLER_EMAIL", value: invoice.sellerEmail }, + { env: "EIMS_SELLER_REGION", value: invoice.sellerRegion }, + { env: "EIMS_SELLER_WEREDA", value: invoice.sellerWereda }, + { env: "EIMS_TAX_CODE", value: invoice.taxCode }, + { env: "EIMS_TAX_RATE_PERCENT", value: invoice.taxRatePercent }, + { env: "EIMS_INCOME_WITHHOLD_VALUE", value: invoice.incomeWithholdValue }, + { env: "EIMS_TRANSACTION_WITHHOLD_VALUE", value: invoice.transactionWithholdValue }, + { env: "EIMS_TRANSACTION_TYPE", value: invoice.transactionType }, + { env: "EIMS_NATURE_OF_SUPPLIES", value: invoice.natureOfSupplies }, + { env: "EIMS_PAYMENT_MODE", value: invoice.paymentMode }, + { env: "EIMS_PAYMENT_TERM", value: invoice.paymentTerm }, + { env: "EIMS_UNIT_DEFAULT", value: invoice.unitDefault }, +]; + +/** Throws naming every unset variable at once, so one round trip fixes the whole configuration. */ +export function assertEimsInvoiceConfig(config: EimsConfig): void { + const missing = REQUIRED(config.invoice, config.tin, config.systemNumber, config.systemType) + .filter(({ value }) => value === null || value === undefined || value === "") + .map(({ env }) => env); + + if (missing.length > 0) { + throw new BadRequestException({ + code: "EIMS_INVOICE_CONFIG_INCOMPLETE", + message: + "EIMS invoice registration is not configured. Set these environment variables " + + `(tax values need finance sign-off — they are deliberately not defaulted): ${missing.join(", ")}`, + }); + } +} + +export function buildEimsSeller(config: EimsConfig): EimsSellerDetails { + const { invoice } = config; + return { + City: invoice.sellerCity, + Email: invoice.sellerEmail, + HouseNumber: invoice.sellerHouseNumber, + LegalName: invoice.sellerLegalName, + Locality: invoice.sellerLocality, + Phone: invoice.sellerPhone, + Region: invoice.sellerRegion, + SubCity: invoice.sellerSubCity, + Tin: config.tin, + VatNumber: invoice.sellerVatNumber, + Wereda: invoice.sellerWereda, + }; +} + +export interface EimsContextInput { + /** `DocumentDetails.DocumentNumber`. The caller decides its source. */ + documentNumber: string; + invoiceCounter: number; + previousIrn: string | null; + /** Required when the invoice currency is not ETB. */ + exchangeRate?: number | null; +} + +export function buildEimsContext(config: EimsConfig, input: EimsContextInput): EimsMapperContext { + const { invoice } = config; + // Validated by assertEimsInvoiceConfig; the non-null assertions below are safe after that call. + const taxCode = invoice.taxCode; + const ratePercent = invoice.taxRatePercent!; + const exciseTaxValue = invoice.exciseTaxValue ?? 0; + + return { + systemNumber: config.systemNumber, + systemType: config.systemType, + documentNumber: input.documentNumber, + invoiceCounter: input.invoiceCounter, + previousIrn: input.previousIrn, + cashierName: invoice.cashierName, + salesPersonName: invoice.salesPersonName, + transactionType: invoice.transactionType, + payment: { mode: invoice.paymentMode, term: invoice.paymentTerm }, + // One treatment for every line today. The mapper resolves tax per line, so a future + // charge-type-specific rule slots in here without touching the mapper. + taxForLine: (_line: EimsMapperLine) => ({ code: taxCode, ratePercent, exciseTaxValue }), + natureOfSupplies: invoice.natureOfSupplies, + unitDefault: invoice.unitDefault, + incomeWithholdValue: invoice.incomeWithholdValue!, + transactionWithholdValue: invoice.transactionWithholdValue!, + buyerCountryCode: invoice.buyerCountryCode, + exchangeRate: input.exchangeRate ?? null, + }; +} diff --git a/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.spec.ts b/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.spec.ts new file mode 100644 index 000000000..362c754cb --- /dev/null +++ b/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.spec.ts @@ -0,0 +1,507 @@ +import { BadRequestException, ConflictException } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { DataSource } from "typeorm"; + +import { EimsConfig } from "../../config/eims.config"; +import { Invoice } from "../billing/entities/invoice.entity"; +import { EimsInvoiceRequest } from "../billing/eims-invoice.mapper"; +import { eimsInvoiceConfig } from "./eims-auth.service.spec"; +import { EimsClientService } from "./eims-client.service"; +import { EimsApiException } from "./eims.errors"; +import { EimsInvoiceRegistrationService } from "./eims-invoice-registration.service"; +import { EimsSystemState } from "./entities/eims-system-state.entity"; +import { EimsInvoiceStatus } from "./eims-registration.types"; + +const SYSTEM_NUMBER = "B0360154BA"; +const INVOICE_ID = "11111111-1111-4111-8111-111111111111"; +const OTHER_INVOICE_ID = "22222222-2222-4222-8222-222222222222"; +const IRN = "9fe9bbbece6ab76c112b617534e6aac7aa8b819d5be79f4d3d088ed2e887b2e0"; + +const config = (over: Partial = {}): EimsConfig => + ({ + enabled: true, + baseUrl: "https://core.mor.gov.et", + clientId: "cid", + clientSecret: "secret", + apiKey: "key", + tin: "0000034558", + systemNumber: SYSTEM_NUMBER, + systemType: "SYS", + privateKeyPath: "/dev/null", + certificatePath: "/dev/null", + httpTimeoutMs: 30_000, + tokenSkewMs: 45_000, + invoice: eimsInvoiceConfig(over), + }) as EimsConfig; + +const invoiceRow = (over: Partial = {}): Invoice => + ({ + id: INVOICE_ID, + invoiceNumber: "INV-20260807-00042", + currency: "ETB", + issuedAt: new Date(2026, 7, 7, 9, 5, 3), + totalAmount: "10000.00", + eimsStatus: EimsInvoiceStatus.NotSubmitted, + eimsIrn: null, + eimsInvoiceCounter: null, + eimsSubmittedAt: null, + eimsAckDate: null, + eimsLastError: null, + company: { + name: "ABC Trading PLC", + tin: "0999930000", + vatNumber: "123475885858", + phone: "0912345678", + email: "buyer@abc.et", + region: "13", + zone: "SHA", + woreda: "574", + kebele: "03", + houseNo: "NEW", + country: "Ethiopia", + }, + ...over, + }) as unknown as Invoice; + +const LINES = [ + { + chargeType: "RAIL_FREIGHT", + description: "Addis to Djibouti", + quantity: "1.00", + unitRate: "10000.00", + amount: "10000.00", + }, +]; + +/** + * In-memory stand-in for the two locked rows. `update` merges, `createQueryBuilder(...).getOne()` + * returns the live object — enough to assert ordering, values and the reservation lifecycle without + * a database. + */ +class FakeDb { + invoices = new Map(); + state: EimsSystemState | null = null; + /** Runs before every transaction body, to simulate a concurrent writer. */ + onTransaction: (() => void) | null = null; + + constructor(invoices: Invoice[], state?: Partial) { + for (const inv of invoices) this.invoices.set(inv.id, inv); + this.state = { + id: "state-1", + systemNumber: SYSTEM_NUMBER, + nextInvoiceCounter: 7, + previousIrn: null, + inFlightInvoiceId: null, + inFlightCounter: null, + blockedReason: null, + ...state, + } as EimsSystemState; + } + + private manager = { + createQueryBuilder: (entity: unknown) => { + const isInvoice = entity === Invoice; + let id: string | undefined; + const builder = { + setLock: () => builder, + where: (_clause: string, params: Record) => { + id = params.invoiceId ?? params.systemNumber; + return builder; + }, + getOne: async () => (isInvoice ? (this.invoices.get(id!) ?? null) : this.state), + }; + return builder; + }, + findOne: async (_entity: unknown, options: { where: { id: string } }) => + this.invoices.get(options.where.id) ?? null, + update: async (entity: unknown, id: string, patch: Record) => { + if (entity === Invoice) Object.assign(this.invoices.get(id)!, patch); + else Object.assign(this.state!, patch); + }, + query: async () => [], + getRepository: () => ({ + findOne: async (options: { where: { id: string } }) => + this.invoices.get(options.where.id) ?? null, + }), + }; + + asDataSource(): DataSource { + return { + manager: this.manager, + getRepository: this.manager.getRepository, + query: async () => LINES, + transaction: async (body: (m: unknown) => Promise) => { + this.onTransaction?.(); + return body(this.manager); + }, + } as unknown as DataSource; + } +} + +const build = ( + db: FakeDb, + postSigned: jest.Mock, + cfg: EimsConfig = config(), + postBearer: jest.Mock = jest.fn(), +) => + new EimsInvoiceRegistrationService( + db.asDataSource(), + { get: () => cfg } as unknown as ConfigService, + { postSigned, postBearer } as unknown as EimsClientService, + ); + +/** Document number the fixtures register under; `/v1/verify` must echo it back. */ +const DOCUMENT_NUMBER = "INV-20260807-00042"; + +/** + * `/v1/verify` success. The response spells the reference `Irn` while the request uses `irn`, and + * the collection's own fixture uses a *different* example value on each side — so nothing here + * assumes the two match. + */ +const verifyResponse = (over: Record = {}) => ({ + statusCode: 200, + message: "SUCCESS", + body: { + Irn: IRN, + TransactionType: "B2B", + DocumentDetails: { Type: "INV", DocumentNumber: DOCUMENT_NUMBER, Date: "07-08-2026T09:05:03" }, + Version: "1", + ...over, + }, +}); + +const okResponse = (irn = IRN) => + ({ statusCode: 200, message: "SUCCESS", body: { irn, ackDate: "2026-08-07T09:05:03Z[Etc/UTC]" } }); + +const apiError = (kind: string, status?: number) => + new EimsApiException(kind as never, `EIMS register failed (${status ?? "-"})`, status); + +describe("EimsInvoiceRegistrationService.registerInvoiceWithEims", () => { + it("registers, persists the IRN and advances the chain", async () => { + const db = new FakeDb([invoiceRow()]); + const postSigned = jest.fn().mockResolvedValue(okResponse()); + + const view = await build(db, postSigned).registerInvoiceWithEims(INVOICE_ID); + + expect(postSigned).toHaveBeenCalledTimes(1); + expect(postSigned.mock.calls[0][0]).toBe("/v1/register"); + expect(view).toMatchObject({ + eimsStatus: EimsInvoiceStatus.Registered, + eimsIrn: IRN, + eimsInvoiceCounter: 7, + eimsAckDate: "2026-08-07T09:05:03Z[Etc/UTC]", + }); + expect(db.state).toMatchObject({ + previousIrn: IRN, + nextInvoiceCounter: 8, + inFlightInvoiceId: null, + inFlightCounter: null, + blockedReason: null, + }); + }); + + it("sends the exact reserved counter and previous IRN to the mapper", async () => { + const db = new FakeDb([invoiceRow()], { nextInvoiceCounter: 42, previousIrn: "PRIOR-IRN" }); + const postSigned = jest.fn().mockResolvedValue(okResponse()); + + await build(db, postSigned).registerInvoiceWithEims(INVOICE_ID); + + const request = postSigned.mock.calls[0][1] as EimsInvoiceRequest; + expect(request.SourceSystem.InvoiceCounter).toBe(42); + expect(request.ReferenceDetails.PreviousIrn).toBe("PRIOR-IRN"); + expect(request.DocumentDetails.DocumentNumber).toBe("INV-20260807-00042"); + expect(request.SourceSystem.SystemNumber).toBe(SYSTEM_NUMBER); + }); + + it("is idempotent — an invoice with an IRN never reaches EIMS", async () => { + const db = new FakeDb([ + invoiceRow({ eimsIrn: IRN, eimsStatus: EimsInvoiceStatus.Registered }), + ]); + const postSigned = jest.fn(); + + const view = await build(db, postSigned).registerInvoiceWithEims(INVOICE_ID); + + expect(postSigned).not.toHaveBeenCalled(); + expect(view.eimsIrn).toBe(IRN); + }); + + it("lets only one of two concurrent calls reach EIMS", async () => { + const db = new FakeDb([invoiceRow()]); + let resolvePost: (v: unknown) => void = () => {}; + const postSigned = jest + .fn() + .mockImplementation(() => new Promise((resolve) => (resolvePost = resolve))); + const service = build(db, postSigned); + + const first = service.registerInvoiceWithEims(INVOICE_ID); + // Let the first reservation commit and its HTTP call start; it is now parked on `resolvePost`. + await new Promise((resolve) => setImmediate(resolve)); + expect(postSigned).toHaveBeenCalledTimes(1); + + const second = service.registerInvoiceWithEims(INVOICE_ID); + + await expect(second).rejects.toBeInstanceOf(ConflictException); + resolvePost(okResponse()); + await first; + expect(postSigned).toHaveBeenCalledTimes(1); + }); + + it("blocks a different invoice while a submission is in flight (survives a restart)", async () => { + // A committed reservation left behind by a dead process. + const db = new FakeDb( + [ + invoiceRow({ eimsStatus: EimsInvoiceStatus.Submitting, eimsInvoiceCounter: 7 }), + invoiceRow({ id: OTHER_INVOICE_ID, invoiceNumber: "INV-20260807-00043" }), + ], + { inFlightInvoiceId: INVOICE_ID, inFlightCounter: 7, nextInvoiceCounter: 8 }, + ); + const postSigned = jest.fn(); + + await expect( + build(db, postSigned).registerInvoiceWithEims(OTHER_INVOICE_ID), + ).rejects.toThrow(/already in flight/); + expect(postSigned).not.toHaveBeenCalled(); + }); + + it("fails locally on incomplete tax configuration, with zero HTTP calls", async () => { + const db = new FakeDb([invoiceRow()]); + const postSigned = jest.fn(); + + await expect( + build(db, postSigned, config({ taxCode: "", taxRatePercent: null })).registerInvoiceWithEims( + INVOICE_ID, + ), + ).rejects.toBeInstanceOf(BadRequestException); + + expect(postSigned).not.toHaveBeenCalled(); + expect(db.invoices.get(INVOICE_ID)!.eimsStatus).toBe(EimsInvoiceStatus.NotSubmitted); + expect(db.state).toMatchObject({ nextInvoiceCounter: 7, inFlightInvoiceId: null }); + }); + + it.each([ + ["SCHEMA_VALIDATION", 400], + ["RULE_VALIDATION", 406], + ])("marks %s (%i) FAILED and clears the global block", async (kind, status) => { + const db = new FakeDb([invoiceRow()]); + const postSigned = jest.fn().mockRejectedValue(apiError(kind, status)); + + await expect(build(db, postSigned).registerInvoiceWithEims(INVOICE_ID)).rejects.toBeInstanceOf( + EimsApiException, + ); + + expect(db.invoices.get(INVOICE_ID)).toMatchObject({ + eimsStatus: EimsInvoiceStatus.Failed, + eimsIrn: null, + }); + expect(db.state).toMatchObject({ + inFlightInvoiceId: null, + blockedReason: null, + previousIrn: null, + nextInvoiceCounter: 8, // consumed: the attempt reached the gateway + }); + }); + + it("treats a success response with no IRN as a failed registration", async () => { + const db = new FakeDb([invoiceRow()]); + const postSigned = jest.fn().mockResolvedValue({ statusCode: 200, body: { irn: "" } }); + + await expect(build(db, postSigned).registerInvoiceWithEims(INVOICE_ID)).rejects.toThrow( + /returned no IRN/, + ); + expect(db.invoices.get(INVOICE_ID)!.eimsStatus).toBe(EimsInvoiceStatus.Failed); + expect(db.state).toMatchObject({ inFlightInvoiceId: null, blockedReason: null }); + }); + + it("marks a timeout UNKNOWN and keeps the system blocked", async () => { + const db = new FakeDb([invoiceRow()]); + const postSigned = jest.fn().mockRejectedValue(apiError("TIMEOUT")); + + await expect(build(db, postSigned).registerInvoiceWithEims(INVOICE_ID)).rejects.toBeInstanceOf( + EimsApiException, + ); + + expect(db.invoices.get(INVOICE_ID)).toMatchObject({ + eimsStatus: EimsInvoiceStatus.Unknown, + eimsIrn: null, + }); + expect(db.state!.inFlightInvoiceId).toBe(INVOICE_ID); + expect(db.state!.blockedReason).toMatch(/never acknowledged/); + expect(db.state!.previousIrn).toBeNull(); + }); + + it("an UNKNOWN result blocks a different invoice too", async () => { + const db = new FakeDb([invoiceRow(), invoiceRow({ id: OTHER_INVOICE_ID })]); + const postSigned = jest.fn().mockRejectedValueOnce(apiError("TIMEOUT")); + const service = build(db, postSigned); + + await expect(service.registerInvoiceWithEims(INVOICE_ID)).rejects.toBeInstanceOf( + EimsApiException, + ); + await expect(service.registerInvoiceWithEims(OTHER_INVOICE_ID)).rejects.toThrow( + /registration is blocked/, + ); + expect(postSigned).toHaveBeenCalledTimes(1); + }); + + it("never reuses a counter once an attempt has begun", async () => { + const db = new FakeDb([invoiceRow(), invoiceRow({ id: OTHER_INVOICE_ID })]); + const postSigned = jest + .fn() + .mockRejectedValueOnce(apiError("RULE_VALIDATION", 406)) + .mockResolvedValueOnce(okResponse()); + const service = build(db, postSigned); + + await expect(service.registerInvoiceWithEims(INVOICE_ID)).rejects.toBeInstanceOf( + EimsApiException, + ); + await service.registerInvoiceWithEims(OTHER_INVOICE_ID); + + expect((postSigned.mock.calls[0][1] as EimsInvoiceRequest).SourceSystem.InvoiceCounter).toBe(7); + expect((postSigned.mock.calls[1][1] as EimsInvoiceRequest).SourceSystem.InvoiceCounter).toBe(8); + }); +}); + +describe("EimsInvoiceRegistrationService.verifyInvoiceWithEims", () => { + it("verifies the stored IRN over the unsigned bearer transport", async () => { + const db = new FakeDb([invoiceRow({ eimsIrn: IRN })]); + const postSigned = jest.fn(); + const postBearer = jest.fn().mockResolvedValue(verifyResponse()); + + const result = await build(db, postSigned, config(), postBearer).verifyInvoiceWithEims( + INVOICE_ID, + ); + + // Lowercase `irn`, raw body — not a signed envelope. `postSigned` must stay untouched. + expect(postBearer).toHaveBeenCalledWith("/v1/verify", { irn: IRN }); + expect(postSigned).not.toHaveBeenCalled(); + expect(result.body).toMatchObject({ Irn: IRN }); + }); + + it("accepts a response whose Irn differs from the one sent", async () => { + // The supplied collection's own fixture does exactly this; equality would assert a property + // of the mock, not of the gateway. + const db = new FakeDb([invoiceRow({ eimsIrn: IRN })]); + const postBearer = jest.fn().mockResolvedValue(verifyResponse({ Irn: "a-different-irn" })); + + await expect( + build(db, jest.fn(), config(), postBearer).verifyInvoiceWithEims(INVOICE_ID), + ).resolves.toMatchObject({ body: { Irn: "a-different-irn" } }); + }); + + it("rejects a 200 that carries no Irn", async () => { + const db = new FakeDb([invoiceRow({ eimsIrn: IRN })]); + const postBearer = jest.fn().mockResolvedValue({ statusCode: 200, body: { Irn: " " } }); + + await expect( + build(db, jest.fn(), config(), postBearer).verifyInvoiceWithEims(INVOICE_ID), + ).rejects.toThrow(/returned no Irn/); + }); + + it("refuses to verify an invoice with no IRN", async () => { + const db = new FakeDb([invoiceRow({ eimsStatus: EimsInvoiceStatus.Unknown })]); + const postBearer = jest.fn(); + + await expect( + build(db, jest.fn(), config(), postBearer).verifyInvoiceWithEims(INVOICE_ID), + ).rejects.toThrow(/no EIMS IRN to verify/); + expect(postBearer).not.toHaveBeenCalled(); + }); +}); + +describe("EimsInvoiceRegistrationService.resolveEimsRegistration", () => { + const blocked = () => + new FakeDb([invoiceRow({ eimsStatus: EimsInvoiceStatus.Unknown, eimsInvoiceCounter: 7 })], { + inFlightInvoiceId: INVOICE_ID, + inFlightCounter: 7, + nextInvoiceCounter: 8, + blockedReason: "never acknowledged", + }); + + it("records a confirmed IRN, resumes the chain and clears the block", async () => { + const db = blocked(); + const postBearer = jest.fn().mockResolvedValue(verifyResponse()); + + const view = await build(db, jest.fn(), config(), postBearer).resolveEimsRegistration( + INVOICE_ID, + { irn: IRN }, + ); + + // The IRN is confirmed at the gateway before it is ever written. + expect(postBearer).toHaveBeenCalledWith("/v1/verify", { irn: IRN }); + expect(view).toMatchObject({ eimsStatus: EimsInvoiceStatus.Registered, eimsIrn: IRN }); + expect(db.state).toMatchObject({ + previousIrn: IRN, + inFlightInvoiceId: null, + blockedReason: null, + }); + }); + + it("refuses an IRN whose document number is not this invoice, leaving the block intact", async () => { + const db = blocked(); + const postBearer = jest.fn().mockResolvedValue( + verifyResponse({ + DocumentDetails: { Type: "INV", DocumentNumber: "INV-20260807-99999" }, + }), + ); + + await expect( + build(db, jest.fn(), config(), postBearer).resolveEimsRegistration(INVOICE_ID, { irn: IRN }), + ).rejects.toThrow(/not INV-20260807-00042/); + + expect(db.invoices.get(INVOICE_ID)).toMatchObject({ + eimsStatus: EimsInvoiceStatus.Unknown, + eimsIrn: null, + }); + expect(db.state).toMatchObject({ + inFlightInvoiceId: INVOICE_ID, + blockedReason: "never acknowledged", + previousIrn: null, + }); + }); + + it("refuses an IRN the gateway does not acknowledge at all", async () => { + const db = blocked(); + const postBearer = jest.fn().mockResolvedValue({ statusCode: 200, body: {} }); + + await expect( + build(db, jest.fn(), config(), postBearer).resolveEimsRegistration(INVOICE_ID, { irn: IRN }), + ).rejects.toThrow(/returned no Irn/); + expect(db.state!.blockedReason).toBe("never acknowledged"); + }); + + it("discards the attempt, leaving the chain where it was", async () => { + const db = blocked(); + const postBearer = jest.fn(); + + const view = await build(db, jest.fn(), config(), postBearer).resolveEimsRegistration( + INVOICE_ID, + { discard: true }, + ); + + expect(view).toMatchObject({ eimsStatus: EimsInvoiceStatus.Failed, eimsIrn: null }); + expect(postBearer).not.toHaveBeenCalled(); // nothing to confirm + expect(db.state).toMatchObject({ + previousIrn: null, + inFlightInvoiceId: null, + blockedReason: null, + }); + }); + + it("refuses to resolve an invoice that is not the in-flight one", async () => { + const db = blocked(); + db.invoices.set(OTHER_INVOICE_ID, invoiceRow({ id: OTHER_INVOICE_ID })); + const postBearer = jest.fn().mockResolvedValue(verifyResponse()); + + await expect( + build(db, jest.fn(), config(), postBearer).resolveEimsRegistration(OTHER_INVOICE_ID, { + irn: IRN, + }), + ).rejects.toThrow(/in-flight EIMS submission is invoice/); + }); + + it("requires either an IRN or an explicit discard", async () => { + await expect( + build(blocked(), jest.fn()).resolveEimsRegistration(INVOICE_ID, {}), + ).rejects.toBeInstanceOf(BadRequestException); + }); +}); diff --git a/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.ts b/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.ts new file mode 100644 index 000000000..44b92cd9b --- /dev/null +++ b/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.ts @@ -0,0 +1,472 @@ +import { + BadRequestException, + ConflictException, + Injectable, + Logger, + NotFoundException, +} from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { InjectDataSource } from "@nestjs/typeorm"; +import { DataSource, EntityManager } from "typeorm"; +import type { QueryDeepPartialEntity } from "typeorm/query-builder/QueryPartialEntity.js"; + +import { EimsConfig } from "../../config/eims.config"; +import { Invoice } from "../billing/entities/invoice.entity"; +import { + EimsInvoiceRequest, + EimsMapperLine, + toEimsInvoice, +} from "../billing/eims-invoice.mapper"; +import { EimsClientService } from "./eims-client.service"; +import { EimsApiException } from "./eims.errors"; +import { EimsSystemState } from "./entities/eims-system-state.entity"; +import { + assertEimsInvoiceConfig, + buildEimsContext, + buildEimsSeller, +} from "./eims-invoice-context"; +import { + EimsInvoiceError, + EimsInvoiceStatus, + EimsInvoiceStatusView, + EimsRegisterResponse, + EimsVerifyRequest, + EimsVerifyResponse, +} from "./eims-registration.types"; + +/** + * Failure kinds where the gateway gave a complete answer: the document was rejected and is + * definitively not registered. These clear the system-wide block; anything else does not. + */ +const DETERMINISTIC_KINDS = new Set(["SCHEMA_VALIDATION", "RULE_VALIDATION", "AUTH", "FORBIDDEN"]); + +interface Reservation { + stateId: string; + invoiceCounter: number; + previousIrn: string; +} + +/** + * Registers a single invoice with MoR EIMS. + * + * Sequencing is a **durable reservation**: the counter is consumed and the holder recorded in a + * committed transaction *before* the request leaves the process, and the network call happens + * outside any transaction. That gives three properties the naive design could not: + * + * - a counter is never reused once an attempt has begun, even across a crash; + * - a crash mid-flight leaves the reservation standing, so nothing blindly resubmits a document + * that may already have reached MoR; + * - an ambiguous result blocks every invoice for the system number, not just its own, because + * `PreviousIrn` is unknown and any later document would chain to a stale IRN. + * + * Signing, authentication and error normalisation belong to `EimsClientService`. Manual only — + * nothing in invoice creation calls this. + */ +@Injectable() +export class EimsInvoiceRegistrationService { + private readonly logger = new Logger(EimsInvoiceRegistrationService.name); + + constructor( + @InjectDataSource() private readonly dataSource: DataSource, + private readonly config: ConfigService, + private readonly client: EimsClientService, + ) {} + + private get cfg(): EimsConfig { + return this.config.get("eims")!; + } + + async registerInvoiceWithEims(invoiceId: string): Promise { + const cfg = this.cfg; + // Static seller/tax configuration is validated before anything is locked, allocated or sent. + assertEimsInvoiceConfig(cfg); + + const invoice = await this.loadInvoiceForMapping(invoiceId); + if (invoice.eimsIrn) return this.toView(invoice); + + const reservation = await this.reserve(invoiceId, cfg.systemNumber); + if (!reservation) return this.getEimsStatus(invoiceId); + + // The request can only be built now: InvoiceCounter and PreviousIrn come from the reservation. + const request = toEimsInvoice( + invoice, + buildEimsSeller(cfg), + buildEimsContext(cfg, { + // Our own invoice number is the document number; EIMS only requires it to be unique. + documentNumber: invoice.invoiceNumber, + invoiceCounter: reservation.invoiceCounter, + previousIrn: reservation.previousIrn, + }), + ); + + let irn: string; + let ackDate: string | undefined; + try { + // Deliberately outside every transaction — no DB lock is held across the wire. + const result = await this.submit(request); + irn = result.irn; + ackDate = result.ackDate; + } catch (err) { + await this.settleFailure(invoiceId, reservation, err); + throw err; + } + + await this.settleSuccess(invoiceId, reservation, irn, ackDate); + this.logger.log( + `Invoice ${invoice.invoiceNumber} registered with EIMS (counter ${reservation.invoiceCounter})`, + ); + return this.getEimsStatus(invoiceId); + } + + /** + * Verify a registered invoice at `POST /v1/verify`. + * + * Requires a stored IRN. An invoice whose submission was never acknowledged cannot be reconciled + * here — the gateway offers no lookup by document number — so it must be resolved with MoR and + * recorded through `resolveEimsRegistration`. + */ + async verifyInvoiceWithEims(invoiceId: string): Promise { + const invoice = await this.loadInvoiceRow(this.dataSource.manager, invoiceId); + if (!invoice.eimsIrn) { + throw new BadRequestException({ + code: "EIMS_NO_IRN", + message: + `Invoice ${invoice.invoiceNumber} has no EIMS IRN to verify (status ${invoice.eimsStatus}). ` + + "EIMS can only be queried by IRN, so an unacknowledged submission must be resolved with MoR first.", + }); + } + return this.queryVerify(invoice.eimsIrn); + } + + /** + * `POST /v1/verify` for one IRN, with the one check that always applies: the gateway must echo + * an `Irn` back. A 200 without it is not a confirmation of anything. + * + * The request property is lowercase `irn`; the response spells it `Irn`. The two are never + * compared — the supplied collection's own fixture uses different example values on each side, + * so equality there would assert a property of the mock rather than of the gateway. + * + * Bearer-authenticated but unsigned, via `postBearer` — see that method for why. + */ + private async queryVerify(irn: string): Promise { + const response = await this.client.postBearer( + "/v1/verify", + { irn }, + ); + if (!response?.body?.Irn?.trim()) { + throw new EimsApiException( + "SCHEMA_VALIDATION", + "EIMS verify returned no Irn in its response body", + response?.statusCode, + ); + } + return response; + } + + /** + * Refuse a manual resolution unless the gateway agrees the IRN belongs to this invoice. + * + * The check is on `DocumentDetails.DocumentNumber`, which registration set from our own + * `invoiceNumber`. That is the only field tying an IRN back to a row in this database. + */ + private async assertIrnBelongsToInvoice( + irn: string, + expectedDocumentNumber: string, + ): Promise { + const response = await this.queryVerify(irn); + const documentNumber = response.body?.DocumentDetails?.DocumentNumber?.trim(); + + if (documentNumber !== expectedDocumentNumber) { + throw new ConflictException({ + code: "EIMS_RESOLVE_DOCUMENT_MISMATCH", + message: + `EIMS reports IRN ${irn} against document ${documentNumber ?? "(none)"}, not ` + + `${expectedDocumentNumber}. Refusing to record it — recheck the IRN in the MoR portal.`, + }); + } + } + + /** + * Manual reconciliation of a blocked system number. + * + * With an `irn` (found in the MoR portal) the invoice is recorded as registered and the chain + * resumes from it. With `discard` the invoice is marked failed and the chain resumes from the + * previous IRN. Either way the block is cleared — this is the only exit from an ambiguous result. + * + * An IRN is never taken on trust: it is verified at the gateway first, and the document it + * belongs to must be *this* invoice. A transposed digit would otherwise chain every later + * document to a stranger's IRN and mark this invoice registered when it is not. + */ + async resolveEimsRegistration( + invoiceId: string, + input: { irn?: string; discard?: boolean }, + ): Promise { + const irn = input.irn?.trim(); + if (!irn && !input.discard) { + throw new BadRequestException({ + code: "EIMS_RESOLVE_INPUT_REQUIRED", + message: "Provide the IRN confirmed with MoR, or discard: true to abandon the submission", + }); + } + + // Outside the transaction: no lock is held across the wire, and a refused verification must + // leave the block exactly as it was. + if (irn) { + const invoice = await this.loadInvoiceRow(this.dataSource.manager, invoiceId); + await this.assertIrnBelongsToInvoice(irn, invoice.invoiceNumber); + } + + await this.dataSource.transaction(async (manager) => { + const state = await this.lockSystemState(manager, this.cfg.systemNumber); + if (state.inFlightInvoiceId && state.inFlightInvoiceId !== invoiceId) { + throw new ConflictException({ + code: "EIMS_RESOLVE_WRONG_INVOICE", + message: `The in-flight EIMS submission is invoice ${state.inFlightInvoiceId}, not ${invoiceId}`, + }); + } + const invoice = await this.lockInvoice(manager, invoiceId); + if (invoice.eimsIrn) { + throw new ConflictException({ + code: "EIMS_ALREADY_REGISTERED", + message: `Invoice ${invoice.invoiceNumber} already has IRN ${invoice.eimsIrn}`, + }); + } + + await manager.update(Invoice, invoiceId, { + eimsStatus: irn ? EimsInvoiceStatus.Registered : EimsInvoiceStatus.Failed, + eimsIrn: irn ?? null, + }); + await manager.update(EimsSystemState, state.id, { + // Only a confirmed IRN may advance the chain; a discard leaves it where it was. + ...(irn ? { previousIrn: irn } : {}), + inFlightInvoiceId: null, + inFlightCounter: null, + blockedReason: null, + }); + }); + + this.logger.warn( + `EIMS block on invoice ${invoiceId} resolved manually (${irn ? "IRN recorded" : "discarded"})`, + ); + return this.getEimsStatus(invoiceId); + } + + async getEimsStatus(invoiceId: string): Promise { + return this.toView(await this.loadInvoiceRow(this.dataSource.manager, invoiceId)); + } + + // ── transactions ───────────────────────────────────────────────────────────────────────────── + + /** + * TX1. Consume a counter and record the holder, committed before any HTTP call. Returns `null` + * when the invoice turned out to be registered already (checked under the lock). + */ + private async reserve(invoiceId: string, systemNumber: string): Promise { + return this.dataSource.transaction(async (manager) => { + const state = await this.lockSystemState(manager, systemNumber); + + if (state.blockedReason) { + throw new ConflictException({ + code: "EIMS_SYSTEM_BLOCKED", + message: + `EIMS registration is blocked for system ${systemNumber}: ${state.blockedReason}. ` + + "Resolve the affected invoice before registering anything else.", + }); + } + if (state.inFlightInvoiceId) { + throw new ConflictException({ + code: "EIMS_SUBMISSION_IN_FLIGHT", + message: + `A submission for invoice ${state.inFlightInvoiceId} is already in flight on system ` + + `${systemNumber}. Wait for it to settle, or resolve it if the process was interrupted.`, + }); + } + + const invoice = await this.lockInvoice(manager, invoiceId); + if (invoice.eimsIrn) return null; + + const invoiceCounter = Number(state.nextInvoiceCounter); + const previousIrn = state.previousIrn ?? ""; + + // Counter consumed here, not on success: once an attempt begins it can never be reused, + // whatever happens next. A gap is harmless at MoR; a collision is not. + await manager.update(EimsSystemState, state.id, { + nextInvoiceCounter: invoiceCounter + 1, + inFlightInvoiceId: invoiceId, + inFlightCounter: invoiceCounter, + }); + await manager.update(Invoice, invoiceId, { + eimsStatus: EimsInvoiceStatus.Submitting, + eimsInvoiceCounter: invoiceCounter, + eimsSubmittedAt: new Date(), + eimsLastError: null, + }); + + return { stateId: state.id, invoiceCounter, previousIrn }; + }); + } + + /** TX2a. Record the IRN, advance the chain, release the reservation. */ + private async settleSuccess( + invoiceId: string, + reservation: Reservation, + irn: string, + ackDate?: string, + ): Promise { + await this.dataSource.transaction(async (manager) => { + await this.lockInvoice(manager, invoiceId); + await manager.update(Invoice, invoiceId, { + eimsStatus: EimsInvoiceStatus.Registered, + eimsIrn: irn, + eimsAckDate: ackDate ?? null, + eimsLastError: null, + }); + await manager.update(EimsSystemState, reservation.stateId, { + previousIrn: irn, + inFlightInvoiceId: null, + inFlightCounter: null, + blockedReason: null, + }); + }); + } + + /** + * TX2b. A deterministic rejection releases the reservation; an ambiguous result keeps it and + * blocks the system number, because `PreviousIrn` is now unknown for every later document. + * The counter stays consumed either way. + */ + private async settleFailure( + invoiceId: string, + reservation: Reservation, + err: unknown, + ): Promise { + const api = err instanceof EimsApiException ? err : null; + const deterministic = api ? DETERMINISTIC_KINDS.has(api.kind) : false; + const status = deterministic ? EimsInvoiceStatus.Failed : EimsInvoiceStatus.Unknown; + const lastError: EimsInvoiceError = { + kind: api?.kind ?? "UNKNOWN", + message: (err as Error)?.message ?? "unknown error", + httpStatus: api?.httpStatus, + details: api?.details, + at: new Date().toISOString(), + }; + + await this.dataSource.transaction(async (manager) => { + await manager.update(Invoice, invoiceId, { + eimsStatus: status, + eimsLastError: lastError, + } as QueryDeepPartialEntity); + + await manager.update( + EimsSystemState, + reservation.stateId, + deterministic + ? { inFlightInvoiceId: null, inFlightCounter: null, blockedReason: null } + : { + blockedReason: + `Invoice ${invoiceId} was submitted with counter ${reservation.invoiceCounter} but ` + + `never acknowledged (${lastError.kind}). Its IRN is unknown, so no further document ` + + "can be chained until it is resolved with MoR.", + }, + ); + }); + + this.logger.error(`Invoice ${invoiceId} EIMS registration ${status}: ${lastError.message}`); + } + + // ── internals ──────────────────────────────────────────────────────────────────────────────── + + /** A non-empty IRN is the only success signal; anything else is a failed registration. */ + private async submit(request: EimsInvoiceRequest): Promise<{ irn: string; ackDate?: string }> { + const response = await this.client.postSigned( + "/v1/register", + request, + ); + const irn = response?.body?.irn; + if (!irn) { + // The gateway answered, so this is deterministic: the document is not registered. + throw new EimsApiException( + "SCHEMA_VALIDATION", + `EIMS register returned no IRN${response?.body?.errorMessage ? `: ${response.body.errorMessage}` : ""}`, + response?.statusCode, + ); + } + return { irn, ackDate: response.body?.ackDate }; + } + + private async lockInvoice(manager: EntityManager, invoiceId: string): Promise { + const invoice = await manager + .createQueryBuilder(Invoice, "invoice") + .setLock("pessimistic_write") + .where("invoice.id = :invoiceId", { invoiceId }) + .getOne(); + if (!invoice) throw new NotFoundException(`Invoice ${invoiceId} not found`); + return invoice; + } + + /** Locks the system-state row, creating it on first use. */ + private async lockSystemState( + manager: EntityManager, + systemNumber: string, + ): Promise { + const select = () => + manager + .createQueryBuilder(EimsSystemState, "state") + .setLock("pessimistic_write") + .where("state.system_number = :systemNumber", { systemNumber }) + .getOne(); + + const existing = await select(); + if (existing) return existing; + + await manager.query( + `INSERT INTO freight.eims_system_state (system_number) VALUES ($1) + ON CONFLICT (system_number) DO NOTHING`, + [systemNumber], + ); + const created = await select(); + if (!created) throw new Error(`Could not initialise EIMS system state for ${systemNumber}`); + return created; + } + + /** Header + buyer + lines — everything the mapper needs. */ + private async loadInvoiceForMapping( + invoiceId: string, + ): Promise { + const invoice = await this.dataSource.getRepository(Invoice).findOne({ + where: { id: invoiceId }, + relations: { company: true, companyProfile: true }, + }); + if (!invoice) throw new NotFoundException(`Invoice ${invoiceId} not found`); + + const lines: EimsMapperLine[] = await this.dataSource.query( + `SELECT charge_type AS "chargeType", description, quantity, unit_rate AS "unitRate", + amount, currency, metadata + FROM freight.invoice_lines + WHERE invoice_id = $1 AND deleted_at IS NULL + ORDER BY created_at ASC`, + [invoiceId], + ); + return Object.assign(invoice, { lines }); + } + + private async loadInvoiceRow(manager: EntityManager, invoiceId: string): Promise { + const invoice = await manager.findOne(Invoice, { where: { id: invoiceId } }); + if (!invoice) throw new NotFoundException(`Invoice ${invoiceId} not found`); + return invoice; + } + + private toView(invoice: Invoice): EimsInvoiceStatusView { + const counter = invoice.eimsInvoiceCounter; + return { + invoiceId: invoice.id, + invoiceNumber: invoice.invoiceNumber, + eimsStatus: invoice.eimsStatus ?? EimsInvoiceStatus.NotSubmitted, + eimsIrn: invoice.eimsIrn ?? null, + eimsInvoiceCounter: counter === null || counter === undefined ? null : Number(counter), + eimsSubmittedAt: invoice.eimsSubmittedAt ?? null, + eimsAckDate: invoice.eimsAckDate ?? null, + eimsLastError: invoice.eimsLastError ?? null, + }; + } +} diff --git a/apps/edr-freight-api/src/modules/eims/eims-invoice.controller.ts b/apps/edr-freight-api/src/modules/eims/eims-invoice.controller.ts new file mode 100644 index 000000000..9dab21107 --- /dev/null +++ b/apps/edr-freight-api/src/modules/eims/eims-invoice.controller.ts @@ -0,0 +1,60 @@ +import { Body, Controller, Get, Param, ParseUUIDPipe, Post } from "@nestjs/common"; +import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger"; + +import { BookingStaff } from "../../common/booking-guards"; +import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry"; +import { ResolveEimsRegistrationDto } from "./dto/resolve-eims-registration.dto"; +import { EimsInvoiceRegistrationService } from "./eims-invoice-registration.service"; + +/** + * Staff-triggered EIMS actions on an existing invoice. Registration is manual and one invoice at a + * time — nothing in invoice creation submits automatically. + * + * Filing gets its own permission (`invoices:eims_register`) rather than riding on an existing key: + * registration is irreversible at MoR, so it must not follow from the right to download a PDF. + * The key is seeded through FINANCE_PERMISSIONS, which reaches `iam.permissions` via + * ADVANCED_BACKOFFICE_PERMISSIONS → BOOKING_RULE_ENGINE_PERMISSIONS → EDR_FREIGHT_PERMISSIONS. + */ +@ApiTags("eims") +@ApiBearerAuth() +@Controller("invoices") +export class EimsInvoiceController { + constructor(private readonly registration: EimsInvoiceRegistrationService) {} + + @Post(":id/eims/register") + @BookingStaff(FREIGHT_PERMS.invoices.eimsRegister) + @ApiOperation({ + summary: + "Register the invoice with MoR EIMS. Idempotent — an invoice that already has an IRN is returned unchanged.", + }) + register(@Param("id", ParseUUIDPipe) id: string) { + return this.registration.registerInvoiceWithEims(id); + } + + @Post(":id/eims/verify") + @BookingStaff(FREIGHT_PERMS.invoices.eimsRegister) + @ApiOperation({ summary: "Verify the invoice's stored IRN against EIMS" }) + verify(@Param("id", ParseUUIDPipe) id: string) { + return this.registration.verifyInvoiceWithEims(id); + } + + @Post(":id/eims/resolve") + @BookingStaff(FREIGHT_PERMS.invoices.eimsRegister) + @ApiOperation({ + summary: + "Resolve an unacknowledged submission: record the IRN confirmed with MoR, or discard it. Clears the system-wide block.", + }) + resolve( + @Param("id", ParseUUIDPipe) id: string, + @Body() dto: ResolveEimsRegistrationDto, + ) { + return this.registration.resolveEimsRegistration(id, dto); + } + + @Get(":id/eims/status") + @BookingStaff(FREIGHT_PERMS.invoices.view) + @ApiOperation({ summary: "EIMS registration status, IRN and last error for the invoice" }) + status(@Param("id", ParseUUIDPipe) id: string) { + return this.registration.getEimsStatus(id); + } +} diff --git a/apps/edr-freight-api/src/modules/eims/eims-registration.types.ts b/apps/edr-freight-api/src/modules/eims/eims-registration.types.ts new file mode 100644 index 000000000..ad6a3aa34 --- /dev/null +++ b/apps/edr-freight-api/src/modules/eims/eims-registration.types.ts @@ -0,0 +1,87 @@ +import { EimsErrorResponse } from "./eims.types"; + +/** + * Registration state of one invoice at MoR EIMS. + * + * `UNKNOWN` is not a synonym for failure: the request left this process and no answer came back, + * so the invoice may or may not be registered at the gateway. It is never auto-retried — a resend + * would risk a duplicate registration. + */ +export enum EimsInvoiceStatus { + NotSubmitted = "NOT_SUBMITTED", + Submitting = "SUBMITTING", + Registered = "REGISTERED", + Failed = "FAILED", + Unknown = "UNKNOWN", +} + +/** `body` of a successful `POST /v1/register`, as observed in the collection. */ +export interface EimsRegisterResponseBody { + irn: string; + ackDate?: string; + signedQR?: string; + signedInvoice?: string; + status?: string; + documentNumber?: string; + errorMessage?: string | null; +} + +export interface EimsRegisterResponse { + statusCode?: number; + message?: string; + body?: EimsRegisterResponseBody; +} + +/** + * Inner request of `POST /v1/verify`. The wire property is lowercase `irn` and is required — + * omitting it yields a 400 "SCHEMA ERROR" reporting `$: required property 'irn' not found`. + */ +export interface EimsVerifyRequest { + irn: string; +} + +/** + * `body` of a successful `POST /v1/verify` — the stored document echoed back. Note the casing + * flip against the request: the response spells the reference `Irn`. + * + * Only the fields we actually assert on are typed; the rest of the echoed document (SellerDetails, + * BuyerDetails, ItemList, …) is carried through untyped because nothing here reads it. + */ +export interface EimsVerifyResponseBody { + Irn?: string; + TransactionType?: string; + DocumentDetails?: { + Type?: string; + DocumentNumber?: string; + Date?: string; + }; + Version?: string; + [section: string]: unknown; +} + +export interface EimsVerifyResponse { + statusCode?: number; + message?: string; + body?: EimsVerifyResponseBody; +} + +/** Persisted failure detail. Carries the gateway's own error fields only — never our envelope. */ +export interface EimsInvoiceError { + kind: string; + message: string; + httpStatus?: number; + details?: EimsErrorResponse; + at: string; +} + +/** What the status endpoint returns, and what a later invoice-detail panel will render. */ +export interface EimsInvoiceStatusView { + invoiceId: string; + invoiceNumber: string; + eimsStatus: EimsInvoiceStatus; + eimsIrn: string | null; + eimsInvoiceCounter: number | null; + eimsSubmittedAt: Date | null; + eimsAckDate: string | null; + eimsLastError: EimsInvoiceError | null; +} diff --git a/apps/edr-freight-api/src/modules/eims/eims.module.ts b/apps/edr-freight-api/src/modules/eims/eims.module.ts index 4c953c14e..c3b50a489 100644 --- a/apps/edr-freight-api/src/modules/eims/eims.module.ts +++ b/apps/edr-freight-api/src/modules/eims/eims.module.ts @@ -1,19 +1,35 @@ import { HttpModule } from "@nestjs/axios"; import { Module } from "@nestjs/common"; +import { TypeOrmModule } from "@nestjs/typeorm"; + +import { Invoice } from "../billing/entities/invoice.entity"; import { EimsAuthService } from "./eims-auth.service"; import { EimsClientService } from "./eims-client.service"; import { EimsCredentialsProvider } from "./eims-credentials.provider"; +import { EimsInvoiceController } from "./eims-invoice.controller"; +import { EimsInvoiceRegistrationService } from "./eims-invoice-registration.service"; import { EimsSignerService } from "./eims-signer.service"; +import { EimsSystemState } from "./entities/eims-system-state.entity"; /** - * MoR EIMS e-invoicing transport. Exports only what other modules will consume; the credential - * loader and signer stay internal so the private key has exactly one user. + * MoR EIMS e-invoicing: signed transport, authentication, and manual single-invoice registration. + * + * Exports only what other modules will consume; the credential loader and signer stay internal so + * the private key has exactly one user. Nothing here is called from invoice creation. */ @Module({ imports: [ HttpModule.register({ timeout: Number(process.env.EIMS_HTTP_TIMEOUT_MS) || 30_000 }), + TypeOrmModule.forFeature([EimsSystemState, Invoice]), ], - providers: [EimsCredentialsProvider, EimsSignerService, EimsAuthService, EimsClientService], - exports: [EimsAuthService, EimsClientService], + controllers: [EimsInvoiceController], + providers: [ + EimsCredentialsProvider, + EimsSignerService, + EimsAuthService, + EimsClientService, + EimsInvoiceRegistrationService, + ], + exports: [EimsAuthService, EimsClientService, EimsInvoiceRegistrationService], }) export class EimsModule {} diff --git a/apps/edr-freight-api/src/modules/eims/entities/eims-system-state.entity.ts b/apps/edr-freight-api/src/modules/eims/entities/eims-system-state.entity.ts new file mode 100644 index 000000000..ac6489c93 --- /dev/null +++ b/apps/edr-freight-api/src/modules/eims/entities/eims-system-state.entity.ts @@ -0,0 +1,42 @@ +import { BaseEntity } from "@edr/api-common"; +import { Column, Entity } from "typeorm"; + +/** + * One row per MoR system number, holding the sequence state EIMS expects across registrations: + * the next `SourceSystem.InvoiceCounter` and the IRN that the next document must chain to via + * `ReferenceDetails.PreviousIrn`. + * + * Registration locks this row `FOR UPDATE` for the duration of the submission, which is what keeps + * two concurrent registrations from claiming the same counter or breaking the IRN chain. + */ +@Entity({ schema: "freight", name: "eims_system_state" }) +export class EimsSystemState extends BaseEntity { + @Column({ name: "system_number", type: "varchar", length: 32, unique: true }) + systemNumber!: string; + + /** Counter to send on the next registration; advanced only once an attempt has consumed it. */ + @Column({ name: "next_invoice_counter", type: "bigint", default: 1 }) + nextInvoiceCounter!: number; + + /** IRN of the last successful registration; null until the first one succeeds. */ + @Column({ name: "previous_irn", type: "varchar", length: 64, nullable: true }) + previousIrn?: string | null; + + /** + * Invoice holding the current reservation. Committed before the HTTP call, so it survives a + * crash and blocks a blind resubmission of a document that may already have reached MoR. + */ + @Column({ name: "in_flight_invoice_id", type: "uuid", nullable: true }) + inFlightInvoiceId?: string | null; + + /** Counter handed to the in-flight submission. */ + @Column({ name: "in_flight_counter", type: "bigint", nullable: true }) + inFlightCounter?: number | null; + + /** + * Why registration is blocked for this system number. Set when a submission ends ambiguously: + * the IRN is unknown, so no further document can chain correctly until it is resolved. + */ + @Column({ name: "blocked_reason", type: "text", nullable: true }) + blockedReason?: string | null; +} diff --git a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts index 92df72607..7fa994060 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts @@ -467,6 +467,13 @@ export const FINANCE_PERMISSIONS: FreightPermissionSeed[] = [ "edr_freight_app:invoices:export", "Download invoice document", ), + // Filing with the tax authority is its own grant: registration is irreversible at MoR, so it + // must not ride along with the right to download an invoice PDF. + perm( + "d2b00001-0001-4000-8000-000000000005", + "edr_freight_app:invoices:eims_register", + "Register invoice with MoR EIMS", + ), ]; // E. First / last mile operations @@ -1591,6 +1598,7 @@ export const FREIGHT_PERMS = { invoices: { view: "edr_freight_app:invoices:view", export: "edr_freight_app:invoices:export", + eimsRegister: "edr_freight_app:invoices:eims_register", }, firstMile: { view: "edr_freight_app:first_mile:view", From eadecf3fcf9d45d0e10df34b3ea22c444e54017c Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Fri, 7 Aug 2026 13:30:24 +0000 Subject: [PATCH 009/276] chore(eims): default the EIMS tax treatment to 0 Set EIMS_TAX_CODE=0 and EIMS_TAX_RATE_PERCENT=0 in .env.example as instructed. Every line is emitted with TaxAmount 0 and TotalLineAmount equal to PreTaxValue. The collection's only observed TaxCode is "VAT15", so "0" is unverified against the gateway and may draw a 406 rule-validation error. Both values are env-only, so correcting them needs no code change. Co-Authored-By: Claude Opus 5 --- apps/edr-freight-api/.env.example | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/edr-freight-api/.env.example b/apps/edr-freight-api/.env.example index a11b98520..0df076f57 100644 --- a/apps/edr-freight-api/.env.example +++ b/apps/edr-freight-api/.env.example @@ -164,8 +164,8 @@ EIMS_SELLER_LOCALITY= # Tax treatment — REQUIRES FINANCE SIGN-OFF. The application models no tax at all # (invoice.taxAmount is always 0), so nothing here is defaulted: registration fails # locally, naming the missing variables, until these are set. -EIMS_TAX_CODE= -EIMS_TAX_RATE_PERCENT= +EIMS_TAX_CODE=0 +EIMS_TAX_RATE_PERCENT=0 EIMS_EXCISE_TAX_VALUE=0 EIMS_INCOME_WITHHOLD_VALUE=0 EIMS_TRANSACTION_WITHHOLD_VALUE=0 From 2e7ef40d9edb36ff14b02665119771d5ebf6561c Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Fri, 7 Aug 2026 13:50:05 +0000 Subject: [PATCH 010/276] feat(eims): take the source system from the access token MoR stamps systemNumber and systemType into the access token it issues for the authenticating credentials, which makes the token the authority on them. Registration now reads both from there instead of from configuration, so the SourceSystem block cannot drift from what the gateway believes we are. EimsAuthService decodes the token payload after login, requires both claims to be non-empty, and exposes them through getSessionContext(). The token is decoded but never verified -- it is MoR's, signed with MoR's key -- and is kept out of the log line, which names only the system it identified. EIMS_SYSTEM_NUMBER and EIMS_SYSTEM_TYPE become optional expectations rather than inputs: when set they are compared against the claims and a mismatch fails fast, so neither side silently wins. Neither is required to register any more. Registration and manual resolution both resolve the session before touching the state row, which is keyed by the system number: a login failure now costs nothing because no counter has been reserved yet. Test fixtures move to eims-test-fixtures.ts. They previously lived in eims-auth.service.spec.ts, which made jest execute that suite again inside every importing spec. Co-Authored-By: Claude Opus 5 --- apps/edr-freight-api/.env.example | 4 +- .../edr-freight-api/src/config/eims.config.ts | 9 +- .../modules/eims/eims-auth.service.spec.ts | 155 +++++++++++------- .../src/modules/eims/eims-auth.service.ts | 98 ++++++++++- .../src/modules/eims/eims-invoice-context.ts | 15 +- .../eims-invoice-registration.service.spec.ts | 88 ++++++++-- .../eims/eims-invoice-registration.service.ts | 35 +++- .../src/modules/eims/eims-test-fixtures.ts | 75 +++++++++ 8 files changed, 391 insertions(+), 88 deletions(-) create mode 100644 apps/edr-freight-api/src/modules/eims/eims-test-fixtures.ts diff --git a/apps/edr-freight-api/.env.example b/apps/edr-freight-api/.env.example index 0df076f57..26641ee70 100644 --- a/apps/edr-freight-api/.env.example +++ b/apps/edr-freight-api/.env.example @@ -135,7 +135,9 @@ EIMS_CLIENT_ID= EIMS_CLIENT_SECRET= EIMS_API_KEY= EIMS_TIN= -# MoR-issued source-system identifiers (used once invoice registration lands) +# Source-system identity comes from the access token's systemNumber/systemType claims. +# Setting these turns them into expected-value checks: a mismatch against the token fails +# fast rather than one side silently winning. Leave empty to take the gateway's word. EIMS_SYSTEM_NUMBER= EIMS_SYSTEM_TYPE= # Absolute paths to the INSA-issued credentials. Keep them OUTSIDE the repo; the file diff --git a/apps/edr-freight-api/src/config/eims.config.ts b/apps/edr-freight-api/src/config/eims.config.ts index 4e8684a5b..0cadb55fb 100644 --- a/apps/edr-freight-api/src/config/eims.config.ts +++ b/apps/edr-freight-api/src/config/eims.config.ts @@ -16,7 +16,14 @@ export interface EimsConfig { clientSecret: string; apiKey: string; tin: string; - /** MoR-issued source-system identifiers; unused until invoice registration lands. */ + /** + * Optional *expectations* for the source-system identity, not inputs. + * + * The access token MoR issues carries `systemNumber` and `systemType` claims for the credentials + * that authenticated, and those are what registration uses. When these are set they are compared + * against the token and a mismatch fails fast — neither side silently wins. Leave them empty to + * take whatever the gateway says. + */ systemNumber: string; systemType: string; /** Filesystem path to the INSA-issued RSA private key (PEM). Never leaves the server. */ diff --git a/apps/edr-freight-api/src/modules/eims/eims-auth.service.spec.ts b/apps/edr-freight-api/src/modules/eims/eims-auth.service.spec.ts index 75a702e94..1bed1fe29 100644 --- a/apps/edr-freight-api/src/modules/eims/eims-auth.service.spec.ts +++ b/apps/edr-freight-api/src/modules/eims/eims-auth.service.spec.ts @@ -2,57 +2,18 @@ import { HttpService } from "@nestjs/axios"; import { ConfigService } from "@nestjs/config"; import { AxiosError, AxiosHeaders } from "axios"; import { of, throwError } from "rxjs"; -import { EimsConfig, EimsInvoiceConfig } from "../../config/eims.config"; +import { EimsConfig } from "../../config/eims.config"; +import { eimsConfig, eimsToken } from "./eims-test-fixtures"; import { EimsAuthService } from "./eims-auth.service"; import { EimsSignerService } from "./eims-signer.service"; const CLIENT_SECRET = "super-secret-value"; const API_KEY = "super-secret-apikey"; -const cfg = (over: Partial = {}): EimsConfig => ({ - enabled: true, - baseUrl: "https://core.mor.gov.et", - clientId: "cid", - clientSecret: CLIENT_SECRET, - apiKey: API_KEY, - tin: "0000034558", - systemNumber: "B0360154BA", - systemType: "SYS", - privateKeyPath: "/dev/null", - certificatePath: "/dev/null", - httpTimeoutMs: 30_000, - tokenSkewMs: 45_000, - invoice: eimsInvoiceConfig(), - ...over, -}); +const cfg = (over: Partial = {}): EimsConfig => eimsConfig(over); -/** Authentication never reads these; they exist so the fixture satisfies EimsConfig. */ -export const eimsInvoiceConfig = (over: Partial = {}): EimsInvoiceConfig => ({ - sellerLegalName: "Ethio-Djibouti Railway S.C.", - sellerVatNumber: "0000000000", - sellerPhone: "0911223344", - sellerEmail: "finance@example.et", - sellerRegion: "13", - sellerWereda: "574", - sellerCity: null, - sellerSubCity: null, - sellerHouseNumber: null, - sellerLocality: null, - taxCode: "VAT15", - taxRatePercent: 15, - exciseTaxValue: 0, - incomeWithholdValue: 0, - transactionWithholdValue: 0, - transactionType: "B2B", - natureOfSupplies: "Service", - paymentMode: "CASH", - paymentTerm: "IMMIDIATE", - unitDefault: "PCS", - buyerCountryCode: null, - cashierName: null, - salesPersonName: null, - ...over, -}); +const TOKEN_1 = eimsToken({ jti: "one" }); +const TOKEN_2 = eimsToken({ jti: "two" }); const loginBody = (accessToken: string, expiresIn = 3600) => ({ data: { accessToken, refreshToken: "refresh-1", encryptionKey: null, expiresIn }, @@ -82,7 +43,7 @@ const axiosErr = (status: number, data: unknown) => describe("EimsAuthService.getValidAccessToken", () => { it("posts the signed login envelope to /auth/login with no Authorization header", async () => { - const post = jest.fn().mockReturnValue(of({ data: loginBody("token-1") })); + const post = jest.fn().mockReturnValue(of({ data: loginBody(TOKEN_1) })); await build(post).getValidAccessToken(); @@ -101,38 +62,38 @@ describe("EimsAuthService.getValidAccessToken", () => { }); it("returns the access token from data.accessToken", async () => { - const post = jest.fn().mockReturnValue(of({ data: loginBody("token-1") })); - await expect(build(post).getValidAccessToken()).resolves.toBe("token-1"); + const post = jest.fn().mockReturnValue(of({ data: loginBody(TOKEN_1) })); + await expect(build(post).getValidAccessToken()).resolves.toBe(TOKEN_1); }); it("reuses a cached token instead of logging in again", async () => { - const post = jest.fn().mockReturnValue(of({ data: loginBody("token-1") })); + const post = jest.fn().mockReturnValue(of({ data: loginBody(TOKEN_1) })); const auth = build(post); await auth.getValidAccessToken(); - await expect(auth.getValidAccessToken()).resolves.toBe("token-1"); + await expect(auth.getValidAccessToken()).resolves.toBe(TOKEN_1); expect(post).toHaveBeenCalledTimes(1); }); it("re-authenticates a skew-window before the token actually expires", async () => { const post = jest .fn() - .mockReturnValueOnce(of({ data: loginBody("token-1", 100) })) // 100s ttl, 45s skew ⇒ usable 55s - .mockReturnValueOnce(of({ data: loginBody("token-2") })); + .mockReturnValueOnce(of({ data: loginBody(TOKEN_1, 100) })) // 100s ttl, 45s skew ⇒ usable 55s + .mockReturnValueOnce(of({ data: loginBody(TOKEN_2) })); const auth = build(post); const start = Date.now(); const clock = jest.spyOn(Date, "now"); try { clock.mockReturnValue(start); - await expect(auth.getValidAccessToken()).resolves.toBe("token-1"); + await expect(auth.getValidAccessToken()).resolves.toBe(TOKEN_1); clock.mockReturnValue(start + 50_000); // inside the window: still cached - await expect(auth.getValidAccessToken()).resolves.toBe("token-1"); + await expect(auth.getValidAccessToken()).resolves.toBe(TOKEN_1); expect(post).toHaveBeenCalledTimes(1); clock.mockReturnValue(start + 56_000); // past ttl-minus-skew, before the real 100s expiry - await expect(auth.getValidAccessToken()).resolves.toBe("token-2"); + await expect(auth.getValidAccessToken()).resolves.toBe(TOKEN_2); expect(post).toHaveBeenCalledTimes(2); } finally { clock.mockRestore(); @@ -142,24 +103,38 @@ describe("EimsAuthService.getValidAccessToken", () => { it("logs in again after invalidate()", async () => { const post = jest .fn() - .mockReturnValueOnce(of({ data: loginBody("token-1") })) - .mockReturnValueOnce(of({ data: loginBody("token-2") })); + .mockReturnValueOnce(of({ data: loginBody(TOKEN_1) })) + .mockReturnValueOnce(of({ data: loginBody(TOKEN_2) })); const auth = build(post); await auth.getValidAccessToken(); auth.invalidate(); - await expect(auth.getValidAccessToken()).resolves.toBe("token-2"); + await expect(auth.getValidAccessToken()).resolves.toBe(TOKEN_2); expect(post).toHaveBeenCalledTimes(2); }); it("performs exactly one login for many concurrent callers", async () => { - const post = jest.fn().mockReturnValue(of({ data: loginBody("token-1") })); + const post = jest.fn().mockReturnValue(of({ data: loginBody(TOKEN_1) })); const auth = build(post); const tokens = await Promise.all(Array.from({ length: 20 }, () => auth.getValidAccessToken())); expect(post).toHaveBeenCalledTimes(1); - expect(new Set(tokens)).toEqual(new Set(["token-1"])); + expect(new Set(tokens)).toEqual(new Set([TOKEN_1])); + }); + + it("does not put the access token in its own log line", async () => { + const post = jest.fn().mockReturnValue(of({ data: loginBody(TOKEN_1) })); + const logged: string[] = []; + const auth = build(post); + jest + .spyOn(auth["logger"], "log") + .mockImplementation((message: unknown) => void logged.push(String(message))); + + await auth.getValidAccessToken(); + + expect(logged.join("\n")).not.toContain(TOKEN_1); + expect(logged.join("\n")).toContain("B0360154BA"); }); it("refuses to call the gateway when EIMS is disabled", async () => { @@ -217,3 +192,65 @@ describe("EimsAuthService.getValidAccessToken", () => { await expect(build(post).getValidAccessToken()).rejects.toThrow(/could not reach the gateway/); }); }); + +describe("EimsAuthService.getSessionContext", () => { + it("takes the source system from the token's claims", async () => { + const post = jest + .fn() + .mockReturnValue( + of({ data: loginBody(eimsToken({ systemNumber: "FROM-TOKEN", systemType: "POS" })) }), + ); + + // Env deliberately left empty: with nothing to check against, the token is simply believed. + await expect( + build(post, cfg({ systemNumber: "", systemType: "" })).getSessionContext(), + ).resolves.toEqual({ systemNumber: "FROM-TOKEN", systemType: "POS" }); + }); + + it("serves the session from the cached login rather than re-authenticating", async () => { + const post = jest.fn().mockReturnValue(of({ data: loginBody(TOKEN_1) })); + const auth = build(post); + + await auth.getSessionContext(); + await expect(auth.getSessionContext()).resolves.toEqual({ + systemNumber: "B0360154BA", + systemType: "SYS", + }); + expect(post).toHaveBeenCalledTimes(1); + }); + + it.each(["systemNumber", "systemType"])("rejects a token with no %s claim", async (claim) => { + const post = jest + .fn() + .mockReturnValue(of({ data: loginBody(eimsToken({ [claim]: undefined })) })); + + await expect( + build(post, cfg({ systemNumber: "", systemType: "" })).getSessionContext(), + ).rejects.toThrow(new RegExp(`no ${claim} claim`)); + }); + + it("rejects an access token that is not a decodable JWT", async () => { + const post = jest.fn().mockReturnValue(of({ data: loginBody("not-a-jwt") })); + + await expect(build(post).getSessionContext()).rejects.toThrow(/not a JWT/); + }); + + it.each([ + ["systemNumber", { systemNumber: "SOMETHING-ELSE" }, /EIMS_SYSTEM_NUMBER=B0360154BA/], + ["systemType", { systemType: "POS" }, /EIMS_SYSTEM_TYPE=SYS/], + ])("fails fast when the configured %s disagrees with the token", async (_name, over, pattern) => { + const post = jest.fn().mockReturnValue(of({ data: loginBody(eimsToken(over)) })); + + // cfg() sets EIMS_SYSTEM_NUMBER=B0360154BA and EIMS_SYSTEM_TYPE=SYS as expectations. + await expect(build(post).getSessionContext()).rejects.toThrow(pattern); + }); + + it("accepts a configured value that matches the token", async () => { + const post = jest.fn().mockReturnValue(of({ data: loginBody(TOKEN_1) })); + + await expect(build(post).getSessionContext()).resolves.toEqual({ + systemNumber: "B0360154BA", + systemType: "SYS", + }); + }); +}); diff --git a/apps/edr-freight-api/src/modules/eims/eims-auth.service.ts b/apps/edr-freight-api/src/modules/eims/eims-auth.service.ts index 99af70c57..9e53723d0 100644 --- a/apps/edr-freight-api/src/modules/eims/eims-auth.service.ts +++ b/apps/edr-freight-api/src/modules/eims/eims-auth.service.ts @@ -11,8 +11,43 @@ interface TokenCache { accessToken: string; /** Epoch ms, already reduced by the configured skew. */ expiresAt: number; + session: EimsSessionContext; } +/** + * Source-system identity, taken from the access token MoR issues us. + * + * The gateway stamps `systemNumber` and `systemType` into the token for the credentials that + * authenticated, which makes the token the authority on them — not our environment file. Anything + * we configured locally can only ever disagree with what MoR believes. + */ +export interface EimsSessionContext { + systemNumber: string; + systemType: string; +} + +/** Decode a JWT payload without verifying it: this is MoR's token, signed with MoR's key. */ +function decodeTokenClaims(accessToken: string): Record { + const payload = accessToken.split(".")[1]; + if (!payload) { + throw new EimsApiException("UNKNOWN", "EIMS access token is not a JWT (no payload segment)"); + } + try { + return JSON.parse(Buffer.from(payload, "base64url").toString("utf8")) as Record; + } catch (err) { + // The token itself is never included — only that its payload would not parse. + throw new EimsApiException( + "UNKNOWN", + `EIMS access token payload could not be decoded: ${(err as Error).message}`, + ); + } +} + +const claimString = (claims: Record, name: string): string => { + const value = claims[name]; + return typeof value === "string" ? value.trim() : ""; +}; + /** Used when the gateway omits `expiresIn`; the observed value is 3600. */ const FALLBACK_EXPIRES_IN_SECONDS = 3600; @@ -57,11 +92,64 @@ export class EimsAuthService { } } + /** + * The source-system identity MoR issued this session, refreshing the login if needed. + * + * This is the authority for `SourceSystem.SystemNumber` / `SystemType`: the gateway stamps both + * into the access token for the authenticating credentials, so a local env value could only ever + * disagree with it. + */ + async getSessionContext(): Promise { + await this.getValidAccessToken(); + return this.cache!.session; + } + /** Drop the cached token — called after a 401 so the next request re-authenticates. */ invalidate(): void { this.cache = null; } + /** + * Read the source-system claims out of the token, and cross-check anything configured locally. + * + * `EIMS_SYSTEM_NUMBER` / `EIMS_SYSTEM_TYPE` are optional expectations, not inputs: when set they + * are compared and a mismatch fails immediately rather than one silently winning. Registering + * under the wrong source system is not something to discover from a rejected invoice. + */ + private readSessionContext(accessToken: string, cfg: EimsConfig): EimsSessionContext { + const claims = decodeTokenClaims(accessToken); + const systemNumber = claimString(claims, "systemNumber"); + const systemType = claimString(claims, "systemType"); + + const missing = [ + !systemNumber && "systemNumber", + !systemType && "systemType", + ].filter(Boolean); + if (missing.length > 0) { + throw new EimsApiException( + "UNKNOWN", + `EIMS access token carries no ${missing.join(" or ")} claim; cannot identify the source system`, + ); + } + + const mismatches = [ + cfg.systemNumber && cfg.systemNumber !== systemNumber + ? `EIMS_SYSTEM_NUMBER=${cfg.systemNumber} but the token says ${systemNumber}` + : null, + cfg.systemType && cfg.systemType !== systemType + ? `EIMS_SYSTEM_TYPE=${cfg.systemType} but the token says ${systemType}` + : null, + ].filter(Boolean); + if (mismatches.length > 0) { + throw new EimsConfigException( + `EIMS source-system configuration disagrees with the issued token: ${mismatches.join("; ")}. ` + + "Correct the environment or the credentials — neither value is assumed to win.", + ); + } + + return { systemNumber, systemType }; + } + private async login(): Promise { const cfg = this.cfg; if (!cfg.enabled) { @@ -106,11 +194,19 @@ export class EimsAuthService { // examples of calls that do require signing, so whether refresh must be signed is unconfirmed. // Until MoR confirms it, an expired token just triggers a fresh login — `expiresIn` is 3600s, // so that is one extra call an hour. + // Reject the session before caching it: a token we cannot identify a source system from is + // useless for registration, and a configured expectation that disagrees is a deployment fault. + const session = this.readSessionContext(accessToken, cfg); + this.cache = { accessToken, expiresAt: Date.now() + Math.max(expiresIn * 1000 - cfg.tokenSkewMs, 1000), + session, }; - this.logger.log(`EIMS login succeeded; token cached for ~${expiresIn}s`); + this.logger.log( + `EIMS login succeeded; token cached for ~${expiresIn}s ` + + `(system ${session.systemNumber}, type ${session.systemType})`, + ); return accessToken; } } diff --git a/apps/edr-freight-api/src/modules/eims/eims-invoice-context.ts b/apps/edr-freight-api/src/modules/eims/eims-invoice-context.ts index 1a68f2ce4..20ccfdfba 100644 --- a/apps/edr-freight-api/src/modules/eims/eims-invoice-context.ts +++ b/apps/edr-freight-api/src/modules/eims/eims-invoice-context.ts @@ -1,5 +1,6 @@ import { BadRequestException } from "@nestjs/common"; import { EimsConfig } from "../../config/eims.config"; +import { EimsSessionContext } from "./eims-auth.service"; import { EimsMapperContext, EimsMapperLine, @@ -21,10 +22,10 @@ interface RequiredSpec { value: string | number | null | undefined; } -const REQUIRED = (invoice: EimsConfig["invoice"], tin: string, systemNumber: string, systemType: string): RequiredSpec[] => [ +// `systemNumber` / `systemType` are absent by design: they come from the access token, which is +// MoR's own statement of who we are. See EimsAuthService.getSessionContext. +const REQUIRED = (invoice: EimsConfig["invoice"], tin: string): RequiredSpec[] => [ { env: "EIMS_TIN", value: tin }, - { env: "EIMS_SYSTEM_NUMBER", value: systemNumber }, - { env: "EIMS_SYSTEM_TYPE", value: systemType }, { env: "EIMS_SELLER_LEGAL_NAME", value: invoice.sellerLegalName }, { env: "EIMS_SELLER_VAT_NUMBER", value: invoice.sellerVatNumber }, { env: "EIMS_SELLER_PHONE", value: invoice.sellerPhone }, @@ -44,7 +45,7 @@ const REQUIRED = (invoice: EimsConfig["invoice"], tin: string, systemNumber: str /** Throws naming every unset variable at once, so one round trip fixes the whole configuration. */ export function assertEimsInvoiceConfig(config: EimsConfig): void { - const missing = REQUIRED(config.invoice, config.tin, config.systemNumber, config.systemType) + const missing = REQUIRED(config.invoice, config.tin) .filter(({ value }) => value === null || value === undefined || value === "") .map(({ env }) => env); @@ -80,6 +81,8 @@ export interface EimsContextInput { documentNumber: string; invoiceCounter: number; previousIrn: string | null; + /** Source-system identity from the access token, never from configuration. */ + session: EimsSessionContext; /** Required when the invoice currency is not ETB. */ exchangeRate?: number | null; } @@ -92,8 +95,8 @@ export function buildEimsContext(config: EimsConfig, input: EimsContextInput): E const exciseTaxValue = invoice.exciseTaxValue ?? 0; return { - systemNumber: config.systemNumber, - systemType: config.systemType, + systemNumber: input.session.systemNumber, + systemType: input.session.systemType, documentNumber: input.documentNumber, invoiceCounter: input.invoiceCounter, previousIrn: input.previousIrn, diff --git a/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.spec.ts b/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.spec.ts index 362c754cb..1035c2b33 100644 --- a/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.spec.ts +++ b/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.spec.ts @@ -5,7 +5,8 @@ import { DataSource } from "typeorm"; import { EimsConfig } from "../../config/eims.config"; import { Invoice } from "../billing/entities/invoice.entity"; import { EimsInvoiceRequest } from "../billing/eims-invoice.mapper"; -import { eimsInvoiceConfig } from "./eims-auth.service.spec"; +import { eimsInvoiceConfig } from "./eims-test-fixtures"; +import { EimsAuthService } from "./eims-auth.service"; import { EimsClientService } from "./eims-client.service"; import { EimsApiException } from "./eims.errors"; import { EimsInvoiceRegistrationService } from "./eims-invoice-registration.service"; @@ -138,25 +139,35 @@ class FakeDb { } } +/** The source system comes from the access token, so the service is handed a session, not config. */ +const SESSION = { systemNumber: SYSTEM_NUMBER, systemType: "SYS" }; + const build = ( db: FakeDb, postSigned: jest.Mock, cfg: EimsConfig = config(), postBearer: jest.Mock = jest.fn(), + getSessionContext: jest.Mock = jest.fn().mockResolvedValue(SESSION), ) => new EimsInvoiceRegistrationService( db.asDataSource(), { get: () => cfg } as unknown as ConfigService, { postSigned, postBearer } as unknown as EimsClientService, + { getSessionContext } as unknown as EimsAuthService, ); /** Document number the fixtures register under; `/v1/verify` must echo it back. */ const DOCUMENT_NUMBER = "INV-20260807-00042"; /** - * `/v1/verify` success. The response spells the reference `Irn` while the request uses `irn`, and - * the collection's own fixture uses a *different* example value on each side — so nothing here - * assumes the two match. + * `/v1/verify` success. The response spells the reference `Irn` while the request sends lowercase + * `irn`. + * + * The fixture is deliberately *coherent* — same IRN on both sides. The supplied Postman collection + * pairs a saved request and a saved response whose literal IRNs disagree, which is an artefact of + * the mock rather than gateway behaviour; asserting against that inconsistency would encode the + * mock's bug as a requirement. Resolution requires the returned `Irn` to match the one asked for, + * and these fixtures exercise that honestly. */ const verifyResponse = (over: Record = {}) => ({ statusCode: 200, @@ -213,6 +224,43 @@ describe("EimsInvoiceRegistrationService.registerInvoiceWithEims", () => { expect(request.SourceSystem.SystemNumber).toBe(SYSTEM_NUMBER); }); + it("takes SourceSystem from the token session, not from configuration", async () => { + const db = new FakeDb([invoiceRow()]); + const postSigned = jest.fn().mockResolvedValue(okResponse()); + // Config disagrees on purpose: only the session may reach the wire. + const cfg = config(); + (cfg as { systemNumber: string }).systemNumber = "CONFIG-ONLY"; + (cfg as { systemType: string }).systemType = "MAN"; + + await build( + db, + postSigned, + cfg, + jest.fn(), + jest.fn().mockResolvedValue({ systemNumber: "FROM-TOKEN", systemType: "POS" }), + ).registerInvoiceWithEims(INVOICE_ID); + + const request = postSigned.mock.calls[0][1] as EimsInvoiceRequest; + expect(request.SourceSystem.SystemNumber).toBe("FROM-TOKEN"); + expect(request.SourceSystem.SystemType).toBe("POS"); + }); + + it("does not consume a counter when authentication fails", async () => { + const db = new FakeDb([invoiceRow()]); + const postSigned = jest.fn(); + const getSessionContext = jest.fn().mockRejectedValue(new Error("login failed")); + + await expect( + build(db, postSigned, config(), jest.fn(), getSessionContext).registerInvoiceWithEims( + INVOICE_ID, + ), + ).rejects.toThrow(/login failed/); + + expect(postSigned).not.toHaveBeenCalled(); + expect(db.state).toMatchObject({ nextInvoiceCounter: 7, inFlightInvoiceId: null }); + expect(db.invoices.get(INVOICE_ID)!.eimsStatus).toBe(EimsInvoiceStatus.NotSubmitted); + }); + it("is idempotent — an invoice with an IRN never reaches EIMS", async () => { const db = new FakeDb([ invoiceRow({ eimsIrn: IRN, eimsStatus: EimsInvoiceStatus.Registered }), @@ -377,17 +425,6 @@ describe("EimsInvoiceRegistrationService.verifyInvoiceWithEims", () => { expect(result.body).toMatchObject({ Irn: IRN }); }); - it("accepts a response whose Irn differs from the one sent", async () => { - // The supplied collection's own fixture does exactly this; equality would assert a property - // of the mock, not of the gateway. - const db = new FakeDb([invoiceRow({ eimsIrn: IRN })]); - const postBearer = jest.fn().mockResolvedValue(verifyResponse({ Irn: "a-different-irn" })); - - await expect( - build(db, jest.fn(), config(), postBearer).verifyInvoiceWithEims(INVOICE_ID), - ).resolves.toMatchObject({ body: { Irn: "a-different-irn" } }); - }); - it("rejects a 200 that carries no Irn", async () => { const db = new FakeDb([invoiceRow({ eimsIrn: IRN })]); const postBearer = jest.fn().mockResolvedValue({ statusCode: 200, body: { Irn: " " } }); @@ -436,6 +473,27 @@ describe("EimsInvoiceRegistrationService.resolveEimsRegistration", () => { }); }); + it("refuses an IRN the gateway answers with a different one, leaving the block intact", async () => { + const db = blocked(); + const postBearer = jest + .fn() + .mockResolvedValue(verifyResponse({ Irn: "0000000000000000000000000000000000000000" })); + + await expect( + build(db, jest.fn(), config(), postBearer).resolveEimsRegistration(INVOICE_ID, { irn: IRN }), + ).rejects.toThrow(/answered the lookup for IRN/); + + expect(db.invoices.get(INVOICE_ID)).toMatchObject({ + eimsStatus: EimsInvoiceStatus.Unknown, + eimsIrn: null, + }); + expect(db.state).toMatchObject({ + inFlightInvoiceId: INVOICE_ID, + blockedReason: "never acknowledged", + previousIrn: null, + }); + }); + it("refuses an IRN whose document number is not this invoice, leaving the block intact", async () => { const db = blocked(); const postBearer = jest.fn().mockResolvedValue( diff --git a/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.ts b/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.ts index 44b92cd9b..4ff9ccfb8 100644 --- a/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.ts +++ b/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.ts @@ -17,6 +17,7 @@ import { EimsMapperLine, toEimsInvoice, } from "../billing/eims-invoice.mapper"; +import { EimsAuthService } from "./eims-auth.service"; import { EimsClientService } from "./eims-client.service"; import { EimsApiException } from "./eims.errors"; import { EimsSystemState } from "./entities/eims-system-state.entity"; @@ -70,6 +71,7 @@ export class EimsInvoiceRegistrationService { @InjectDataSource() private readonly dataSource: DataSource, private readonly config: ConfigService, private readonly client: EimsClientService, + private readonly auth: EimsAuthService, ) {} private get cfg(): EimsConfig { @@ -84,7 +86,11 @@ export class EimsInvoiceRegistrationService { const invoice = await this.loadInvoiceForMapping(invoiceId); if (invoice.eimsIrn) return this.toView(invoice); - const reservation = await this.reserve(invoiceId, cfg.systemNumber); + // Authenticate before reserving: the source system comes from the token, and the state row is + // keyed by it. A login failure here costs nothing — no counter has been consumed yet. + const session = await this.auth.getSessionContext(); + + const reservation = await this.reserve(invoiceId, session.systemNumber); if (!reservation) return this.getEimsStatus(invoiceId); // The request can only be built now: InvoiceCounter and PreviousIrn come from the reservation. @@ -96,6 +102,7 @@ export class EimsInvoiceRegistrationService { documentNumber: invoice.invoiceNumber, invoiceCounter: reservation.invoiceCounter, previousIrn: reservation.previousIrn, + session, }), ); @@ -164,18 +171,33 @@ export class EimsInvoiceRegistrationService { } /** - * Refuse a manual resolution unless the gateway agrees the IRN belongs to this invoice. + * Refuse a manual resolution unless the gateway confirms *both* halves of the claim: that this + * IRN is the one it holds, and that it belongs to this invoice. * - * The check is on `DocumentDetails.DocumentNumber`, which registration set from our own - * `invoiceNumber`. That is the only field tying an IRN back to a row in this database. + * The document-number check is against `DocumentDetails.DocumentNumber`, which registration set + * from our own `invoiceNumber` — the only field tying an IRN back to a row in this database. + * + * Recording a wrong IRN is not a local mistake: it marks an unregistered invoice as filed and + * chains every later document to a stranger's reference, so both checks are refusals rather + * than warnings. */ private async assertIrnBelongsToInvoice( irn: string, expectedDocumentNumber: string, ): Promise { const response = await this.queryVerify(irn); + const returnedIrn = response.body?.Irn?.trim(); const documentNumber = response.body?.DocumentDetails?.DocumentNumber?.trim(); + if (returnedIrn !== irn) { + throw new ConflictException({ + code: "EIMS_RESOLVE_IRN_MISMATCH", + message: + `EIMS answered the lookup for IRN ${irn} with ${returnedIrn ?? "(none)"}. ` + + "Refusing to record it — recheck the IRN in the MoR portal.", + }); + } + if (documentNumber !== expectedDocumentNumber) { throw new ConflictException({ code: "EIMS_RESOLVE_DOCUMENT_MISMATCH", @@ -216,8 +238,11 @@ export class EimsInvoiceRegistrationService { await this.assertIrnBelongsToInvoice(irn, invoice.invoiceNumber); } + // Same source of truth as registration: the state row is keyed by the token's system number. + const session = await this.auth.getSessionContext(); + await this.dataSource.transaction(async (manager) => { - const state = await this.lockSystemState(manager, this.cfg.systemNumber); + const state = await this.lockSystemState(manager, session.systemNumber); if (state.inFlightInvoiceId && state.inFlightInvoiceId !== invoiceId) { throw new ConflictException({ code: "EIMS_RESOLVE_WRONG_INVOICE", diff --git a/apps/edr-freight-api/src/modules/eims/eims-test-fixtures.ts b/apps/edr-freight-api/src/modules/eims/eims-test-fixtures.ts new file mode 100644 index 000000000..f411c8b44 --- /dev/null +++ b/apps/edr-freight-api/src/modules/eims/eims-test-fixtures.ts @@ -0,0 +1,75 @@ +import { EimsConfig, EimsInvoiceConfig } from "../../config/eims.config"; + +/** + * Fixtures shared by the EIMS specs. + * + * Deliberately not a `.spec.ts`: importing fixtures from a spec file makes jest execute that + * file's `describe` blocks inside every importing suite, so the same tests run — and report — + * twice. + */ + +export const EIMS_SYSTEM_NUMBER = "B0360154BA"; +export const EIMS_SYSTEM_TYPE = "SYS"; + +export const eimsInvoiceConfig = (over: Partial = {}): EimsInvoiceConfig => ({ + sellerLegalName: "Ethio-Djibouti Railway S.C.", + sellerVatNumber: "0000000000", + sellerPhone: "0911223344", + sellerEmail: "finance@example.et", + sellerRegion: "13", + sellerWereda: "574", + sellerCity: null, + sellerSubCity: null, + sellerHouseNumber: null, + sellerLocality: null, + taxCode: "VAT15", + taxRatePercent: 15, + exciseTaxValue: 0, + incomeWithholdValue: 0, + transactionWithholdValue: 0, + transactionType: "B2B", + natureOfSupplies: "Service", + paymentMode: "CASH", + paymentTerm: "IMMIDIATE", + unitDefault: "PCS", + buyerCountryCode: null, + cashierName: null, + salesPersonName: null, + ...over, +}); + +export const eimsConfig = (over: Partial = {}): EimsConfig => ({ + enabled: true, + baseUrl: "https://core.mor.gov.et", + clientId: "cid", + clientSecret: "super-secret-value", + apiKey: "super-secret-apikey", + tin: "0000034558", + systemNumber: EIMS_SYSTEM_NUMBER, + systemType: EIMS_SYSTEM_TYPE, + privateKeyPath: "/dev/null", + certificatePath: "/dev/null", + httpTimeoutMs: 30_000, + tokenSkewMs: 45_000, + invoice: eimsInvoiceConfig(), + ...over, +}); + +/** + * A structurally real access token. MoR stamps the source-system identity into the JWT payload and + * `EimsAuthService` reads it from there; only the payload segment is meaningful, since the token is + * never verified locally — it is MoR's, signed with MoR's key. + * + * Pass a claim as `undefined` to omit it (spreading beats `delete`, which the defaults would undo). + */ +export const eimsToken = (claims: Record = {}): string => { + const payload = { systemNumber: EIMS_SYSTEM_NUMBER, systemType: EIMS_SYSTEM_TYPE, ...claims }; + for (const [key, value] of Object.entries(payload)) { + if (value === undefined) delete (payload as Record)[key]; + } + return [ + "eyJhbGciOiJSUzI1NiJ9", + Buffer.from(JSON.stringify(payload)).toString("base64url"), + "signature", + ].join("."); +}; From 02db3d2e73409a97f679301bc1b7bbfeb45d8d2d Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Fri, 7 Aug 2026 14:42:24 +0000 Subject: [PATCH 011/276] fix(eims): keep EIMS filing off the Finance role preset Invoices are produced by the freight workflow, not by a person, so filing is not a Finance job function. The manual endpoints exist for controlled testing and exceptional operations, and are left out of every role preset so they are assigned to named admins instead. Split resolve onto its own permission, invoices:eims_resolve: resolving an unacknowledged submission clears the system-wide chain block and can record an IRN against an invoice, which is a supervisor action rather than an operational one. eims/status stays on the ordinary invoices:view. Co-Authored-By: Claude Opus 5 --- .../src/modules/eims/eims-invoice.controller.ts | 14 +++++++++++--- .../src/seed/freight-permissions.registry.ts | 13 +++++++++++++ 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/apps/edr-freight-api/src/modules/eims/eims-invoice.controller.ts b/apps/edr-freight-api/src/modules/eims/eims-invoice.controller.ts index 9dab21107..f47cfe9d9 100644 --- a/apps/edr-freight-api/src/modules/eims/eims-invoice.controller.ts +++ b/apps/edr-freight-api/src/modules/eims/eims-invoice.controller.ts @@ -7,8 +7,16 @@ import { ResolveEimsRegistrationDto } from "./dto/resolve-eims-registration.dto" import { EimsInvoiceRegistrationService } from "./eims-invoice-registration.service"; /** - * Staff-triggered EIMS actions on an existing invoice. Registration is manual and one invoice at a - * time — nothing in invoice creation submits automatically. + * Manual EIMS actions on an existing invoice. + * + * Invoices are produced by the freight workflow, not by a person, so these routes are **not** the + * normal production path — they exist for controlled testing and exceptional operations. Automatic + * submission after an invoice is issued is a separate phase; nothing here is called by it. + * + * `eims_register` and `eims_resolve` are intentionally left out of every role preset and assigned + * to named admins instead. They are also separate permissions: resolving clears the system-wide + * chain block and can record an IRN against an invoice, which is a supervisor action, not an + * operational one. Only `eims/status` rides on the ordinary `invoices:view`. * * Filing gets its own permission (`invoices:eims_register`) rather than riding on an existing key: * registration is irreversible at MoR, so it must not follow from the right to download a PDF. @@ -39,7 +47,7 @@ export class EimsInvoiceController { } @Post(":id/eims/resolve") - @BookingStaff(FREIGHT_PERMS.invoices.eimsRegister) + @BookingStaff(FREIGHT_PERMS.invoices.eimsResolve) @ApiOperation({ summary: "Resolve an unacknowledged submission: record the IRN confirmed with MoR, or discard it. Clears the system-wide block.", diff --git a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts index 7fa994060..3c2be2e09 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts @@ -474,6 +474,14 @@ export const FINANCE_PERMISSIONS: FreightPermissionSeed[] = [ "edr_freight_app:invoices:eims_register", "Register invoice with MoR EIMS", ), + // Separate from registering: resolving an unacknowledged submission clears the + // system-wide chain block and can record an IRN against an invoice, so it is a + // supervisor/admin action rather than an operational one. + perm( + "d2b00001-0001-4000-8000-000000000006", + "edr_freight_app:invoices:eims_resolve", + "Resolve a blocked MoR EIMS submission", + ), ]; // E. First / last mile operations @@ -1599,6 +1607,7 @@ export const FREIGHT_PERMS = { view: "edr_freight_app:invoices:view", export: "edr_freight_app:invoices:export", eimsRegister: "edr_freight_app:invoices:eims_register", + eimsResolve: "edr_freight_app:invoices:eims_resolve", }, firstMile: { view: "edr_freight_app:first_mile:view", @@ -2088,6 +2097,10 @@ export const ROLE_PERMISSION_PRESETS = { FREIGHT_PERMS.bookings.view, FREIGHT_PERMS.invoices.view, FREIGHT_PERMS.invoices.export, + // Deliberately NOT granted here: invoices:eims_register and invoices:eims_resolve. + // Invoices are filed with MoR by the workflow, not by a person, so filing is not a + // Finance job function — the endpoints exist for controlled testing and exceptional + // operations, and are assigned to named admins rather than a role preset. FREIGHT_PERMS.payments.view, FREIGHT_PERMS.bookings.wagonCancellationView, ], From 67573d0835db2719fc55daaf7d42daededf62fdb Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Fri, 7 Aug 2026 14:58:32 +0000 Subject: [PATCH 012/276] feat(eims): file issued invoices on a cron sweep, off by default Invoices are produced by the freight workflow rather than by a person, so the production path for filing is a sweep, not the manual endpoint. A @Cron picks the oldest never-submitted invoice and hands it to the existing EimsInvoiceRegistrationService -- no registration logic is duplicated, and the durable reservation still decides whether the submission may proceed. Sweeping rather than hooking the eleven places an invoice can be created or issued keeps the workflow untouched, puts the HTTP call outside the invoice transaction by construction, and lets a crash or restart be picked up on the next tick. invoices.eims_status is the queue; nothing new is persisted. Only NOT_SUBMITTED is eligible: UNKNOWN is never retried automatically because the document may already be filed, and FAILED waits for an explicit retry policy. The tick also refuses to start while eims_system_state holds an in-flight submission or a block, and only one invoice is filed per tick so a misconfiguration costs one rejected document rather than a burst. Requires both EIMS_ENABLED and EIMS_AUTO_SUBMIT; the second defaults to false so authentication can be live long before filing is. Logs carry the invoice number, status and IRN only. Co-Authored-By: Claude Opus 5 --- apps/edr-freight-api/.env.example | 7 + .../edr-freight-api/src/config/eims.config.ts | 21 +++ .../eims/eims-auto-submit.service.spec.ts | 139 ++++++++++++++++++ .../modules/eims/eims-auto-submit.service.ts | 126 ++++++++++++++++ .../src/modules/eims/eims-test-fixtures.ts | 3 + .../src/modules/eims/eims.module.ts | 2 + 6 files changed, 298 insertions(+) create mode 100644 apps/edr-freight-api/src/modules/eims/eims-auto-submit.service.spec.ts create mode 100644 apps/edr-freight-api/src/modules/eims/eims-auto-submit.service.ts diff --git a/apps/edr-freight-api/.env.example b/apps/edr-freight-api/.env.example index 26641ee70..da0b1ceb9 100644 --- a/apps/edr-freight-api/.env.example +++ b/apps/edr-freight-api/.env.example @@ -181,3 +181,10 @@ EIMS_UNIT_DEFAULT=PCS EIMS_BUYER_COUNTRY_CODE= EIMS_CASHIER_NAME= EIMS_SALESPERSON_NAME= +# Automatic filing of issued invoices (@Cron sweep, one invoice per tick). +# Independent of EIMS_ENABLED on purpose: authentication can be live long before +# filing is. Both must be true before anything is submitted automatically. +EIMS_AUTO_SUBMIT=false +EIMS_AUTO_SUBMIT_CRON=0 */5 * * * * +# MoR rejects documents older than 3 days; the sweep will not attempt those. +EIMS_AUTO_SUBMIT_MAX_AGE_DAYS=3 diff --git a/apps/edr-freight-api/src/config/eims.config.ts b/apps/edr-freight-api/src/config/eims.config.ts index 0cadb55fb..6eaaf8007 100644 --- a/apps/edr-freight-api/src/config/eims.config.ts +++ b/apps/edr-freight-api/src/config/eims.config.ts @@ -33,6 +33,18 @@ export interface EimsConfig { httpTimeoutMs: number; /** Re-authenticate this many ms before the access token actually expires. */ tokenSkewMs: number; + /** + * Automatic submission of issued invoices, off by default. + * + * Invoices are produced by the workflow, so the production path is a sweep rather than a human + * action — but enabling it starts filing real documents with the tax authority, which is + * irreversible from our side. It therefore needs its own deliberate switch, separate from + * `EIMS_ENABLED`, so that authentication can be live long before filing is. + */ + autoSubmit: boolean; + autoSubmitCron: string; + /** MoR rejects a document whose date is more than 3 days old; the sweep will not attempt those. */ + autoSubmitMaxAgeDays: number; /** * Seller identity and tax/business treatment for the invoice document. * @@ -119,6 +131,15 @@ export default registerAs("eims", (): EimsConfig => { certificatePath: process.env.EIMS_CERTIFICATE_PATH ?? "", httpTimeoutMs, tokenSkewMs, + autoSubmit: (process.env.EIMS_AUTO_SUBMIT ?? "false").toLowerCase() === "true", + // Every 5 minutes by default: filing is not latency-sensitive, and a slow cadence keeps a + // misconfiguration from filing a burst of bad documents before anyone notices. + autoSubmitCron: process.env.EIMS_AUTO_SUBMIT_CRON || "0 */5 * * * *", + autoSubmitMaxAgeDays: positiveInt( + process.env.EIMS_AUTO_SUBMIT_MAX_AGE_DAYS, + 3, + "EIMS_AUTO_SUBMIT_MAX_AGE_DAYS", + ), invoice: { sellerLegalName: process.env.EIMS_SELLER_LEGAL_NAME ?? "", sellerVatNumber: process.env.EIMS_SELLER_VAT_NUMBER ?? "", diff --git a/apps/edr-freight-api/src/modules/eims/eims-auto-submit.service.spec.ts b/apps/edr-freight-api/src/modules/eims/eims-auto-submit.service.spec.ts new file mode 100644 index 000000000..6246d5a89 --- /dev/null +++ b/apps/edr-freight-api/src/modules/eims/eims-auto-submit.service.spec.ts @@ -0,0 +1,139 @@ +import { ConfigService } from "@nestjs/config"; +import { DataSource } from "typeorm"; + +import { EimsConfig } from "../../config/eims.config"; +import { EimsAutoSubmitService } from "./eims-auto-submit.service"; +import { EimsInvoiceRegistrationService } from "./eims-invoice-registration.service"; +import { EimsInvoiceStatus } from "./eims-registration.types"; +import { eimsConfig } from "./eims-test-fixtures"; + +const INVOICE_ID = "11111111-1111-4111-8111-111111111111"; + +/** + * `query` is answered by shape: the first call is the system-state guard, the second is the + * candidate lookup. Keeps the fake honest about the order the service actually asks in. + */ +const build = ( + opts: { + cfg?: Partial; + state?: { in_flight_invoice_id?: string | null; blocked_reason?: string | null }; + candidate?: { id: string; invoiceNumber: string } | null; + register?: jest.Mock; + } = {}, +) => { + const register = + opts.register ?? + jest.fn().mockResolvedValue({ eimsStatus: EimsInvoiceStatus.Registered, eimsIrn: "IRN-1" }); + + const query = jest.fn().mockImplementation((sql: string) => { + if (sql.includes("eims_system_state")) { + return Promise.resolve( + opts.state ? [{ in_flight_invoice_id: null, blocked_reason: null, ...opts.state }] : [], + ); + } + return Promise.resolve(opts.candidate === undefined ? [] : opts.candidate ? [opts.candidate] : []); + }); + + const service = new EimsAutoSubmitService( + { query } as unknown as DataSource, + { get: () => eimsConfig({ autoSubmit: true, ...opts.cfg }) } as unknown as ConfigService, + { registerInvoiceWithEims: register } as unknown as EimsInvoiceRegistrationService, + ); + return { service, register, query }; +}; + +const candidate = { id: INVOICE_ID, invoiceNumber: "INV-20260807-00006" }; + +describe("EimsAutoSubmitService.tick", () => { + it("files the oldest eligible invoice through the registration service", async () => { + const { service, register } = build({ candidate }); + + await service.tick(); + + expect(register).toHaveBeenCalledTimes(1); + expect(register).toHaveBeenCalledWith(INVOICE_ID); + }); + + it("files nothing when EIMS_AUTO_SUBMIT is off", async () => { + const { service, register, query } = build({ cfg: { autoSubmit: false }, candidate }); + + await service.tick(); + + expect(register).not.toHaveBeenCalled(); + expect(query).not.toHaveBeenCalled(); + }); + + it("files nothing when EIMS itself is disabled, even with auto-submit on", async () => { + const { service, register, query } = build({ cfg: { enabled: false }, candidate }); + + await service.tick(); + + expect(register).not.toHaveBeenCalled(); + expect(query).not.toHaveBeenCalled(); + }); + + it("does not submit while another submission is in flight", async () => { + const { service, register } = build({ + state: { in_flight_invoice_id: "22222222-2222-4222-8222-222222222222" }, + candidate, + }); + + await service.tick(); + + expect(register).not.toHaveBeenCalled(); + }); + + it("does not submit while the system number is blocked", async () => { + const { service, register } = build({ + state: { blocked_reason: "never acknowledged" }, + candidate, + }); + + await service.tick(); + + expect(register).not.toHaveBeenCalled(); + }); + + it("does nothing when no invoice is eligible", async () => { + const { service, register } = build({ candidate: null }); + + await service.tick(); + + expect(register).not.toHaveBeenCalled(); + }); + + it("asks only for NOT_SUBMITTED invoices, so UNKNOWN and FAILED are never retried", async () => { + const { service, query } = build({ candidate }); + + await service.tick(); + + const [sql, params] = query.mock.calls.find(([s]: [string]) => s.includes("freight.invoices"))!; + expect(sql).toContain("i.eims_status = $1"); + expect(params[0]).toBe(EimsInvoiceStatus.NotSubmitted); + expect(sql).toContain("i.issued_at IS NOT NULL"); + }); + + it("survives a filing failure so the job keeps running", async () => { + const register = jest.fn().mockRejectedValue(new Error("EIMS register failed (406)")); + const { service } = build({ candidate, register }); + + await expect(service.tick()).resolves.toBeUndefined(); + expect(register).toHaveBeenCalledTimes(1); + }); + + it("does not start a second tick while one is still filing", async () => { + let release: () => void = () => {}; + const register = jest.fn().mockImplementation( + () => new Promise((resolve) => (release = () => resolve({ eimsStatus: "REGISTERED" }))), + ); + const { service } = build({ candidate, register }); + + const first = service.tick(); + await new Promise((r) => setImmediate(r)); + await service.tick(); // overlapping tick, must be a no-op + + expect(register).toHaveBeenCalledTimes(1); + release(); + await first; + }); +}); diff --git a/apps/edr-freight-api/src/modules/eims/eims-auto-submit.service.ts b/apps/edr-freight-api/src/modules/eims/eims-auto-submit.service.ts new file mode 100644 index 000000000..fb5a0d1f6 --- /dev/null +++ b/apps/edr-freight-api/src/modules/eims/eims-auto-submit.service.ts @@ -0,0 +1,126 @@ +import { Injectable, Logger } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { Cron } from "@nestjs/schedule"; +import { InjectDataSource } from "@nestjs/typeorm"; +import { DataSource } from "typeorm"; + +import { EimsConfig } from "../../config/eims.config"; +import { EimsInvoiceRegistrationService } from "./eims-invoice-registration.service"; +import { EimsInvoiceStatus } from "./eims-registration.types"; + +/** + * Files issued invoices with MoR EIMS on a timer. + * + * Invoices are produced by the freight workflow rather than by a person, so this — not the manual + * endpoint — is the production path. It is a sweep rather than a hook on the eleven places an + * invoice can be created or issued, which buys three things: the workflow is untouched, the HTTP + * call is by construction outside the invoice's transaction, and an invoice missed through a crash + * or a restart is picked up on the next tick. + * + * `invoices.eims_status` is the queue — nothing new is persisted. Only `NOT_SUBMITTED` is eligible: + * `UNKNOWN` must never be retried automatically (the document may already be filed), and `FAILED` + * waits for an explicit retry policy rather than a timer's guess. + * + * Off unless **both** `EIMS_ENABLED` and `EIMS_AUTO_SUBMIT` are true. Enabling it starts filing + * real documents with the tax authority, and a registration cannot be undone from this side. + */ +@Injectable() +export class EimsAutoSubmitService { + private readonly logger = new Logger(EimsAutoSubmitService.name); + /** Guards against a tick starting while the previous one is still filing. */ + private running = false; + + constructor( + @InjectDataSource() private readonly dataSource: DataSource, + private readonly config: ConfigService, + private readonly registration: EimsInvoiceRegistrationService, + ) {} + + private get cfg(): EimsConfig { + return this.config.get("eims")!; + } + + /** + * One invoice per tick. + * + * Deliberately not a batch: each filing consumes a counter and advances the IRN chain, an + * ambiguous result blocks the system number until a human resolves it, and a misconfiguration + * should cost one rejected document rather than a burst of them. + */ + @Cron(process.env.EIMS_AUTO_SUBMIT_CRON ?? "0 */5 * * * *", { name: "eims-auto-submit" }) + async tick(): Promise { + const cfg = this.cfg; + if (!cfg.enabled || !cfg.autoSubmit) return; + if (this.running) return; + + this.running = true; + try { + // Rule of the chain: nothing may be filed while a submission is in flight or the system is + // blocked. The reservation would refuse anyway — checking first keeps the log quiet and + // avoids burning a tick on a guaranteed conflict. + const blocked = await this.systemBlockReason(); + if (blocked) { + this.logger.warn(`EIMS auto-submit paused: ${blocked}`); + return; + } + + const candidate = await this.nextCandidate(); + if (!candidate) return; + + const view = await this.registration.registerInvoiceWithEims(candidate.id); + this.logger.log( + `EIMS auto-submit: invoice ${candidate.invoiceNumber} -> ${view.eimsStatus}` + + (view.eimsIrn ? ` (IRN ${view.eimsIrn})` : ""), + ); + } catch (err) { + // Never let a filing failure kill the job. The outcome is already persisted on the invoice + // (FAILED or UNKNOWN with the gateway's own message), and a blocked system number stops the + // next tick at the guard above. + this.logger.error(`EIMS auto-submit tick failed: ${(err as Error).message}`); + } finally { + this.running = false; + } + } + + /** Why filing is currently impossible for this system number, or null when it is free. */ + private async systemBlockReason(): Promise { + const rows: { in_flight_invoice_id: string | null; blocked_reason: string | null }[] = + await this.dataSource.query( + `SELECT in_flight_invoice_id, blocked_reason + FROM freight.eims_system_state + WHERE system_number = $1 AND deleted_at IS NULL + LIMIT 1`, + [this.cfg.systemNumber], + ); + const state = rows[0]; + if (!state) return null; + if (state.blocked_reason) return state.blocked_reason; + if (state.in_flight_invoice_id) { + return `a submission for invoice ${state.in_flight_invoice_id} is still in flight`; + } + return null; + } + + /** + * Oldest never-submitted invoice that is issued, still inside MoR's document-age window, and + * carries at least one line. + */ + private async nextCandidate(): Promise<{ id: string; invoiceNumber: string } | null> { + const rows: { id: string; invoiceNumber: string }[] = await this.dataSource.query( + `SELECT i.id, i.invoice_number AS "invoiceNumber" + FROM freight.invoices i + WHERE i.eims_status = $1 + AND i.issued_at IS NOT NULL + AND i.deleted_at IS NULL + AND i.issued_at > now() - ($2 || ' days')::interval + AND EXISTS ( + SELECT 1 FROM freight.invoice_lines l + WHERE l.invoice_id = i.id AND l.deleted_at IS NULL + ) + ORDER BY i.issued_at ASC + LIMIT 1`, + [EimsInvoiceStatus.NotSubmitted, this.cfg.autoSubmitMaxAgeDays], + ); + return rows[0] ?? null; + } +} diff --git a/apps/edr-freight-api/src/modules/eims/eims-test-fixtures.ts b/apps/edr-freight-api/src/modules/eims/eims-test-fixtures.ts index f411c8b44..79fe30f96 100644 --- a/apps/edr-freight-api/src/modules/eims/eims-test-fixtures.ts +++ b/apps/edr-freight-api/src/modules/eims/eims-test-fixtures.ts @@ -51,6 +51,9 @@ export const eimsConfig = (over: Partial = {}): EimsConfig => ({ certificatePath: "/dev/null", httpTimeoutMs: 30_000, tokenSkewMs: 45_000, + autoSubmit: false, + autoSubmitCron: "0 */5 * * * *", + autoSubmitMaxAgeDays: 3, invoice: eimsInvoiceConfig(), ...over, }); diff --git a/apps/edr-freight-api/src/modules/eims/eims.module.ts b/apps/edr-freight-api/src/modules/eims/eims.module.ts index c3b50a489..678b21b52 100644 --- a/apps/edr-freight-api/src/modules/eims/eims.module.ts +++ b/apps/edr-freight-api/src/modules/eims/eims.module.ts @@ -4,6 +4,7 @@ import { TypeOrmModule } from "@nestjs/typeorm"; import { Invoice } from "../billing/entities/invoice.entity"; import { EimsAuthService } from "./eims-auth.service"; +import { EimsAutoSubmitService } from "./eims-auto-submit.service"; import { EimsClientService } from "./eims-client.service"; import { EimsCredentialsProvider } from "./eims-credentials.provider"; import { EimsInvoiceController } from "./eims-invoice.controller"; @@ -29,6 +30,7 @@ import { EimsSystemState } from "./entities/eims-system-state.entity"; EimsAuthService, EimsClientService, EimsInvoiceRegistrationService, + EimsAutoSubmitService, ], exports: [EimsAuthService, EimsClientService, EimsInvoiceRegistrationService], }) From b8e702dbc0e21748cf11569ddbfc881a78193eb2 Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Fri, 7 Aug 2026 15:08:02 +0000 Subject: [PATCH 013/276] CAS total --- .../src/modules/bookings/bookings.service.ts | 44 ++++++++++++------- 1 file changed, 28 insertions(+), 16 deletions(-) diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts index e4b902fe1..15799f000 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -424,7 +424,10 @@ export class BookingsService { const departureStation = booking.originYard?.label ?? booking.originYard?.code ?? '-'; const arrivalStation = booking.destinationYard?.label ?? booking.destinationYard?.code ?? '-'; - const cargoName = booking.cargoType?.cargoTypeName ?? booking.cargoFreeText ?? '-'; + // Container bookings carry no cargo type or free text — name the freight type + // rather than printing a dash in the Cargo Name column. + const cargoName = + booking.cargoType?.cargoTypeName ?? booking.cargoFreeText ?? booking.freightType ?? '-'; const currency = booking.paymentCurrency ?? 'ETB'; const totalAmount = Number(booking.adjustedTotalAmount ?? booking.totalAmount) || 0; const prices = this.splitAmountAcrossWagons( @@ -467,6 +470,28 @@ export class BookingsService { ) .join(''); + // The totals belong in , not : the Chromium-less fallback + // renderer only parses tbody rows, so a silently drops every footer + // figure from the printed sheet. + const totalsRow = ` + TOT + ${wagons.length} ${pendingWagons ? 'received lines' : 'wagons'} + ${ + pendingWagons + ? 'pending marshalling' + : `full ${fullWagons} / empty ${wagons.length - fullWagons}` + } + ${num(totals.tare, 2)} + ${num(totals.length)} + ${num(totals.capacity)} + + Gross ${num(totals.tare + totals.load)} T + + + + ${money(totalAmount)} + `; + return ` @@ -490,7 +515,7 @@ export class BookingsService { th { background: #f8fafc; color: #475569; text-align: left; } th, td { border: 1px solid #cbd5e1; padding: 5px 6px; font-size: 9.5px; vertical-align: top; } .num { text-align: right; } - tfoot td { background: #f8fafc; font-weight: 700; } + tr.totals td { background: #f8fafc; font-weight: 700; } .notice { margin-top: 10px; border-left: 4px solid #0f766e; background: #f0fdfa; padding: 8px 10px; font-size: 10px; color: #134e4a; } .signatures { display: grid; grid-template-columns: repeat(3, 1fr); gap: 18px; margin-top: 34px; } .line { border-top: 1px solid #334155; padding-top: 7px; font-size: 9px; color: #475569; min-height: 34px; } @@ -538,21 +563,8 @@ export class BookingsService { ${rows} + ${totalsRow} - - - ${ - pendingWagons - ? `Received lines: ${wagons.length} — wagons pending marshalling` - : `Total wagons: ${wagons.length} (full ${fullWagons} / empty ${wagons.length - fullWagons})` - } - ${num(totals.tare, 2)} - ${num(totals.length)} - ${num(totals.capacity)} - Gross weight (tare + load): ${num(totals.tare + totals.load)} T - ${money(totalAmount)} - -
From 6b3c055a93251a620cd13aa000fdcfded6be4f6d Mon Sep 17 00:00:00 2001 From: Marshal Date: Fri, 7 Aug 2026 15:21:55 +0000 Subject: [PATCH 014/276] Change the value of Field Status = SUCCESS to Success --- .../src/modules/cbe-bill/mappers/cbe-payment.mapper.ts | 2 +- .../src/modules/cbe-bill/mappers/cbe-query.mapper.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/edr-payment-api/src/modules/cbe-bill/mappers/cbe-payment.mapper.ts b/apps/edr-payment-api/src/modules/cbe-bill/mappers/cbe-payment.mapper.ts index 8e6259930..5b479a8cc 100644 --- a/apps/edr-payment-api/src/modules/cbe-bill/mappers/cbe-payment.mapper.ts +++ b/apps/edr-payment-api/src/modules/cbe-bill/mappers/cbe-payment.mapper.ts @@ -10,7 +10,7 @@ export function mapPaymentSuccess( End_To_End_Txn_Id: request.End_To_End_Txn_Id, Cbe_Txn_Ref: request.Cbe_Txn_Ref, Destination_Txn_Ref: destinationTxnRef, - Status: "SUCCESS", + Status: "Success", Response_Code: "0", Response_Description: "Success", Additional_Fields: [], diff --git a/apps/edr-payment-api/src/modules/cbe-bill/mappers/cbe-query.mapper.ts b/apps/edr-payment-api/src/modules/cbe-bill/mappers/cbe-query.mapper.ts index d2153b6a5..ac96c3ce1 100644 --- a/apps/edr-payment-api/src/modules/cbe-bill/mappers/cbe-query.mapper.ts +++ b/apps/edr-payment-api/src/modules/cbe-bill/mappers/cbe-query.mapper.ts @@ -21,7 +21,7 @@ export function mapQuerySuccess( Credit_Acct_Number: "", Transaction_Type: "", Timestamp: new Date().toISOString(), - Status: "SUCCESS", + Status: "Success", Response_Code: "0", Response_Description: "Success", Additional_Fields: [], From d44c3f3a38608eba9ce12520ef098b869595b961 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Fri, 7 Aug 2026 22:10:05 +0000 Subject: [PATCH 015/276] fix: rm iam migration from migrate --- apps/edr-freight-api/src/config/database.config.ts | 13 ++++++++----- apps/edr-freight-api/src/scripts/migrate.ts | 6 +++--- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/apps/edr-freight-api/src/config/database.config.ts b/apps/edr-freight-api/src/config/database.config.ts index 5de529d15..af4a5b017 100644 --- a/apps/edr-freight-api/src/config/database.config.ts +++ b/apps/edr-freight-api/src/config/database.config.ts @@ -215,8 +215,11 @@ export function buildFreightMigrationDataSourceOptions(): DataSourceOptions { }; } -export default registerAs("database", (): TypeOrmModuleOptions => ({ - ...buildDataSourceOptions(), - autoLoadEntities: true, - migrationsRun: false, -})); +export default registerAs( + "database", + (): TypeOrmModuleOptions => ({ + ...buildDataSourceOptions(), + autoLoadEntities: true, + migrationsRun: false, + }), +); diff --git a/apps/edr-freight-api/src/scripts/migrate.ts b/apps/edr-freight-api/src/scripts/migrate.ts index ac8a97b28..25bd8aa07 100644 --- a/apps/edr-freight-api/src/scripts/migrate.ts +++ b/apps/edr-freight-api/src/scripts/migrate.ts @@ -9,7 +9,7 @@ import { buildIamMigrationDataSourceOptions, buildFreightMigrationDataSourceOptions, FREIGHT_MIGRATIONS, - IAM_MIGRATIONS, + // IAM_MIGRATIONS, LEGACY_MIGRATIONS, } from "../config/database.config"; @@ -109,7 +109,7 @@ async function adoptLegacyHistory( if (!legacyExists[0].present) return; - await createHistoryTable(dataSource, IAM_MIGRATIONS); + // await createHistoryTable(dataSource, IAM_MIGRATIONS); await createHistoryTable(dataSource, FREIGHT_MIGRATIONS); const adopt = async ( @@ -134,7 +134,7 @@ async function adoptLegacyHistory( ); }; - await adopt(IAM_MIGRATIONS, true); + // await adopt(IAM_MIGRATIONS, true); await adopt(FREIGHT_MIGRATIONS, false); } From 7f1d8fa26095009a07c74c375e4cad750a4c5a73 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Fri, 7 Aug 2026 22:18:41 +0000 Subject: [PATCH 016/276] fix: iam migration --- apps/edr-freight-api/src/scripts/migrate.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/edr-freight-api/src/scripts/migrate.ts b/apps/edr-freight-api/src/scripts/migrate.ts index 25bd8aa07..266d015cd 100644 --- a/apps/edr-freight-api/src/scripts/migrate.ts +++ b/apps/edr-freight-api/src/scripts/migrate.ts @@ -158,7 +158,9 @@ export async function runAllMigrations(): Promise { (migration) => migration.name ?? migration.constructor.name, ); await adoptLegacyHistory(iam, iamMigrationNames); - await runMigrations("iam", iam); + // IAM migrations are owned by @tria-plc/iamapi-common's own CLI, not the + // freight deploy — do not apply them here. + // await runMigrations("iam", iam); } finally { await iam.destroy(); } From d2eb47d14b1982c8741164ff8aa1c14327a2b278 Mon Sep 17 00:00:00 2001 From: Marshal Date: Fri, 7 Aug 2026 23:00:06 +0000 Subject: [PATCH 017/276] feat(ui): --- apps/edr-freight-api/.env.example | 6 ++ .../src/modules/auth/list-users.service.ts | 25 ++++++++ .../ruleEngine/RuleEngineCardGrid.tsx | 2 +- .../ruleEngine/RuleEngineRecordActions.tsx | 58 ++++++++++--------- .../ruleEngine/RuleEngineResourcePage.tsx | 16 +++-- .../src/pages/wagons/WagonTransfersPage.tsx | 14 ----- 6 files changed, 74 insertions(+), 47 deletions(-) diff --git a/apps/edr-freight-api/.env.example b/apps/edr-freight-api/.env.example index 96af26034..48a4de52f 100644 --- a/apps/edr-freight-api/.env.example +++ b/apps/edr-freight-api/.env.example @@ -76,6 +76,12 @@ SEED_EDR_ORG=true SEED_FREIGHT_STAFF=true SEED_EXPORT_DJIBOUTI_INTERCHANGE_DEMO=false +# Limits GET /staff/users to employees of this IAM organization (iam.organizations.key). +# Unset = every employee. A key matching no organization returns no users. +# Dev seed key: edr_freight +# Production: ETHIO_DJIBOUTI_STANDARD_GAUGE_RAILWAY_SHARE_COMPANY_001 +FREIGHT_ORG_KEY=edr_freight + # MinIO (used by @tria-plc/iamapi-common for file storage) MINIO_ENDPOINT=localhost MINIO_PORT=9000 diff --git a/apps/edr-freight-api/src/modules/auth/list-users.service.ts b/apps/edr-freight-api/src/modules/auth/list-users.service.ts index cf7e22be2..c598e105a 100644 --- a/apps/edr-freight-api/src/modules/auth/list-users.service.ts +++ b/apps/edr-freight-api/src/modules/auth/list-users.service.ts @@ -1,4 +1,5 @@ import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; import { InjectRepository } from '@nestjs/typeorm'; import { PaginatedResponse } from '@edr/types'; import { User } from '@tria-plc/iamapi-common/entities/iam/user/user.entity'; @@ -19,6 +20,7 @@ import { paginateQuery } from '../../common/utils/pagination.util'; export class ListUsersService { constructor( @InjectRepository(User) private readonly users: Repository, + private readonly config: ConfigService, ) {} findAll(query: ListUsersQueryDto): Promise> { @@ -40,6 +42,29 @@ export class ListUsersService { ]) .orderBy(`user.${sortBy}`, query.sortOrder ?? 'ASC'); + // Restrict to one IAM organization when configured. The org key differs per + // environment (dev seeds `edr_freight`, production uses the registered + // company key), so this is config rather than a constant. An unset key + // means no restriction; a key matching no organization matches no user — + // failing closed rather than silently widening to every org. + const orgKey = this.config.get('FREIGHT_ORG_KEY'); + if (orgKey) { + // EXISTS, not a join: a user with several employee rows would otherwise + // be returned once per row, duplicating them in the list and inflating + // `getManyAndCount`'s total. + qb.andWhere( + `EXISTS ( + SELECT 1 + FROM iam.employees emp + JOIN iam.organizations org ON org.id = emp.organization_id + WHERE emp.user_id = "user".id + AND org.key = :orgKey + AND org.deleted_at IS NULL + )`, + { orgKey }, + ); + } + if (query.userType) { qb.andWhere('user.userType = :userType', { userType: query.userType }); } diff --git a/apps/edr-freight-web/backoffice/src/components/ruleEngine/RuleEngineCardGrid.tsx b/apps/edr-freight-web/backoffice/src/components/ruleEngine/RuleEngineCardGrid.tsx index 762f5bc84..e3fe9cb37 100644 --- a/apps/edr-freight-web/backoffice/src/components/ruleEngine/RuleEngineCardGrid.tsx +++ b/apps/edr-freight-web/backoffice/src/components/ruleEngine/RuleEngineCardGrid.tsx @@ -253,7 +253,7 @@ const RuleEngineCardGrid = ({ config={config} layout="compact" readOnly={readOnly} - onEdit={onEdit ?? (() => { })} + onEdit={onEdit} onDelete={onDelete ?? (() => { })} onViewChain={onViewChain} onSubmitRate={onSubmitRate} diff --git a/apps/edr-freight-web/backoffice/src/components/ruleEngine/RuleEngineRecordActions.tsx b/apps/edr-freight-web/backoffice/src/components/ruleEngine/RuleEngineRecordActions.tsx index 96440c001..f724d5b60 100644 --- a/apps/edr-freight-web/backoffice/src/components/ruleEngine/RuleEngineRecordActions.tsx +++ b/apps/edr-freight-web/backoffice/src/components/ruleEngine/RuleEngineRecordActions.tsx @@ -14,7 +14,7 @@ import type { RuleEngineRecord } from "@/types/rule-engine"; export interface RuleEngineRecordActionsProps { record: RuleEngineRecord; config: RuleEngineResourceConfig; - onEdit: (record: RuleEngineRecord) => void; + onEdit?: (record: RuleEngineRecord) => void; onDelete: (record: RuleEngineRecord) => void; onViewChain?: () => void; onSubmitRate?: (id: string) => void; @@ -93,17 +93,19 @@ const RuleEngineRecordActions = ({ ) : null}
- + {onEdit ? ( + + ) : null} - {canRequest ? ( - - ) : null} } /> From cd90ccb0f50ff92c451cb820c110c351363ac68b Mon Sep 17 00:00:00 2001 From: Marshal Date: Fri, 7 Aug 2026 23:50:27 +0000 Subject: [PATCH 018/276] feat(contracts): per-cargo bulk contract templates --- .../contract-document-view-model.builder.ts | 2 + .../3320000000000-BulkContractTemplates.ts | 100 ++++++ .../contract-templates.controller.ts | 70 +++-- .../contract-templates.repository.ts | 49 ++- .../contract-templates.service.ts | 142 +++++++-- .../dto/contract-template.dto.ts | 29 ++ .../entities/contract-template.entity.ts | 41 ++- .../contracts/contract-transition.service.ts | 2 + .../rule-engine/dto/create-cargo-type.dto.ts | 10 + .../rule-engine/entities/cargo-type.entity.ts | 8 + .../services/cargo-types.service.ts | 45 +++ .../src/seed/freight-permissions.registry.ts | 26 ++ apps/edr-freight-web/backoffice/src/App.tsx | 20 +- .../useContractTemplates.ts | 16 + .../backoffice/src/lib/permissions.ts | 4 + .../ContractTemplatesPage.tsx | 293 ++++++++++++++++-- .../src/pages/ruleEngine/CargoTypesPage.tsx | 13 + .../services/contract-templates.service.ts | 39 ++- 18 files changed, 802 insertions(+), 107 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/3320000000000-BulkContractTemplates.ts diff --git a/apps/edr-freight-api/src/contracts/contract-document-view-model.builder.ts b/apps/edr-freight-api/src/contracts/contract-document-view-model.builder.ts index d4f0c4039..36e4e34d0 100644 --- a/apps/edr-freight-api/src/contracts/contract-document-view-model.builder.ts +++ b/apps/edr-freight-api/src/contracts/contract-document-view-model.builder.ts @@ -120,6 +120,8 @@ export class ContractDocumentViewModelBuilder { contract.tradeDirection, contract.freightType, contract.customsClearingEnabled, + // Bulk templates are keyed by the contract's cargo type. + (contract.cargoScope ?? []).find((c) => c.cargoTypeId)?.cargoTypeId, ); dynamicTemplate = dynamicSource ? { diff --git a/apps/edr-freight-api/src/migrations/3320000000000-BulkContractTemplates.ts b/apps/edr-freight-api/src/migrations/3320000000000-BulkContractTemplates.ts new file mode 100644 index 000000000..39a10ca90 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3320000000000-BulkContractTemplates.ts @@ -0,0 +1,100 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +const CONTAINER_CODES = [ + 'IMPORT_CONTAINER_CUSTOMS', + 'IMPORT_CONTAINER_NO_CUSTOMS', + 'EXPORT_CONTAINER_CUSTOMS', + 'EXPORT_CONTAINER_NO_CUSTOMS', + 'INTERCITY_CONTAINER', +]; + +const BULK_CODES = [ + 'IMPORT_BULK_CUSTOMS', + 'IMPORT_BULK_NO_CUSTOMS', + 'EXPORT_BULK_CUSTOMS', + 'EXPORT_BULK_NO_CUSTOMS', + 'INTERCITY_BULK', +]; + +/** + * Bulk contract templates become staff-created, keyed by (cargo type, customs + * clearing) instead of the fixed direction codes. The five container templates + * stay seeded and become undeletable system rows; the five seeded bulk rows are + * retired (soft-deleted). cargo_types gains has_contract_template, marking + * which bulk commodities may carry their own template. + */ +export class BulkContractTemplates3320000000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.cargo_types + ADD COLUMN IF NOT EXISTS has_contract_template boolean NOT NULL DEFAULT false + `); + + await queryRunner.query(` + ALTER TABLE freight.contract_templates + ADD COLUMN IF NOT EXISTS cargo_type_id uuid REFERENCES freight.cargo_types(id), + ADD COLUMN IF NOT EXISTS with_customs boolean, + ADD COLUMN IF NOT EXISTS is_system boolean NOT NULL DEFAULT false + `); + + // Generated bulk codes (BULK__NO_CUSTOMS) outgrow varchar(40). + await queryRunner.query(` + ALTER TABLE freight.contract_templates + ALTER COLUMN code TYPE varchar(80) + `); + + await queryRunner.query( + `UPDATE freight.contract_templates SET is_system = true WHERE code = ANY($1)`, + [CONTAINER_CODES], + ); + + // Retire the fixed bulk templates; staff recreate them per cargo type. + await queryRunner.query( + `UPDATE freight.contract_templates SET deleted_at = now() + WHERE code = ANY($1) AND deleted_at IS NULL`, + [BULK_CODES], + ); + + // Code stays unique among live rows only, so a deleted combo can be + // recreated under the same generated code. + await queryRunner.query( + `ALTER TABLE freight.contract_templates DROP CONSTRAINT IF EXISTS uq_contract_templates_code`, + ); + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS uq_contract_templates_code + ON freight.contract_templates (code) WHERE deleted_at IS NULL + `); + + // One template per (bulk cargo type, customs option) — the "same + // combination" rule, enforced even under concurrent creates. + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS uq_contract_templates_cargo_customs + ON freight.contract_templates (cargo_type_id, with_customs) + WHERE deleted_at IS NULL AND cargo_type_id IS NOT NULL + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DROP INDEX IF EXISTS freight.uq_contract_templates_cargo_customs`, + ); + await queryRunner.query(`DROP INDEX IF EXISTS freight.uq_contract_templates_code`); + await queryRunner.query(` + ALTER TABLE freight.contract_templates + ADD CONSTRAINT uq_contract_templates_code UNIQUE (code) + `); + await queryRunner.query( + `UPDATE freight.contract_templates SET deleted_at = NULL WHERE code = ANY($1)`, + [BULK_CODES], + ); + await queryRunner.query(` + ALTER TABLE freight.contract_templates + DROP COLUMN IF EXISTS cargo_type_id, + DROP COLUMN IF EXISTS with_customs, + DROP COLUMN IF EXISTS is_system + `); + await queryRunner.query(` + ALTER TABLE freight.cargo_types DROP COLUMN IF EXISTS has_contract_template + `); + } +} diff --git a/apps/edr-freight-api/src/modules/contract-templates/contract-templates.controller.ts b/apps/edr-freight-api/src/modules/contract-templates/contract-templates.controller.ts index 6cba17f61..7053d224f 100644 --- a/apps/edr-freight-api/src/modules/contract-templates/contract-templates.controller.ts +++ b/apps/edr-freight-api/src/modules/contract-templates/contract-templates.controller.ts @@ -3,6 +3,8 @@ import { Controller, Delete, Get, + HttpCode, + HttpStatus, Param, Patch, Post, @@ -15,55 +17,85 @@ import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry"; import { ContractTemplatesService } from "./contract-templates.service"; import { CreateArticleDto, + CreateContractTemplateDto, PreviewContractTemplateDto, ReplaceArticlesDto, UpdateArticleDto, UpdateContractTemplateDto, } from "./dto/contract-template.dto"; +// `view` opens the Templates page; `read` is API-read-only for other pages +// that show template data; create/update/delete gate each write. `manage` is +// the legacy write key and keeps working for roles that already hold it. +const TEMPLATE_READ = [ + FREIGHT_PERMS.settings.contractTemplates.view, + FREIGHT_PERMS.settings.contractTemplates.read, + FREIGHT_PERMS.settings.contractTemplates.update, + FREIGHT_PERMS.settings.contractTemplates.manage, + FREIGHT_PERMS.admin, +]; + +const TEMPLATE_UPDATE = [ + FREIGHT_PERMS.settings.contractTemplates.update, + FREIGHT_PERMS.settings.contractTemplates.manage, + FREIGHT_PERMS.admin, +]; + @ApiTags("contract-templates") @Controller("contract-templates") export class ContractTemplatesController { constructor(private readonly service: ContractTemplatesService) {} - // Reads are staff-only (the backoffice Templates tab is the only consumer); - // writes are admin-guarded like other freight configuration resources. - @Get() - @BookingStaff([ - FREIGHT_PERMS.settings.contractTemplates.view, - FREIGHT_PERMS.settings.contractTemplates.manage, - FREIGHT_PERMS.admin, - ]) - @ApiOperation({ summary: "List the six contract document templates" }) + @BookingStaff(TEMPLATE_READ) + @ApiOperation({ summary: "List contract templates (system container + staff-created bulk)" }) list() { return this.service.list(); } - @Get(":code") + @Post() @BookingStaff([ - FREIGHT_PERMS.settings.contractTemplates.view, + FREIGHT_PERMS.settings.contractTemplates.create, FREIGHT_PERMS.settings.contractTemplates.manage, FREIGHT_PERMS.admin, ]) + @ApiOperation({ + summary: + "Create a bulk contract template for a (cargo type, customs option) pair", + }) + create(@Body() dto: CreateContractTemplateDto) { + return this.service.create(dto); + } + + @Get(":code") + @BookingStaff(TEMPLATE_READ) @ApiOperation({ summary: "Get one contract template by code" }) getByCode(@Param("code") code: string) { return this.service.getByCode(code); } @Patch(":code") - @BookingStaff([FREIGHT_PERMS.settings.contractTemplates.manage, FREIGHT_PERMS.admin]) + @BookingStaff(TEMPLATE_UPDATE) @ApiOperation({ summary: "Update template metadata (name, title, recitals, active flag)" }) update(@Param("code") code: string, @Body() dto: UpdateContractTemplateDto) { return this.service.update(code, dto); } - @Post(":code/preview") + @Delete(":code") @BookingStaff([ - FREIGHT_PERMS.settings.contractTemplates.view, - FREIGHT_PERMS.settings.contractTemplates.manage, + FREIGHT_PERMS.settings.contractTemplates.delete, FREIGHT_PERMS.admin, ]) + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ + summary: "Delete a staff-created bulk template (system templates refuse)", + }) + remove(@Param("code") code: string) { + return this.service.remove(code); + } + + @Post(":code/preview") + @BookingStaff(TEMPLATE_READ) @ApiOperation({ summary: "Render an HTML preview of the template against mock contract data", }) @@ -77,21 +109,21 @@ export class ContractTemplatesController { /* ------------------------- article routes ------------------------- */ @Put(":code/articles") - @BookingStaff([FREIGHT_PERMS.settings.contractTemplates.manage, FREIGHT_PERMS.admin]) + @BookingStaff(TEMPLATE_UPDATE) @ApiOperation({ summary: "Replace the full ordered article list (used for reorder)" }) replaceArticles(@Param("code") code: string, @Body() dto: ReplaceArticlesDto) { return this.service.replaceArticles(code, dto.articles); } @Post(":code/articles") - @BookingStaff([FREIGHT_PERMS.settings.contractTemplates.manage, FREIGHT_PERMS.admin]) + @BookingStaff(TEMPLATE_UPDATE) @ApiOperation({ summary: "Add an article to the template" }) addArticle(@Param("code") code: string, @Body() dto: CreateArticleDto) { return this.service.addArticle(code, dto); } @Patch(":code/articles/:articleId") - @BookingStaff([FREIGHT_PERMS.settings.contractTemplates.manage, FREIGHT_PERMS.admin]) + @BookingStaff(TEMPLATE_UPDATE) @ApiOperation({ summary: "Update an article's title or body" }) updateArticle( @Param("code") code: string, @@ -102,7 +134,7 @@ export class ContractTemplatesController { } @Delete(":code/articles/:articleId") - @BookingStaff([FREIGHT_PERMS.settings.contractTemplates.manage, FREIGHT_PERMS.admin]) + @BookingStaff(TEMPLATE_UPDATE) @ApiOperation({ summary: "Remove an article from the template" }) removeArticle( @Param("code") code: string, diff --git a/apps/edr-freight-api/src/modules/contract-templates/contract-templates.repository.ts b/apps/edr-freight-api/src/modules/contract-templates/contract-templates.repository.ts index 2f4fb0117..9d50b1677 100644 --- a/apps/edr-freight-api/src/modules/contract-templates/contract-templates.repository.ts +++ b/apps/edr-freight-api/src/modules/contract-templates/contract-templates.repository.ts @@ -3,10 +3,8 @@ import { Injectable } from "@nestjs/common"; import { InjectRepository } from "@nestjs/typeorm"; import { Repository } from "typeorm"; -import { - ContractTemplate, - ContractTemplateCode, -} from "./entities/contract-template.entity"; +import { CargoType } from "../rule-engine/entities/cargo-type.entity"; +import { ContractTemplate } from "./entities/contract-template.entity"; @Injectable() export class ContractTemplatesRepository extends BaseRepository { @@ -17,12 +15,51 @@ export class ContractTemplatesRepository extends BaseRepository { + findByCode(code: string): Promise { return this.repository.findOne({ where: { code } }); } override findAll(): Promise { - return this.repository.find({ order: { code: "ASC" } }); + return this.repository.find({ + relations: { cargoType: true }, + order: { code: "ASC" }, + }); + } + + findByCargoCombo( + cargoTypeId: string, + withCustoms: boolean, + ): Promise { + return this.repository.findOne({ where: { cargoTypeId, withCustoms } }); + } + + /** + * The active bulk template covering this cargo type: written against the + * cargo type itself or against its parent group (the two are mutually + * exclusive, so at most one row matches). + */ + findActiveBulkTemplate( + cargoTypeId: string, + withCustoms: boolean, + ): Promise { + return this.repository + .createQueryBuilder("t") + .where("t.is_active = true") + .andWhere("t.with_customs = :withCustoms", { withCustoms }) + .andWhere( + `(t.cargo_type_id = :cargoTypeId OR t.cargo_type_id = ( + SELECT c.parent_group_id FROM freight.cargo_types c + WHERE c.id = :cargoTypeId AND c.deleted_at IS NULL + ))`, + { cargoTypeId }, + ) + .getOne(); + } + + findCargoType(id: string): Promise { + return this.repository.manager + .getRepository(CargoType) + .findOne({ where: { id } }); } async saveTemplate(template: ContractTemplate): Promise { diff --git a/apps/edr-freight-api/src/modules/contract-templates/contract-templates.service.ts b/apps/edr-freight-api/src/modules/contract-templates/contract-templates.service.ts index ea41b57a2..da9c849f7 100644 --- a/apps/edr-freight-api/src/modules/contract-templates/contract-templates.service.ts +++ b/apps/edr-freight-api/src/modules/contract-templates/contract-templates.service.ts @@ -1,4 +1,9 @@ -import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common"; +import { + BadRequestException, + ConflictException, + Injectable, + NotFoundException, +} from "@nestjs/common"; import { randomUUID } from "node:crypto"; import { ContractRendererService } from "../../contracts/contract-renderer.service"; @@ -11,6 +16,7 @@ import { import { ContractTemplatesRepository } from "./contract-templates.repository"; import { CreateArticleDto, + CreateContractTemplateDto, PreviewContractTemplateDto, ReplaceArticleDto, UpdateArticleDto, @@ -53,13 +59,17 @@ export class ContractTemplatesService { async list(): Promise { const templates = await this.repository.findAll(); const rank = new Map(CONTRACT_TEMPLATE_CODES.map((code, i) => [code, i] as const)); - return templates.sort( - (a, b) => (rank.get(a.code) ?? 99) - (rank.get(b.code) ?? 99), - ); + // Seeded container templates first in canonical order, then staff-created + // bulk templates alphabetically. + return templates.sort((a, b) => { + const ra = rank.get(a.code as ContractTemplateCode) ?? 99; + const rb = rank.get(b.code as ContractTemplateCode) ?? 99; + return ra !== rb ? ra - rb : a.name.localeCompare(b.name); + }); } async getByCode(code: string): Promise { - const template = await this.repository.findByCode(this.assertCode(code)); + const template = await this.repository.findByCode(code?.toUpperCase() ?? ""); if (!template) { throw new NotFoundException(`Contract template ${code} not found`); } @@ -67,15 +77,93 @@ export class ContractTemplatesService { } /** - * The active template used when generating a contract document for the given - * direction/freight/customs triple; null when missing or deactivated (the - * renderer then falls back to the built-in generic layout). + * Staff-created bulk template for one (cargo type, customs option) pair. + * The cargo type must have hasContractTemplate enabled and the combination + * must not already exist — the same commodity + customs pairing is edited, + * never duplicated. + */ + async create(dto: CreateContractTemplateDto): Promise { + const cargoType = await this.repository.findCargoType(dto.cargoTypeId); + if (!cargoType) { + throw new NotFoundException(`Cargo type ${dto.cargoTypeId} not found`); + } + if (!cargoType.hasContractTemplate) { + throw new BadRequestException( + `"${cargoType.cargoTypeName}" does not allow contract templates — enable "has contract template" on the cargo type first`, + ); + } + const variant = dto.withCustoms ? "with" : "without"; + const existing = await this.repository.findByCargoCombo( + dto.cargoTypeId, + dto.withCustoms, + ); + if (existing) { + throw new ConflictException( + `A "${cargoType.cargoTypeName}" template ${variant} customs clearing already exists — edit that template instead`, + ); + } + + const template = new ContractTemplate(); + template.code = `BULK_${cargoType.code}_${dto.withCustoms ? "CUSTOMS" : "NO_CUSTOMS"}`.toUpperCase(); + template.name = + dto.name ?? + `${cargoType.cargoTypeName} Bulk Contract (${variant} customs clearing)`; + template.description = dto.description ?? null; + template.documentTitle = dto.withCustoms + ? `${cargoType.cargoTypeName} Transportation and Customs Clearance Services` + : `${cargoType.cargoTypeName} Transportation Services`; + template.whereasClauses = []; + template.articles = []; + template.isActive = true; + template.cargoTypeId = cargoType.id; + template.withCustoms = dto.withCustoms; + template.isSystem = false; + try { + return await this.repository.saveTemplate(template); + } catch (error) { + // Partial unique index backstop for concurrent creates of the same combo. + if ((error as { code?: string })?.code === "23505") { + throw new ConflictException( + `A "${cargoType.cargoTypeName}" template ${variant} customs clearing already exists — edit that template instead`, + ); + } + throw error; + } + } + + /** Bulk templates only — the five seeded container templates are permanent. */ + async remove(code: string): Promise { + const template = await this.getByCode(code); + if (template.isSystem) { + throw new BadRequestException( + "System container templates cannot be deleted", + ); + } + await this.repository.softDelete(template.id); + } + + /** + * The active template used when generating a contract document. Container + * contracts resolve through the fixed direction/customs codes; bulk contracts + * resolve through the staff-created template for the contract's cargo type + * (or its parent group) and customs option. Null when nothing matches or the + * match is deactivated (the renderer then falls back to the built-in generic + * layout). */ async findActiveForContract( tradeDirection?: string | null, freightType?: string | null, customsClearingEnabled?: boolean | null, + cargoTypeId?: string | null, ): Promise { + const isBulk = (freightType ?? "").toUpperCase().includes("BULK"); + if (isBulk) { + if (!cargoTypeId) return null; + return this.repository.findActiveBulkTemplate( + cargoTypeId, + Boolean(customsClearingEnabled), + ); + } const code = contractTemplateCodeFor( tradeDirection, freightType, @@ -180,16 +268,32 @@ export class ContractTemplatesService { : this.sorted(template.articles), }; - const view = this.buildMockView(template.code, dynamicTemplate); + const view = this.buildMockView(template, dynamicTemplate); return { html: this.renderer.render(view) }; } + /** + * Registry key the mock preview renders against. Staff-created bulk + * templates aren't in the fixed code map — they preview against the + * representative bulk import pack matching their customs option. + */ + private previewKeyFor(template: ContractTemplate): string { + if (template.cargoTypeId) { + return template.withCustoms + ? "IMP_BULK_USD_FORWARDING" + : "IMP_BULK_USD_TRANSPORT_ONLY"; + } + return PREVIEW_TEMPLATE_KEYS[template.code as ContractTemplateCode]; + } + private buildMockView( - code: ContractTemplateCode, + template: ContractTemplate, dynamicTemplate: ContractDynamicTemplateView, ): ContractViewModel { - const meta = getTemplateMeta(PREVIEW_TEMPLATE_KEYS[code]); - const isBulk = code.endsWith("_BULK"); + const code = template.code; + const previewKey = this.previewKeyFor(template); + const meta = getTemplateMeta(previewKey); + const isBulk = Boolean(template.cargoTypeId) || code.includes("BULK"); const now = new Date(); // Representative rate schedule so the admin preview shows the live-rate @@ -200,7 +304,7 @@ export class ContractTemplatesService { bookingId: "00000000-0000-0000-0000-000000000000", reference: "EDR/CT/2026/0042", status: "CONTRACT_READY", - templateKey: PREVIEW_TEMPLATE_KEYS[code], + templateKey: previewKey, template: { ...meta, title: dynamicTemplate.name, templateFile: "edr-dynamic.hbs" }, contractDate: now.toLocaleDateString("en-GB", { day: "numeric", @@ -275,7 +379,7 @@ export class ContractTemplatesService { } /** Static, representative rate schedule for the admin preview only. */ - private mockRateSchedule(code: ContractTemplateCode, isBulk: boolean): RateSchedule { + private mockRateSchedule(code: string, isBulk: boolean): RateSchedule { const dir = code.startsWith("IMPORT") ? "import" : code.startsWith("EXPORT") @@ -311,16 +415,6 @@ export class ContractTemplatesService { }; } - private assertCode(code: string): ContractTemplateCode { - const upper = code?.toUpperCase() as ContractTemplateCode; - if (!CONTRACT_TEMPLATE_CODES.includes(upper)) { - throw new BadRequestException( - `Unknown contract template code "${code}". Valid codes: ${CONTRACT_TEMPLATE_CODES.join(", ")}`, - ); - } - return upper; - } - private sorted(articles: ContractTemplateArticle[]): ContractTemplateArticle[] { return [...(articles ?? [])].sort((a, b) => (a.order ?? 0) - (b.order ?? 0)); } diff --git a/apps/edr-freight-api/src/modules/contract-templates/dto/contract-template.dto.ts b/apps/edr-freight-api/src/modules/contract-templates/dto/contract-template.dto.ts index 0ea69f262..c8911d8e5 100644 --- a/apps/edr-freight-api/src/modules/contract-templates/dto/contract-template.dto.ts +++ b/apps/edr-freight-api/src/modules/contract-templates/dto/contract-template.dto.ts @@ -6,12 +6,41 @@ import { IsInt, IsOptional, IsString, + IsUUID, MaxLength, Min, MinLength, ValidateNested, } from "class-validator"; +export class CreateContractTemplateDto { + @ApiProperty({ + description: + "Bulk cargo type this template is written for (must have hasContractTemplate enabled)", + format: "uuid", + }) + @IsUUID() + cargoTypeId!: string; + + @ApiProperty({ + description: "Whether this is the with-customs-clearing variant", + }) + @IsBoolean() + withCustoms!: boolean; + + @ApiPropertyOptional({ description: "Display name (derived from the cargo type when omitted)" }) + @IsOptional() + @IsString() + @MinLength(3) + @MaxLength(200) + name?: string; + + @ApiPropertyOptional({ description: "Short description shown on the template card" }) + @IsOptional() + @IsString() + description?: string; +} + export class UpdateContractTemplateDto { @ApiPropertyOptional({ description: "Display name of the template" }) @IsOptional() diff --git a/apps/edr-freight-api/src/modules/contract-templates/entities/contract-template.entity.ts b/apps/edr-freight-api/src/modules/contract-templates/entities/contract-template.entity.ts index c322c20a4..af0721572 100644 --- a/apps/edr-freight-api/src/modules/contract-templates/entities/contract-template.entity.ts +++ b/apps/edr-freight-api/src/modules/contract-templates/entities/contract-template.entity.ts @@ -1,11 +1,18 @@ import { BaseEntity } from "@edr/api-common"; -import { Column, Entity, Index } from "typeorm"; +import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm"; + +import { CargoType } from "../../rule-engine/entities/cargo-type.entity"; /** - * The ten canonical contract document templates. Import and export split by - * customs clearing (× freight type = 8); intercity does not, because it is a - * purely domestic Ethiopian movement that crosses no border and therefore has - * no customs leg at all (× freight type = 2). + * The five seeded container templates (import/export split by customs + * clearing; intercity is domestic, crosses no border, so it has a single + * template). These are system rows: always present, never deletable. + * + * Bulk templates are NOT seeded — staff create them per bulk cargo type + * (`cargoTypeId`) and customs option (`withCustoms`), one template per + * combination. Their codes are generated as BULK__(NO_)CUSTOMS. + * The retired direction-keyed bulk codes remain listed so old frozen document + * snapshots still label correctly. * * Contracts store DOMESTIC for intercity movements; the template layer labels * those INTERCITY to match the commercial vocabulary used on the printed @@ -76,10 +83,12 @@ export function contractTemplateCodeFor( } @Entity({ schema: "freight", name: "contract_templates" }) -@Index(["code"], { unique: true }) +// Uniqueness lives in partial DB indexes (live rows only): code, and +// (cargo_type_id, with_customs) for staff-created bulk templates. +@Index(["code"]) export class ContractTemplate extends BaseEntity { - @Column({ name: "code", type: "varchar", length: 40, unique: true }) - code!: ContractTemplateCode; + @Column({ name: "code", type: "varchar", length: 80 }) + code!: string; @Column({ name: "name", type: "varchar", length: 200 }) name!: string; @@ -100,4 +109,20 @@ export class ContractTemplate extends BaseEntity { @Column({ name: "is_active", type: "boolean", default: true }) isActive!: boolean; + + /** Bulk templates only: the cargo type this template is written for. */ + @Column({ name: "cargo_type_id", type: "uuid", nullable: true }) + cargoTypeId?: string | null; + + @ManyToOne(() => CargoType, { nullable: true }) + @JoinColumn({ name: "cargo_type_id" }) + cargoType?: CargoType | null; + + /** Bulk templates only: whether this is the with-customs-clearing variant. */ + @Column({ name: "with_customs", type: "boolean", nullable: true }) + withCustoms?: boolean | null; + + /** The five seeded container templates — cannot be deleted. */ + @Column({ name: "is_system", type: "boolean", default: false }) + isSystem!: boolean; } diff --git a/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts index 0a073c9f8..ba3e3fd8b 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts @@ -424,6 +424,8 @@ export class ContractTransitionService { contract.tradeDirection, contract.freightType, contract.customsClearingEnabled, + // Bulk templates are keyed by the contract's cargo type. + (contract.cargoScope ?? []).find((c) => c.cargoTypeId)?.cargoTypeId, ); if (!active) return null; return { diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/create-cargo-type.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/create-cargo-type.dto.ts index eefc560e8..d116a4b4a 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/dto/create-cargo-type.dto.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/create-cargo-type.dto.ts @@ -70,6 +70,16 @@ export class CreateCargoTypeDto { @IsBoolean() hasLashing?: boolean; + @ApiPropertyOptional({ + default: false, + description: + 'Allow staff to write bulk contract templates for this cargo type. ' + + 'Mutually exclusive with the parent group / children having it.', + }) + @IsOptional() + @IsBoolean() + hasContractTemplate?: boolean; + @ApiPropertyOptional({ default: true }) @IsOptional() @IsBoolean() diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/cargo-type.entity.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/cargo-type.entity.ts index e685f3a2d..b7717684a 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/entities/cargo-type.entity.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/cargo-type.entity.ts @@ -82,6 +82,14 @@ export class CargoType extends BaseEntity { @Column({ name: 'has_lashing', type: 'boolean', default: false }) hasLashing!: boolean; + /** + * Whether staff may write bulk contract templates against this cargo type. + * Mutually exclusive between a parent group and its children: if the parent + * provides the template, no child may, and vice versa. + */ + @Column({ name: 'has_contract_template', type: 'boolean', default: false }) + hasContractTemplate!: boolean; + @Column({ name: 'is_active', type: 'boolean', default: true }) isActive!: boolean; diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/cargo-types.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/cargo-types.service.ts index 1f25b0823..9c11005e9 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/cargo-types.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/cargo-types.service.ts @@ -135,6 +135,35 @@ export class CargoTypesService { return map; } + /** + * A cargo type and its parent group may not BOTH offer a contract template — + * the template would be ambiguous for bookings of the child. To enable the + * child, the parent must be turned off first (and vice versa). + */ + private async assertContractTemplateExclusive(input: { + id?: string; + parentGroupId?: string | null; + }): Promise { + if (input.parentGroupId) { + const parent = await this.repository.findById(input.parentGroupId); + if (parent?.hasContractTemplate) { + throw new BadRequestException( + `Parent group "${parent.cargoTypeName}" already has a contract template — turn it off there first`, + ); + } + } + if (input.id) { + const children = await this.repository.findAll({ + where: { parentGroupId: input.id, hasContractTemplate: true }, + }); + if (children.length) { + throw new BadRequestException( + `Child cargo type(s) ${children.map((c) => `"${c.cargoTypeName}"`).join(', ')} already have their own contract template — turn those off first`, + ); + } + } + } + /** Create a new cargo type. */ async create(dto: CreateCargoTypeDto): Promise { const code = generateCode(dto.cargoTypeName); @@ -144,6 +173,9 @@ export class CargoTypesService { const parent = await this.repository.findById(dto.parentGroupId); if (!parent) throw new NotFoundException(`Parent cargo type ${dto.parentGroupId} not found`); } + if (dto.hasContractTemplate) { + await this.assertContractTemplateExclusive({ parentGroupId: dto.parentGroupId }); + } const displayOrder = await this.displayOrder.resolveCreateOrder(CargoType, 'displayOrder', { explicitOrder: dto.displayOrder, @@ -160,6 +192,7 @@ export class CargoTypesService { code, cargoTypeName: dto.cargoTypeName, parentGroupId: dto.parentGroupId ?? null, + hasContractTemplate: dto.hasContractTemplate ?? false, requiresDirectorApproval: dto.requiresDirectorApproval ?? false, isActive: dto.isActive ?? true, unitOfMeasure: dto.unitOfMeasure ?? null, @@ -183,6 +216,18 @@ export class CargoTypesService { const parent = await this.repository.findById(dto.parentGroupId); if (!parent) throw new NotFoundException(`Parent cargo type ${dto.parentGroupId} not found`); } + // Re-check the parent/child template exclusivity whenever the flag or the + // parent moves and the row ends up flagged. + const willHaveTemplate = dto.hasContractTemplate ?? existing.hasContractTemplate; + if ( + willHaveTemplate && + (dto.hasContractTemplate !== undefined || dto.parentGroupId !== undefined) + ) { + await this.assertContractTemplateExclusive({ + id, + parentGroupId: dto.parentGroupId ?? existing.parentGroupId, + }); + } const { wagonTypeIds, itemsPerWagonMap, diff --git a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts index b103dead4..e119c0fe2 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts @@ -1249,6 +1249,28 @@ export const GRANULAR_SPLIT_PERMISSIONS: FreightPermissionSeed[] = [ "edr_freight_app:settings:contract_templates:manage", "Edit contract templates & articles", ), + // Granular split of contract-template access. `view` opens the sidebar page; + // `read` is API-read-only for other pages that display template data. + perm( + "b4e00001-0001-4000-8000-000000000003", + "edr_freight_app:settings:contract_templates:create", + "Create bulk contract templates", + ), + perm( + "b4e00001-0001-4000-8000-000000000004", + "edr_freight_app:settings:contract_templates:update", + "Update contract templates & articles", + ), + perm( + "b4e00001-0001-4000-8000-000000000005", + "edr_freight_app:settings:contract_templates:delete", + "Delete bulk contract templates", + ), + perm( + "b4e00001-0001-4000-8000-000000000006", + "edr_freight_app:settings:contract_templates:read", + "Read contract template data (API only)", + ), ]; // N. Previously-ungated staff surfaces (support inbox, procurement, compliance, @@ -1670,6 +1692,10 @@ export const FREIGHT_PERMS = { contractTemplates: { view: "edr_freight_app:settings:contract_templates:view", manage: "edr_freight_app:settings:contract_templates:manage", + create: "edr_freight_app:settings:contract_templates:create", + update: "edr_freight_app:settings:contract_templates:update", + delete: "edr_freight_app:settings:contract_templates:delete", + read: "edr_freight_app:settings:contract_templates:read", }, }, audit: { diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index 6fd666a8d..15aa1987f 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -590,7 +590,11 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ label: "Contract templates", href: "/dashboard/contract-templates", icon: , - permission: FREIGHT_PERMS.admin, + // `view` opens the page; `read` alone is API-only and shows no menu. + permission: [ + FREIGHT_PERMS.settings.contractTemplates.view, + FREIGHT_PERMS.admin, + ], }, { label: "Audit logs", @@ -1470,7 +1474,12 @@ const App = () => { + } @@ -1478,7 +1487,12 @@ const App = () => { + } diff --git a/apps/edr-freight-web/backoffice/src/hooks/contract-templates/useContractTemplates.ts b/apps/edr-freight-web/backoffice/src/hooks/contract-templates/useContractTemplates.ts index 7e01d883c..0d7d78b56 100644 --- a/apps/edr-freight-web/backoffice/src/hooks/contract-templates/useContractTemplates.ts +++ b/apps/edr-freight-web/backoffice/src/hooks/contract-templates/useContractTemplates.ts @@ -4,6 +4,7 @@ import toast from "react-hot-toast"; import { contractTemplatesService, type ArticlePayload, + type CreateContractTemplatePayload, type UpdateContractTemplatePayload, } from "@/services/contract-templates.service"; @@ -59,6 +60,21 @@ function useTemplateMutation( }); } +export function useCreateContractTemplate() { + return useTemplateMutation( + (payload: CreateContractTemplatePayload) => + contractTemplatesService.create(payload), + "Template created", + ); +} + +export function useDeleteContractTemplate() { + return useTemplateMutation( + (code: string) => contractTemplatesService.remove(code), + "Template deleted", + ); +} + export function useUpdateContractTemplate(code: string) { return useTemplateMutation( (payload: UpdateContractTemplatePayload) => diff --git a/apps/edr-freight-web/backoffice/src/lib/permissions.ts b/apps/edr-freight-web/backoffice/src/lib/permissions.ts index 368e9e74b..8e58d642d 100644 --- a/apps/edr-freight-web/backoffice/src/lib/permissions.ts +++ b/apps/edr-freight-web/backoffice/src/lib/permissions.ts @@ -317,6 +317,10 @@ export const FREIGHT_PERMS = { contractTemplates: { view: "edr_freight_app:settings:contract_templates:view", manage: "edr_freight_app:settings:contract_templates:manage", + create: "edr_freight_app:settings:contract_templates:create", + update: "edr_freight_app:settings:contract_templates:update", + delete: "edr_freight_app:settings:contract_templates:delete", + read: "edr_freight_app:settings:contract_templates:read", }, }, audit: { diff --git a/apps/edr-freight-web/backoffice/src/pages/contract_templates/ContractTemplatesPage.tsx b/apps/edr-freight-web/backoffice/src/pages/contract_templates/ContractTemplatesPage.tsx index 30b31f54d..1a66bbdd5 100644 --- a/apps/edr-freight-web/backoffice/src/pages/contract_templates/ContractTemplatesPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/contract_templates/ContractTemplatesPage.tsx @@ -1,11 +1,15 @@ -import { useState } from "react"; +import { useMemo, useState } from "react"; import { useNavigate } from "react-router-dom"; +import { useQuery } from "@tanstack/react-query"; import { Badge, Box, Button, Card, Group, + Modal, + SegmentedControl, + Select, SimpleGrid, Skeleton, Stack, @@ -19,11 +23,21 @@ import { Container, Eye, FileText, + Lock, Pencil, + Plus, + Trash2, } from "lucide-react"; +import { useAuth } from "@/auth/useAuth"; import { PageContainer, PageHeader } from "@/components/page"; -import { useContractTemplates } from "@/hooks/contract-templates/useContractTemplates"; +import { + useContractTemplates, + useCreateContractTemplate, + useDeleteContractTemplate, +} from "@/hooks/contract-templates/useContractTemplates"; +import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions"; +import { cargoTypesService } from "@/services/cargo-types.service"; import type { ContractTemplate } from "@/services/contract-templates.service"; import TemplatePreviewModal from "./TemplatePreviewModal"; @@ -39,22 +53,18 @@ const DIRECTION_DOT: Record = { INTERCITY: "var(--mantine-color-orange-5)", }; -function templateDirection(code: ContractTemplate["code"]): string { - return code.split("_")[0]; +function isBulk(template: ContractTemplate): boolean { + return Boolean(template.cargoTypeId); } -// Codes are DIRECTION_FREIGHT_{CUSTOMS,NO_CUSTOMS}, so the freight segment is -// the second one — never the suffix. -function isBulk(code: ContractTemplate["code"]): boolean { - return code.split("_")[1] === "BULK"; -} - -// Intercity is domestic and crosses no border, so it has no customs variant at -// all — hence null rather than false, which would wrongly read as a deliberate -// "client clears its own customs" choice. -function customsVariant(code: ContractTemplate["code"]): boolean | null { - if (code.endsWith("_NO_CUSTOMS")) return false; - if (code.endsWith("_CUSTOMS")) return true; +// System container codes are DIRECTION_CONTAINER(_CUSTOMS); intercity is +// domestic and crosses no border, so it has no customs variant at all — hence +// null rather than false, which would wrongly read as a deliberate "client +// clears its own customs" choice. +function customsVariant(template: ContractTemplate): boolean | null { + if (isBulk(template)) return template.withCustoms ?? null; + if (template.code.endsWith("_NO_CUSTOMS")) return false; + if (template.code.endsWith("_CUSTOMS")) return true; return null; } @@ -66,10 +76,28 @@ function formatUpdated(value: string): string { }); } +interface CargoTypeOption { + id: string; + cargoTypeName?: string; + hasContractTemplate?: boolean; +} + export default function ContractTemplatesPage() { const navigate = useNavigate(); + const { user } = useAuth(); const { data: templates, isLoading } = useContractTemplates(); const [previewCode, setPreviewCode] = useState(null); + const [createOpen, setCreateOpen] = useState(false); + const [deleteTarget, setDeleteTarget] = useState(null); + + const perms = FREIGHT_PERMS.settings.contractTemplates; + const isAdmin = hasPermission(user, FREIGHT_PERMS.admin); + const canManage = isAdmin || hasPermission(user, perms.manage); + const canCreate = canManage || hasPermission(user, perms.create); + const canUpdate = canManage || hasPermission(user, perms.update); + const canDelete = isAdmin || hasPermission(user, perms.delete); + + const deleteTemplate = useDeleteContractTemplate(); const previewTemplate = templates?.find((t) => t.code === previewCode); @@ -77,20 +105,34 @@ export default function ContractTemplatesPage() { } + onClick={() => setCreateOpen(true)} + > + New bulk template + + ) : undefined + } /> {isLoading - ? Array.from({ length: 10 }, (_, i) => ) + ? Array.from({ length: 6 }, (_, i) => ) : (templates ?? []).map((template) => ( setPreviewCode(template.code)} onEdit={() => navigate(`/dashboard/contract-templates/${template.code}`) } + onDelete={() => setDeleteTarget(template)} /> ))} @@ -100,22 +142,174 @@ export default function ContractTemplatesPage() { title={previewTemplate ? `${previewTemplate.name} — preview` : undefined} onClose={() => setPreviewCode(null)} /> + + setCreateOpen(false)} + onCreated={(code) => { + setCreateOpen(false); + navigate(`/dashboard/contract-templates/${code}`); + }} + /> + + {/* ── Delete confirm ─────────────────────────────────────── */} + setDeleteTarget(null)} + title="Delete contract template?" + centered + size="sm" + > + + + This will delete{" "} + + {deleteTarget?.name} + {" "} + and its articles. Contracts already generated keep their frozen + document; new contracts for this combination fall back to the + generic layout until a new template is created. + + + + + + + ); } +/** + * Staff pick the customs option first, then a bulk cargo type that has + * "has contract template" enabled. One template per combination — the API + * rejects duplicates, so an existing pairing must be edited instead. + */ +function CreateTemplateModal({ + opened, + onClose, + onCreated, +}: { + opened: boolean; + onClose: () => void; + onCreated: (code: string) => void; +}) { + const [withCustoms, setWithCustoms] = useState("true"); + const [cargoTypeId, setCargoTypeId] = useState(null); + const create = useCreateContractTemplate(); + + const { data: cargoTypes, isLoading } = useQuery({ + queryKey: ["cargo-types", "contract-template-options"], + queryFn: () => cargoTypesService.getCargoTypes(), + enabled: opened, + }); + + const options = useMemo( + () => + ((cargoTypes ?? []) as CargoTypeOption[]) + .filter((cargoType) => cargoType.hasContractTemplate) + .map((cargoType) => ({ + value: cargoType.id, + label: cargoType.cargoTypeName ?? "Untitled", + })), + [cargoTypes], + ); + + const close = () => { + setCargoTypeId(null); + onClose(); + }; + + return ( + + +
+ + Customs clearing + + +
+ + + {passengerList.length > 0 && ( Date: Sat, 8 Aug 2026 09:24:59 +0000 Subject: [PATCH 027/276] fix: stamp export self-haul truck arrival on receive to warehouse MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Import's customer_truck_assignments.arrived_at is set by a separate later gate action (release()'s arrival branch — the truck returning to collect already-warehoused goods). Export has no equivalent second step: the truck delivering cargo to the warehouse arrives and is received in the same act, so its arrival was never recorded anywhere. Add markCustomerTruckArrived, mirroring release()'s existing self-haul departure UPDATE (plate-matched, COALESCE(arrived_at, NOW())), and call it from receive()/bulkReceive() for EXPORT bookings --- .../mark-customer-truck-arrived.spec.ts | 56 +++++++++++++++++ .../warehouses/warehouse-inventory.service.ts | 63 ++++++++++++++++++- 2 files changed, 118 insertions(+), 1 deletion(-) create mode 100644 apps/edr-freight-api/src/modules/warehouses/mark-customer-truck-arrived.spec.ts diff --git a/apps/edr-freight-api/src/modules/warehouses/mark-customer-truck-arrived.spec.ts b/apps/edr-freight-api/src/modules/warehouses/mark-customer-truck-arrived.spec.ts new file mode 100644 index 000000000..51a1f4c1c --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/mark-customer-truck-arrived.spec.ts @@ -0,0 +1,56 @@ +import { WarehouseInventoryService } from './warehouse-inventory.service'; + +/** + * Export self-haul has no separate "truck arrived" gate action the way import + * does (release()'s arrival branch, fired later when a truck shows up to + * COLLECT already-warehoused goods) — the truck delivering cargo TO the + * warehouse arrives and is received in the same act, so receive()/ + * bulkReceive() must stamp customer_truck_assignments.arrived_at themselves. + */ +type Marker = ( + manager: { query: jest.Mock }, + bookingId: string, + plateNumber: string | null | undefined, +) => Promise; + +function makeMarker() { + const service = Object.create(WarehouseInventoryService.prototype) as Record; + const marker = ( + service as unknown as { markCustomerTruckArrived: Marker } + ).markCustomerTruckArrived.bind(service); + return marker; +} + +describe('markCustomerTruckArrived', () => { + it('stamps arrival matched by booking + plate', async () => { + const marker = makeMarker(); + const manager = { query: jest.fn().mockResolvedValue(undefined) }; + + await marker(manager, 'b-1', 'AAA-2323'); + + expect(manager.query).toHaveBeenCalledTimes(1); + const [sql, params] = manager.query.mock.calls[0]; + expect(sql).toMatch(/UPDATE freight\.customer_truck_assignments/); + expect(sql).toMatch(/UPPER\(a\.plate_number\) = UPPER\(\$2\)/); + expect(params).toEqual(['b-1', 'AAA-2323']); + }); + + it('trims the plate before matching', async () => { + const marker = makeMarker(); + const manager = { query: jest.fn().mockResolvedValue(undefined) }; + + await marker(manager, 'b-1', ' AAA-2323 '); + + expect(manager.query.mock.calls[0][1]).toEqual(['b-1', 'AAA-2323']); + }); + + it('no-ops on a missing/blank plate — no query, nothing to match on', async () => { + const marker = makeMarker(); + const manager = { query: jest.fn() }; + + await marker(manager, 'b-1', undefined); + await marker(manager, 'b-1', ' '); + + expect(manager.query).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts index 1159c1151..3e11380c1 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts @@ -1643,6 +1643,12 @@ export class WarehouseInventoryService { [bookingId], ); + // Export self-haul: this receive IS the truck's arrival — see + // markCustomerTruckArrived / receive()'s single-booking mirror. + if (dto.direction === 'EXPORT') { + await this.markCustomerTruckArrived(manager, bookingId, truckEntrance?.truckPlateNumber); + } + await this.activityLog.record( { activityType: 'INVENTORY_RECEIVED', @@ -2886,6 +2892,13 @@ export class WarehouseInventoryService { ); } + // Export self-haul: this receive IS the truck's arrival — stamp it on + // its own customer_truck_assignments row (mirror of import's arrival, + // see markCustomerTruckArrived). + if (dto.bookingId && bookingDirection === 'EXPORT') { + await this.markCustomerTruckArrived(manager, dto.bookingId, truckEntrance.truckPlateNumber); + } + await this.activityLog.record( { activityType: 'INVENTORY_RECEIVED', @@ -6008,6 +6021,33 @@ export class WarehouseInventoryService { }; } + /** + * EXPORT self-haul mirror of the customer truck lifecycle IMPORT already has: + * import stamps a truck's arrival on the SEPARATE gate action that comes + * later (release()'s arrival branch, when the customer's truck shows up to + * COLLECT already-warehoused goods). Export has no such separate step — the + * truck delivering cargo TO the warehouse arrives and is received in the + * same act, so receive()/bulkReceive() themselves are the arrival event. + * Matched by plate (not assignmentId — neither receive endpoint carries + * one), same as release()'s departure-branch self-haul UPDATE. + */ + private async markCustomerTruckArrived( + manager: EntityManager, + bookingId: string, + plateNumber: string | null | undefined, + ): Promise { + const plate = plateNumber?.trim(); + if (!plate) return; + await manager.query( + `UPDATE freight.customer_truck_assignments a + SET arrived_at = COALESCE(a.arrived_at, NOW()), updated_at = NOW() + WHERE a.booking_id = $1 + AND UPPER(a.plate_number) = UPPER($2) + AND a.deleted_at IS NULL`, + [bookingId, plate], + ); + } + private async getBookingTruckEntranceSource( manager: EntityManager, bookingId: string, @@ -6027,6 +6067,10 @@ export class WarehouseInventoryService { firstMileDriverPhone?: string | null; firstMileDriverLicenseNumber?: string | null; firstMileTruckType?: string | null; + customerTruckPlateNumber?: string | null; + customerTruckDriverName?: string | null; + customerTruckType?: string | null; + customerTruckContainerNumber?: string | null; }> { const [booking] = await manager.query( `SELECT b.reference AS "reference", @@ -6046,7 +6090,24 @@ export class WarehouseInventoryService { ) AS "firstMileDriverName", driver.phone_number AS "firstMileDriverPhone", driver.license_number AS "firstMileDriverLicenseNumber", - v.vehicle_type AS "firstMileTruckType" + v.vehicle_type AS "firstMileTruckType", + -- Self-haul truck assigned via the portal — export delivering to + -- the warehouse or import collecting from it. Same pattern as + -- eligibleBookings/importQueueByStatuses: multi-truck self-haul + -- writes plates/drivers to customer_truck_assignments and leaves + -- the booking columns null, so read the assignments first and + -- keep the legacy column as the fallback for single-truck + -- bookings written before that table existed. + COALESCE((SELECT string_agg(DISTINCT cta.plate_number, ', ') + FROM freight.customer_truck_assignments cta + WHERE cta.booking_id = b.id AND cta.deleted_at IS NULL), + b.customer_truck_plate_number) AS "customerTruckPlateNumber", + COALESCE((SELECT string_agg(DISTINCT cta.driver_name, ', ') + FROM freight.customer_truck_assignments cta + WHERE cta.booking_id = b.id AND cta.deleted_at IS NULL), + b.customer_truck_driver_name) AS "customerTruckDriverName", + b.customer_truck_type AS "customerTruckType", + b.customer_truck_container_number AS "customerTruckContainerNumber" FROM freight.bookings b LEFT JOIN freight.companies company ON company.id = b.company_id ${primaryContactUserJoin('company')} From ee25de8817939cb96927745797a35fca232b7f55 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Sat, 8 Aug 2026 09:36:51 +0000 Subject: [PATCH 028/276] fix(freight:backoffice): wire dedicated permission keys instead of broad fallbacks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Compliance & Alerts, Procurement, File settings, Dropdown settings, Trade access, and Exchange rate all fell back to a broad permission (fleet:view or admin) even though a dedicated key already existed in FREIGHT_PERMS — meaning granting one of these pages meant granting several unrelated ones too. Each now checks its own key first, with the broad permission kept as a fallback for existing role grants. Incidents left as-is: no dedicated edr_freight_app:incidents:* key exists yet on the backend. Co-Authored-By: Claude Sonnet 5 --- apps/edr-freight-web/backoffice/src/App.tsx | 24 ++++++++++++++----- .../components/layout/sidebar-sections.tsx | 14 ++++++----- 2 files changed, 26 insertions(+), 12 deletions(-) diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index 07299462c..7ef07dd09 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -764,7 +764,9 @@ const App = () => { + } @@ -788,7 +790,9 @@ const App = () => { + } @@ -811,7 +815,9 @@ const App = () => { + } @@ -819,7 +825,9 @@ const App = () => { + } @@ -876,7 +884,9 @@ const App = () => { + } @@ -884,7 +894,9 @@ const App = () => { +
diff --git a/apps/edr-freight-web/backoffice/src/components/layout/sidebar-sections.tsx b/apps/edr-freight-web/backoffice/src/components/layout/sidebar-sections.tsx index 5883da5b8..8b9e7bd3f 100644 --- a/apps/edr-freight-web/backoffice/src/components/layout/sidebar-sections.tsx +++ b/apps/edr-freight-web/backoffice/src/components/layout/sidebar-sections.tsx @@ -279,19 +279,21 @@ export const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] label: "Compliance & Alerts", href: "/dashboard/compliance", icon: , - permission: FREIGHT_PERMS.fleet.view, + permission: [FREIGHT_PERMS.compliance.view, FREIGHT_PERMS.fleet.view], }, { label: "Incidents", href: "/dashboard/incidents", icon: , + // No dedicated backend key exists for incidents yet — stuck on the + // blanket fleet:view fallback until one is added. permission: FREIGHT_PERMS.fleet.view, }, { label: "Procurement", href: "/dashboard/procurement", icon: , - permission: FREIGHT_PERMS.fleet.view, + permission: [FREIGHT_PERMS.procurement.view, FREIGHT_PERMS.fleet.view], }, { label: "Financial Reports", @@ -476,13 +478,13 @@ export const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] label: "File settings", href: "/dashboard/file-settings", icon: , - permission: FREIGHT_PERMS.admin, + permission: [FREIGHT_PERMS.settings.fileUpload.view, FREIGHT_PERMS.admin], }, { label: "Dropdown settings", href: "/dashboard/dropdown-settings", icon: , - permission: FREIGHT_PERMS.admin, + permission: [FREIGHT_PERMS.settings.dropdown.view, FREIGHT_PERMS.admin], }, { label: "Contract templates", @@ -514,12 +516,12 @@ export const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] { label: "Trade access", href: "/dashboard/configuration/trade-access", - permission: FREIGHT_PERMS.admin, + permission: [FREIGHT_PERMS.tradeAccess.view, FREIGHT_PERMS.admin], }, { label: "Exchange rate", href: "/dashboard/configuration/exchange-rate", - permission: FREIGHT_PERMS.admin, + permission: [FREIGHT_PERMS.settings.exchangeRate.view, FREIGHT_PERMS.admin], }, ], }, From 41e8c08ba38481d069329591c193b8d7336a49dc Mon Sep 17 00:00:00 2001 From: Nathnael Date: Sat, 8 Aug 2026 09:37:28 +0000 Subject: [PATCH 029/276] feat: WIP Contnet managemtn --- apps/edr-freight-api/src/app.module.ts | 9 + .../3350000000000-SupportContent.ts | 73 + .../3360000000000-SupportHelpSections.ts | 112 + .../dto/support-content.dto.ts | 311 +++ .../entities/support-document.entity.ts | 69 + .../public-support-content.controller.ts | 29 + .../support-content.controller.ts | 135 + .../support-content/support-content.module.ts | 23 + .../support-content.repository.ts | 71 + .../support-content.service.spec.ts | 237 ++ .../support-content.service.ts | 368 +++ .../src/seed/freight-permissions.registry.ts | 15 + .../src/seed/support-content.seeder.ts | 62 + apps/edr-freight-web/backoffice/package.json | 2 + apps/edr-freight-web/backoffice/src/App.tsx | 15 + .../components/layout/sidebar-sections.tsx | 11 + .../portal-content/usePortalContentAdmin.ts | 78 + .../backoffice/src/lib/permissions.ts | 5 + .../src/pages/portal_content/AccordionRow.tsx | 95 + .../src/pages/portal_content/FaqEditor.tsx | 242 ++ .../src/pages/portal_content/HelpEditor.tsx | 125 + .../pages/portal_content/LegalDocEditor.tsx | 110 + .../src/pages/portal_content/Markdown.tsx | 24 + .../pages/portal_content/MarkdownEditor.tsx | 142 + .../src/pages/portal_content/MediaManager.tsx | 122 + .../portal_content/PortalContentPage.tsx | 261 ++ .../portal_content/VersionHistoryModal.tsx | 198 ++ .../src/pages/portal_content/array-helpers.ts | 24 + .../pages/portal_content/markdown-editor.css | 109 + .../pages/portal_content/version-preview.ts | 79 + .../src/services/portal-content.service.ts | 98 + apps/edr-freight-web/portal/package.json | 1 + .../portal/src/constants/URLS.ts | 5 + .../portal/src/hooks/usePortalContent.ts | 39 + .../portal/src/pages/support/DocShell.tsx | 30 +- .../portal/src/pages/support/FaqPage.tsx | 53 +- .../portal/src/pages/support/HelpPage.tsx | 246 +- .../portal/src/pages/support/Markdown.tsx | 72 + .../src/pages/support/PrivacyPolicyPage.tsx | 15 +- .../portal/src/pages/support/TermsPage.tsx | 16 +- .../portal/src/pages/support/content.ts | 358 --- .../src/pages/support/portal-content.test.ts | 84 + .../src/pages/support/portal-content.ts | 103 + packages/types/src/freight/index.ts | 2 + .../src/freight/portal-content.defaults.ts | 375 +++ packages/types/src/freight/portal-content.ts | 206 ++ pnpm-lock.yaml | 2399 ++++++++++++++++- portal-content-contact.png | Bin 0 -> 74931 bytes 48 files changed, 6601 insertions(+), 657 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/3350000000000-SupportContent.ts create mode 100644 apps/edr-freight-api/src/migrations/3360000000000-SupportHelpSections.ts create mode 100644 apps/edr-freight-api/src/modules/support-content/dto/support-content.dto.ts create mode 100644 apps/edr-freight-api/src/modules/support-content/entities/support-document.entity.ts create mode 100644 apps/edr-freight-api/src/modules/support-content/public-support-content.controller.ts create mode 100644 apps/edr-freight-api/src/modules/support-content/support-content.controller.ts create mode 100644 apps/edr-freight-api/src/modules/support-content/support-content.module.ts create mode 100644 apps/edr-freight-api/src/modules/support-content/support-content.repository.ts create mode 100644 apps/edr-freight-api/src/modules/support-content/support-content.service.spec.ts create mode 100644 apps/edr-freight-api/src/modules/support-content/support-content.service.ts create mode 100644 apps/edr-freight-api/src/seed/support-content.seeder.ts create mode 100644 apps/edr-freight-web/backoffice/src/hooks/portal-content/usePortalContentAdmin.ts create mode 100644 apps/edr-freight-web/backoffice/src/pages/portal_content/AccordionRow.tsx create mode 100644 apps/edr-freight-web/backoffice/src/pages/portal_content/FaqEditor.tsx create mode 100644 apps/edr-freight-web/backoffice/src/pages/portal_content/HelpEditor.tsx create mode 100644 apps/edr-freight-web/backoffice/src/pages/portal_content/LegalDocEditor.tsx create mode 100644 apps/edr-freight-web/backoffice/src/pages/portal_content/Markdown.tsx create mode 100644 apps/edr-freight-web/backoffice/src/pages/portal_content/MarkdownEditor.tsx create mode 100644 apps/edr-freight-web/backoffice/src/pages/portal_content/MediaManager.tsx create mode 100644 apps/edr-freight-web/backoffice/src/pages/portal_content/PortalContentPage.tsx create mode 100644 apps/edr-freight-web/backoffice/src/pages/portal_content/VersionHistoryModal.tsx create mode 100644 apps/edr-freight-web/backoffice/src/pages/portal_content/array-helpers.ts create mode 100644 apps/edr-freight-web/backoffice/src/pages/portal_content/markdown-editor.css create mode 100644 apps/edr-freight-web/backoffice/src/pages/portal_content/version-preview.ts create mode 100644 apps/edr-freight-web/backoffice/src/services/portal-content.service.ts create mode 100644 apps/edr-freight-web/portal/src/hooks/usePortalContent.ts create mode 100644 apps/edr-freight-web/portal/src/pages/support/Markdown.tsx delete mode 100644 apps/edr-freight-web/portal/src/pages/support/content.ts create mode 100644 apps/edr-freight-web/portal/src/pages/support/portal-content.test.ts create mode 100644 apps/edr-freight-web/portal/src/pages/support/portal-content.ts create mode 100644 packages/types/src/freight/portal-content.defaults.ts create mode 100644 packages/types/src/freight/portal-content.ts create mode 100644 portal-content-contact.png diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index 50dde1034..1e0908da6 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -50,6 +50,7 @@ import { FileUploadSettingsModule } from "./modules/file-upload-settings/file-up 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 { SupportContentModule } from "./modules/support-content/support-content.module"; import { OtpModule } from "./modules/otp/otp.module"; import { HealthModule } from "./modules/health/health.module"; import { RuleEngineModule } from "./modules/rule-engine/rule-engine.module"; @@ -67,6 +68,7 @@ import { FreightPositionsSeeder } from "./seed/freight-positions.seeder"; import { PaymentModule } from "./modules/payment/payment.module"; // import { PricingDataSeeder } from "./seed/pricing-data.seeder"; import { FileUploadSettingsSeeder } from "./seed/file-upload-settings.seeder"; +import { SupportContentSeeder } from "./seed/support-content.seeder"; // import { YardFacilitiesSeeder } from "./seed/yard-facilities.seeder"; // import { IndodeFacilitySeeder } from "./seed/indode-facility.seeder"; // import { Batch14TestDataSeeder } from "./seed/batch1-4-test-data.seeder"; @@ -220,6 +222,7 @@ if (!process.env.APPLICATION_NAME) { DropdownSettingsModule, ExchangeSettingsModule, ContractTemplatesModule, + SupportContentModule, OtpModule, HealthModule, RuleEngineModule, @@ -260,6 +263,7 @@ if (!process.env.APPLICATION_NAME) { EdrOrgSeeder, FreightPositionsSeeder, FileUploadSettingsSeeder, + SupportContentSeeder, // YardFacilitiesSeeder, FreightPermissionKeyMigrationSeeder, FreightNotificationPermissionsSeeder, @@ -291,6 +295,7 @@ export class AppModule implements OnApplicationBootstrap { private readonly edrOrgSeeder: EdrOrgSeeder, private readonly freightPositionsSeeder: FreightPositionsSeeder, private readonly fileUploadSettingsSeeder: FileUploadSettingsSeeder, + private readonly supportContentSeeder: SupportContentSeeder, // private readonly yardFacilitiesSeeder: YardFacilitiesSeeder, private readonly freightPermissionKeyMigrationSeeder: FreightPermissionKeyMigrationSeeder, private readonly freightNotificationPermissionsSeeder: FreightNotificationPermissionsSeeder, @@ -349,6 +354,10 @@ export class AppModule implements OnApplicationBootstrap { // File upload settings — keep enabled. await this.fileUploadSettingsSeeder.run(); + // Portal help/FAQ/legal copy — keep enabled. Idempotent by emptiness, so + // it fills an empty table once and never touches admin edits afterwards. + await this.supportContentSeeder.run(); + // Flags which yards can load/unload cargo (Indode, Sebeta, Modjo, Adama, // Dire Dawa). Idempotent; creates no yards. // await this.yardFacilitiesSeeder.run(); diff --git a/apps/edr-freight-api/src/migrations/3350000000000-SupportContent.ts b/apps/edr-freight-api/src/migrations/3350000000000-SupportContent.ts new file mode 100644 index 000000000..168ee2c6d --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3350000000000-SupportContent.ts @@ -0,0 +1,73 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Editable customer-facing copy for the portal's public pages (/help, /faq, + * /terms, /privacy) plus the shared support-contact block, with an append-only + * version log behind it. + * + * `payload` is opaque jsonb: the five documents have genuinely different shapes + * and the help page's blocks change with the copy, so typed columns would mean + * a migration per wording tweak. The shape is enforced by per-slug DTOs on + * write instead. + * + * No rows are inserted here — `SupportContentSeeder` fills the table on first + * boot and skips whenever it is non-empty, so a redeploy never overwrites + * admin edits the way a migration-embedded INSERT eventually would. + */ +export class SupportContent3350000000000 implements MigrationInterface { + name = "SupportContent3350000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.support_documents ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + slug varchar(32) NOT NULL, + payload jsonb NOT NULL DEFAULT '{}'::jsonb, + version integer NOT NULL DEFAULT 1, + updated_by_id uuid, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz + ); + `); + + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS uq_support_documents_slug + ON freight.support_documents (slug); + `); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.support_document_versions ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + document_id uuid NOT NULL + REFERENCES freight.support_documents(id) ON DELETE CASCADE, + version integer NOT NULL, + payload jsonb NOT NULL, + actor_id uuid, + note varchar(255), + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz + ); + `); + + // Closes the concurrent-save race: two editors saving at once cannot both + // claim the same version number. + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS uq_support_doc_version + ON freight.support_document_versions (document_id, version); + `); + + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_support_doc_versions_document + ON freight.support_document_versions (document_id); + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DROP TABLE IF EXISTS freight.support_document_versions;`, + ); + await queryRunner.query(`DROP TABLE IF EXISTS freight.support_documents;`); + } +} diff --git a/apps/edr-freight-api/src/migrations/3360000000000-SupportHelpSections.ts b/apps/edr-freight-api/src/migrations/3360000000000-SupportHelpSections.ts new file mode 100644 index 000000000..5220d3a6c --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3360000000000-SupportHelpSections.ts @@ -0,0 +1,112 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Converts the HELP document from its original fixed-block shape + * (`video` / `chat` / `channels` / `topics` / `checklist`) to the free-form + * `sections[]` builder, where every block is a heading plus markdown plus + * attached media. + * + * Only rows still in the old shape are touched — detected by the presence of a + * `channels` key — so this is a no-op on any environment seeded after the + * change, and re-running it does nothing. + * + * The payload literal is inlined rather than imported from + * `SUPPORT_CONTENT_DEFAULTS`: a migration must keep doing the same thing + * forever, and that constant will keep moving. + * + * The rewrite also bumps `version` and writes a matching history row. The live + * row's version always having a matching entry in + * `support_document_versions` is the invariant the history list and rollback + * both depend on, and a silent payload swap would break it. + */ +const HELP_SECTIONS = [ + { + id: "help-walkthrough", + heading: "Portal walkthrough", + body: "A guided tour of the portal — registering your company, raising a booking against a contract, and settling an invoice.", + media: [ + { + id: "help-walkthrough-video", + kind: "video", + src: "/assets/edr-portal-guide.webm", + caption: null, + }, + ], + }, + { + id: "help-chat", + heading: "Chat with our team", + body: "Signed-in customers can open a support conversation from the headset button at the bottom right of every portal page. You can send screenshots and documents in the chat, and replies appear there and as a notification.\n\n[Open the portal](/portal)", + media: [], + }, + { + id: "help-contact", + heading: "Contact us", + body: "- **Email** — [{{supportEmail}}](mailto:{{supportEmail}}). Best for document issues and anything needing an attachment.\n- **Phone** — [{{supportPhone}}](tel:{{supportPhoneTel}}). Best for urgent problems with cargo already in transit.\n- **Head office** — {{supportOffice}}. Walk-in support during working hours.\n- **Support hours** — {{supportHours}}. Outside these hours, email us and we reply the next working day.", + media: [], + }, + { + id: "help-topics", + heading: "Common topics", + body: "- **[Account & onboarding](/faq)** — registering your company, uploading your trade licence and TIN, and getting an operational profile approved.\n- **[Contracts](/faq)** — requesting a freight contract, reviewing its terms and signing it with your saved signature and stamp.\n- **[Bookings & tracking](/faq)** — raising a booking against a contract, adding last-mile transport and following the consignment along the corridor.\n- **[Invoices & payments](/faq)** — finding invoices, paying through the bank channels and confirming a payment that has not yet settled.", + media: [], + }, + { + id: "help-checklist", + heading: "What to include when you contact us", + body: "- Your company name and the email you sign in with.\n- The reference of the contract, booking or invoice involved.\n- What you expected to happen and what happened instead.\n- A screenshot of any error message the portal showed.", + media: [], + }, +]; + +export class SupportHelpSections3360000000000 implements MigrationInterface { + name = "SupportHelpSections3360000000000"; + + public async up(queryRunner: QueryRunner): Promise { + const rows: { id: string; version: number; payload: Record }[] = + await queryRunner.query(` + SELECT id, version, payload + FROM freight.support_documents + WHERE slug = 'HELP' AND payload ? 'channels' + `); + + for (const row of rows) { + const payload = { + title: row.payload.title ?? "Help & Support", + subtitle: + row.payload.subtitle ?? + "Get answers fast — watch the walkthrough, browse the common topics, check the FAQ, or reach our team directly.", + sections: HELP_SECTIONS, + }; + const version = row.version + 1; + + await queryRunner.query( + `UPDATE freight.support_documents + SET payload = $1::jsonb, version = $2, updated_at = now() + WHERE id = $3`, + [JSON.stringify(payload), version, row.id], + ); + + await queryRunner.query( + `INSERT INTO freight.support_document_versions + (document_id, version, payload, actor_id, note) + VALUES ($1, $2, $3::jsonb, NULL, $4)`, + [ + row.id, + version, + JSON.stringify(payload), + "Converted help page to free-form sections", + ], + ); + } + } + + /** + * Not reversible: the old fixed blocks cannot be recovered from markdown + * sections an editor may since have rewritten. The version history holds the + * pre-conversion payload if it is ever genuinely needed. + */ + public async down(): Promise { + // no-op + } +} diff --git a/apps/edr-freight-api/src/modules/support-content/dto/support-content.dto.ts b/apps/edr-freight-api/src/modules/support-content/dto/support-content.dto.ts new file mode 100644 index 000000000..fdfffe8be --- /dev/null +++ b/apps/edr-freight-api/src/modules/support-content/dto/support-content.dto.ts @@ -0,0 +1,311 @@ +import { SUPPORT_MEDIA_PREFIX, SupportDocSlug } from "@edr/types"; +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { Type } from "class-transformer"; +import { + ArrayMaxSize, + IsArray, + IsIn, + IsObject, + IsOptional, + IsString, + Matches, + MaxLength, + MinLength, + ValidateNested, +} from "class-validator"; + +/** + * Markdown bodies are safe on read — the portal renders them with + * `react-markdown` and no `rehype-raw`, so any HTML in them is inert. The + * fields worth validating are these: they land in `href`/`src` attributes and + * bypass markdown entirely, which is where a `javascript:` URL would actually + * execute. + * + * Placeholders survive the check because they sit after the scheme + * (`mailto:{{supportEmail}}`, `tel:{{supportPhoneTel}}`). + */ +const LINK_PATTERN = /^(https?:\/\/|mailto:|tel:|\/)/; +const LINK_MESSAGE = + "$property must start with http(s)://, mailto:, tel: or /"; + +/** + * A media source is either an uploaded MinIO object key, a same-origin path, or + * an https URL. Anything else — notably `javascript:` — is refused, since this + * value lands in an ``/`
} /> + + + + } + /> , + permission: [ + FREIGHT_PERMS.settings.supportContent.view, + FREIGHT_PERMS.settings.supportContent.manage, + FREIGHT_PERMS.admin, + ], + }, { label: "Audit logs", href: "/dashboard/audit-logs", diff --git a/apps/edr-freight-web/backoffice/src/hooks/portal-content/usePortalContentAdmin.ts b/apps/edr-freight-web/backoffice/src/hooks/portal-content/usePortalContentAdmin.ts new file mode 100644 index 000000000..49e76c80b --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/hooks/portal-content/usePortalContentAdmin.ts @@ -0,0 +1,78 @@ +import type { SupportDocPayload, SupportDocSlug } from "@edr/types"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import toast from "react-hot-toast"; + +import { portalContentService } from "@/services/portal-content.service"; + +/** + * Every key shares the `portal-content` prefix so one invalidate after a save + * or a restore sweeps the document and its version list together. + */ +const KEYS = { + ROOT: ["portal-content"] as const, + bySlug: (slug: string) => ["portal-content", "detail", slug] as const, + versions: (slug: string) => ["portal-content", "versions", slug] as const, +}; + +export function usePortalDoc(slug: SupportDocSlug) { + return useQuery({ + queryKey: KEYS.bySlug(slug), + queryFn: () => portalContentService.getBySlug(slug), + }); +} + +/** Version history. Stays idle until the history modal is opened. */ +export function usePortalDocVersions(slug: SupportDocSlug, enabled: boolean) { + return useQuery({ + queryKey: KEYS.versions(slug), + queryFn: () => portalContentService.listVersions(slug), + enabled, + }); +} + +/** One historical payload, fetched only when a version is previewed. */ +export function usePortalDocVersion( + slug: SupportDocSlug, + version: number | null, +) { + return useQuery({ + queryKey: [...KEYS.versions(slug), version], + queryFn: () => portalContentService.getVersion(slug, version as number), + enabled: version !== null, + }); +} + +function usePortalContentMutation( + mutationFn: (vars: TVariables) => Promise, + successMessage: string, +) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn, + onSuccess: () => { + toast.success(successMessage); + void queryClient.invalidateQueries({ queryKey: KEYS.ROOT }); + }, + onError: (error: unknown) => { + const message = + (error as { response?: { data?: { message?: string } } })?.response?.data + ?.message ?? "Something went wrong"; + toast.error(Array.isArray(message) ? message.join(", ") : message); + }, + }); +} + +export function useUpdatePortalDoc(slug: SupportDocSlug) { + return usePortalContentMutation( + (vars: { payload: SupportDocPayload; note?: string }) => + portalContentService.update(slug, vars.payload, vars.note), + "Portal content saved", + ); +} + +export function useRestorePortalVersion(slug: SupportDocSlug) { + return usePortalContentMutation( + (version: number) => portalContentService.restore(slug, version), + "Version restored", + ); +} diff --git a/apps/edr-freight-web/backoffice/src/lib/permissions.ts b/apps/edr-freight-web/backoffice/src/lib/permissions.ts index 88dadb1f7..511017805 100644 --- a/apps/edr-freight-web/backoffice/src/lib/permissions.ts +++ b/apps/edr-freight-web/backoffice/src/lib/permissions.ts @@ -326,6 +326,11 @@ export const FREIGHT_PERMS = { delete: "edr_freight_app:settings:contract_templates:delete", read: "edr_freight_app:settings:contract_templates:read", }, + // Portal-facing help/FAQ/legal copy, edited from Portal content. + supportContent: { + view: "edr_freight_app:settings:support_content:view", + manage: "edr_freight_app:settings:support_content:manage", + }, }, audit: { view: "edr_freight_app:audit:view", diff --git a/apps/edr-freight-web/backoffice/src/pages/portal_content/AccordionRow.tsx b/apps/edr-freight-web/backoffice/src/pages/portal_content/AccordionRow.tsx new file mode 100644 index 000000000..309bdb34c --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/portal_content/AccordionRow.tsx @@ -0,0 +1,95 @@ +import { Accordion, ActionIcon, Center, Group, Text, Tooltip } from "@mantine/core"; +import { ChevronDown, ChevronUp, Trash2 } from "lucide-react"; +import type { ReactNode } from "react"; + +interface AccordionRowProps { + value: string; + /** Collapsed summary — the heading, question or card title. */ + title: string; + /** Small dimmed line under the title, e.g. a body excerpt. */ + subtitle?: string; + index: number; + length: number; + onMove: (delta: number) => void; + onRemove: () => void; + children: ReactNode; +} + +/** + * One collapsible item with reorder and delete controls in its header. + * + * Collapsing is the point: a legal document has fifteen sections and the FAQ + * seventeen answers, and rendering every textarea expanded turned each tab into + * an unnavigable mile of boxes. Collapsed, the tab reads as the list of + * headings the customer actually sees. + * + * The buttons sit outside `Accordion.Control` so clicking one does not also + * toggle the panel. + */ +export function AccordionRow({ + value, + title, + subtitle, + index, + length, + onMove, + onRemove, + children, +}: AccordionRowProps) { + return ( + +
+ +
+ + {title || (untitled)} + + {subtitle && ( + + {subtitle} + + )} +
+
+ + + + onMove(-1)} + > + + + + + onMove(1)} + > + + + + + + + + + +
+ + {children} +
+ ); +} + +/** First line of a markdown body, for an accordion subtitle. */ +export function excerpt(markdown: string, max = 90): string { + const line = markdown.replace(/[#*`>-]/g, "").trim().split("\n")[0] ?? ""; + return line.length > max ? `${line.slice(0, max)}…` : line; +} + +export default AccordionRow; diff --git a/apps/edr-freight-web/backoffice/src/pages/portal_content/FaqEditor.tsx b/apps/edr-freight-web/backoffice/src/pages/portal_content/FaqEditor.tsx new file mode 100644 index 000000000..b5a9a7fca --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/portal_content/FaqEditor.tsx @@ -0,0 +1,242 @@ +import type { PortalFaqContent, PortalFaqGroup } from "@edr/types"; +import { + Accordion, + Badge, + Button, + Card, + Group, + Stack, + Switch, + TextInput, +} from "@mantine/core"; +import { Plus } from "lucide-react"; + +import { AccordionRow, excerpt } from "./AccordionRow"; +import { moveAt, newId, removeAt, replaceAt } from "./array-helpers"; +import { MarkdownEditor, MarkdownHint } from "./MarkdownEditor"; + +interface FaqEditorProps { + value: PortalFaqContent; + onChange: (next: PortalFaqContent) => void; +} + +const EMPTY_FOOTER = { + heading: "Still need a hand?", + body: "", + ctaLabel: "Go to Help & Support", + ctaTo: "/help", +}; + +export function FaqEditor({ value, onChange }: FaqEditorProps) { + const setGroups = (groups: PortalFaqGroup[]) => onChange({ ...value, groups }); + + const setGroup = (index: number, next: PortalFaqGroup) => + setGroups(replaceAt(value.groups, index, next)); + + return ( + + + + + onChange({ ...value, title: e.currentTarget.value }) + } + /> + + onChange({ ...value, subtitle: e.currentTarget.value }) + } + /> + + + + + + + {value.groups.map((group, groupIndex) => ( + setGroups(moveAt(value.groups, groupIndex, delta))} + onRemove={() => setGroups(removeAt(value.groups, groupIndex))} + > + + + setGroup(groupIndex, { + ...group, + title: e.currentTarget.value, + }) + } + /> + + + {group.items.map((item, itemIndex) => ( + + setGroup(groupIndex, { + ...group, + items: moveAt(group.items, itemIndex, delta), + }) + } + onRemove={() => + setGroup(groupIndex, { + ...group, + items: removeAt(group.items, itemIndex), + }) + } + > + + + setGroup(groupIndex, { + ...group, + items: replaceAt(group.items, itemIndex, { + ...item, + question: e.currentTarget.value, + }), + }) + } + /> + + setGroup(groupIndex, { + ...group, + items: replaceAt(group.items, itemIndex, { + ...item, + answer, + }), + }) + } + /> + + + ))} + + + + + + ))} + + + + + + + + + onChange({ + ...value, + footer: e.currentTarget.checked ? EMPTY_FOOTER : null, + }) + } + /> + {!value.footer && Hidden} + + + {value.footer && ( + <> + + onChange({ + ...value, + footer: { ...value.footer!, heading: e.currentTarget.value }, + }) + } + /> + + onChange({ ...value, footer: { ...value.footer!, body } }) + } + /> + + + onChange({ + ...value, + footer: { + ...value.footer!, + ctaLabel: e.currentTarget.value, + }, + }) + } + /> + + onChange({ + ...value, + footer: { ...value.footer!, ctaTo: e.currentTarget.value }, + }) + } + /> + + + )} + + + + ); +} + +export default FaqEditor; diff --git a/apps/edr-freight-web/backoffice/src/pages/portal_content/HelpEditor.tsx b/apps/edr-freight-web/backoffice/src/pages/portal_content/HelpEditor.tsx new file mode 100644 index 000000000..e7b648b43 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/portal_content/HelpEditor.tsx @@ -0,0 +1,125 @@ +import type { PortalHelpContent, PortalHelpSection } from "@edr/types"; +import { Accordion, Button, Card, Divider, Stack, TextInput } from "@mantine/core"; +import { Plus } from "lucide-react"; + +import { AccordionRow, excerpt } from "./AccordionRow"; +import { moveAt, newId, removeAt, replaceAt } from "./array-helpers"; +import { MarkdownEditor, MarkdownHint } from "./MarkdownEditor"; +import { MediaManager } from "./MediaManager"; + +interface HelpEditorProps { + value: PortalHelpContent; + onChange: (next: PortalHelpContent) => void; +} + +/** + * The help page is built, not filled in: an ordered list of sections, each a + * heading plus free markdown plus any images or videos. Nothing about the page + * is fixed except its title, so support can add, reorder or drop a section + * without a code change. + */ +export function HelpEditor({ value, onChange }: HelpEditorProps) { + // A row written before the free-form conversion has no `sections` at all. + // Tolerate it rather than crashing the tab: the migration rewrites it, but + // an environment can be mid-deploy. + const sections = value.sections ?? []; + + const setSections = (next: PortalHelpSection[]) => + onChange({ ...value, sections: next }); + + return ( + + + + + onChange({ ...value, title: e.currentTarget.value }) + } + /> + + onChange({ ...value, subtitle: e.currentTarget.value }) + } + /> + + + + + + + {sections.map((section, index) => ( + setSections(moveAt(sections, index, delta))} + onRemove={() => setSections(removeAt(sections, index))} + > + + + setSections( + replaceAt(sections, index, { + ...section, + heading: e.currentTarget.value, + }), + ) + } + /> + + + setSections( + replaceAt(sections, index, { ...section, body }), + ) + } + /> + + + + + setSections( + replaceAt(sections, index, { ...section, media }), + ) + } + /> + + + ))} + + + + + ); +} + +export default HelpEditor; diff --git a/apps/edr-freight-web/backoffice/src/pages/portal_content/LegalDocEditor.tsx b/apps/edr-freight-web/backoffice/src/pages/portal_content/LegalDocEditor.tsx new file mode 100644 index 000000000..b24bf9be4 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/portal_content/LegalDocEditor.tsx @@ -0,0 +1,110 @@ +import type { PortalLegalContent } from "@edr/types"; +import { Accordion, Button, Card, Group, Stack, TextInput } from "@mantine/core"; +import { Plus } from "lucide-react"; + +import { AccordionRow, excerpt } from "./AccordionRow"; +import { moveAt, newId, removeAt, replaceAt } from "./array-helpers"; +import { MarkdownEditor, MarkdownHint } from "./MarkdownEditor"; + +interface LegalDocEditorProps { + value: PortalLegalContent; + onChange: (next: PortalLegalContent) => void; +} + +/** Shared by the Privacy and Terms tabs — the two documents have one shape. */ +export function LegalDocEditor({ value, onChange }: LegalDocEditorProps) { + const setSections = (sections: PortalLegalContent["sections"]) => + onChange({ ...value, sections }); + + return ( + + + + + + onChange({ ...value, title: e.currentTarget.value }) + } + /> + + onChange({ ...value, lastUpdated: e.currentTarget.value }) + } + /> + + + + onChange({ ...value, subtitle: e.currentTarget.value }) + } + /> + + + + + + + {value.sections.map((section, index) => ( + setSections(moveAt(value.sections, index, delta))} + onRemove={() => setSections(removeAt(value.sections, index))} + > + + + setSections( + replaceAt(value.sections, index, { + ...section, + heading: e.currentTarget.value, + }), + ) + } + /> + + + setSections( + replaceAt(value.sections, index, { ...section, body }), + ) + } + /> + + + ))} + + + + + ); +} + +export default LegalDocEditor; diff --git a/apps/edr-freight-web/backoffice/src/pages/portal_content/Markdown.tsx b/apps/edr-freight-web/backoffice/src/pages/portal_content/Markdown.tsx new file mode 100644 index 000000000..53a6937e8 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/portal_content/Markdown.tsx @@ -0,0 +1,24 @@ +import ReactMarkdown from "react-markdown"; + +// Same preflight fix the editor needs — Mantine's `Typography` defines its list +// and margin rules with `:where()`, which Tailwind's preflight outranks, so +// bullets rendered without markers here too. +import "./markdown-editor.css"; + +/** + * Read-only markdown rendering for the version-history preview. Editing goes + * through `MarkdownEditor` (MDXEditor); this is only for showing what an old + * version said. + * + * Same options as the portal's renderer — no `rehype-raw`, no custom + * `urlTransform` — so neither app grows an HTML-injection surface. + */ +export function Markdown({ children }: { children: string }) { + return ( +
+ {children} +
+ ); +} + +export default Markdown; diff --git a/apps/edr-freight-web/backoffice/src/pages/portal_content/MarkdownEditor.tsx b/apps/edr-freight-web/backoffice/src/pages/portal_content/MarkdownEditor.tsx new file mode 100644 index 000000000..83ca7ef46 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/portal_content/MarkdownEditor.tsx @@ -0,0 +1,142 @@ +import { PORTAL_MEDIA_URI_SCHEME } from "@edr/types"; +import { Box, Stack, Text } from "@mantine/core"; +import { + BlockTypeSelect, + BoldItalicUnderlineToggles, + CreateLink, + InsertImage, + InsertThematicBreak, + ListsToggle, + MDXEditor, + UndoRedo, + headingsPlugin, + imagePlugin, + linkDialogPlugin, + linkPlugin, + listsPlugin, + markdownShortcutPlugin, + quotePlugin, + thematicBreakPlugin, + toolbarPlugin, +} from "@mdxeditor/editor"; +import "@mdxeditor/editor/style.css"; + +import { portalContentService } from "@/services/portal-content.service"; + +// Undoes Tailwind's preflight inside the editor's content area — see the file. +import "./markdown-editor.css"; + +interface MarkdownEditorProps { + label: string; + value: string; + onChange: (next: string) => void; + description?: string; +} + +/** + * Signed URLs are per-request and short-lived, so previews are memoised for the + * life of the page rather than re-signed on every keystroke re-render. + */ +const previewCache = new Map>(); + +/** + * Inserted images are stored as `minio:`, never as the signed URL the + * upload returns: a presigned URL expires, so persisting one would leave every + * embedded image broken a few hours later. `imagePreviewHandler` resolves the + * ref back to a temporary URL purely for display, on both sides of the wire. + */ +function resolvePreview(url: string): Promise { + if (!url.startsWith(PORTAL_MEDIA_URI_SCHEME)) return Promise.resolve(url); + + const key = url.slice(PORTAL_MEDIA_URI_SCHEME.length); + let pending = previewCache.get(key); + if (!pending) { + pending = portalContentService + .mediaUrl(key) + .catch(() => url); // show a broken image rather than blowing up the editor + previewCache.set(key, pending); + } + return pending; +} + +export function MarkdownEditor({ + label, + value, + onChange, + description, +}: MarkdownEditorProps) { + return ( + + + {label} + + {description && ( + + {description} + + )} + + + { + if (!initialMarkdownNormalize) onChange(markdown); + }} + plugins={[ + headingsPlugin(), + listsPlugin(), + quotePlugin(), + linkPlugin(), + linkDialogPlugin(), + thematicBreakPlugin(), + imagePlugin({ + imageUploadHandler: async (file) => { + const { key } = await portalContentService.uploadMedia(file); + return `${PORTAL_MEDIA_URI_SCHEME}${key}`; + }, + imagePreviewHandler: resolvePreview, + }), + markdownShortcutPlugin(), + toolbarPlugin({ + toolbarContents: () => ( + <> + + + + + + + + + ), + }), + ]} + /> + + + ); +} + +/** Reminder of the substitution tokens, rendered once per tab. */ +export function MarkdownHint() { + return ( + + Placeholders resolve from the Contact tab, so one edit there updates every + page: {"{{supportEmail}}"} · {"{{supportPhone}}"}{" "} + · {"{{supportOffice}}"} · {"{{supportHours}}"} ·{" "} + {"{{supportPhoneTel}}"} (inside a tel: link). + + ); +} + +export default MarkdownEditor; diff --git a/apps/edr-freight-web/backoffice/src/pages/portal_content/MediaManager.tsx b/apps/edr-freight-web/backoffice/src/pages/portal_content/MediaManager.tsx new file mode 100644 index 000000000..a80bd3b55 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/portal_content/MediaManager.tsx @@ -0,0 +1,122 @@ +import type { PortalMedia } from "@edr/types"; +import { + ActionIcon, + Button, + Group, + Paper, + Stack, + Text, + TextInput, + Tooltip, +} from "@mantine/core"; +import { Film, Image as ImageIcon, Trash2, Upload } from "lucide-react"; +import { useRef, useState } from "react"; +import toast from "react-hot-toast"; + +import { portalContentService } from "@/services/portal-content.service"; + +import { newId, removeAt, replaceAt } from "./array-helpers"; + +interface MediaManagerProps { + value: PortalMedia[]; + onChange: (next: PortalMedia[]) => void; +} + +/** + * Attachments for one help section. Uploads store the MinIO object *key*; the + * signed URL the upload returns is short-lived and is never persisted, so the + * list shows the key rather than pretending to be a gallery. + */ +export function MediaManager({ value, onChange }: MediaManagerProps) { + const inputRef = useRef(null); + const [uploading, setUploading] = useState(false); + + const upload = async (file: File) => { + setUploading(true); + try { + const { key, kind } = await portalContentService.uploadMedia(file); + onChange([...value, { id: newId(), kind, src: key, caption: null }]); + } catch (error) { + const message = + (error as { response?: { data?: { message?: string } } })?.response?.data + ?.message ?? "Upload failed"; + toast.error(Array.isArray(message) ? message.join(", ") : message); + } finally { + setUploading(false); + if (inputRef.current) inputRef.current.value = ""; + } + }; + + return ( + + + Attachments + + + {value.map((item, index) => ( + + + {item.kind === "video" ? ( + + ) : ( + + )} + + + + {item.src} + + + onChange( + replaceAt(value, index, { + ...item, + caption: e.currentTarget.value || null, + }), + ) + } + /> + + + + onChange(removeAt(value, index))} + > + + + + + + ))} + + { + const file = e.currentTarget.files?.[0]; + if (file) void upload(file); + }} + /> + + + + ); +} + +export default MediaManager; diff --git a/apps/edr-freight-web/backoffice/src/pages/portal_content/PortalContentPage.tsx b/apps/edr-freight-web/backoffice/src/pages/portal_content/PortalContentPage.tsx new file mode 100644 index 000000000..17d5d190f --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/portal_content/PortalContentPage.tsx @@ -0,0 +1,261 @@ +import type { + PortalFaqContent, + PortalHelpContent, + PortalLegalContent, + PortalSupportContact, + SupportDocPayload, + SupportDocSlug, +} from "@edr/types"; +import { + Badge, + Button, + Card, + Group, + Loader, + Stack, + Tabs, + Text, + TextInput, +} from "@mantine/core"; +import { History, RotateCcw, Save } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; + +import { PageContainer, PageHeader } from "@/components/page"; +import { + usePortalDoc, + useUpdatePortalDoc, +} from "@/hooks/portal-content/usePortalContentAdmin"; + +import { FaqEditor } from "./FaqEditor"; +import { HelpEditor } from "./HelpEditor"; +import { LegalDocEditor } from "./LegalDocEditor"; +import { VersionHistoryModal } from "./VersionHistoryModal"; + +const TABS: { slug: SupportDocSlug; label: string }[] = [ + { slug: "CONTACT", label: "Contact" }, + { slug: "HELP", label: "Help" }, + { slug: "FAQ", label: "FAQ" }, + { slug: "PRIVACY", label: "Privacy" }, + { slug: "TERMS", label: "Terms" }, +]; + +/** + * Edits the copy on the freight portal's public pages — /help, /faq, /terms, + * /privacy — and the support contact block all four quote. + * + * Each tab is a local draft saved in one PATCH of the whole document, rather + * than a mutation per field. That is what makes one editorial change equal one + * version, which is the difference between a history you can read and a history + * of keystrokes. + */ +export default function PortalContentPage() { + const [active, setActive] = useState("CONTACT"); + + return ( + + + + setActive(value as SupportDocSlug)} + keepMounted={false} + > + + {TABS.map((tab) => ( + + {tab.label} + + ))} + + + {TABS.map((tab) => ( + + + + ))} + + + ); +} + +function DocumentTab({ slug }: { slug: SupportDocSlug }) { + const { data, isLoading } = usePortalDoc(slug); + const update = useUpdatePortalDoc(slug); + + const [draft, setDraft] = useState(null); + const [note, setNote] = useState(""); + const [historyOpen, setHistoryOpen] = useState(false); + + // Reseed only when the server's version number moves (load, save, restore). + // Keying off `data` itself would let a background refetch wipe edits that are + // still in progress. + const seededVersion = useRef(null); + useEffect(() => { + if (data && seededVersion.current !== data.version) { + seededVersion.current = data.version; + setDraft(data.payload); + setNote(""); + } + }, [data]); + + if (isLoading || !data || !draft) return ; + + const dirty = JSON.stringify(draft) !== JSON.stringify(data.payload); + + const reset = () => { + setDraft(data.payload); + setNote(""); + }; + + return ( + + {/* Sticky: these tabs are long lists, and a Save button that scrolls out + of reach is the fastest way to lose an edit. */} + + + + + v{data.version} + + + {dirty ? "Unsaved changes" : "Saved"} + + + + + {dirty && ( + setNote(e.currentTarget.value)} + w={240} + /> + )} + + + + + + + + + + setHistoryOpen(false)} + hasUnsavedChanges={dirty} + onRestored={reset} + /> + + ); +} + +function DocumentEditor({ + slug, + value, + onChange, +}: { + slug: SupportDocSlug; + value: SupportDocPayload; + onChange: (next: SupportDocPayload) => void; +}) { + switch (slug) { + case "CONTACT": + return ( + + ); + case "HELP": + return ( + + ); + case "FAQ": + return ; + case "PRIVACY": + case "TERMS": + return ( + + ); + } +} + +/** + * Four fields, so no separate file. These values feed the help page's contact + * cards and resolve the `{{supportEmail}}`-style placeholders used throughout + * the FAQ and legal copy — editing them here updates every page at once. + */ +function ContactEditor({ + value, + onChange, +}: { + value: PortalSupportContact; + onChange: (next: PortalSupportContact) => void; +}) { + return ( + + onChange({ ...value, email: e.currentTarget.value })} + /> + onChange({ ...value, phone: e.currentTarget.value })} + /> + onChange({ ...value, office: e.currentTarget.value })} + /> + onChange({ ...value, hours: e.currentTarget.value })} + /> + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/pages/portal_content/VersionHistoryModal.tsx b/apps/edr-freight-web/backoffice/src/pages/portal_content/VersionHistoryModal.tsx new file mode 100644 index 000000000..f9bcbb9d3 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/portal_content/VersionHistoryModal.tsx @@ -0,0 +1,198 @@ +import type { SupportDocSlug } from "@edr/types"; +import { + Alert, + Badge, + Button, + Card, + Group, + Loader, + Modal, + Stack, + Text, +} from "@mantine/core"; +import { AlertTriangle } from "lucide-react"; +import { useState } from "react"; + +import { + usePortalDocVersion, + usePortalDocVersions, + useRestorePortalVersion, +} from "@/hooks/portal-content/usePortalContentAdmin"; + +import { Markdown } from "./Markdown"; +import { summarizeVersion } from "./version-preview"; + +interface VersionHistoryModalProps { + slug: SupportDocSlug; + opened: boolean; + onClose: () => void; + /** True when the tab holds unsaved edits a restore would discard. */ + hasUnsavedChanges: boolean; + onRestored: () => void; +} + +function formatSavedAt(value: string): string { + return new Date(value).toLocaleString("en-GB", { + day: "numeric", + month: "short", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + }); +} + +/** + * Version history for one document. Restoring re-saves the old payload as a new + * version server-side, so the list only ever grows and a restore is itself + * undoable — there is nothing here that can destroy history. + */ +export function VersionHistoryModal({ + slug, + opened, + onClose, + hasUnsavedChanges, + onRestored, +}: VersionHistoryModalProps) { + const { data: versions, isLoading } = usePortalDocVersions(slug, opened); + const [previewing, setPreviewing] = useState(null); + const [confirming, setConfirming] = useState(null); + const { data: preview } = usePortalDocVersion(slug, previewing); + const restore = useRestorePortalVersion(slug); + + const close = () => { + setPreviewing(null); + setConfirming(null); + onClose(); + }; + + const latest = versions?.[0]?.version; + + return ( + + + {hasUnsavedChanges && ( + } + title="Unsaved changes" + > + This tab has edits that have not been saved. Restoring a version + discards them. + + )} + + {isLoading && } + + {versions?.map((version) => ( + + + + + v{version.version} + {version.version === latest && ( + + Current + + )} + + {formatSavedAt(version.createdAt)} + + + {version.note && ( + + {version.note} + + )} + + + {confirming === version.version ? ( + + Restore v{version.version}? + + + + ) : ( + + + + + )} + + + {previewing === version.version && ( + + {preview ? ( + + {summarizeVersion(slug, preview.payload).map((entry, i) => ( + + + {entry.label} + + {entry.body} + + ))} + + ) : ( + + )} + + )} + + ))} + + {versions?.length === 0 && ( + + No history yet. + + )} + + + ); +} + +export default VersionHistoryModal; diff --git a/apps/edr-freight-web/backoffice/src/pages/portal_content/array-helpers.ts b/apps/edr-freight-web/backoffice/src/pages/portal_content/array-helpers.ts new file mode 100644 index 000000000..3a3ae40b0 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/portal_content/array-helpers.ts @@ -0,0 +1,24 @@ +/** Immutable list edits shared by the three payload editors. */ + +export function replaceAt(items: T[], index: number, next: T): T[] { + return items.map((item, i) => (i === index ? next : item)); +} + +export function removeAt(items: T[], index: number): T[] { + return items.filter((_, i) => i !== index); +} + +/** + * Swaps an item with its neighbour. Out-of-range moves return the list + * unchanged, so the ▲/▼ buttons need no disabled-state bookkeeping of their own. + */ +export function moveAt(items: T[], index: number, delta: number): T[] { + const target = index + delta; + if (target < 0 || target >= items.length) return items; + + const next = [...items]; + [next[index], next[target]] = [next[target], next[index]]; + return next; +} + +export const newId = () => crypto.randomUUID(); diff --git a/apps/edr-freight-web/backoffice/src/pages/portal_content/markdown-editor.css b/apps/edr-freight-web/backoffice/src/pages/portal_content/markdown-editor.css new file mode 100644 index 000000000..0c5b0eb3f --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/portal_content/markdown-editor.css @@ -0,0 +1,109 @@ +/* + * Tailwind's preflight zeroes margins on `p`, strips `list-style` from `ul`/`ol` + * and flattens heading sizes. MDXEditor's own stylesheet assumes browser + * defaults, so inside this app its content area renders as one undifferentiated + * block — paragraphs run together and bullets lose their markers. + * + * This restores the handful of element styles the editor needs, scoped to its + * content area so nothing leaks back into the rest of the backoffice. It is a + * deliberate alternative to pulling in @tailwindcss/typography for one widget. + */ + +.edr-md-content p { + margin: 0 0 0.75rem; + line-height: 1.6; +} + +.edr-md-content p:last-child { + margin-bottom: 0; +} + +.edr-md-content ul, +.edr-md-content ol { + margin: 0 0 0.75rem; + padding-left: 1.5rem; +} + +.edr-md-content ul { + list-style: disc; +} + +.edr-md-content ol { + list-style: decimal; +} + +.edr-md-content li { + margin: 0.25rem 0; + line-height: 1.6; +} + +/* Nested lists — the editor's indent button produces these. */ +.edr-md-content li > ul, +.edr-md-content li > ol { + margin: 0.25rem 0 0; +} + +.edr-md-content h1, +.edr-md-content h2, +.edr-md-content h3, +.edr-md-content h4 { + font-weight: 700; + line-height: 1.3; + margin: 1rem 0 0.5rem; +} + +.edr-md-content h1 { + font-size: 1.5rem; +} + +.edr-md-content h2 { + font-size: 1.25rem; +} + +.edr-md-content h3 { + font-size: 1.1rem; +} + +.edr-md-content h4 { + font-size: 1rem; +} + +.edr-md-content strong { + font-weight: 600; +} + +.edr-md-content em { + font-style: italic; +} + +.edr-md-content a { + color: var(--mantine-color-blue-6); + text-decoration: underline; +} + +.edr-md-content blockquote { + margin: 0 0 0.75rem; + padding-left: 0.75rem; + border-left: 3px solid var(--mantine-color-gray-3); + color: var(--mantine-color-dimmed); +} + +.edr-md-content hr { + border: 0; + border-top: 1px solid var(--mantine-color-gray-3); + margin: 1rem 0; +} + +.edr-md-content code { + font-family: var(--mantine-font-family-monospace); + font-size: 0.875em; + background: var(--mantine-color-gray-1); + padding: 0.05rem 0.25rem; + border-radius: 3px; +} + +.edr-md-content img { + max-width: 100%; + height: auto; + border-radius: 8px; +} diff --git a/apps/edr-freight-web/backoffice/src/pages/portal_content/version-preview.ts b/apps/edr-freight-web/backoffice/src/pages/portal_content/version-preview.ts new file mode 100644 index 000000000..465c91aba --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/portal_content/version-preview.ts @@ -0,0 +1,79 @@ +import type { + PortalFaqContent, + PortalHelpContent, + PortalLegalContent, + PortalSupportContact, + SupportDocPayload, + SupportDocSlug, +} from "@edr/types"; + +export interface PreviewEntry { + label: string; + /** Markdown, rendered read-only. */ + body: string; +} + +/** + * Flattens a stored payload into labelled markdown blocks for the history + * modal. An editor deciding whether to roll back needs to read the wording of + * that version — a raw JSON dump technically shows it, but not in a form + * anyone can compare legal prose in. + */ +export function summarizeVersion( + slug: SupportDocSlug, + payload: SupportDocPayload, +): PreviewEntry[] { + switch (slug) { + case "CONTACT": { + const contact = payload as PortalSupportContact; + return [ + { label: "Email", body: contact.email }, + { label: "Phone", body: contact.phone }, + { label: "Head office", body: contact.office }, + { label: "Support hours", body: contact.hours }, + ]; + } + + case "HELP": { + const help = payload as PortalHelpContent; + return [ + { label: "Title", body: help.title }, + { label: "Subtitle", body: help.subtitle }, + ...help.sections.map((section) => ({ + label: section.heading, + body: section.media.length + ? `${section.body}\n\n_${section.media.length} attachment${section.media.length === 1 ? "" : "s"}: ${section.media.map((m) => m.src).join(", ")}_` + : section.body, + })), + ]; + } + + case "FAQ": { + const faq = payload as PortalFaqContent; + return [ + { label: "Title", body: faq.title }, + ...faq.groups.flatMap((group) => + group.items.map((item) => ({ + label: `${group.title} — ${item.question}`, + body: item.answer, + })), + ), + ...(faq.footer + ? [{ label: faq.footer.heading, body: faq.footer.body }] + : []), + ]; + } + + case "PRIVACY": + case "TERMS": { + const legal = payload as PortalLegalContent; + return [ + { label: "Last updated", body: legal.lastUpdated }, + ...legal.sections.map((section) => ({ + label: section.heading, + body: section.body, + })), + ]; + } + } +} diff --git a/apps/edr-freight-web/backoffice/src/services/portal-content.service.ts b/apps/edr-freight-web/backoffice/src/services/portal-content.service.ts new file mode 100644 index 000000000..0f42b2a5a --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/services/portal-content.service.ts @@ -0,0 +1,98 @@ +import type { + PortalMediaKind, + SupportDocPayload, + SupportDocSlug, + SupportDocumentDetail, + SupportDocVersionDetail, + SupportDocVersionSummary, +} from "@edr/types"; + +import { api as client } from "../auth/http"; + +const ROOT = "/support-content"; +const BASE = `${ROOT}/documents`; + +/** + * Customer-facing help/FAQ/legal copy for the freight portal. The client's + * response interceptor already unwraps the `{ success, data }` envelope, so + * every method is a one-liner. + */ +export const portalContentService = { + async getBySlug(slug: SupportDocSlug): Promise { + const { data } = await client.get(`${BASE}/${slug}`); + return data; + }, + + /** + * Replaces the document's whole payload. Whole-payload rather than per-field + * on purpose: one Save becomes exactly one version, which is what keeps the + * history list readable. + */ + async update( + slug: SupportDocSlug, + payload: SupportDocPayload, + note?: string, + ): Promise { + const { data } = await client.patch( + `${BASE}/${slug}`, + { payload, note }, + ); + return data; + }, + + async listVersions(slug: SupportDocSlug): Promise { + const { data } = await client.get( + `${BASE}/${slug}/versions`, + ); + return data; + }, + + async getVersion( + slug: SupportDocSlug, + version: number, + ): Promise { + const { data } = await client.get( + `${BASE}/${slug}/versions/${version}`, + ); + return data; + }, + + /** + * Uploads an image or video and returns its object *key*. The key is what + * gets saved in the document; `url` is only for showing the editor a preview + * right now, and expires. + */ + async uploadMedia( + file: File, + ): Promise<{ key: string; kind: PortalMediaKind; url: string }> { + const form = new FormData(); + form.append("file", file); + + const { data } = await client.post<{ + key: string; + kind: PortalMediaKind; + url: string; + }>(`${ROOT}/media`, form); + return data; + }, + + /** Resolves one stored key to a temporary URL, for editor previews. */ + async mediaUrl(key: string): Promise { + const { data } = await client.get<{ url: string }>(`${ROOT}/media-url`, { + params: { key }, + }); + return data.url; + }, + + /** Re-saves an old payload as a new version — never destructive. */ + async restore( + slug: SupportDocSlug, + version: number, + ): Promise { + const { data } = await client.post( + `${BASE}/${slug}/versions/${version}/restore`, + {}, + ); + return data; + }, +}; diff --git a/apps/edr-freight-web/portal/package.json b/apps/edr-freight-web/portal/package.json index 4bb96ee4f..81800f447 100644 --- a/apps/edr-freight-web/portal/package.json +++ b/apps/edr-freight-web/portal/package.json @@ -33,6 +33,7 @@ "react-dom": "19.2.6", "react-hook-form": "^7.76.0", "react-hot-toast": "^2.6.0", + "react-markdown": "^9.1.0", "react-phone-number-input": "^3.4.17", "react-router-dom": "^6.27.0", "recharts": "^3.8.1", diff --git a/apps/edr-freight-web/portal/src/constants/URLS.ts b/apps/edr-freight-web/portal/src/constants/URLS.ts index 88b310e84..153ed5e8b 100644 --- a/apps/edr-freight-web/portal/src/constants/URLS.ts +++ b/apps/edr-freight-web/portal/src/constants/URLS.ts @@ -218,6 +218,11 @@ export const URL_CONSTANTS = { PAY_ONLINE: (id: string) => `/api/warehouse-fee-invoices/${id}/pay-online`, }, + // Public — no session required; the sign-up screen links to these pages. + PORTAL_CONTENT: { + PUBLIC: "/api/support-content", + }, + LAST_MILE_REQUESTS: { BY_ID: (id: string) => `/last-mile-requests/${id}`, SUBMIT: (id: string) => `/last-mile-requests/${id}/submit`, diff --git a/apps/edr-freight-web/portal/src/hooks/usePortalContent.ts b/apps/edr-freight-web/portal/src/hooks/usePortalContent.ts new file mode 100644 index 000000000..39d5d7a09 --- /dev/null +++ b/apps/edr-freight-web/portal/src/hooks/usePortalContent.ts @@ -0,0 +1,39 @@ +import type { PortalContentBundle } from "@edr/types"; +import { useQuery } from "@tanstack/react-query"; + +import { URL_CONSTANTS } from "@/constants/URLS"; +import { + FALLBACK_PORTAL_CONTENT, + withSupportVars, +} from "@/pages/support/portal-content"; +import type { ApiResponse } from "@/types/apiResponse"; +import { client } from "@/utils/api"; +import { unwrap } from "@/utils/endpoint"; + +/** + * The whole public help/FAQ/legal bundle in one request, shared by the four + * public pages (react-query dedupes it across the routes). + * + * The endpoint is unauthenticated and the shared axios client attaches a token + * only when the cookie exists, so this works for anonymous visitors as-is. + * + * `placeholderData` means `data` is never undefined: the pages render the + * shipped copy immediately and swap in the live copy when the fetch resolves. + * That is deliberate — it is what lets the four public pages skip loading and + * error states entirely. If the fallback is ever removed, those states are + * owed back. + */ +export function usePortalContent() { + return useQuery({ + queryKey: ["portal-content"], + queryFn: async (): Promise => { + const response = await client.get>( + URL_CONSTANTS.PORTAL_CONTENT.PUBLIC, + ); + return unwrap(response.data); + }, + placeholderData: FALLBACK_PORTAL_CONTENT, + select: withSupportVars, + staleTime: 5 * 60_000, + }); +} diff --git a/apps/edr-freight-web/portal/src/pages/support/DocShell.tsx b/apps/edr-freight-web/portal/src/pages/support/DocShell.tsx index 080189b3b..4808f5d52 100644 --- a/apps/edr-freight-web/portal/src/pages/support/DocShell.tsx +++ b/apps/edr-freight-web/portal/src/pages/support/DocShell.tsx @@ -1,8 +1,9 @@ +import type { PortalDocSection } from "@edr/types"; import { ArrowLeft, Train } from "lucide-react"; import type { ReactNode } from "react"; import { Link } from "react-router-dom"; -import type { Section } from "./content"; +import { Markdown } from "./Markdown"; /** Public pages reachable from every doc page's header and footer. */ const DOC_LINKS = [ @@ -87,32 +88,21 @@ export function DocShell({ ); } -/** Renders a legal document's numbered sections. */ -export function DocSections({ sections }: { sections: Section[] }) { +/** + * Renders a legal document's numbered sections. Bodies are markdown, so the + * paragraph and bullet arrays this used to walk are one string now — keyed by + * `id` rather than by heading, which admin-authored text can duplicate. + */ +export function DocSections({ sections }: { sections: PortalDocSection[] }) { return (
{sections.map((section) => ( -
+

{section.heading}

- {section.body?.map((paragraph) => ( -

- {paragraph} -

- ))} - - {section.bullets && ( -
    - {section.bullets.map((bullet) => ( -
  • {bullet}
  • - ))} -
- )} + {section.body}
))}
diff --git a/apps/edr-freight-web/portal/src/pages/support/FaqPage.tsx b/apps/edr-freight-web/portal/src/pages/support/FaqPage.tsx index 9704c8cf8..7aa702a21 100644 --- a/apps/edr-freight-web/portal/src/pages/support/FaqPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/support/FaqPage.tsx @@ -1,19 +1,21 @@ import { ChevronDown } from "lucide-react"; import { Link } from "react-router-dom"; +import { usePortalContent } from "@/hooks/usePortalContent"; + import { DocShell } from "./DocShell"; -import { FAQ_GROUPS, SUPPORT_CONTACT } from "./content"; +import { Markdown } from "./Markdown"; export default function FaqPage() { + // Never undefined — see TermsPage. + const { data } = usePortalContent(); + const faq = data!.faq; + return ( - +
- {FAQ_GROUPS.map((group) => ( -
+ {faq.groups.map((group) => ( +

{group.title}

@@ -21,7 +23,7 @@ export default function FaqPage() { // Native disclosure: keyboard- and screen-reader-accessible // without any state of our own.
@@ -29,9 +31,7 @@ export default function FaqPage() { -

- {item.answer} -

+ {item.answer}
))}
@@ -39,21 +39,20 @@ export default function FaqPage() { ))}
-
-

- Still need a hand? -

-

- Our team is on {SUPPORT_CONTACT.email} and {SUPPORT_CONTACT.phone}, or - you can start a chat from the support button inside the portal. -

- - Go to Help & Support - -
+ {faq.footer && ( +
+

+ {faq.footer.heading} +

+ {faq.footer.body} + + {faq.footer.ctaLabel} + +
+ )}
); } diff --git a/apps/edr-freight-web/portal/src/pages/support/HelpPage.tsx b/apps/edr-freight-web/portal/src/pages/support/HelpPage.tsx index b310dfa2c..37c748d79 100644 --- a/apps/edr-freight-web/portal/src/pages/support/HelpPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/support/HelpPage.tsx @@ -1,208 +1,74 @@ -import { - Clock3, - FileText, - HelpCircle, - Mail, - MapPin, - MessageSquare, - Package, - Phone, - Receipt, - ShieldCheck, -} from "lucide-react"; -import { Link } from "react-router-dom"; +import type { PortalMedia } from "@edr/types"; + +import { usePortalContent } from "@/hooks/usePortalContent"; import { DocShell } from "./DocShell"; -import { SUPPORT_CONTACT } from "./content"; +import { Markdown } from "./Markdown"; +import { safeMediaSrc } from "./portal-content"; -const channels = [ - { - icon: Mail, - title: "Email", - value: SUPPORT_CONTACT.email, - href: `mailto:${SUPPORT_CONTACT.email}`, - note: "Best for document issues and anything needing an attachment.", - }, - { - icon: Phone, - title: "Phone", - value: SUPPORT_CONTACT.phone, - href: `tel:${SUPPORT_CONTACT.phone.replace(/\s/g, "")}`, - note: "Best for urgent problems with cargo already in transit.", - }, - { - icon: MapPin, - title: "Head office", - value: SUPPORT_CONTACT.office, - note: "Walk-in support during working hours.", - }, - { - icon: Clock3, - title: "Support hours", - value: SUPPORT_CONTACT.hours, - note: "Outside these hours, email us and we reply the next working day.", - }, -]; +/** + * An attached image or video. The API has already swapped stored MinIO keys for + * freshly signed URLs, so `src` is ready to render — `safeMediaSrc` is a last + * check that an admin-entered value is a path or an https URL. + */ +function Media({ item }: { item: PortalMedia }) { + const src = safeMediaSrc(item.src); + if (!src) return null; -const topics = [ - { - icon: ShieldCheck, - title: "Account & onboarding", - body: "Registering your company, uploading your trade licence and TIN, and getting an operational profile approved.", - }, - { - icon: FileText, - title: "Contracts", - body: "Requesting a freight contract, reviewing its terms and signing it with your saved signature and stamp.", - }, - { - icon: Package, - title: "Bookings & tracking", - body: "Raising a booking against a contract, adding last-mile transport and following the consignment along the corridor.", - }, - { - icon: Receipt, - title: "Invoices & payments", - body: "Finding invoices, paying through the bank channels and confirming a payment that has not yet settled.", - }, -]; - -export default function HelpPage() { return ( - -
-

- Portal walkthrough -

-

- A guided tour of the portal — registering your company, raising a - booking against a contract, and settling an invoice. -

- - {/* preload="metadata" so the 28 MB file is not pulled on every visit; - the browser fetches it only once playback starts. */} +
+ {item.kind === "video" ? ( + // preload="metadata" so a large file is not pulled on every visit; the + // browser fetches it only once playback starts. -
+ ) : ( + {item.caption + )} - {/* Live chat is the fastest route, so lead with it. */} -
-
-
- -
+ {item.caption && ( +
+ {item.caption} +
+ )} + + ); +} -
+export default function HelpPage() { + // Never undefined — see TermsPage. + const { data } = usePortalContent(); + const help = data!.help; + + return ( + +
+ {help.sections.map((section) => ( +

- Chat with our team + {section.heading}

-

- Signed-in customers can open a support conversation from the - headset button at the bottom right of every portal page. You can - send screenshots and documents in the chat, and replies appear - there and as a notification. -

- - Open the portal - -
-
+ + {section.body} + + {section.media.map((item) => ( + + ))} + + ))}
- -
-

Contact us

- -
- {channels.map((channel) => ( -
-
- -
- -
-

{channel.title}

- {channel.href ? ( - - {channel.value} - - ) : ( -

{channel.value}

- )} -

- {channel.note} -

-
-
- ))} -
-
- -
-

Common topics

- -
- {topics.map((topic) => ( - -
- -
-

{topic.title}

-

- {topic.body} -

- - ))} -
-
- -
-
-
- -
- -
-

- What to include when you contact us -

-
    -
  • Your company name and the email you sign in with.
  • -
  • - The reference of the contract, booking or invoice involved. -
  • -
  • What you expected to happen and what happened instead.
  • -
  • A screenshot of any error message the portal showed.
  • -
-
-
-
); } diff --git a/apps/edr-freight-web/portal/src/pages/support/Markdown.tsx b/apps/edr-freight-web/portal/src/pages/support/Markdown.tsx new file mode 100644 index 000000000..11924cf23 --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/support/Markdown.tsx @@ -0,0 +1,72 @@ +import ReactMarkdown from "react-markdown"; + +/** + * Renders admin-authored markdown from the support-content API. + * + * Deliberately plain `react-markdown`: it builds React elements directly, so + * unlike a markdown→HTML-string library it needs no `dangerouslySetInnerHTML` + * and no sanitizer, and the portal keeps its zero HTML-injection surface. + * + * Two things must stay absent for that to hold: + * - `rehype-raw`, which would start rendering raw HTML embedded in the copy; + * - a custom `urlTransform`, which would override the built-in stripping of + * `javascript:` and `data:` hrefs. + * + * `remark-gfm` is also left out: tables and strikethrough are not used in the + * legal or FAQ copy, and CommonMark already covers lists, emphasis and links. + * + * The component map reproduces the Tailwind classes the pages used when this + * copy was hardcoded, so switching to markdown changed nothing visually. + */ +export function Markdown({ children }: { children: string }) { + return ( + ( +

{content}

+ ), + ul: ({ children: content }) => ( +
    + {content} +
+ ), + ol: ({ children: content }) => ( +
    + {content} +
+ ), + li: ({ children: content }) =>
  • {content}
  • , + a: ({ href, children: content }) => ( + + {content} + + ), + strong: ({ children: content }) => ( + {content} + ), + em: ({ children: content }) => {content}, + h3: ({ children: content }) => ( +

    {content}

    + ), + // Images embedded by the editor. The API has already resolved these to + // signed URLs; react-markdown's default urlTransform still guards the + // scheme. + img: ({ src, alt }) => ( + {alt + ), + }} + > + {children} +
    + ); +} + +export default Markdown; diff --git a/apps/edr-freight-web/portal/src/pages/support/PrivacyPolicyPage.tsx b/apps/edr-freight-web/portal/src/pages/support/PrivacyPolicyPage.tsx index 76c2441ae..07e79d207 100644 --- a/apps/edr-freight-web/portal/src/pages/support/PrivacyPolicyPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/support/PrivacyPolicyPage.tsx @@ -1,15 +1,20 @@ +import { usePortalContent } from "@/hooks/usePortalContent"; + import { DocSections, DocShell } from "./DocShell"; -import { LEGAL_LAST_UPDATED, PRIVACY_SECTIONS } from "./content"; export default function PrivacyPolicyPage() { + // Never undefined — see TermsPage. + const { data } = usePortalContent(); + const privacy = data!.privacy; + return ( - + ); } diff --git a/apps/edr-freight-web/portal/src/pages/support/TermsPage.tsx b/apps/edr-freight-web/portal/src/pages/support/TermsPage.tsx index b095acae0..6302e838d 100644 --- a/apps/edr-freight-web/portal/src/pages/support/TermsPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/support/TermsPage.tsx @@ -1,15 +1,21 @@ +import { usePortalContent } from "@/hooks/usePortalContent"; + import { DocSections, DocShell } from "./DocShell"; -import { LEGAL_LAST_UPDATED, TERMS_SECTIONS } from "./content"; export default function TermsPage() { + // Never undefined — the hook seeds it with the shipped copy, so this public + // page renders instantly and survives the API being unreachable. + const { data } = usePortalContent(); + const terms = data!.terms; + return ( - + ); } diff --git a/apps/edr-freight-web/portal/src/pages/support/content.ts b/apps/edr-freight-web/portal/src/pages/support/content.ts deleted file mode 100644 index d7b8e2d2c..000000000 --- a/apps/edr-freight-web/portal/src/pages/support/content.ts +++ /dev/null @@ -1,358 +0,0 @@ -/** - * Copy for the public help/FAQ/legal pages. Kept as data so the pages stay - * thin — the shell in `DocShell.tsx` renders any `Section[]` the same way. - * - * The privacy and terms text is the platform's working draft; legal counsel - * signs off on the final wording, and `LEGAL_LAST_UPDATED` is bumped with it. - */ - -export const SUPPORT_CONTACT = { - email: "support@edrfreight.com", - phone: "+251 11 000 0000", - office: "Addis Ababa, Ethiopia", - hours: "Monday – Saturday, 8:30 AM – 5:30 PM (EAT)", -}; - -export const LEGAL_LAST_UPDATED = "6 August 2026"; - -export interface Section { - heading: string; - /** Paragraphs, rendered in order. */ - body?: string[]; - /** Optional bullet list, rendered after the paragraphs. */ - bullets?: string[]; -} - -export interface FaqItem { - question: string; - answer: string; -} - -export interface FaqGroup { - title: string; - items: FaqItem[]; -} - -export const FAQ_GROUPS: FaqGroup[] = [ - { - title: "Getting started", - items: [ - { - question: "How do I open an account on EDR Freight?", - answer: - "Sign up with your work email and verify the one-time code we send you. After you set a password, the onboarding wizard collects your company details, trade licence, TIN certificate and the operational services you need (importer, exporter, freight forwarder or transporter). Submit the wizard and our team reviews the application.", - }, - { - question: "How long does account approval take?", - answer: - "Most complete applications are reviewed within two working days. You will see the status on your dashboard, and we email you when a profile is approved or when a document needs to be re-uploaded.", - }, - { - question: "My profile was rejected. What now?", - answer: - "The rejection notice states the reason. Open Settings, correct the details or replace the document that was flagged, and re-apply — you do not need to create a new account.", - }, - { - question: "Can one company hold several operational services?", - answer: - "Yes. A company can hold importer, exporter, freight forwarder and transporter profiles at the same time. Each is approved separately, and the header lets you switch between the ones you hold.", - }, - ], - }, - { - title: "Contracts and bookings", - items: [ - { - question: "What is the difference between a contract and a booking?", - answer: - "A contract is the commercial agreement covering a cargo movement — route, commodity, volume and rates. A booking is a single shipment executed under that contract. You create the contract once, then raise bookings against it for each consignment.", - }, - { - question: "How do I create a booking?", - answer: - "Open the contract from the Contracts list and choose New Booking. Provide the consignment details, containers or tonnage, and the last-mile requirement if you need one. Bookings can also be started from the Bookings page, which routes you through contract selection first.", - }, - { - question: "Why do I have to sign a contract before shipping?", - answer: - "The contract document is the binding agreement for the movement. You must scroll to the end, accept the terms, and sign it with your saved signature and stamp before EDR schedules any wagon against it.", - }, - { - question: "Where do I set up my signature and stamp?", - answer: - "Under Signature & Stamp in the portal. It is saved once and reused for every contract you sign, so you do not have to upload it per document.", - }, - { - question: "Can I change a booking after submitting it?", - answer: - "You can edit a booking while it is still pending review. Once EDR has confirmed it and allocated capacity, changes go through our operations team — contact support with the booking reference.", - }, - { - question: "How do I track a consignment?", - answer: - "Open the booking and use the tracking panel, which shows the current milestone, the wagon or container assigned, and the timestamps recorded at each corridor point.", - }, - ], - }, - { - title: "Invoices and payments", - items: [ - { - question: "Where do I find my invoices?", - answer: - "The Invoices page lists every invoice raised against your company, with its status, due date and outstanding balance. Open any invoice to see its line items and download a PDF copy.", - }, - { - question: "Which payment methods are supported?", - answer: - "Payments are made through the integrated bank channels shown at checkout. After you complete the payment on the bank's page you are returned to the portal, and the invoice status updates once the bank confirms the transaction.", - }, - { - question: "My payment was deducted but the invoice still shows unpaid.", - answer: - "Bank confirmations can lag by a few minutes. Use the Check Payment Status page linked from your receipt; if it still has not settled after an hour, email support with the invoice number and the bank reference and we will reconcile it.", - }, - { - question: "Why is my invoice amount rounded?", - answer: - "Some bank channels only accept whole-birr amounts, so invoices routed through them are rounded up to the nearest birr. The rounding is shown on the invoice detail page.", - }, - ], - }, - { - title: "Account and security", - items: [ - { - question: "How do I reset my password?", - answer: - "Use Forgot Password on the sign-in page. We email you a reset link that is valid for a limited time. If a member of our staff issued the link, it works the same way even if you are already signed in.", - }, - { - question: "Can I add colleagues to my company account?", - answer: - "Yes. Company administrators can invite additional users from Settings. Each user signs in with their own credentials, and actions are recorded against the individual who performed them.", - }, - { - question: "How do I update company details after approval?", - answer: - "Edit them in Settings. Changes to regulated fields — trade licence, TIN, legal name — are re-verified by our team before they take effect.", - }, - ], - }, -]; - -export const PRIVACY_SECTIONS: Section[] = [ - { - heading: "1. Introduction", - body: [ - "The Ethio-Djibouti Standard Gauge Rail Share Company (\"EDR\", \"we\", \"us\") operates the EDR Freight platform, which lets customers register their business, agree freight contracts, raise bookings, track consignments and settle invoices online.", - "This policy explains what personal and business information we collect through the platform, why we collect it, how long we keep it and what rights you have over it. It applies to the EDR Freight customer portal and the services reached through it.", - ], - }, - { - heading: "2. Information we collect", - body: [ - "We collect information you give us, information generated by your use of the platform, and information we receive from the regulators and financial institutions we work with.", - ], - bullets: [ - "Account details — name, work email address, phone number and the credentials used to sign in.", - "Company and compliance records — legal name, trade licence, TIN certificate, VAT registration, ownership and manager details, and the operational services you apply for.", - "Identity verification data — where you verify through a national identity service, the verification result and the attributes that service returns to us.", - "Operational data — contracts, bookings, consignment and cargo details, container and wagon assignments, tracking events and delivery confirmations.", - "Financial data — invoices, payment references, transaction status and settlement confirmations received from banks. We do not store your card numbers or online banking credentials.", - "Support data — the messages and files you send us through the in-app support chat or by email.", - "Technical data — IP address, device and browser information, and event logs generated when you use the platform.", - ], - }, - { - heading: "3. How we use your information", - bullets: [ - "To create and administer your account and verify that your company is entitled to the services it applies for.", - "To perform the freight contracts and bookings you place, including allocating capacity and coordinating rail and last-mile movements.", - "To issue invoices, process payments and keep the accounting records the law requires us to keep.", - "To provide customer support and respond to the questions and complaints you raise.", - "To keep the platform secure, detect misuse and investigate incidents.", - "To meet our legal, tax, customs and regulatory obligations in Ethiopia and Djibouti.", - "To improve the platform — measuring which features are used and where users encounter errors, using aggregated and pseudonymised data wherever that is sufficient.", - ], - }, - { - heading: "4. Legal basis for processing", - body: [ - "We process your information because it is necessary to perform the contract between you and EDR, because we have a legal obligation to do so (customs, tax and transport regulation), or because we have a legitimate interest in operating and securing the platform. Where we rely on your consent — for example, optional marketing messages — you can withdraw it at any time.", - ], - }, - { - heading: "5. Sharing your information", - body: [ - "We do not sell your information. We share it only where it is necessary to deliver the service or where the law requires it.", - ], - bullets: [ - "Government and regulatory bodies — customs, revenue and transport authorities in Ethiopia and Djibouti, to the extent required for the movement of your cargo.", - "Ports, terminals and last-mile transporters involved in executing your bookings.", - "Banks and payment providers, to initiate and reconcile the payments you make.", - "Technology suppliers who host and maintain the platform on our behalf, under contracts that restrict them to processing data on our instructions.", - "Courts, law enforcement and other authorities where we are legally compelled to disclose.", - ], - }, - { - heading: "6. International transfers", - body: [ - "Cross-border freight inherently involves parties in more than one country, so consignment and clearance information is shared with counterparties and authorities in Djibouti as well as Ethiopia. Where we transfer information outside Ethiopia, we do so only as far as the movement requires or the law permits, and we require recipients to protect it to a comparable standard.", - ], - }, - { - heading: "7. Data retention", - body: [ - "We keep account and company records for as long as your account is active. Contract, booking, customs and financial records are kept for the period required by Ethiopian commercial, tax and customs law after the relevant transaction, because we are obliged to be able to produce them. Support conversations and technical logs are kept for a shorter period, sufficient to resolve disputes and investigate security incidents.", - ], - }, - { - heading: "8. Security", - body: [ - "Access to the platform requires authentication, and staff access to customer records is limited to what each role needs. Data is transmitted over encrypted connections and stored on systems protected by access controls and logging. No system is perfectly secure, so please keep your credentials confidential and tell us immediately if you believe your account has been compromised.", - ], - }, - { - heading: "9. Your rights", - body: [ - "Subject to Ethiopian law, you may ask us to give you a copy of the personal information we hold about you, correct it if it is inaccurate, restrict or object to certain processing, or delete it where we are not required to keep it. Requests are handled through the contact details below; we may need to verify your identity before acting.", - ], - }, - { - heading: "10. Cookies and similar technologies", - body: [ - "The platform uses cookies and browser storage to keep you signed in, remember your interface preferences and measure how the product is used so we can fix problems. Essential cookies cannot be turned off without breaking sign-in. You can clear or block the rest through your browser settings.", - ], - }, - { - heading: "11. Children", - body: [ - "The platform is a business service and is not directed at children. We do not knowingly collect information from anyone under 18.", - ], - }, - { - heading: "12. Changes to this policy", - body: [ - "We may update this policy as the platform and the law change. Material changes are announced in the portal before they take effect, and the date at the top of this page always reflects the current version.", - ], - }, - { - heading: "13. Contact us", - body: [ - `Questions about this policy or about how we handle your information can be sent to ${SUPPORT_CONTACT.email}, called in on ${SUPPORT_CONTACT.phone}, or addressed to our head office in ${SUPPORT_CONTACT.office}.`, - ], - }, -]; - -export const TERMS_SECTIONS: Section[] = [ - { - heading: "1. These terms", - body: [ - "These terms govern your use of the EDR Freight platform operated by the Ethio-Djibouti Standard Gauge Rail Share Company (\"EDR\"). By creating an account or using the platform, the company you represent agrees to them.", - "The platform is the channel through which you register, request and manage freight services. The commercial terms of each movement — routes, rates, volumes and payment terms — are set out in the freight contract you sign in the platform. Where a signed contract and these terms conflict, the signed contract governs that movement.", - ], - }, - { - heading: "2. Eligibility and accounts", - bullets: [ - "The platform is for registered businesses. You confirm that you are authorised to act for the company you register and to bind it to these terms.", - "The information and documents you submit — trade licence, TIN, VAT registration, ownership details — must be accurate, current and genuine.", - "Accounts and operational profiles are activated only after EDR has reviewed and approved them, and approval may be refused or withdrawn.", - "You are responsible for keeping credentials confidential and for everything done under your account. Tell us at once if you suspect unauthorised use.", - ], - }, - { - heading: "3. Contracts and bookings", - bullets: [ - "A freight contract takes effect when it is signed in the platform by you and countersigned by EDR.", - "A booking is a request for a specific movement under a contract. It becomes binding when EDR confirms it and allocates capacity — submission alone does not reserve a wagon or container.", - "You are responsible for the accuracy of consignment data: commodity description, weight, dimensions, container numbers, hazardous classification and consignee details.", - "Capacity is finite. EDR may decline, defer or reschedule a booking where capacity, safety, operating conditions or regulatory direction require it.", - ], - }, - { - heading: "4. Cargo, documents and compliance", - bullets: [ - "You must obtain and provide every permit, customs declaration and clearance document the movement requires, and you warrant that the cargo may lawfully be carried.", - "Prohibited and restricted goods may not be tendered without EDR's prior written agreement and any licence the law requires.", - "Cargo must be packed, secured and, where applicable, labelled to the standard the mode of carriage requires. EDR may inspect, refuse or offload cargo that is misdeclared or unsafe.", - "You are liable for fines, demurrage, storage charges and losses arising from misdeclared cargo, missing documents or delays attributable to you.", - ], - }, - { - heading: "5. Rates, invoicing and payment", - bullets: [ - "Charges are calculated from the rates in your contract, the tariffs published in the platform, and any accessorial services actually rendered.", - "Invoices are issued in the platform and are payable by the due date shown on them, through the payment channels the platform offers.", - "Payment is confirmed when the funds are confirmed by the bank, not when payment is initiated.", - "Overdue amounts may attract interest and may result in suspension of new bookings or of the account until the balance is cleared.", - "Taxes and statutory duties are your responsibility unless the contract expressly says otherwise.", - ], - }, - { - heading: "6. Delivery, delay and liability", - body: [ - "Transit times shown in the platform are estimates based on planned schedules. They are not guarantees, and EDR is not liable for indirect or consequential loss, loss of profit or loss of market arising from delay.", - "EDR's liability for loss of or damage to cargo is limited to the extent set out in the applicable freight contract and in the transport law governing the carriage. Claims must be notified in writing within the period the contract specifies; late claims may be rejected.", - "Neither party is liable for failure to perform caused by events beyond its reasonable control, including natural disasters, industrial action, civil unrest, infrastructure failure, or acts of government and regulatory authorities.", - ], - }, - { - heading: "7. Acceptable use of the platform", - bullets: [ - "Use the platform only for its intended purpose and in accordance with applicable law.", - "Do not attempt to gain unauthorised access, probe or disrupt the service, or interfere with other customers' data.", - "Do not scrape, resell or redistribute platform content, rates or data without written permission.", - "Do not upload malware or content that infringes the rights of others.", - ], - }, - { - heading: "8. Electronic signatures and records", - body: [ - "You agree that contracts signed in the platform using your stored signature and stamp are validly executed, that the records the platform keeps of those signatures are admissible evidence of them, and that they carry the same effect as signatures on paper.", - ], - }, - { - heading: "9. Availability and changes to the service", - body: [ - "We aim to keep the platform available, but it may be interrupted for maintenance, upgrades or reasons outside our control. We may add, change or withdraw features. Where a change materially affects how you use the platform, we will give reasonable notice in the portal.", - ], - }, - { - heading: "10. Suspension and termination", - body: [ - "We may suspend or terminate access where these terms are breached, where documents prove to be false, where amounts remain unpaid, or where the law or a regulator requires it. You may stop using the platform at any time. Termination does not affect obligations already incurred — cargo in transit, invoices outstanding, or records we are required to retain.", - ], - }, - { - heading: "11. Intellectual property", - body: [ - "The platform, its software, design and content belong to EDR or its licensors. You are granted a non-exclusive, non-transferable right to use it for your own freight operations. Your commercial and consignment data remains yours; you grant us the right to process it as needed to deliver the service and as described in the Privacy Policy.", - ], - }, - { - heading: "12. Confidentiality and data protection", - body: [ - "Each party will keep the other's non-public commercial information confidential and use it only for the purposes of the services. Our handling of personal information is described in the Privacy Policy, which forms part of these terms.", - ], - }, - { - heading: "13. Governing law and disputes", - body: [ - "These terms are governed by the laws of the Federal Democratic Republic of Ethiopia. The parties will first attempt to resolve any dispute amicably; failing that, the dispute is subject to the jurisdiction of the competent courts of Ethiopia, without prejudice to any arbitration clause agreed in a specific freight contract.", - ], - }, - { - heading: "14. Changes to these terms", - body: [ - "We may update these terms as the service and the law change. Updates are published here and announced in the portal. Continuing to use the platform after an update takes effect means you accept the revised terms.", - ], - }, - { - heading: "15. Contact", - body: [ - `For questions about these terms, write to ${SUPPORT_CONTACT.email} or call ${SUPPORT_CONTACT.phone}.`, - ], - }, -]; diff --git a/apps/edr-freight-web/portal/src/pages/support/portal-content.test.ts b/apps/edr-freight-web/portal/src/pages/support/portal-content.test.ts new file mode 100644 index 000000000..2523ca3ae --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/support/portal-content.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from "vitest"; + +import { + applyPortalVars, + FALLBACK_PORTAL_CONTENT, + safeMediaSrc, + withSupportVars, +} from "./portal-content"; + +const contact = { + email: "support@edrfreight.com", + phone: "+251 11 000 0000", + office: "Addis Ababa, Ethiopia", + hours: "Monday – Saturday, 8:30 AM – 5:30 PM (EAT)", +}; + +describe("applyPortalVars", () => { + it("substitutes every known token", () => { + expect( + applyPortalVars( + "Mail {{supportEmail}}, call {{supportPhone}}, visit {{supportOffice}}, open {{supportHours}}.", + contact, + ), + ).toBe( + "Mail support@edrfreight.com, call +251 11 000 0000, visit Addis Ababa, Ethiopia, open Monday – Saturday, 8:30 AM – 5:30 PM (EAT).", + ); + }); + + it("strips spacing for the tel: variant", () => { + expect(applyPortalVars("tel:{{supportPhoneTel}}", contact)).toBe( + "tel:+251110000000", + ); + }); + + it("leaves an unknown token verbatim so the typo is visible", () => { + expect(applyPortalVars("Mail {{supportEmial}}.", contact)).toBe( + "Mail {{supportEmial}}.", + ); + }); + + it("does not let $& in a contact value corrupt the output", () => { + // Guards the replace-callback choice: with a replacement *string*, `$&` + // would expand to the matched token and the address would come out wrong. + expect( + applyPortalVars("Write to {{supportOffice}}.", { + ...contact, + office: "Bole $& Road", + }), + ).toBe("Write to Bole $& Road."); + }); +}); + +describe("withSupportVars", () => { + it("resolves placeholders buried in the shipped legal copy", () => { + const resolved = withSupportVars(FALLBACK_PORTAL_CONTENT); + const contactSection = resolved.privacy.sections.at(-1)!; + + expect(contactSection.body).toContain(contact.email); + expect(contactSection.body).not.toContain("{{"); + }); + + it("leaves the contact block itself alone — it is the substitution source", () => { + expect(withSupportVars(FALLBACK_PORTAL_CONTENT).contact).toEqual( + FALLBACK_PORTAL_CONTENT.contact, + ); + }); +}); + +describe("safeMediaSrc", () => { + it("keeps same-origin paths and https sources", () => { + expect(safeMediaSrc("/assets/guide.webm")).toBe("/assets/guide.webm"); + expect(safeMediaSrc("https://minio.internal/support-content/a.png?sig=x")).toBe( + "https://minio.internal/support-content/a.png?sig=x", + ); + }); + + it("drops javascript: and protocol-relative sources", () => { + expect(safeMediaSrc("javascript:alert(1)")).toBeNull(); + expect(safeMediaSrc("//evil.example.com/g.webm")).toBeNull(); + // A bare object key means the API failed to sign it — render nothing + // rather than a broken relative URL. + expect(safeMediaSrc("support-content/a.png")).toBeNull(); + }); +}); diff --git a/apps/edr-freight-web/portal/src/pages/support/portal-content.ts b/apps/edr-freight-web/portal/src/pages/support/portal-content.ts new file mode 100644 index 000000000..06bfdbce9 --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/support/portal-content.ts @@ -0,0 +1,103 @@ +import { + SUPPORT_CONTENT_DEFAULTS, + type PortalContentBundle, + type PortalContentVar, + type PortalSupportContact, +} from "@edr/types"; + +/** + * Copy for the public help/FAQ/legal pages now lives in the database and is + * edited from the backoffice. This module holds what is left in the app: the + * shipped copy as a fallback, and the two pure helpers the pages need. + * + * The fallback matters because these four routes are public and linked from + * the sign-up screen — they are often the first thing an anonymous visitor + * sees. Rendering the shipped text while the request is in flight (or if the + * API is down) beats showing them a spinner or an error card, and it is why + * none of the four pages carry loading or error branches. + */ +export const FALLBACK_PORTAL_CONTENT: PortalContentBundle = { + contact: SUPPORT_CONTENT_DEFAULTS.CONTACT, + help: SUPPORT_CONTENT_DEFAULTS.HELP, + faq: SUPPORT_CONTENT_DEFAULTS.FAQ, + privacy: SUPPORT_CONTENT_DEFAULTS.PRIVACY, + terms: SUPPORT_CONTENT_DEFAULTS.TERMS, +}; + +const VAR_PATTERN = + /\{\{(supportEmail|supportPhone|supportPhoneTel|supportOffice|supportHours)\}\}/g; + +function resolveVar(name: PortalContentVar, contact: PortalSupportContact) { + switch (name) { + case "supportEmail": + return contact.email; + case "supportPhone": + return contact.phone; + case "supportPhoneTel": + // tel: hrefs must not carry the display spacing. + return contact.phone.replace(/\s/g, ""); + case "supportOffice": + return contact.office; + case "supportHours": + return contact.hours; + } +} + +/** + * Substitutes `{{supportEmail}}`-style placeholders against the editable + * contact block. The support address used to be interpolated into the privacy + * and terms prose at build time, which meant editing it would have left those + * paragraphs quoting a stale one. + * + * Uses a replacement *callback* on purpose: with a replacement string, a `$&` + * or `$1` inside an admin-typed office address would be treated as a + * backreference and corrupt the output. + * + * Unknown tokens are left verbatim — the pattern only matches the four known + * names — so a typo shows up as `{{supportEmial}}` rather than a blank. + */ +export function applyPortalVars( + text: string, + contact: PortalSupportContact, +): string { + return text.replace(VAR_PATTERN, (_match, name: PortalContentVar) => + resolveVar(name, contact), + ); +} + +/** + * Applies {@link applyPortalVars} to every string in the bundle except the + * contact block itself, which is the substitution source. Walking the whole + * object means a placeholder works in any field, including ones added later. + */ +export function withSupportVars( + bundle: PortalContentBundle, +): PortalContentBundle { + const { contact, ...rest } = bundle; + + const walk = (value: unknown): unknown => { + if (typeof value === "string") return applyPortalVars(value, contact); + if (Array.isArray(value)) return value.map(walk); + if (value && typeof value === "object") { + return Object.fromEntries( + Object.entries(value).map(([key, inner]) => [key, walk(inner)]), + ); + } + return value; + }; + + return { contact, ...(walk(rest) as Omit) }; +} + +/** + * Accepts only a same-origin path or an https URL for an attached image or + * video, returning null for anything else so the caller renders nothing. + * + * `(?!\/)` rejects protocol-relative `//host/...`, which would otherwise pass + * as a path. An ``/`` src is not a navigation, so a `javascript:` + * URL would not execute anyway — but the guard is cheaper than re-deriving + * that every time someone reads this file. + */ +export function safeMediaSrc(src: string): string | null { + return /^(https:\/\/|\/(?!\/))/.test(src) ? src : null; +} diff --git a/packages/types/src/freight/index.ts b/packages/types/src/freight/index.ts index 0eab2cfbb..3041f02d5 100644 --- a/packages/types/src/freight/index.ts +++ b/packages/types/src/freight/index.ts @@ -11,6 +11,8 @@ export * from "./ethiopian-regions.catalog"; export * from "./notifications"; export * from "./booking-window-ws"; export * from "./support-chat"; +export * from "./portal-content"; +export * from "./portal-content.defaults"; export enum TradeDirection { IMPORT = "IMPORT", diff --git a/packages/types/src/freight/portal-content.defaults.ts b/packages/types/src/freight/portal-content.defaults.ts new file mode 100644 index 000000000..30ae3cf79 --- /dev/null +++ b/packages/types/src/freight/portal-content.defaults.ts @@ -0,0 +1,375 @@ +import type { SupportDocPayloadMap } from "./portal-content"; + +/** + * The copy the portal shipped with, transcribed from what used to be + * `edr-freight-web/portal/src/pages/support/content.ts` and the inline blocks + * of `HelpPage.tsx`. + * + * Two mechanical changes from the original: + * + * 1. `Section { body: string[]; bullets: string[] }` collapses to one markdown + * string — paragraphs separated by a blank line, bullets as `- ` lines. + * 2. The support email/phone/office, previously string-interpolated into the + * privacy and terms prose at build time, are now `{{supportEmail}}`-style + * placeholders resolved against the CONTACT document at read time. That is + * what stops the legal text keeping a stale phone number after an edit. + * + * It lives in the shared package because three consumers need the same bytes + * and any drift between them would only surface during an outage: the API + * seeds the database from it and serves it for any row still missing, and the + * portal renders it while the request is in flight or if the API is down. + * Once seeded, the database is authoritative and this is only a floor. + */ +export const SUPPORT_CONTENT_DEFAULTS: SupportDocPayloadMap = { + CONTACT: { + email: "support@edrfreight.com", + phone: "+251 11 000 0000", + office: "Addis Ababa, Ethiopia", + hours: "Monday – Saturday, 8:30 AM – 5:30 PM (EAT)", + }, + + HELP: { + title: "Help & Support", + subtitle: + "Get answers fast — watch the walkthrough, browse the common topics, check the FAQ, or reach our team directly.", + sections: [ + { + id: "help-walkthrough", + heading: "Portal walkthrough", + body: "A guided tour of the portal — registering your company, raising a booking against a contract, and settling an invoice.", + media: [ + { + id: "help-walkthrough-video", + kind: "video", + // Ships with the app rather than MinIO, so it is used verbatim. + src: "/assets/edr-portal-guide.webm", + caption: null, + }, + ], + }, + { + id: "help-chat", + heading: "Chat with our team", + body: "Signed-in customers can open a support conversation from the headset button at the bottom right of every portal page. You can send screenshots and documents in the chat, and replies appear there and as a notification.\n\n[Open the portal](/portal)", + media: [], + }, + { + id: "help-contact", + heading: "Contact us", + body: "- **Email** — [{{supportEmail}}](mailto:{{supportEmail}}). Best for document issues and anything needing an attachment.\n- **Phone** — [{{supportPhone}}](tel:{{supportPhoneTel}}). Best for urgent problems with cargo already in transit.\n- **Head office** — {{supportOffice}}. Walk-in support during working hours.\n- **Support hours** — {{supportHours}}. Outside these hours, email us and we reply the next working day.", + media: [], + }, + { + id: "help-topics", + heading: "Common topics", + body: "- **[Account & onboarding](/faq)** — registering your company, uploading your trade licence and TIN, and getting an operational profile approved.\n- **[Contracts](/faq)** — requesting a freight contract, reviewing its terms and signing it with your saved signature and stamp.\n- **[Bookings & tracking](/faq)** — raising a booking against a contract, adding last-mile transport and following the consignment along the corridor.\n- **[Invoices & payments](/faq)** — finding invoices, paying through the bank channels and confirming a payment that has not yet settled.", + media: [], + }, + { + id: "help-checklist", + heading: "What to include when you contact us", + body: "- Your company name and the email you sign in with.\n- The reference of the contract, booking or invoice involved.\n- What you expected to happen and what happened instead.\n- A screenshot of any error message the portal showed.", + media: [], + }, + ], + }, + + FAQ: { + title: "Frequently Asked Questions", + subtitle: + "Answers to the questions customers ask most about registering, booking cargo and settling invoices on EDR Freight.", + groups: [ + { + id: "faq-getting-started", + title: "Getting started", + items: [ + { + id: "faq-open-account", + question: "How do I open an account on EDR Freight?", + answer: + "Sign up with your work email and verify the one-time code we send you. After you set a password, the onboarding wizard collects your company details, trade licence, TIN certificate and the operational services you need (importer, exporter, freight forwarder or transporter). Submit the wizard and our team reviews the application.", + }, + { + id: "faq-approval-time", + question: "How long does account approval take?", + answer: + "Most complete applications are reviewed within two working days. You will see the status on your dashboard, and we email you when a profile is approved or when a document needs to be re-uploaded.", + }, + { + id: "faq-rejected", + question: "My profile was rejected. What now?", + answer: + "The rejection notice states the reason. Open Settings, correct the details or replace the document that was flagged, and re-apply — you do not need to create a new account.", + }, + { + id: "faq-multiple-services", + question: "Can one company hold several operational services?", + answer: + "Yes. A company can hold importer, exporter, freight forwarder and transporter profiles at the same time. Each is approved separately, and the header lets you switch between the ones you hold.", + }, + ], + }, + { + id: "faq-contracts-bookings", + title: "Contracts and bookings", + items: [ + { + id: "faq-contract-vs-booking", + question: "What is the difference between a contract and a booking?", + answer: + "A contract is the commercial agreement covering a cargo movement — route, commodity, volume and rates. A booking is a single shipment executed under that contract. You create the contract once, then raise bookings against it for each consignment.", + }, + { + id: "faq-create-booking", + question: "How do I create a booking?", + answer: + "Open the contract from the Contracts list and choose New Booking. Provide the consignment details, containers or tonnage, and the last-mile requirement if you need one. Bookings can also be started from the Bookings page, which routes you through contract selection first.", + }, + { + id: "faq-sign-contract", + question: "Why do I have to sign a contract before shipping?", + answer: + "The contract document is the binding agreement for the movement. You must scroll to the end, accept the terms, and sign it with your saved signature and stamp before EDR schedules any wagon against it.", + }, + { + id: "faq-signature-setup", + question: "Where do I set up my signature and stamp?", + answer: + "Under Signature & Stamp in the portal. It is saved once and reused for every contract you sign, so you do not have to upload it per document.", + }, + { + id: "faq-change-booking", + question: "Can I change a booking after submitting it?", + answer: + "You can edit a booking while it is still pending review. Once EDR has confirmed it and allocated capacity, changes go through our operations team — contact support with the booking reference.", + }, + { + id: "faq-track-consignment", + question: "How do I track a consignment?", + answer: + "Open the booking and use the tracking panel, which shows the current milestone, the wagon or container assigned, and the timestamps recorded at each corridor point.", + }, + ], + }, + { + id: "faq-invoices-payments", + title: "Invoices and payments", + items: [ + { + id: "faq-find-invoices", + question: "Where do I find my invoices?", + answer: + "The Invoices page lists every invoice raised against your company, with its status, due date and outstanding balance. Open any invoice to see its line items and download a PDF copy.", + }, + { + id: "faq-payment-methods", + question: "Which payment methods are supported?", + answer: + "Payments are made through the integrated bank channels shown at checkout. After you complete the payment on the bank's page you are returned to the portal, and the invoice status updates once the bank confirms the transaction.", + }, + { + id: "faq-payment-not-settled", + question: + "My payment was deducted but the invoice still shows unpaid.", + answer: + "Bank confirmations can lag by a few minutes. Use the Check Payment Status page linked from your receipt; if it still has not settled after an hour, email support with the invoice number and the bank reference and we will reconcile it.", + }, + { + id: "faq-rounding", + question: "Why is my invoice amount rounded?", + answer: + "Some bank channels only accept whole-birr amounts, so invoices routed through them are rounded up to the nearest birr. The rounding is shown on the invoice detail page.", + }, + ], + }, + { + id: "faq-account-security", + title: "Account and security", + items: [ + { + id: "faq-reset-password", + question: "How do I reset my password?", + answer: + "Use Forgot Password on the sign-in page. We email you a reset link that is valid for a limited time. If a member of our staff issued the link, it works the same way even if you are already signed in.", + }, + { + id: "faq-add-colleagues", + question: "Can I add colleagues to my company account?", + answer: + "Yes. Company administrators can invite additional users from Settings. Each user signs in with their own credentials, and actions are recorded against the individual who performed them.", + }, + { + id: "faq-update-company", + question: "How do I update company details after approval?", + answer: + "Edit them in Settings. Changes to regulated fields — trade licence, TIN, legal name — are re-verified by our team before they take effect.", + }, + ], + }, + ], + footer: { + heading: "Still need a hand?", + body: "Our team is on {{supportEmail}} and {{supportPhone}}, or you can start a chat from the support button inside the portal.", + ctaLabel: "Go to Help & Support", + ctaTo: "/help", + }, + }, + + PRIVACY: { + title: "Privacy Policy", + subtitle: + "How EDR Freight collects, uses, shares and protects the information you provide when you use the platform.", + lastUpdated: "6 August 2026", + sections: [ + { + id: "privacy-1", + heading: "1. Introduction", + body: 'The Ethio-Djibouti Standard Gauge Rail Share Company ("EDR", "we", "us") operates the EDR Freight platform, which lets customers register their business, agree freight contracts, raise bookings, track consignments and settle invoices online.\n\nThis policy explains what personal and business information we collect through the platform, why we collect it, how long we keep it and what rights you have over it. It applies to the EDR Freight customer portal and the services reached through it.', + }, + { + id: "privacy-2", + heading: "2. Information we collect", + body: "We collect information you give us, information generated by your use of the platform, and information we receive from the regulators and financial institutions we work with.\n\n- Account details — name, work email address, phone number and the credentials used to sign in.\n- Company and compliance records — legal name, trade licence, TIN certificate, VAT registration, ownership and manager details, and the operational services you apply for.\n- Identity verification data — where you verify through a national identity service, the verification result and the attributes that service returns to us.\n- Operational data — contracts, bookings, consignment and cargo details, container and wagon assignments, tracking events and delivery confirmations.\n- Financial data — invoices, payment references, transaction status and settlement confirmations received from banks. We do not store your card numbers or online banking credentials.\n- Support data — the messages and files you send us through the in-app support chat or by email.\n- Technical data — IP address, device and browser information, and event logs generated when you use the platform.", + }, + { + id: "privacy-3", + heading: "3. How we use your information", + body: "- To create and administer your account and verify that your company is entitled to the services it applies for.\n- To perform the freight contracts and bookings you place, including allocating capacity and coordinating rail and last-mile movements.\n- To issue invoices, process payments and keep the accounting records the law requires us to keep.\n- To provide customer support and respond to the questions and complaints you raise.\n- To keep the platform secure, detect misuse and investigate incidents.\n- To meet our legal, tax, customs and regulatory obligations in Ethiopia and Djibouti.\n- To improve the platform — measuring which features are used and where users encounter errors, using aggregated and pseudonymised data wherever that is sufficient.", + }, + { + id: "privacy-4", + heading: "4. Legal basis for processing", + body: "We process your information because it is necessary to perform the contract between you and EDR, because we have a legal obligation to do so (customs, tax and transport regulation), or because we have a legitimate interest in operating and securing the platform. Where we rely on your consent — for example, optional marketing messages — you can withdraw it at any time.", + }, + { + id: "privacy-5", + heading: "5. Sharing your information", + body: "We do not sell your information. We share it only where it is necessary to deliver the service or where the law requires it.\n\n- Government and regulatory bodies — customs, revenue and transport authorities in Ethiopia and Djibouti, to the extent required for the movement of your cargo.\n- Ports, terminals and last-mile transporters involved in executing your bookings.\n- Banks and payment providers, to initiate and reconcile the payments you make.\n- Technology suppliers who host and maintain the platform on our behalf, under contracts that restrict them to processing data on our instructions.\n- Courts, law enforcement and other authorities where we are legally compelled to disclose.", + }, + { + id: "privacy-6", + heading: "6. International transfers", + body: "Cross-border freight inherently involves parties in more than one country, so consignment and clearance information is shared with counterparties and authorities in Djibouti as well as Ethiopia. Where we transfer information outside Ethiopia, we do so only as far as the movement requires or the law permits, and we require recipients to protect it to a comparable standard.", + }, + { + id: "privacy-7", + heading: "7. Data retention", + body: "We keep account and company records for as long as your account is active. Contract, booking, customs and financial records are kept for the period required by Ethiopian commercial, tax and customs law after the relevant transaction, because we are obliged to be able to produce them. Support conversations and technical logs are kept for a shorter period, sufficient to resolve disputes and investigate security incidents.", + }, + { + id: "privacy-8", + heading: "8. Security", + body: "Access to the platform requires authentication, and staff access to customer records is limited to what each role needs. Data is transmitted over encrypted connections and stored on systems protected by access controls and logging. No system is perfectly secure, so please keep your credentials confidential and tell us immediately if you believe your account has been compromised.", + }, + { + id: "privacy-9", + heading: "9. Your rights", + body: "Subject to Ethiopian law, you may ask us to give you a copy of the personal information we hold about you, correct it if it is inaccurate, restrict or object to certain processing, or delete it where we are not required to keep it. Requests are handled through the contact details below; we may need to verify your identity before acting.", + }, + { + id: "privacy-10", + heading: "10. Cookies and similar technologies", + body: "The platform uses cookies and browser storage to keep you signed in, remember your interface preferences and measure how the product is used so we can fix problems. Essential cookies cannot be turned off without breaking sign-in. You can clear or block the rest through your browser settings.", + }, + { + id: "privacy-11", + heading: "11. Children", + body: "The platform is a business service and is not directed at children. We do not knowingly collect information from anyone under 18.", + }, + { + id: "privacy-12", + heading: "12. Changes to this policy", + body: "We may update this policy as the platform and the law change. Material changes are announced in the portal before they take effect, and the date at the top of this page always reflects the current version.", + }, + { + id: "privacy-13", + heading: "13. Contact us", + body: "Questions about this policy or about how we handle your information can be sent to {{supportEmail}}, called in on {{supportPhone}}, or addressed to our head office in {{supportOffice}}.", + }, + ], + }, + + TERMS: { + title: "Terms of Service", + subtitle: + "The terms on which EDR provides the EDR Freight platform and the freight services you request through it.", + lastUpdated: "6 August 2026", + sections: [ + { + id: "terms-1", + heading: "1. These terms", + body: 'These terms govern your use of the EDR Freight platform operated by the Ethio-Djibouti Standard Gauge Rail Share Company ("EDR"). By creating an account or using the platform, the company you represent agrees to them.\n\nThe platform is the channel through which you register, request and manage freight services. The commercial terms of each movement — routes, rates, volumes and payment terms — are set out in the freight contract you sign in the platform. Where a signed contract and these terms conflict, the signed contract governs that movement.', + }, + { + id: "terms-2", + heading: "2. Eligibility and accounts", + body: "- The platform is for registered businesses. You confirm that you are authorised to act for the company you register and to bind it to these terms.\n- The information and documents you submit — trade licence, TIN, VAT registration, ownership details — must be accurate, current and genuine.\n- Accounts and operational profiles are activated only after EDR has reviewed and approved them, and approval may be refused or withdrawn.\n- You are responsible for keeping credentials confidential and for everything done under your account. Tell us at once if you suspect unauthorised use.", + }, + { + id: "terms-3", + heading: "3. Contracts and bookings", + body: "- A freight contract takes effect when it is signed in the platform by you and countersigned by EDR.\n- A booking is a request for a specific movement under a contract. It becomes binding when EDR confirms it and allocates capacity — submission alone does not reserve a wagon or container.\n- You are responsible for the accuracy of consignment data: commodity description, weight, dimensions, container numbers, hazardous classification and consignee details.\n- Capacity is finite. EDR may decline, defer or reschedule a booking where capacity, safety, operating conditions or regulatory direction require it.", + }, + { + id: "terms-4", + heading: "4. Cargo, documents and compliance", + body: "- You must obtain and provide every permit, customs declaration and clearance document the movement requires, and you warrant that the cargo may lawfully be carried.\n- Prohibited and restricted goods may not be tendered without EDR's prior written agreement and any licence the law requires.\n- Cargo must be packed, secured and, where applicable, labelled to the standard the mode of carriage requires. EDR may inspect, refuse or offload cargo that is misdeclared or unsafe.\n- You are liable for fines, demurrage, storage charges and losses arising from misdeclared cargo, missing documents or delays attributable to you.", + }, + { + id: "terms-5", + heading: "5. Rates, invoicing and payment", + body: "- Charges are calculated from the rates in your contract, the tariffs published in the platform, and any accessorial services actually rendered.\n- Invoices are issued in the platform and are payable by the due date shown on them, through the payment channels the platform offers.\n- Payment is confirmed when the funds are confirmed by the bank, not when payment is initiated.\n- Overdue amounts may attract interest and may result in suspension of new bookings or of the account until the balance is cleared.\n- Taxes and statutory duties are your responsibility unless the contract expressly says otherwise.", + }, + { + id: "terms-6", + heading: "6. Delivery, delay and liability", + body: "Transit times shown in the platform are estimates based on planned schedules. They are not guarantees, and EDR is not liable for indirect or consequential loss, loss of profit or loss of market arising from delay.\n\nEDR's liability for loss of or damage to cargo is limited to the extent set out in the applicable freight contract and in the transport law governing the carriage. Claims must be notified in writing within the period the contract specifies; late claims may be rejected.\n\nNeither party is liable for failure to perform caused by events beyond its reasonable control, including natural disasters, industrial action, civil unrest, infrastructure failure, or acts of government and regulatory authorities.", + }, + { + id: "terms-7", + heading: "7. Acceptable use of the platform", + body: "- Use the platform only for its intended purpose and in accordance with applicable law.\n- Do not attempt to gain unauthorised access, probe or disrupt the service, or interfere with other customers' data.\n- Do not scrape, resell or redistribute platform content, rates or data without written permission.\n- Do not upload malware or content that infringes the rights of others.", + }, + { + id: "terms-8", + heading: "8. Electronic signatures and records", + body: "You agree that contracts signed in the platform using your stored signature and stamp are validly executed, that the records the platform keeps of those signatures are admissible evidence of them, and that they carry the same effect as signatures on paper.", + }, + { + id: "terms-9", + heading: "9. Availability and changes to the service", + body: "We aim to keep the platform available, but it may be interrupted for maintenance, upgrades or reasons outside our control. We may add, change or withdraw features. Where a change materially affects how you use the platform, we will give reasonable notice in the portal.", + }, + { + id: "terms-10", + heading: "10. Suspension and termination", + body: "We may suspend or terminate access where these terms are breached, where documents prove to be false, where amounts remain unpaid, or where the law or a regulator requires it. You may stop using the platform at any time. Termination does not affect obligations already incurred — cargo in transit, invoices outstanding, or records we are required to retain.", + }, + { + id: "terms-11", + heading: "11. Intellectual property", + body: "The platform, its software, design and content belong to EDR or its licensors. You are granted a non-exclusive, non-transferable right to use it for your own freight operations. Your commercial and consignment data remains yours; you grant us the right to process it as needed to deliver the service and as described in the Privacy Policy.", + }, + { + id: "terms-12", + heading: "12. Confidentiality and data protection", + body: "Each party will keep the other's non-public commercial information confidential and use it only for the purposes of the services. Our handling of personal information is described in the Privacy Policy, which forms part of these terms.", + }, + { + id: "terms-13", + heading: "13. Governing law and disputes", + body: "These terms are governed by the laws of the Federal Democratic Republic of Ethiopia. The parties will first attempt to resolve any dispute amicably; failing that, the dispute is subject to the jurisdiction of the competent courts of Ethiopia, without prejudice to any arbitration clause agreed in a specific freight contract.", + }, + { + id: "terms-14", + heading: "14. Changes to these terms", + body: "We may update these terms as the service and the law change. Updates are published here and announced in the portal. Continuing to use the platform after an update takes effect means you accept the revised terms.", + }, + { + id: "terms-15", + heading: "15. Contact", + body: "For questions about these terms, write to {{supportEmail}} or call {{supportPhone}}.", + }, + ], + }, +}; diff --git a/packages/types/src/freight/portal-content.ts b/packages/types/src/freight/portal-content.ts new file mode 100644 index 000000000..38af9e41e --- /dev/null +++ b/packages/types/src/freight/portal-content.ts @@ -0,0 +1,206 @@ +/** + * Customer-facing copy for the freight portal's public pages — /help, /faq, + * /terms and /privacy. Edited in the backoffice, served to the portal by one + * public endpoint, and versioned so a bad edit can be rolled back. + * + * Body copy is **markdown**. It is rendered with `react-markdown` and no + * `rehype-raw`, so raw HTML inside it is inert — the fields that actually need + * validating are the structured ones that land in `href`/`src` attributes. + */ + +/** One editable document. Each is a row in `freight.support_documents`. */ +export const SUPPORT_DOC_SLUGS = [ + "CONTACT", + "HELP", + "FAQ", + "PRIVACY", + "TERMS", +] as const; + +export type SupportDocSlug = (typeof SUPPORT_DOC_SLUGS)[number]; + +/** MinIO key prefix every uploaded help attachment is stored under. */ +export const SUPPORT_MEDIA_PREFIX = "support-content/"; + +/** + * Marks a MinIO object reference inside markdown, e.g. + * `![Wagon](minio:support-content/abc.png)`. + * + * Stored copy always holds the *key*, never a signed URL: a presigned URL + * expires, so writing one into the saved markdown would silently rot every + * embedded image a few hours later. The API swaps these for freshly signed + * URLs on each read instead. + */ +export const PORTAL_MEDIA_URI_SCHEME = "minio:"; + +/** 50 MB — walkthrough videos are the large case. */ +export const SUPPORT_MEDIA_MAX_BYTES = 50 * 1024 * 1024; + +export type PortalMediaKind = "image" | "video"; + +/** An image or video attached to a help section. */ +export interface PortalMedia { + id: string; + kind: PortalMediaKind; + /** + * Stored: a MinIO object key, a same-origin `/path`, or an `https://` URL. + * Served: the same value with MinIO keys replaced by a fresh presigned URL — + * the API rewrites this field in place, so the portal just renders it. + */ + src: string; + caption?: string | null; +} + +/** + * Placeholders usable inside any markdown or link field. They are substituted + * against the CONTACT document when the portal reads the bundle, which is what + * keeps the support address in the privacy/terms prose from drifting out of + * sync with the contact cards. + * + * `supportPhoneTel` is the whitespace-stripped phone, for `tel:` hrefs. + */ +export const PORTAL_CONTENT_VARS = [ + "supportEmail", + "supportPhone", + "supportPhoneTel", + "supportOffice", + "supportHours", +] as const; + +export type PortalContentVar = (typeof PORTAL_CONTENT_VARS)[number]; + +/** Slug `CONTACT`. The one place support contact details are edited. */ +export interface PortalSupportContact { + email: string; + phone: string; + office: string; + hours: string; +} + +/** One numbered section of a legal document. `body` is markdown. */ +export interface PortalDocSection { + /** Stable per-section id — the React key, since headings can collide. */ + id: string; + heading: string; + body: string; +} + +/** Slugs `PRIVACY` and `TERMS` — same shape, separate documents. */ +export interface PortalLegalContent { + title: string; + subtitle: string; + /** Free text, e.g. "6 August 2026". Shown as "Last updated …". */ + lastUpdated: string; + sections: PortalDocSection[]; +} + +export interface PortalFaqItem { + id: string; + question: string; + /** Markdown. */ + answer: string; +} + +export interface PortalFaqGroup { + id: string; + title: string; + items: PortalFaqItem[]; +} + +/** A call-to-action card closing a page. `body` is markdown. */ +export interface PortalCtaCard { + heading: string; + body: string; + ctaLabel: string; + /** In-app route (`/help`) or absolute URL. */ + ctaTo: string; +} + +/** Slug `FAQ`. */ +export interface PortalFaqContent { + title: string; + subtitle: string; + groups: PortalFaqGroup[]; + /** The "Still need a hand?" card. Null hides it. */ + footer: PortalCtaCard | null; +} + +/** + * One free-form block of the help page: a heading, a full markdown body, and + * any number of attached images or videos. + * + * Deliberately not a fixed set of typed blocks (video / channels / topics / + * checklist, as this once was). The help page is the one document whose shape + * genuinely changes with what support needs to explain that quarter, so it is + * built rather than filled in — add, reorder and delete sections freely. + */ +export interface PortalHelpSection { + id: string; + heading: string; + /** + * Markdown. Embedded images reference uploads as + * `![alt](minio:support-content/)` — see {@link PORTAL_MEDIA_URI_SCHEME}. + */ + body: string; + /** Rendered under the body, in order. */ + media: PortalMedia[]; +} + +/** Slug `HELP`. */ +export interface PortalHelpContent { + title: string; + subtitle: string; + sections: PortalHelpSection[]; +} + +/** Payload shape per slug — the jsonb column's type, keyed by document. */ +export interface SupportDocPayloadMap { + CONTACT: PortalSupportContact; + HELP: PortalHelpContent; + FAQ: PortalFaqContent; + PRIVACY: PortalLegalContent; + TERMS: PortalLegalContent; +} + +export type SupportDocPayload = SupportDocPayloadMap[SupportDocSlug]; + +/** What `GET /api/support-content` returns — everything the portal needs. */ +export interface PortalContentBundle { + contact: PortalSupportContact; + help: PortalHelpContent; + faq: PortalFaqContent; + privacy: PortalLegalContent; + terms: PortalLegalContent; +} + +// ── Backoffice (staff) projections ───────────────────────────────────────── + +/** A document row without its payload — the admin list. */ +export interface SupportDocumentSummary { + id: string; + slug: SupportDocSlug; + version: number; + updatedAt: string; + updatedById: string | null; +} + +export interface SupportDocumentDetail< + S extends SupportDocSlug = SupportDocSlug, +> extends SupportDocumentSummary { + slug: S; + payload: SupportDocPayloadMap[S]; +} + +/** A history row without its payload — the version list. */ +export interface SupportDocVersionSummary { + id: string; + version: number; + actorId: string | null; + note: string | null; + createdAt: string; +} + +/** A history row with its payload — fetched when a version is previewed. */ +export interface SupportDocVersionDetail extends SupportDocVersionSummary { + payload: SupportDocPayload; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8e1d47c20..68573264b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -250,6 +250,9 @@ importers: '@mantine/hooks': specifier: ^9.3.0 version: 9.3.0(react@19.2.6) + '@mdxeditor/editor': + specifier: ^4.2.0 + version: 4.2.0(@codemirror/language@6.12.4)(@lezer/highlight@1.2.3)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.9.3)(yjs@13.6.32) '@posthog/react': specifier: ^1.10.3 version: 1.10.3(@types/react@18.3.31)(posthog-js@1.400.1)(react@19.2.6) @@ -469,6 +472,9 @@ importers: react-intersection-observer: specifier: ^9.16.0 version: 9.16.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + react-markdown: + specifier: ^9.1.0 + version: 9.1.0(@types/react@18.3.31)(react@19.2.6) react-pdf: specifier: ^10.4.1 version: 10.4.1(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) @@ -598,7 +604,7 @@ importers: version: 5.101.0(react@19.2.6) '@tria-plc/iamui': specifier: file:../../../local-packages/tria-plc-iamui-0.1.1.tgz - version: file:local-packages/tria-plc-iamui-0.1.1.tgz(0ce39b7e349029277dcd938d06eeb0f7) + version: file:local-packages/tria-plc-iamui-0.1.1.tgz(a5d0bdee55164ae56064fb273b530e00) '@vis.gl/react-google-maps': specifier: ^1.8.3 version: 1.8.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6) @@ -635,6 +641,9 @@ importers: react-hot-toast: specifier: ^2.6.0 version: 2.6.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + react-markdown: + specifier: ^9.1.0 + version: 9.1.0(@types/react@18.3.31)(react@19.2.6) react-phone-number-input: specifier: ^3.4.17 version: 3.4.17(react-dom@19.2.6(react@19.2.6))(react@19.2.6) @@ -1744,6 +1753,99 @@ packages: '@borewit/text-codec@0.2.2': resolution: {integrity: sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ==} + '@codemirror/autocomplete@6.20.3': + resolution: {integrity: sha512-tlosUqb+3BbxCxZdu4tKeRghPFC+QM7q4X5YhKV2eCmPG+1r2F3f4AaSz5sCrFqUtX4Jh20VFTKecl16MgiV9g==} + + '@codemirror/commands@6.10.4': + resolution: {integrity: sha512-Ryk9y9T0FFVF0cUGhAknveAyUOl/A1qReTFi+qPKtOh2Z9F4AUBz3XOrYD4ZEgZirdugVzHvd/2/Wcwy5OliTg==} + + '@codemirror/lang-angular@0.1.4': + resolution: {integrity: sha512-oap+gsltb/fzdlTQWD6BFF4bSLKcDnlxDsLdePiJpCVNKWXSTAbiiQeYI3UmES+BLAdkmIC1WjyztC1pi/bX4g==} + + '@codemirror/lang-cpp@6.0.3': + resolution: {integrity: sha512-URM26M3vunFFn9/sm6rzqrBzDgfWuDixp85uTY49wKudToc2jTHUrKIGGKs+QWND+YLofNNZpxcNGRynFJfvgA==} + + '@codemirror/lang-css@6.3.1': + resolution: {integrity: sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==} + + '@codemirror/lang-go@6.0.1': + resolution: {integrity: sha512-7fNvbyNylvqCphW9HD6WFnRpcDjr+KXX/FgqXy5H5ZS0eC5edDljukm/yNgYkwTsgp2busdod50AOTIy6Jikfg==} + + '@codemirror/lang-html@6.4.12': + resolution: {integrity: sha512-pw2ReWKUqSkbvh76RAT4NYxiogRu+PWkR2ukAwO9uOgrm8uipkzjtKKtNpyeAQwHOqxEeSvAXZ6vr3AfyB9y/w==} + + '@codemirror/lang-java@6.0.2': + resolution: {integrity: sha512-m5Nt1mQ/cznJY7tMfQTJchmrjdjQ71IDs+55d1GAa8DGaB8JXWsVCkVT284C3RTASaY43YknrK2X3hPO/J3MOQ==} + + '@codemirror/lang-javascript@6.2.5': + resolution: {integrity: sha512-zD4e5mS+50htS7F+TYjBPsiIFGanfVqg4HyUz6WNFikgOPf2BgKlx+TQedI1w6n/IqRBVBbBWmGFdLB/7uxO4A==} + + '@codemirror/lang-jinja@6.0.1': + resolution: {integrity: sha512-P5kyHLObzjtbGj16h+hyvZTxJhSjBEeSx4wMjbnAf3b0uwTy2+F0zGjMZL4PQOm/mh2eGZ5xUDVZXgwP783Nsw==} + + '@codemirror/lang-json@6.0.2': + resolution: {integrity: sha512-x2OtO+AvwEHrEwR0FyyPtfDUiloG3rnVTSZV1W8UteaLL8/MajQd8DpvUb2YVzC+/T18aSDv0H9mu+xw0EStoQ==} + + '@codemirror/lang-less@6.0.2': + resolution: {integrity: sha512-EYdQTG22V+KUUk8Qq582g7FMnCZeEHsyuOJisHRft/mQ+ZSZ2w51NupvDUHiqtsOy7It5cHLPGfHQLpMh9bqpQ==} + + '@codemirror/lang-liquid@6.3.2': + resolution: {integrity: sha512-6PDVU3ZnfeYyz1at1E/ttorErZvZFXXt1OPhtfe1EZJ2V2iDFa0CwPqPgG5F7NXN0yONGoBogKmFAafKTqlwIw==} + + '@codemirror/lang-markdown@6.5.2': + resolution: {integrity: sha512-AwBOdkWYuA//WcM0xO5PfHPUcmz/O2i5o0Nsg1U69SII/loCJlFI1Romd9xp2HYb1kYJRGZotyqRghuHH5n8Kw==} + + '@codemirror/lang-php@6.0.2': + resolution: {integrity: sha512-ZKy2v1n8Fc8oEXj0Th0PUMXzQJ0AIR6TaZU+PbDHExFwdu+guzOA4jmCHS1Nz4vbFezwD7LyBdDnddSJeScMCA==} + + '@codemirror/lang-python@6.2.1': + resolution: {integrity: sha512-IRjC8RUBhn9mGR9ywecNhB51yePWCGgvHfY1lWN/Mrp3cKuHr0isDKia+9HnvhiWNnMpbGhWrkhuWOc09exRyw==} + + '@codemirror/lang-rust@6.0.2': + resolution: {integrity: sha512-EZaGjCUegtiU7kSMvOfEZpaCReowEf3yNidYu7+vfuGTm9ow4mthAparY5hisJqOHmJowVH3Upu+eJlUji6qqA==} + + '@codemirror/lang-sass@6.0.2': + resolution: {integrity: sha512-l/bdzIABvnTo1nzdY6U+kPAC51czYQcOErfzQ9zSm9D8GmNPD0WTW8st/CJwBTPLO8jlrbyvlSEcN20dc4iL0Q==} + + '@codemirror/lang-sql@6.10.0': + resolution: {integrity: sha512-6ayPkEd/yRw0XKBx5uAiToSgGECo/GY2NoJIHXIIQh1EVwLuKoU8BP/qK0qH5NLXAbtJRLuT73hx7P9X34iO4w==} + + '@codemirror/lang-vue@0.1.3': + resolution: {integrity: sha512-QSKdtYTDRhEHCfo5zOShzxCmqKJvgGrZwDQSdbvCRJ5pRLWBS7pD/8e/tH44aVQT6FKm0t6RVNoSUWHOI5vNug==} + + '@codemirror/lang-wast@6.0.2': + resolution: {integrity: sha512-Imi2KTpVGm7TKuUkqyJ5NRmeFWF7aMpNiwHnLQe0x9kmrxElndyH0K6H/gXtWwY6UshMRAhpENsgfpSwsgmC6Q==} + + '@codemirror/lang-xml@6.1.0': + resolution: {integrity: sha512-3z0blhicHLfwi2UgkZYRPioSgVTo9PV5GP5ducFH6FaHy0IAJRg+ixj5gTR1gnT/glAIC8xv4w2VL1LoZfs+Jg==} + + '@codemirror/lang-yaml@6.1.3': + resolution: {integrity: sha512-AZ8DJBuXGVHybpBQhmZtgew5//4hv3tdkXnr3vDmOUMJRuB6vn/uuwtmTOTlqEaQFg3hQSVeA90NmvIQyUV6FQ==} + + '@codemirror/language-data@6.5.2': + resolution: {integrity: sha512-CPkWBKrNS8stYbEU5kwBwTf3JB1kghlbh4FSAwzGW2TEscdeHHH4FGysREW86Mqnj3Qn09s0/6Ea/TutmoTobg==} + + '@codemirror/language@6.12.4': + resolution: {integrity: sha512-1q4PaT+o6PbgpkJt4Q8Fv5XJxTy4FUZ4MWETtyiDw3J0Pyr9E2vqcKL+k9wcvjNTIsauxvE7OfmWj3FRPHQ76A==} + + '@codemirror/legacy-modes@6.5.3': + resolution: {integrity: sha512-xCsmIzH78MyWkib9jlPaaun57XNkfbMIhagfaZVd0iLTqlpw3jXaIcbZm72MTmmn64eTZpBVNjbyYh+QXnxRsg==} + + '@codemirror/lint@6.9.7': + resolution: {integrity: sha512-28/+iWLYxKxsvGYhSYL7zaCZqLz5+FFFDq9tVsvGv9kv8RY4fFAchJ5WX9M3YrrRlTIsECjsXPqeNgnSmNP2dg==} + + '@codemirror/merge@6.12.2': + resolution: {integrity: sha512-V8JvyAPjHbPupqP7BeMcsdsYCbyPij74jxIbaIJDORI+VZzW44zFmon8bF+oxGWvOKhcRmkiUMXd8MxHr3YA2w==} + + '@codemirror/search@6.7.1': + resolution: {integrity: sha512-uMe5UO6PamJtSHrXhhHOzSX3ReWtiJrva6GnPMwSOrZtiExb5X5eExhr2OUZQVvdxPsKpY3Ro2mFbQadpPWmHA==} + + '@codemirror/state@6.7.1': + resolution: {integrity: sha512-9QzNDgE4EYDnAHfrTlR2lwiPciiOymLtwKK+8yHQzCc7GXhAP9xdEbEJFy2IWB1j9UGUl9BsgMmTo/ImA02T7A==} + + '@codemirror/view@6.43.8': + resolution: {integrity: sha512-qtItTDssZ/5GFfi94hrILu9j/VUeFPDPkhovEfmWFj2ipTxnzPB8DdHgfbb8HYTzLTYhrndKmyQxXUz/PDLenw==} + '@colors/colors@1.5.0': resolution: {integrity: sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==} engines: {node: '>=0.1.90'} @@ -2551,6 +2653,249 @@ packages: '@jridgewell/trace-mapping@0.3.9': resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} + '@lexical/a11y@0.48.0': + resolution: {integrity: sha512-18W4ehyipkUim4YVoDZitoH63Om3j6iCN4c84zdqE9RgkWf/PE4rvI/8BHTm6Ni7NkVE14nimXgkpaP5ok15zA==} + peerDependencies: + typescript: '>=5.2' + peerDependenciesMeta: + typescript: + optional: true + + '@lexical/clipboard@0.48.0': + resolution: {integrity: sha512-xO2trk6+yBl8XXa/VNe20kXczmPxFoWtUHjidbBLEtlGBj+mo63pJj6H5o/WlZsoMKmIOJxwxsn2ejrS8G0/7A==} + peerDependencies: + typescript: '>=5.2' + peerDependenciesMeta: + typescript: + optional: true + + '@lexical/code-core@0.48.0': + resolution: {integrity: sha512-+O1Ge06AuSo6+r8R2Xk6SkWG07H5/e4K/Scw9aqCM/BRjITxgvFTHTNvgQbaobCsP0uBJiwW1+ZGeWAWcBCY4w==} + peerDependencies: + typescript: '>=5.2' + peerDependenciesMeta: + typescript: + optional: true + + '@lexical/devtools-core@0.48.0': + resolution: {integrity: sha512-4kvKWW6ebgQnJNLXPLmw7dqgSChvzYIBNYtfuR6c48Sw+V/QXQTWqfIUbCIe5X4uG8EEXd5O/udXaJx7GBuP+w==} + peerDependencies: + react: '>=18.x' + react-dom: '>=18.x' + typescript: '>=5.2' + peerDependenciesMeta: + typescript: + optional: true + + '@lexical/dragon@0.48.0': + resolution: {integrity: sha512-uPuu7fVca9vmL/Oz30CRZ7FIPIodwMrTgNsRmV8jE6Qd6a7RNiTW7r3+EhbhIdkLb/sjcWsYyOMyUR1TJAB0wQ==} + peerDependencies: + typescript: '>=5.2' + peerDependenciesMeta: + typescript: + optional: true + + '@lexical/extension@0.48.0': + resolution: {integrity: sha512-4uBObgz84mVbQWiumndmIhkuJL0ojHiMwFSvSUM/FCo1YMVIZvpI56blI0y+2Vix/oLui9EgVQSJjjWV4NAszw==} + peerDependencies: + typescript: '>=5.2' + peerDependenciesMeta: + typescript: + optional: true + + '@lexical/hashtag@0.48.0': + resolution: {integrity: sha512-hPQtdnbVoNAFsmfnCGfgY7mDbvk6mIznlCRmIR7tLeQKXqz/0Tb6eH3W24EESk9hAF8wFUYNKWE1/Kb3Hl2vEQ==} + peerDependencies: + typescript: '>=5.2' + peerDependenciesMeta: + typescript: + optional: true + + '@lexical/history@0.48.0': + resolution: {integrity: sha512-NllvUfO+u3mfi5uC8k2CodwdzeeopFFVtMZ/NMifzFbZFysdWi9m9mqfO46NrEA1rSFOydyefv6oMzC8ULXInA==} + peerDependencies: + typescript: '>=5.2' + peerDependenciesMeta: + typescript: + optional: true + + '@lexical/html@0.48.0': + resolution: {integrity: sha512-uBxlgKl4YgSNEgHJSshdBqtGDzruWdx1ewop+u6faT67qHUdP3P0cUXIrG6NToDWvsL6fzCstAbN76PMER1Pnw==} + peerDependencies: + typescript: '>=5.2' + peerDependenciesMeta: + typescript: + optional: true + + '@lexical/internal@0.48.0': + resolution: {integrity: sha512-sRwg53K7N0ZQ7KNAvcCY38LSwGizbXP1zlR1lIojZp0GoqHWNvR+vL49t1wYXu1nXx3Osf4ilHHm+aGcwq5hTw==} + peerDependencies: + typescript: '>=5.2' + peerDependenciesMeta: + typescript: + optional: true + + '@lexical/link@0.48.0': + resolution: {integrity: sha512-E0UDmNLUXs/yMCnnE7hbFO0CvhWghmqa+qqPksFfzLkpMHdPpdS1yg59YEbYoNHLi/DXIu4cFRvpHIEuooxwNg==} + peerDependencies: + typescript: '>=5.2' + peerDependenciesMeta: + typescript: + optional: true + + '@lexical/list@0.48.0': + resolution: {integrity: sha512-9Qe/Vur44v9F9enj55SUzf79FVsijcGOQug7SpiIU8ekLr7JNzcilKYBYcZ6etGEo7bqQHsYMHXeJcSBbCI2zA==} + peerDependencies: + typescript: '>=5.2' + peerDependenciesMeta: + typescript: + optional: true + + '@lexical/mark@0.48.0': + resolution: {integrity: sha512-DTtypWvnYSXyNUxEmsUnh4y0xXkmdk8Y72EZl8WDHcCwjqaLJlUakH7p/TuSJczs3uVSaoJzu0yh7oGkP1Vsvw==} + peerDependencies: + typescript: '>=5.2' + peerDependenciesMeta: + typescript: + optional: true + + '@lexical/markdown@0.48.0': + resolution: {integrity: sha512-1WasBenW4bEsa5xtnycVo8G2hcxIqyYLn3/r98yD2Y+54ZyeFuIQfOeJYhIgCh4YkKpfqyrFwkMQwAOsQDqZjA==} + peerDependencies: + typescript: '>=5.2' + peerDependenciesMeta: + typescript: + optional: true + + '@lexical/overflow@0.48.0': + resolution: {integrity: sha512-1YEvMz2tW3EbwrON9mjrkjMVl/vdTcPYSn9P1j6mf5gj0LOoLDNI4TbvSD4SViy+TDghxNdG8YdCISyU2b4YKA==} + peerDependencies: + typescript: '>=5.2' + peerDependenciesMeta: + typescript: + optional: true + + '@lexical/plain-text@0.48.0': + resolution: {integrity: sha512-q4f/4VZKVgCrIW2FhDFR2RII1BU0ljedPgEmJ8XQn1zc+JOFPom8Lp0lV5nyEvZaAJYwM/TfoJ9g2V7ESFKznA==} + peerDependencies: + typescript: '>=5.2' + peerDependenciesMeta: + typescript: + optional: true + + '@lexical/react@0.48.0': + resolution: {integrity: sha512-uVh9/QSrbtjLjVbxfJ+sfiMyhUq/rv7H6uBEVDDIw1rkZJSDY1fvf/CX+dyKgwcDFjKQZ8/9i5f9UCVPeQ01hA==} + peerDependencies: + react: '>=18.x' + react-dom: '>=18.x' + typescript: '>=5.2' + yjs: '>=13.5.22' + peerDependenciesMeta: + typescript: + optional: true + yjs: + optional: true + + '@lexical/rich-text@0.48.0': + resolution: {integrity: sha512-QMXFnwCKAQ4yzxvx5FwmANcx3K+NaBkGTxAVD8s8pOKDD/U5rzDS1iIvhH6TLWaFp7VzvLmLB+Sl1Ie/RnkaDQ==} + peerDependencies: + typescript: '>=5.2' + peerDependenciesMeta: + typescript: + optional: true + + '@lexical/selection@0.48.0': + resolution: {integrity: sha512-Uc0wTrEtHcYK6z/aHHjkgH3vX/R4Bf8mO+qH3VbxfSAKYzNYktM0j+ZGdq5kIEL4frnw/9SulNCXlH7xpjuDgA==} + peerDependencies: + typescript: '>=5.2' + peerDependenciesMeta: + typescript: + optional: true + + '@lexical/table@0.48.0': + resolution: {integrity: sha512-t9Mz7q6ODLUz0lG5Xn9EY/5YiVpTHCqlPQP4EtFXlnBQT3DuKeDS3cC0Cn8sGSZc11YY5OLDfWpB64Frs9BL3g==} + peerDependencies: + typescript: '>=5.2' + peerDependenciesMeta: + typescript: + optional: true + + '@lexical/text@0.48.0': + resolution: {integrity: sha512-ktTMRbsX4wKxdG2OpZCkrqtt8k9Vg/ZpWdukOQ0r1xPRtCuL1T+q91l7cy2ywIuCfMGYS0aoZGB4LdpUMe/H1g==} + peerDependencies: + typescript: '>=5.2' + peerDependenciesMeta: + typescript: + optional: true + + '@lexical/utils@0.48.0': + resolution: {integrity: sha512-W4k4P+y6jmRfna8+ad4X+iMd5h8es5PC3bUw5tbi7MRApxaaFG/0w+uJiZVSwbT2Q6JnA2xhBaqzPgt/Gn6djg==} + peerDependencies: + typescript: '>=5.2' + peerDependenciesMeta: + typescript: + optional: true + + '@lexical/yjs@0.48.0': + resolution: {integrity: sha512-fFsE8EnPM/2KK9rMJ0z6T+Da5UW5V4P+XiAA03LoHTY5YQ/Oy8Q0i7Wcmocv/B/SpsY2o8g07euZEfudl9MVKA==} + peerDependencies: + typescript: '>=5.2' + yjs: '>=13.5.22' + peerDependenciesMeta: + typescript: + optional: true + + '@lezer/common@1.5.2': + resolution: {integrity: sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ==} + + '@lezer/cpp@1.1.6': + resolution: {integrity: sha512-vh9gWWJOXFVY8HBHK3Twzq8MgwG2iN4GSyzBP9sCGTe37P15x2R14VaBQk0VA0ezTRN1KHYBBsHhvpGZ2Xy/pA==} + + '@lezer/css@1.3.6': + resolution: {integrity: sha512-YJE78Wcg+zX8f10hiHWQ4Az48Qr/c13eId0VtRQYLBpxHDmDeSrXIlkbl+fJGW42rWC/uoUco9mhBZeVWP/A1g==} + + '@lezer/go@1.0.1': + resolution: {integrity: sha512-xToRsYxwsgJNHTgNdStpcvmbVuKxTapV0dM0wey1geMMRc9aggoVyKgzYp41D2/vVOx+Ii4hmE206kvxIXBVXQ==} + + '@lezer/highlight@1.2.3': + resolution: {integrity: sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==} + + '@lezer/html@1.3.13': + resolution: {integrity: sha512-oI7n6NJml729m7pjm9lvLvmXbdoMoi2f+1pwSDJkl9d68zGr7a9Btz8NdHTGQZtW2DA25ybeuv/SyDb9D5tseg==} + + '@lezer/java@1.1.3': + resolution: {integrity: sha512-yHquUfujwg6Yu4Fd1GNHCvidIvJwi/1Xu2DaKl/pfWIA2c1oXkVvawH3NyXhCaFx4OdlYBVX5wvz2f7Aoa/4Xw==} + + '@lezer/javascript@1.5.4': + resolution: {integrity: sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA==} + + '@lezer/json@1.0.3': + resolution: {integrity: sha512-BP9KzdF9Y35PDpv04r0VeSTKDeox5vVr3efE7eBbx3r4s3oNLfunchejZhjArmeieBH+nVOpgIiBJpEAv8ilqQ==} + + '@lezer/lr@1.4.10': + resolution: {integrity: sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A==} + + '@lezer/markdown@1.7.2': + resolution: {integrity: sha512-iTkYvoVcKt3WkeL7qUDyXHONZEwLio4wj8KTNi2dnjQEXBZKMV63BpQrPqfsM+OkvuRbiSTAcycYAsQzLhRNoQ==} + + '@lezer/php@1.0.5': + resolution: {integrity: sha512-W7asp9DhM6q0W6DYNwIkLSKOvxlXRrif+UXBMxzsJUuqmhE7oVU+gS3THO4S/Puh7Xzgm858UNaFi6dxTP8dJA==} + + '@lezer/python@1.1.19': + resolution: {integrity: sha512-MhQIURHRytsNzP/YXnqpYKW6la6voAH3kyplTOOiCdjyFY6cWWGFVmYVdHIPrElqSDf4iCDktQCockB9FxuhzQ==} + + '@lezer/rust@1.0.2': + resolution: {integrity: sha512-Lz5sIPBdF2FUXcWeCu1//ojFAZqzTQNRga0aYv6dYXqJqPfMdCAI0NzajWUd4Xijj1IKJLtjoXRPMvTKWBcqKg==} + + '@lezer/sass@1.1.0': + resolution: {integrity: sha512-3mMGdCTUZ/84ArHOuXWQr37pnf7f+Nw9ycPUeKX+wu19b7pSMcZGLbaXwvD2APMBDOGxPmpK/O6S1v1EvLoqgQ==} + + '@lezer/xml@1.0.6': + resolution: {integrity: sha512-CdDwirL0OEaStFue/66ZmFSeppuL6Dwjlk8qk153mSQwiSH/Dlri4GNymrNWnUmPl2Um7QfV1FO9KFUyX3Twww==} + + '@lezer/yaml@1.0.4': + resolution: {integrity: sha512-2lrrHqxalACEbxIbsjhqGpSW8kWpUKuY6RHgnSAFZa6qK62wvnPxA8hGOwOoDbwHcOFs5M4o27mjGu+P7TvBmw==} + '@lottiefiles/react-lottie-player@3.6.0': resolution: {integrity: sha512-WK5TriLJT93VF3w4IjSVyveiedraZCnDhKzCPhpbeLgQeMi6zufxa3dXNc4HmAFRXq+LULPAy+Idv1rAfkReMA==} peerDependencies: @@ -2636,6 +2981,23 @@ packages: peerDependencies: react: ^18.x || ^19.x + '@marijn/find-cluster-break@1.0.3': + resolution: {integrity: sha512-FY+MKLBoTsLNJF/eLWaOsXGdz6uh3Iu1axjPf6TUq92IYumcTcXWHoS747JARLkcdlJ/Waiaxc5wQfFO8jC6NA==} + + '@mdxeditor/editor@4.2.0': + resolution: {integrity: sha512-S/IPY8AjWTV2v1TGbEy5lsbQ5w+J2M2j2QDeswxP4FpNh2B49OPkpSqHbbX5GbV0YBlhfXJxtzwBJv/sQACufw==} + engines: {node: '>=16'} + peerDependencies: + react: '>= 18 || >= 19' + react-dom: '>= 18 || >= 19' + + '@mdxeditor/gurx@1.2.4': + resolution: {integrity: sha512-9ZykIFYhKaXaaSPCs1cuI+FvYDegJjbKwmA4ASE/zY+hJY6EYqvoye4esiO85CjhOw9aoD/izD/CU78/egVqmg==} + engines: {node: '>=16'} + peerDependencies: + react: '>= 18 || >= 19' + react-dom: '>= 18 || >= 19' + '@microsoft/tsdoc@0.15.1': resolution: {integrity: sha512-4aErSrCR/On/e5G2hDP0wjooqDdauzEbIq8hIkIe5pXV0rtWJZvdCEKL0ykZxex+IxIwBp0eGeV48hQN07dXtw==} @@ -3361,6 +3723,9 @@ packages: '@posthog/types@1.394.0': resolution: {integrity: sha512-ifQ7p8o8hoHErlJmpzCFzHQcuRam0vXk8LBVhBu4BlPYP6S0tog4FSAFItnI/nwN6cHZI0WFQilr7sIqQa7Flg==} + '@preact/signals-core@1.14.4': + resolution: {integrity: sha512-HNB6HYeYKhQbJ1aKl+YRjrS4+QWHLKX6qKoUsfS/m0vqzsVaEBiZiaKbG/e+NKk2ch5ALQr/ihWaMHxiCuuWHA==} + '@prisma/client@6.19.3': resolution: {integrity: sha512-mKq3jQFhjvko5LTJFHGilsuQs+W+T3Gm451NzuTDGQxwCzwXHYnIu2zGkRoW+Exq3Rob7yp2MfzSrdIiZVhrBg==} engines: {node: '>=18.18'} @@ -3396,6 +3761,9 @@ packages: engines: {node: '>=18'} hasBin: true + '@radix-ui/colors@3.0.0': + resolution: {integrity: sha512-FUOsGBkHrYJwCSEtWRCIfQbZG7q1e6DgxCIOe1SUQzDe/7rXXeA47s8yCn6fuTNQAj1Zq4oTFi9Yjp3wzElcxg==} + '@radix-ui/number@1.1.2': resolution: {integrity: sha512-ceTwaxc4I5IOi97DgCotl3pqiyRGvffcc0oOsE2dQYaJOFIDsDt4VWG6xEbg1QePv9QWausCEIppud/tJ1wNig==} @@ -3646,6 +4014,11 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-icons@1.3.2': + resolution: {integrity: sha512-fyQIhGDhzfc9pK2kH6Pl9c4BDJGfMkPqkyIgYDthyNYoNg3wVhoJMMh19WS4Up/1KMPFVpNsT2q3WmXn2N1m6g==} + peerDependencies: + react: ^16.x || ^17.x || ^18.x || ^19.0.0 || ^19.0.0-rc + '@radix-ui/react-id@1.1.2': resolution: {integrity: sha512-orBC88futVpqCmhX1p4cvquNHsELQ+w+vBJnuj3ftETI5bJb0bZn3Tqu3SWN2IOcPycTnMGnhwoermvISt72sA==} peerDependencies: @@ -4804,6 +5177,9 @@ packages: '@types/d3-timer@3.0.2': resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==} + '@types/debug@4.1.13': + resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==} + '@types/dompurify@3.2.0': resolution: {integrity: sha512-Fgg31wv9QbLDA0SpTOXO3MaxySc4DKGLi8sna4/Utjo4r3ZRPdCt4UQee8BWr+Q5z21yifghREPJGYaEOEIACg==} deprecated: This is a stub types definition. dompurify provides its own type definitions, so you do not need this installed. @@ -4814,6 +5190,9 @@ packages: '@types/eslint@9.6.1': resolution: {integrity: sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==} + '@types/estree-jsx@1.0.5': + resolution: {integrity: sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==} + '@types/estree@1.0.9': resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} @@ -4835,6 +5214,9 @@ packages: '@types/graceful-fs@4.1.9': resolution: {integrity: sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ==} + '@types/hast@3.0.5': + resolution: {integrity: sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==} + '@types/hoist-non-react-statics@3.3.7': resolution: {integrity: sha512-PQTyIulDkIDro8P+IHbKCsw7U2xxBYflVzW/FgWdCAePD9xGSidgA76/GeJ6lBKoblyhf9pBY763gbrN+1dI8g==} peerDependencies: @@ -4879,6 +5261,9 @@ packages: '@types/luxon@3.7.1': resolution: {integrity: sha512-H3iskjFIAn5SlJU7OuxUmTEpebK6TKB8rxZShDslBMZJ5u9S//KM1sbdAisiSrqwLQncVjnpi2OK2J51h+4lsg==} + '@types/mdast@4.0.4': + resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} + '@types/methods@1.1.4': resolution: {integrity: sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==} @@ -4991,6 +5376,12 @@ packages: '@types/trusted-types@2.0.7': resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} + '@types/unist@2.0.11': + resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==} + + '@types/unist@3.0.3': + resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} + '@types/use-sync-external-store@0.0.6': resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==} @@ -5814,6 +6205,9 @@ packages: babel-runtime@6.26.0: resolution: {integrity: sha512-ITKNuq2wKlW1fJg9sSW52eepoYgZBggvOAHC0u/CYu/qxQ9EVzThCgR69BnSXLHjy2f7SY5zaQ4yt7H9ZVxY2g==} + bail@2.0.2: + resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==} + balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} @@ -6075,6 +6469,9 @@ packages: caseless@0.12.0: resolution: {integrity: sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==} + ccount@2.0.1: + resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} + cfb@1.2.2: resolution: {integrity: sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==} engines: {node: '>=0.8'} @@ -6105,6 +6502,18 @@ packages: resolution: {integrity: sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==} engines: {node: '>=10'} + character-entities-html4@2.1.0: + resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==} + + character-entities-legacy@3.0.0: + resolution: {integrity: sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==} + + character-entities@2.0.2: + resolution: {integrity: sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==} + + character-reference-invalid@2.0.1: + resolution: {integrity: sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==} + chardet@2.1.1: resolution: {integrity: sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ==} @@ -6227,6 +6636,14 @@ packages: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} + cm6-theme-basic-light@0.2.0: + resolution: {integrity: sha512-1prg2gv44sYfpHscP26uLT/ePrh0mlmVwMSoSd3zYKQ92Ab3jPRLzyCnpyOCQLJbK+YdNs4HvMRqMNYdy4pMhA==} + peerDependencies: + '@codemirror/language': ^6.0.0 + '@codemirror/state': ^6.0.0 + '@codemirror/view': ^6.0.0 + '@lezer/highlight': ^1.0.0 + cmdk@1.1.1: resolution: {integrity: sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==} peerDependencies: @@ -6244,6 +6661,9 @@ packages: resolution: {integrity: sha512-RpAVKQA5T63xEj6/giIbUEtZwJ4UFIc3ZtvEkiaUERylqe8xb5IvqcgOurZLahv93CLKfxcw5YI+DZcUBRyLXA==} engines: {node: '>=0.10.0'} + codemirror@6.0.2: + resolution: {integrity: sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==} + codepage@1.15.0: resolution: {integrity: sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==} engines: {node: '>=0.8'} @@ -6281,6 +6701,9 @@ packages: resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} engines: {node: '>= 0.8'} + comma-separated-tokens@2.0.3: + resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} + commander@11.1.0: resolution: {integrity: sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==} engines: {node: '>=16'} @@ -6322,6 +6745,9 @@ packages: resolution: {integrity: sha512-D3uMHtGc/fcO1Gt1/L7i1e33VOvD4A9hfQLP+6ewd+BvG/gQ84Yh4oftEhAdjSMgBgwGL+jsppT7JYNpo6MHHg==} engines: {node: '>= 10'} + compute-scroll-into-view@2.0.4: + resolution: {integrity: sha512-y/ZA3BGnxoM/QHHQ2Uy49CLtnWPbt4tTPpEEZiEmmiWBFKjej7nEyH8Ryz54jH0MLXflUYA3Er2zUxPSJu5R+g==} + concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} @@ -6467,6 +6893,9 @@ packages: create-require@1.1.1: resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==} + crelt@1.0.7: + resolution: {integrity: sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==} + cron@4.4.0: resolution: {integrity: sha512-fkdfq+b+AHI4cKdhZlppHveI/mgz2qpiYxcm+t5E5TsxX7QrLS1VE0+7GENEk9z0EeGPcpSciGv6ez24duWhwQ==} engines: {node: '>=18.x'} @@ -6658,6 +7087,9 @@ packages: decimal.js@10.6.0: resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + decode-named-character-reference@1.3.0: + resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==} + decode-uri-component@0.2.2: resolution: {integrity: sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ==} engines: {node: '>=0.10'} @@ -6765,6 +7197,9 @@ packages: detect-node-es@1.1.0: resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==} + devlop@1.1.0: + resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} + devtools-protocol@0.0.1608973: resolution: {integrity: sha512-Tpm17fxYzt+J7VrGdc1k8YdRqS3YV7se/M6KeemEqvUbq/n7At1rWVuXMxQgpWkdwSdIEKYbU//Bve+Shm4YNQ==} @@ -6785,6 +7220,10 @@ packages: resolution: {integrity: sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==} engines: {node: '>=0.3.1'} + diff@5.2.2: + resolution: {integrity: sha512-vtcDfH3TOjP8UekytvnHH1o1P4FcUdt4eQ1Y+Abap1tk/OB2MWQvcwS2ClCd1zuIhc3JKOx6p3kod8Vfys3E+A==} + engines: {node: '>=0.3.1'} + diff@8.0.4: resolution: {integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==} engines: {node: '>=0.3.1'} @@ -6846,6 +7285,11 @@ packages: resolution: {integrity: sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==} engines: {node: '>=12'} + downshift@7.6.2: + resolution: {integrity: sha512-iOv+E1Hyt3JDdL9yYcOgW7nZ7GQ2Uz6YbggwXvKUSleetYhU2nXD482Rz6CzvM4lvI1At34BYruKAL4swRGxaA==} + peerDependencies: + react: '>=16.12.0' + dunder-proto@1.0.1: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} @@ -7023,6 +7467,10 @@ packages: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} engines: {node: '>=10'} + escape-string-regexp@5.0.0: + resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==} + engines: {node: '>=12'} + escodegen@2.1.0: resolution: {integrity: sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==} engines: {node: '>=6.0'} @@ -7160,6 +7608,12 @@ packages: resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} engines: {node: '>=4.0'} + estree-util-is-identifier-name@3.0.0: + resolution: {integrity: sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==} + + estree-util-visit@2.0.0: + resolution: {integrity: sha512-m5KgiH85xAhhW8Wta0vShLcUvOsh3LLPI2YVwcbio1l7E09NTLL1EyMZFM1OyWowoH0skScNbhOPl4kcBgzTww==} + estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} @@ -7357,6 +7811,9 @@ packages: fastq@1.20.1: resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} + fault@2.0.1: + resolution: {integrity: sha512-WtySTkS4OKev5JtpHXnib4Gxiurzh5NCGvWrFaZ34m6JehfTUhKZvn9njTfw48t6JumVQOmrKqpmGcdwxnhqBQ==} + fb-watchman@2.0.2: resolution: {integrity: sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==} @@ -7494,6 +7951,10 @@ packages: resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} engines: {node: '>= 6'} + format@0.2.2: + resolution: {integrity: sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==} + engines: {node: '>=0.4.x'} + formdata-polyfill@4.0.10: resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==} engines: {node: '>=12.20.0'} @@ -7817,6 +8278,12 @@ packages: resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} engines: {node: '>= 0.4'} + hast-util-to-jsx-runtime@2.3.6: + resolution: {integrity: sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==} + + hast-util-whitespace@3.0.0: + resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==} + he@1.2.0: resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==} hasBin: true @@ -7871,6 +8338,9 @@ packages: resolution: {integrity: sha512-XxzooSo6oBoxBEUazgjdXj7VwTn/iSTSZzTYKzYY6I916tkaYzypHxy+pbVU1h+0UQ9JlVf5XkNQyxOAiiQO1g==} engines: {node: '>=0.10.0'} + html-url-attributes@3.0.1: + resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==} + html2canvas@1.4.1: resolution: {integrity: sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==} engines: {node: '>=8.0.0'} @@ -8012,6 +8482,9 @@ packages: resolution: {integrity: sha512-QQnnxNyfvmHFIsj7gkPcYymR8Jdw/o7mp5ZFihxn6h8Ci6fh3Dx4E1gPjpQEpIuPo9XVNY/ZUwh4BPMjGyL01g==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + inline-style-parser@0.2.7: + resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==} + input-format@0.3.14: resolution: {integrity: sha512-gHMrgrbCgmT4uK5Um5eVDUohuV9lcs95ZUUN9Px2Y0VIfjTzT2wF8Q3Z4fwLFm7c5Z2OXCm53FHoovj6SlOKdg==} peerDependencies: @@ -8053,6 +8526,12 @@ packages: resolution: {integrity: sha512-AIbwAcazqP3R65dGvqk1V+a+vE5Fg1yu/ZKMOiBWSUIXXiwQkYmXQcVa2O0nh0tSDKDFKxG2mY7dB1Sr4hEP1g==} engines: {node: '>= 0.4'} + is-alphabetical@2.0.1: + resolution: {integrity: sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==} + + is-alphanumerical@2.0.1: + resolution: {integrity: sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==} + is-arguments@1.2.0: resolution: {integrity: sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==} engines: {node: '>= 0.4'} @@ -8106,6 +8585,9 @@ packages: resolution: {integrity: sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==} engines: {node: '>= 0.4'} + is-decimal@2.0.1: + resolution: {integrity: sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==} + is-descriptor@0.1.8: resolution: {integrity: sha512-SceYGWXvdqlWa/OnQ5FQuV+NxvNmMRhMw/w9AHkH71hTzveND4BTYgvp16g+oITK47qbOl/3D0bl0iygehWAWQ==} engines: {node: '>= 0.4'} @@ -8172,6 +8654,9 @@ packages: resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} engines: {node: '>=0.10.0'} + is-hexadecimal@2.0.1: + resolution: {integrity: sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==} + is-in-ssh@1.0.0: resolution: {integrity: sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw==} engines: {node: '>=20'} @@ -8369,6 +8854,9 @@ packages: resolution: {integrity: sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==} engines: {node: '>=0.10.0'} + isomorphic.js@0.2.5: + resolution: {integrity: sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw==} + isstream@0.1.2: resolution: {integrity: sha512-Yljz7ffyPbrLpLngrMtZ7NduUgVvi6wG9RJ9IUcyCd59YQ911PBJphODUcbOVbqYfxe1wuYf/LJ8PauMRwsM/g==} @@ -8600,6 +9088,10 @@ packages: resolution: {integrity: sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==} hasBin: true + js-yaml@4.3.0: + resolution: {integrity: sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==} + hasBin: true + jsbn@0.1.1: resolution: {integrity: sha512-UVU9dibq2JcFWxQPA6KCqj5O42VOmAY3zQUfEKxU0KpTGXwNoCjkX1e13eHNvw/xPynt6pU0rZ1htjWTNTSXsg==} @@ -8745,6 +9237,19 @@ packages: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} + lexical@0.48.0: + resolution: {integrity: sha512-KK4Tyr/cPsleoZ7XvhGRiRmcrZidSmoFUdIXK9nPubIifoC+80Dc5THyc4xtGKtsW24S1TsHzk5gmfBU+TxmEg==} + peerDependencies: + typescript: '>=5.2' + peerDependenciesMeta: + typescript: + optional: true + + lib0@0.2.117: + resolution: {integrity: sha512-DeXj9X5xDCjgKLU/7RR+/HQEVzuuEUiwldwOGsHK/sfAfELGWEyTcf0x+uOvCvK3O2zPmZePXWL85vtia6GyZw==} + engines: {node: '>=16'} + hasBin: true + libphonenumber-js@1.13.6: resolution: {integrity: sha512-NdB6O6QvlGMCoG003m0YIKG2+Xw7DjmCZhmc1RH+K6HncADUbRf8TZeLegxBBN1VFyPHcNpPTKpIhYLXzJVy1Q==} @@ -9016,6 +9521,9 @@ packages: resolution: {integrity: sha512-qyIh2goLt1sOgQQrrIWuwkRjUx4NUcEqEGAcYqD8VOnOC6ItwkrVE8/tA4smGpjzyp4Svhc6RodDp9IO5ghpyA==} engines: {node: '>=0.10.0'} + longest-streak@3.1.0: + resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} + loose-envify@1.4.0: resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} hasBin: true @@ -9105,10 +9613,58 @@ packages: resolution: {integrity: sha512-4y7uGv8bd2WdM9vpQsiQNo41Ln1NvhvDRuVt0k2JZQ+ezN2uaQes7lZeZ+QQUHOLQAtDaBJ+7wCbi+ab/KFs+w==} engines: {node: '>=0.10.0'} + markdown-table@3.0.4: + resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==} + math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} + mdast-util-directive@3.1.0: + resolution: {integrity: sha512-I3fNFt+DHmpWCYAT7quoM6lHf9wuqtI+oCOfvILnoicNIqjh5E3dEJWiXuYME2gNe8vl1iMQwyUHa7bgFmak6Q==} + + mdast-util-from-markdown@2.0.3: + resolution: {integrity: sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==} + + mdast-util-frontmatter@2.0.1: + resolution: {integrity: sha512-LRqI9+wdgC25P0URIJY9vwocIzCcksduHQ9OF2joxQoyTNVduwLAFUzjoopuRJbJAReaKrNQKAZKL3uCMugWJA==} + + mdast-util-gfm-strikethrough@2.0.0: + resolution: {integrity: sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==} + + mdast-util-gfm-table@2.0.0: + resolution: {integrity: sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==} + + mdast-util-gfm-task-list-item@2.0.0: + resolution: {integrity: sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==} + + mdast-util-highlight-mark@1.2.2: + resolution: {integrity: sha512-OYumVoytj+B9YgwzBhBcYUCLYHIPvJtAvwnMyKhUXbfUFuER5S+FDZyu9fadUxm2TCT5fRYK3jQXh2ioWAxrMw==} + + mdast-util-mdx-expression@2.0.1: + resolution: {integrity: sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==} + + mdast-util-mdx-jsx@3.2.0: + resolution: {integrity: sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==} + + mdast-util-mdx@3.0.0: + resolution: {integrity: sha512-JfbYLAW7XnYTTbUsmpu0kdBUVe+yKVJZBItEjwyYJiDJuZ9w4eeaqks4HQO+R7objWgS2ymV60GYpI14Ug554w==} + + mdast-util-mdxjs-esm@2.0.1: + resolution: {integrity: sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==} + + mdast-util-phrasing@4.1.0: + resolution: {integrity: sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==} + + mdast-util-to-hast@13.2.1: + resolution: {integrity: sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==} + + mdast-util-to-markdown@2.1.2: + resolution: {integrity: sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==} + + mdast-util-to-string@4.0.0: + resolution: {integrity: sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==} + mdn-data@2.0.14: resolution: {integrity: sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==} @@ -9160,6 +9716,108 @@ packages: resolution: {integrity: sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==} engines: {node: '>= 0.6'} + micromark-core-commonmark@2.0.3: + resolution: {integrity: sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==} + + micromark-extension-directive@3.0.2: + resolution: {integrity: sha512-wjcXHgk+PPdmvR58Le9d7zQYWy+vKEU9Se44p2CrCDPiLr2FMyiT4Fyb5UFKFC66wGB3kPlgD7q3TnoqPS7SZA==} + + micromark-extension-frontmatter@2.0.0: + resolution: {integrity: sha512-C4AkuM3dA58cgZha7zVnuVxBhDsbttIMiytjgsM2XbHAB2faRVaHRle40558FBN+DJcrLNCoqG5mlrpdU4cRtg==} + + micromark-extension-gfm-strikethrough@2.1.0: + resolution: {integrity: sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==} + + micromark-extension-gfm-table@2.1.1: + resolution: {integrity: sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==} + + micromark-extension-gfm-task-list-item@2.1.0: + resolution: {integrity: sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==} + + micromark-extension-highlight-mark@1.2.0: + resolution: {integrity: sha512-huGtbd/9kQsMk8u7nrVMaS5qH/47yDG6ZADggo5Owz5JoY8wdfQjfuy118/QiYNCvdFuFDbzT0A7K7Hp2cBsXA==} + + micromark-extension-mdx-expression@3.0.1: + resolution: {integrity: sha512-dD/ADLJ1AeMvSAKBwO22zG22N4ybhe7kFIZ3LsDI0GlsNr2A3KYxb0LdC1u5rj4Nw+CHKY0RVdnHX8vj8ejm4Q==} + + micromark-extension-mdx-jsx@3.0.2: + resolution: {integrity: sha512-e5+q1DjMh62LZAJOnDraSSbDMvGJ8x3cbjygy2qFEi7HCeUT4BDKCvMozPozcD6WmOt6sVvYDNBKhFSz3kjOVQ==} + + micromark-extension-mdx-md@2.0.0: + resolution: {integrity: sha512-EpAiszsB3blw4Rpba7xTOUptcFeBFi+6PY8VnJ2hhimH+vCQDirWgsMpz7w1XcZE7LVrSAUGb9VJpG9ghlYvYQ==} + + micromark-extension-mdxjs-esm@3.0.0: + resolution: {integrity: sha512-DJFl4ZqkErRpq/dAPyeWp15tGrcrrJho1hKK5uBS70BCtfrIFg81sqcTVu3Ta+KD1Tk5vAtBNElWxtAa+m8K9A==} + + micromark-extension-mdxjs@3.0.0: + resolution: {integrity: sha512-A873fJfhnJ2siZyUrJ31l34Uqwy4xIFmvPY1oj+Ean5PHcPBYzEsvqvWGaWcfEIr11O5Dlw3p2y0tZWpKHDejQ==} + + micromark-factory-destination@2.0.1: + resolution: {integrity: sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==} + + micromark-factory-label@2.0.1: + resolution: {integrity: sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==} + + micromark-factory-mdx-expression@2.0.3: + resolution: {integrity: sha512-kQnEtA3vzucU2BkrIa8/VaSAsP+EJ3CKOvhMuJgOEGg9KDC6OAY6nSnNDVRiVNRqj7Y4SlSzcStaH/5jge8JdQ==} + + micromark-factory-space@2.0.1: + resolution: {integrity: sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==} + + micromark-factory-title@2.0.1: + resolution: {integrity: sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==} + + micromark-factory-whitespace@2.0.1: + resolution: {integrity: sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==} + + micromark-util-character@2.1.1: + resolution: {integrity: sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==} + + micromark-util-chunked@2.0.1: + resolution: {integrity: sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==} + + micromark-util-classify-character@2.0.1: + resolution: {integrity: sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==} + + micromark-util-combine-extensions@2.0.1: + resolution: {integrity: sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==} + + micromark-util-decode-numeric-character-reference@2.0.2: + resolution: {integrity: sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==} + + micromark-util-decode-string@2.0.1: + resolution: {integrity: sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==} + + micromark-util-encode@2.0.1: + resolution: {integrity: sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==} + + micromark-util-events-to-acorn@2.0.3: + resolution: {integrity: sha512-jmsiEIiZ1n7X1Rr5k8wVExBQCg5jy4UXVADItHmNk1zkwEVhBuIUKRu3fqv+hs4nxLISi2DQGlqIOGiFxgbfHg==} + + micromark-util-html-tag-name@2.0.1: + resolution: {integrity: sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==} + + micromark-util-normalize-identifier@2.0.1: + resolution: {integrity: sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==} + + micromark-util-resolve-all@2.0.1: + resolution: {integrity: sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==} + + micromark-util-sanitize-uri@2.0.1: + resolution: {integrity: sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==} + + micromark-util-subtokenize@2.1.0: + resolution: {integrity: sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==} + + micromark-util-symbol@2.0.1: + resolution: {integrity: sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==} + + micromark-util-types@2.0.2: + resolution: {integrity: sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==} + + micromark@4.0.2: + resolution: {integrity: sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==} + micromatch@3.1.10: resolution: {integrity: sha512-MWikgl9n9M3w+bpsY3He8L+w9eF9338xRl8IAO5viDizwSzziFEyUzo2xrrloB64ADbTf8uA8vRqqttDTOmccg==} engines: {node: '>=0.10.0'} @@ -9611,6 +10269,9 @@ packages: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} + parse-entities@4.0.2: + resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==} + parse-json@5.2.0: resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} engines: {node: '>=8'} @@ -9969,6 +10630,9 @@ packages: prop-types@15.8.1: resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} + property-information@7.2.0: + resolution: {integrity: sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==} + proxy-addr@2.0.7: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} engines: {node: '>= 0.10'} @@ -10190,12 +10854,21 @@ packages: react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + react-is@17.0.2: + resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + react-is@18.3.1: resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} react-is@19.2.7: resolution: {integrity: sha512-kZFnouyVv7eP/Phmrlo9FK+zcAdriZJvzxXHF1Sl1P377WSGe2G/JxVolhTrB/jeV47lKImhNUsijjHAAbcl/A==} + react-markdown@9.1.0: + resolution: {integrity: sha512-xaijuJB0kzGiUdG7nc2MOMDUDBWPyGAjZtUrow9XxUeua8IqeP+VlIfAZ3bphpcLTnSZXz6z9jcVC/TCwbfgdw==} + peerDependencies: + '@types/react': '>=18' + react: '>=18' + react-number-format@5.4.5: resolution: {integrity: sha512-y8O2yHHj3w0aE9XO8d2BCcUOOdQTRSVq+WIuMlLVucAm5XNjJAy+BoOJiuQMldVYVOKTMyvVNfnbl2Oqp+YxGw==} peerDependencies: @@ -10453,6 +11126,12 @@ packages: resolution: {integrity: sha512-Q5W2qeYtY9GbiR8z1yHNZ1DGhyjb4AnLEjt8iE6XfcC1QIu+FAtj3HQaO0wH28H1mX6cqNLvAqWhP402dxJGyA==} engines: {node: '>= 0.8.0'} + remark-parse@11.0.0: + resolution: {integrity: sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==} + + remark-rehype@11.1.2: + resolution: {integrity: sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==} + remarkable@1.7.4: resolution: {integrity: sha512-e6NKUXgX95whv7IgddywbeN/ItCkWbISmc2DiqHJb0wTrqZIexqdco5b8Z3XZoo/48IdNVKM9ZCvTPJ4F5uvhg==} engines: {node: '>= 0.10.0'} @@ -10615,6 +11294,10 @@ packages: rxjs@7.8.2: resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==} + sade@1.8.1: + resolution: {integrity: sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==} + engines: {node: '>=6'} + safe-array-concat@1.1.4: resolution: {integrity: sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg==} engines: {node: '>=0.4'} @@ -10883,6 +11566,9 @@ packages: resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==} engines: {node: '>= 12'} + space-separated-tokens@2.0.2: + resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} + split-on-first@1.1.0: resolution: {integrity: sha512-43ZssAJaMusuKWL8sKUBQXHWOpq8d6CfN/u1p4gUzfJkM05C8rxTmYrkIPTXapZpORA6LkkzcUulJ8FqA7Uudw==} engines: {node: '>=6'} @@ -11018,6 +11704,9 @@ packages: string_decoder@1.3.0: resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + stringify-entities@4.0.4: + resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==} + stringify-object@5.0.0: resolution: {integrity: sha512-zaJYxz2FtcMb4f+g60KsRNFOpVMUyuJgA51Zi5Z1DOTC3S59+OQiVOzE9GZt0x72uBGWKsQIuBKeF9iusmKFsg==} engines: {node: '>=14.16'} @@ -11072,9 +11761,18 @@ packages: resolution: {integrity: sha512-mKX8HA/cdBqMKUr0MMZAFssCkIGoZeSCMXgnt79yKxNFguMLVFgRe6wB+fsL0NmoHDbeyZXczy7vEPSoo3rkzg==} engines: {node: '>=16'} + style-mod@4.1.3: + resolution: {integrity: sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==} + style-object-to-css-string@1.1.3: resolution: {integrity: sha512-bISQoUsir/qGfo7vY8rw00ia9nnyE1jvYt3zZ2jhdkcXZ6dAEi74inMzQ6On57vFI+I4Fck6wOv5UI9BEwJDgw==} + style-to-js@1.1.21: + resolution: {integrity: sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==} + + style-to-object@1.0.14: + resolution: {integrity: sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==} + styled-components@5.3.11: resolution: {integrity: sha512-uuzIIfnVkagcVHv9nE0VPlHPSCmXIUGKfJ42LNjxCCTDTL5sgnJ8Z7GZBq0EnLYGln77tPpEpExt2+qa+cZqSw==} engines: {node: '>=10'} @@ -11421,6 +12119,12 @@ packages: trim-canvas@0.1.2: resolution: {integrity: sha512-nd4Ga3iLFV94mdhW9JFMLpQbHUyCQuhFOD71PEAt1NjtMD5wbZctzhX8c3agHNybMR5zXD1XTGoIEWk995E6pQ==} + trim-lines@3.0.1: + resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} + + trough@2.2.0: + resolution: {integrity: sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==} + ts-api-utils@2.5.0: resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} engines: {node: '>=18.12'} @@ -11682,10 +12386,34 @@ packages: resolution: {integrity: sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==} engines: {node: '>=18'} + unidiff@1.0.4: + resolution: {integrity: sha512-ynU0vsAXw0ir8roa+xPCUHmnJ5goc5BTM2Kuc3IJd8UwgaeRs7VSD5+eeaQL+xp1JtB92hu/Zy/Lgy7RZcr1pQ==} + + unified@11.0.5: + resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==} + union-value@1.0.1: resolution: {integrity: sha512-tJfXmxMeWYnczCVs7XAEvIV7ieppALdyepWMkHkwciRpZraG/xwT+s2JN8+pr1+8jCRf80FFzvr+MpQeeoF4Xg==} engines: {node: '>=0.10.0'} + unist-util-is@6.0.1: + resolution: {integrity: sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==} + + unist-util-position-from-estree@2.0.0: + resolution: {integrity: sha512-KaFVRjoqLyF6YXCbVLNad/eS4+OfPQQn2yOd7zF/h5T/CSL2v8NpN6a5TPvtbXthAGw5nG+PuTtq+DdIZr+cRQ==} + + unist-util-position@5.0.0: + resolution: {integrity: sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==} + + unist-util-stringify-position@4.0.0: + resolution: {integrity: sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==} + + unist-util-visit-parents@6.0.2: + resolution: {integrity: sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==} + + unist-util-visit@5.1.0: + resolution: {integrity: sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==} + universal-cookie@8.1.2: resolution: {integrity: sha512-kcKzTGNsxVytujrYOvQbvh//QyFrA53HrzCGyzh6i9ujCww5gfPrLK0tG+jJD40SIIldiEjBNPPSR8fBMS21GA==} @@ -11826,6 +12554,11 @@ packages: deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). hasBin: true + uvu@0.5.6: + resolution: {integrity: sha512-+g8ENReyr8YsOc6fv/NVJs2vFdHBnBNdfE49rshrTzDWOlUx4Gq7KOS2GD8eqhy2j+Ejq29+SbKH8yjkAqXqoA==} + engines: {node: '>=8'} + hasBin: true + v8-compile-cache-lib@3.0.1: resolution: {integrity: sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==} @@ -11855,6 +12588,12 @@ packages: resolution: {integrity: sha512-ZZKSmDAEFOijERBLkmYfJ+vmk3w+7hOLYDNkRCuRuMJGEmqYNCNLyBBFwWKVMhfwaEF3WOd0Zlw86U/WC/+nYw==} engines: {'0': node >=0.6.0} + vfile-message@4.0.3: + resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==} + + vfile@6.0.3: + resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} + victory-vendor@36.9.2: resolution: {integrity: sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==} @@ -11934,6 +12673,9 @@ packages: resolution: {integrity: sha512-lYEhd75l75P3D1LKpm4KqdOSpNyNdDJ9ixEZmC5ZAZUKGy6JNexfMdQ9SNaT5pCHuzuXXRJQedJ+CdqNg/D4Kw==} engines: {iojs: '>= 1.0.0', node: '>= 0.10.0'} + w3c-keyname@2.2.8: + resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==} + w3c-xmlserializer@5.0.0: resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} engines: {node: '>=18'} @@ -12192,6 +12934,10 @@ packages: resolution: {integrity: sha512-9GnJUZ0QM4OgXuOzsKNzTJ5EOkums1Xc+3YQXp+Q+UxFjf7zLucp9dQ8QMIft0Szs1E1hUiXFim1OYfEKFq97w==} engines: {node: '>=0.8'} + yjs@13.6.32: + resolution: {integrity: sha512-lfiJIIC4Xayt5ItynE407ehlE03pCjeOc4hkR4yxxvvNJ4kuiN25B0g+Qp8XagYz361LLL7DCzR5bvFJ81QKtQ==} + engines: {node: '>=16.0.0', npm: '>=8.0.0'} + yn@3.1.1: resolution: {integrity: sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==} engines: {node: '>=6'} @@ -12252,6 +12998,9 @@ packages: use-sync-external-store: optional: true + zwitch@2.0.4: + resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} + snapshots: '@alloc/quick-lru@5.2.0': {} @@ -12314,11 +13063,11 @@ snapshots: '@babel/helpers': 7.29.7 '@babel/parser': 7.29.7 '@babel/template': 7.29.7 - '@babel/traverse': 7.29.7(supports-color@5.5.0) + '@babel/traverse': 7.29.7 '@babel/types': 7.29.7 '@jridgewell/remapping': 2.3.5 convert-source-map: 2.0.0 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) gensync: 1.0.0-beta.2 json5: 2.2.3 semver: 6.3.1 @@ -12353,7 +13102,7 @@ snapshots: '@babel/helper-optimise-call-expression': 7.29.7 '@babel/helper-replace-supers': 7.29.7(@babel/core@7.29.7) '@babel/helper-skip-transparent-expression-wrappers': 7.29.7 - '@babel/traverse': 7.29.7(supports-color@5.5.0) + '@babel/traverse': 7.29.7 semver: 6.3.1 transitivePeerDependencies: - supports-color @@ -12362,7 +13111,14 @@ snapshots: '@babel/helper-member-expression-to-functions@7.29.7': dependencies: - '@babel/traverse': 7.29.7(supports-color@5.5.0) + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-imports@7.29.7': + dependencies: + '@babel/traverse': 7.29.7 '@babel/types': 7.29.7 transitivePeerDependencies: - supports-color @@ -12377,9 +13133,9 @@ snapshots: '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-module-imports': 7.29.7(supports-color@5.5.0) + '@babel/helper-module-imports': 7.29.7 '@babel/helper-validator-identifier': 7.29.7 - '@babel/traverse': 7.29.7(supports-color@5.5.0) + '@babel/traverse': 7.29.7 transitivePeerDependencies: - supports-color @@ -12394,13 +13150,13 @@ snapshots: '@babel/core': 7.29.7 '@babel/helper-member-expression-to-functions': 7.29.7 '@babel/helper-optimise-call-expression': 7.29.7 - '@babel/traverse': 7.29.7(supports-color@5.5.0) + '@babel/traverse': 7.29.7 transitivePeerDependencies: - supports-color '@babel/helper-skip-transparent-expression-wrappers@7.29.7': dependencies: - '@babel/traverse': 7.29.7(supports-color@5.5.0) + '@babel/traverse': 7.29.7 '@babel/types': 7.29.7 transitivePeerDependencies: - supports-color @@ -12553,6 +13309,18 @@ snapshots: '@babel/parser': 7.29.7 '@babel/types': 7.29.7 + '@babel/traverse@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.7 + '@babel/helper-globals': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/template': 7.29.7 + '@babel/types': 7.29.7 + debug: 4.4.3(supports-color@8.1.1) + transitivePeerDependencies: + - supports-color + '@babel/traverse@7.29.7(supports-color@5.5.0)': dependencies: '@babel/code-frame': 7.29.7 @@ -12574,6 +13342,265 @@ snapshots: '@borewit/text-codec@0.2.2': {} + '@codemirror/autocomplete@6.20.3': + dependencies: + '@codemirror/language': 6.12.4 + '@codemirror/state': 6.7.1 + '@codemirror/view': 6.43.8 + '@lezer/common': 1.5.2 + + '@codemirror/commands@6.10.4': + dependencies: + '@codemirror/language': 6.12.4 + '@codemirror/state': 6.7.1 + '@codemirror/view': 6.43.8 + '@lezer/common': 1.5.2 + + '@codemirror/lang-angular@0.1.4': + dependencies: + '@codemirror/lang-html': 6.4.12 + '@codemirror/lang-javascript': 6.2.5 + '@codemirror/language': 6.12.4 + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + + '@codemirror/lang-cpp@6.0.3': + dependencies: + '@codemirror/language': 6.12.4 + '@lezer/cpp': 1.1.6 + + '@codemirror/lang-css@6.3.1': + dependencies: + '@codemirror/autocomplete': 6.20.3 + '@codemirror/language': 6.12.4 + '@codemirror/state': 6.7.1 + '@lezer/common': 1.5.2 + '@lezer/css': 1.3.6 + + '@codemirror/lang-go@6.0.1': + dependencies: + '@codemirror/autocomplete': 6.20.3 + '@codemirror/language': 6.12.4 + '@codemirror/state': 6.7.1 + '@lezer/common': 1.5.2 + '@lezer/go': 1.0.1 + + '@codemirror/lang-html@6.4.12': + dependencies: + '@codemirror/autocomplete': 6.20.3 + '@codemirror/lang-css': 6.3.1 + '@codemirror/lang-javascript': 6.2.5 + '@codemirror/language': 6.12.4 + '@codemirror/state': 6.7.1 + '@codemirror/view': 6.43.8 + '@lezer/common': 1.5.2 + '@lezer/css': 1.3.6 + '@lezer/html': 1.3.13 + + '@codemirror/lang-java@6.0.2': + dependencies: + '@codemirror/language': 6.12.4 + '@lezer/java': 1.1.3 + + '@codemirror/lang-javascript@6.2.5': + dependencies: + '@codemirror/autocomplete': 6.20.3 + '@codemirror/language': 6.12.4 + '@codemirror/lint': 6.9.7 + '@codemirror/state': 6.7.1 + '@codemirror/view': 6.43.8 + '@lezer/common': 1.5.2 + '@lezer/javascript': 1.5.4 + + '@codemirror/lang-jinja@6.0.1': + dependencies: + '@codemirror/autocomplete': 6.20.3 + '@codemirror/lang-html': 6.4.12 + '@codemirror/language': 6.12.4 + '@codemirror/state': 6.7.1 + '@codemirror/view': 6.43.8 + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + + '@codemirror/lang-json@6.0.2': + dependencies: + '@codemirror/language': 6.12.4 + '@lezer/json': 1.0.3 + + '@codemirror/lang-less@6.0.2': + dependencies: + '@codemirror/lang-css': 6.3.1 + '@codemirror/language': 6.12.4 + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + + '@codemirror/lang-liquid@6.3.2': + dependencies: + '@codemirror/autocomplete': 6.20.3 + '@codemirror/lang-html': 6.4.12 + '@codemirror/language': 6.12.4 + '@codemirror/state': 6.7.1 + '@codemirror/view': 6.43.8 + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + + '@codemirror/lang-markdown@6.5.2': + dependencies: + '@codemirror/autocomplete': 6.20.3 + '@codemirror/lang-html': 6.4.12 + '@codemirror/language': 6.12.4 + '@codemirror/state': 6.7.1 + '@codemirror/view': 6.43.8 + '@lezer/common': 1.5.2 + '@lezer/markdown': 1.7.2 + + '@codemirror/lang-php@6.0.2': + dependencies: + '@codemirror/lang-html': 6.4.12 + '@codemirror/language': 6.12.4 + '@codemirror/state': 6.7.1 + '@lezer/common': 1.5.2 + '@lezer/php': 1.0.5 + + '@codemirror/lang-python@6.2.1': + dependencies: + '@codemirror/autocomplete': 6.20.3 + '@codemirror/language': 6.12.4 + '@codemirror/state': 6.7.1 + '@lezer/common': 1.5.2 + '@lezer/python': 1.1.19 + + '@codemirror/lang-rust@6.0.2': + dependencies: + '@codemirror/language': 6.12.4 + '@lezer/rust': 1.0.2 + + '@codemirror/lang-sass@6.0.2': + dependencies: + '@codemirror/lang-css': 6.3.1 + '@codemirror/language': 6.12.4 + '@codemirror/state': 6.7.1 + '@lezer/common': 1.5.2 + '@lezer/sass': 1.1.0 + + '@codemirror/lang-sql@6.10.0': + dependencies: + '@codemirror/autocomplete': 6.20.3 + '@codemirror/language': 6.12.4 + '@codemirror/state': 6.7.1 + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + + '@codemirror/lang-vue@0.1.3': + dependencies: + '@codemirror/lang-html': 6.4.12 + '@codemirror/lang-javascript': 6.2.5 + '@codemirror/language': 6.12.4 + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + + '@codemirror/lang-wast@6.0.2': + dependencies: + '@codemirror/language': 6.12.4 + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + + '@codemirror/lang-xml@6.1.0': + dependencies: + '@codemirror/autocomplete': 6.20.3 + '@codemirror/language': 6.12.4 + '@codemirror/state': 6.7.1 + '@codemirror/view': 6.43.8 + '@lezer/common': 1.5.2 + '@lezer/xml': 1.0.6 + + '@codemirror/lang-yaml@6.1.3': + dependencies: + '@codemirror/autocomplete': 6.20.3 + '@codemirror/language': 6.12.4 + '@codemirror/state': 6.7.1 + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + '@lezer/yaml': 1.0.4 + + '@codemirror/language-data@6.5.2': + dependencies: + '@codemirror/lang-angular': 0.1.4 + '@codemirror/lang-cpp': 6.0.3 + '@codemirror/lang-css': 6.3.1 + '@codemirror/lang-go': 6.0.1 + '@codemirror/lang-html': 6.4.12 + '@codemirror/lang-java': 6.0.2 + '@codemirror/lang-javascript': 6.2.5 + '@codemirror/lang-jinja': 6.0.1 + '@codemirror/lang-json': 6.0.2 + '@codemirror/lang-less': 6.0.2 + '@codemirror/lang-liquid': 6.3.2 + '@codemirror/lang-markdown': 6.5.2 + '@codemirror/lang-php': 6.0.2 + '@codemirror/lang-python': 6.2.1 + '@codemirror/lang-rust': 6.0.2 + '@codemirror/lang-sass': 6.0.2 + '@codemirror/lang-sql': 6.10.0 + '@codemirror/lang-vue': 0.1.3 + '@codemirror/lang-wast': 6.0.2 + '@codemirror/lang-xml': 6.1.0 + '@codemirror/lang-yaml': 6.1.3 + '@codemirror/language': 6.12.4 + '@codemirror/legacy-modes': 6.5.3 + + '@codemirror/language@6.12.4': + dependencies: + '@codemirror/state': 6.7.1 + '@codemirror/view': 6.43.8 + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + style-mod: 4.1.3 + + '@codemirror/legacy-modes@6.5.3': + dependencies: + '@codemirror/language': 6.12.4 + + '@codemirror/lint@6.9.7': + dependencies: + '@codemirror/state': 6.7.1 + '@codemirror/view': 6.43.8 + crelt: 1.0.7 + + '@codemirror/merge@6.12.2': + dependencies: + '@codemirror/language': 6.12.4 + '@codemirror/state': 6.7.1 + '@codemirror/view': 6.43.8 + '@lezer/highlight': 1.2.3 + style-mod: 4.1.3 + + '@codemirror/search@6.7.1': + dependencies: + '@codemirror/state': 6.7.1 + '@codemirror/view': 6.43.8 + crelt: 1.0.7 + + '@codemirror/state@6.7.1': + dependencies: + '@marijn/find-cluster-break': 1.0.3 + + '@codemirror/view@6.43.8': + dependencies: + '@codemirror/state': 6.7.1 + crelt: 1.0.7 + style-mod: 4.1.3 + w3c-keyname: 2.2.8 + '@colors/colors@1.5.0': optional: true @@ -12778,7 +13805,7 @@ snapshots: '@emotion/babel-plugin@11.13.5': dependencies: - '@babel/helper-module-imports': 7.29.7(supports-color@5.5.0) + '@babel/helper-module-imports': 7.29.7 '@babel/runtime': 7.29.7 '@emotion/hash': 0.9.2 '@emotion/memoize': 0.9.0 @@ -12944,7 +13971,7 @@ snapshots: '@eslint/eslintrc@2.1.4': dependencies: ajv: 6.15.0 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) espree: 9.6.1 globals: 13.24.0 ignore: 5.3.2 @@ -13104,7 +14131,7 @@ snapshots: '@humanwhocodes/config-array@0.13.0': dependencies: '@humanwhocodes/object-schema': 2.0.3 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) minimatch: 3.1.5 transitivePeerDependencies: - supports-color @@ -13516,6 +14543,329 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@lexical/a11y@0.48.0(typescript@5.9.3)': + dependencies: + '@lexical/extension': 0.48.0(typescript@5.9.3) + '@lexical/utils': 0.48.0(typescript@5.9.3) + lexical: 0.48.0(typescript@5.9.3) + optionalDependencies: + typescript: 5.9.3 + + '@lexical/clipboard@0.48.0(typescript@5.9.3)': + dependencies: + '@lexical/extension': 0.48.0(typescript@5.9.3) + '@lexical/html': 0.48.0(typescript@5.9.3) + '@lexical/internal': 0.48.0(typescript@5.9.3) + '@lexical/list': 0.48.0(typescript@5.9.3) + '@lexical/selection': 0.48.0(typescript@5.9.3) + '@lexical/utils': 0.48.0(typescript@5.9.3) + '@types/trusted-types': 2.0.7 + lexical: 0.48.0(typescript@5.9.3) + optionalDependencies: + typescript: 5.9.3 + + '@lexical/code-core@0.48.0(typescript@5.9.3)': + dependencies: + '@lexical/extension': 0.48.0(typescript@5.9.3) + '@lexical/html': 0.48.0(typescript@5.9.3) + '@lexical/internal': 0.48.0(typescript@5.9.3) + '@lexical/utils': 0.48.0(typescript@5.9.3) + lexical: 0.48.0(typescript@5.9.3) + optionalDependencies: + typescript: 5.9.3 + + '@lexical/devtools-core@0.48.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.9.3)': + dependencies: + '@lexical/html': 0.48.0(typescript@5.9.3) + '@lexical/link': 0.48.0(typescript@5.9.3) + '@lexical/mark': 0.48.0(typescript@5.9.3) + '@lexical/table': 0.48.0(typescript@5.9.3) + '@lexical/utils': 0.48.0(typescript@5.9.3) + lexical: 0.48.0(typescript@5.9.3) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + optionalDependencies: + typescript: 5.9.3 + + '@lexical/dragon@0.48.0(typescript@5.9.3)': + dependencies: + '@lexical/extension': 0.48.0(typescript@5.9.3) + lexical: 0.48.0(typescript@5.9.3) + optionalDependencies: + typescript: 5.9.3 + + '@lexical/extension@0.48.0(typescript@5.9.3)': + dependencies: + '@lexical/internal': 0.48.0(typescript@5.9.3) + '@lexical/utils': 0.48.0(typescript@5.9.3) + '@preact/signals-core': 1.14.4 + lexical: 0.48.0(typescript@5.9.3) + optionalDependencies: + typescript: 5.9.3 + + '@lexical/hashtag@0.48.0(typescript@5.9.3)': + dependencies: + '@lexical/text': 0.48.0(typescript@5.9.3) + '@lexical/utils': 0.48.0(typescript@5.9.3) + lexical: 0.48.0(typescript@5.9.3) + optionalDependencies: + typescript: 5.9.3 + + '@lexical/history@0.48.0(typescript@5.9.3)': + dependencies: + '@lexical/extension': 0.48.0(typescript@5.9.3) + '@lexical/utils': 0.48.0(typescript@5.9.3) + lexical: 0.48.0(typescript@5.9.3) + optionalDependencies: + typescript: 5.9.3 + + '@lexical/html@0.48.0(typescript@5.9.3)': + dependencies: + '@lexical/extension': 0.48.0(typescript@5.9.3) + '@lexical/internal': 0.48.0(typescript@5.9.3) + '@lexical/selection': 0.48.0(typescript@5.9.3) + '@lexical/utils': 0.48.0(typescript@5.9.3) + lexical: 0.48.0(typescript@5.9.3) + optionalDependencies: + typescript: 5.9.3 + + '@lexical/internal@0.48.0(typescript@5.9.3)': + optionalDependencies: + typescript: 5.9.3 + + '@lexical/link@0.48.0(typescript@5.9.3)': + dependencies: + '@lexical/extension': 0.48.0(typescript@5.9.3) + '@lexical/html': 0.48.0(typescript@5.9.3) + '@lexical/internal': 0.48.0(typescript@5.9.3) + '@lexical/utils': 0.48.0(typescript@5.9.3) + lexical: 0.48.0(typescript@5.9.3) + optionalDependencies: + typescript: 5.9.3 + + '@lexical/list@0.48.0(typescript@5.9.3)': + dependencies: + '@lexical/extension': 0.48.0(typescript@5.9.3) + '@lexical/html': 0.48.0(typescript@5.9.3) + '@lexical/internal': 0.48.0(typescript@5.9.3) + '@lexical/utils': 0.48.0(typescript@5.9.3) + lexical: 0.48.0(typescript@5.9.3) + optionalDependencies: + typescript: 5.9.3 + + '@lexical/mark@0.48.0(typescript@5.9.3)': + dependencies: + '@lexical/utils': 0.48.0(typescript@5.9.3) + lexical: 0.48.0(typescript@5.9.3) + optionalDependencies: + typescript: 5.9.3 + + '@lexical/markdown@0.48.0(typescript@5.9.3)': + dependencies: + '@lexical/code-core': 0.48.0(typescript@5.9.3) + '@lexical/internal': 0.48.0(typescript@5.9.3) + '@lexical/link': 0.48.0(typescript@5.9.3) + '@lexical/list': 0.48.0(typescript@5.9.3) + '@lexical/rich-text': 0.48.0(typescript@5.9.3) + '@lexical/selection': 0.48.0(typescript@5.9.3) + '@lexical/text': 0.48.0(typescript@5.9.3) + '@lexical/utils': 0.48.0(typescript@5.9.3) + lexical: 0.48.0(typescript@5.9.3) + optionalDependencies: + typescript: 5.9.3 + + '@lexical/overflow@0.48.0(typescript@5.9.3)': + dependencies: + lexical: 0.48.0(typescript@5.9.3) + optionalDependencies: + typescript: 5.9.3 + + '@lexical/plain-text@0.48.0(typescript@5.9.3)': + dependencies: + '@lexical/clipboard': 0.48.0(typescript@5.9.3) + '@lexical/dragon': 0.48.0(typescript@5.9.3) + '@lexical/extension': 0.48.0(typescript@5.9.3) + '@lexical/selection': 0.48.0(typescript@5.9.3) + '@lexical/utils': 0.48.0(typescript@5.9.3) + lexical: 0.48.0(typescript@5.9.3) + optionalDependencies: + typescript: 5.9.3 + + '@lexical/react@0.48.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.9.3)(yjs@13.6.32)': + dependencies: + '@floating-ui/react': 0.27.19(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@lexical/a11y': 0.48.0(typescript@5.9.3) + '@lexical/devtools-core': 0.48.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.9.3) + '@lexical/dragon': 0.48.0(typescript@5.9.3) + '@lexical/extension': 0.48.0(typescript@5.9.3) + '@lexical/hashtag': 0.48.0(typescript@5.9.3) + '@lexical/history': 0.48.0(typescript@5.9.3) + '@lexical/internal': 0.48.0(typescript@5.9.3) + '@lexical/link': 0.48.0(typescript@5.9.3) + '@lexical/list': 0.48.0(typescript@5.9.3) + '@lexical/mark': 0.48.0(typescript@5.9.3) + '@lexical/markdown': 0.48.0(typescript@5.9.3) + '@lexical/overflow': 0.48.0(typescript@5.9.3) + '@lexical/plain-text': 0.48.0(typescript@5.9.3) + '@lexical/rich-text': 0.48.0(typescript@5.9.3) + '@lexical/table': 0.48.0(typescript@5.9.3) + '@lexical/text': 0.48.0(typescript@5.9.3) + '@lexical/utils': 0.48.0(typescript@5.9.3) + '@lexical/yjs': 0.48.0(typescript@5.9.3)(yjs@13.6.32) + lexical: 0.48.0(typescript@5.9.3) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + optionalDependencies: + typescript: 5.9.3 + yjs: 13.6.32 + + '@lexical/rich-text@0.48.0(typescript@5.9.3)': + dependencies: + '@lexical/clipboard': 0.48.0(typescript@5.9.3) + '@lexical/dragon': 0.48.0(typescript@5.9.3) + '@lexical/extension': 0.48.0(typescript@5.9.3) + '@lexical/html': 0.48.0(typescript@5.9.3) + '@lexical/selection': 0.48.0(typescript@5.9.3) + '@lexical/utils': 0.48.0(typescript@5.9.3) + lexical: 0.48.0(typescript@5.9.3) + optionalDependencies: + typescript: 5.9.3 + + '@lexical/selection@0.48.0(typescript@5.9.3)': + dependencies: + '@lexical/internal': 0.48.0(typescript@5.9.3) + lexical: 0.48.0(typescript@5.9.3) + optionalDependencies: + typescript: 5.9.3 + + '@lexical/table@0.48.0(typescript@5.9.3)': + dependencies: + '@lexical/clipboard': 0.48.0(typescript@5.9.3) + '@lexical/extension': 0.48.0(typescript@5.9.3) + '@lexical/html': 0.48.0(typescript@5.9.3) + '@lexical/internal': 0.48.0(typescript@5.9.3) + '@lexical/utils': 0.48.0(typescript@5.9.3) + lexical: 0.48.0(typescript@5.9.3) + optionalDependencies: + typescript: 5.9.3 + + '@lexical/text@0.48.0(typescript@5.9.3)': + dependencies: + '@lexical/internal': 0.48.0(typescript@5.9.3) + lexical: 0.48.0(typescript@5.9.3) + optionalDependencies: + typescript: 5.9.3 + + '@lexical/utils@0.48.0(typescript@5.9.3)': + dependencies: + '@lexical/internal': 0.48.0(typescript@5.9.3) + '@lexical/selection': 0.48.0(typescript@5.9.3) + lexical: 0.48.0(typescript@5.9.3) + optionalDependencies: + typescript: 5.9.3 + + '@lexical/yjs@0.48.0(typescript@5.9.3)(yjs@13.6.32)': + dependencies: + '@lexical/internal': 0.48.0(typescript@5.9.3) + '@lexical/selection': 0.48.0(typescript@5.9.3) + lexical: 0.48.0(typescript@5.9.3) + yjs: 13.6.32 + optionalDependencies: + typescript: 5.9.3 + + '@lezer/common@1.5.2': {} + + '@lezer/cpp@1.1.6': + dependencies: + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + + '@lezer/css@1.3.6': + dependencies: + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + + '@lezer/go@1.0.1': + dependencies: + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + + '@lezer/highlight@1.2.3': + dependencies: + '@lezer/common': 1.5.2 + + '@lezer/html@1.3.13': + dependencies: + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + + '@lezer/java@1.1.3': + dependencies: + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + + '@lezer/javascript@1.5.4': + dependencies: + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + + '@lezer/json@1.0.3': + dependencies: + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + + '@lezer/lr@1.4.10': + dependencies: + '@lezer/common': 1.5.2 + + '@lezer/markdown@1.7.2': + dependencies: + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + + '@lezer/php@1.0.5': + dependencies: + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + + '@lezer/python@1.1.19': + dependencies: + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + + '@lezer/rust@1.0.2': + dependencies: + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + + '@lezer/sass@1.1.0': + dependencies: + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + + '@lezer/xml@1.0.6': + dependencies: + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + + '@lezer/yaml@1.0.4': + dependencies: + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + '@lottiefiles/react-lottie-player@3.6.0(react@19.2.6)': dependencies: lottie-web: 5.13.0 @@ -13640,6 +14990,83 @@ snapshots: dependencies: react: 19.2.6 + '@marijn/find-cluster-break@1.0.3': {} + + '@mdxeditor/editor@4.2.0(@codemirror/language@6.12.4)(@lezer/highlight@1.2.3)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.9.3)(yjs@13.6.32)': + dependencies: + '@codemirror/commands': 6.10.4 + '@codemirror/lang-markdown': 6.5.2 + '@codemirror/language-data': 6.5.2 + '@codemirror/merge': 6.12.2 + '@codemirror/state': 6.7.1 + '@codemirror/view': 6.43.8 + '@lexical/clipboard': 0.48.0(typescript@5.9.3) + '@lexical/extension': 0.48.0(typescript@5.9.3) + '@lexical/history': 0.48.0(typescript@5.9.3) + '@lexical/link': 0.48.0(typescript@5.9.3) + '@lexical/list': 0.48.0(typescript@5.9.3) + '@lexical/markdown': 0.48.0(typescript@5.9.3) + '@lexical/plain-text': 0.48.0(typescript@5.9.3) + '@lexical/react': 0.48.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.9.3)(yjs@13.6.32) + '@lexical/rich-text': 0.48.0(typescript@5.9.3) + '@lexical/selection': 0.48.0(typescript@5.9.3) + '@lexical/utils': 0.48.0(typescript@5.9.3) + '@mdxeditor/gurx': 1.2.4(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/colors': 3.0.0 + '@radix-ui/react-dialog': 1.1.16(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-icons': 1.3.2(react@19.2.6) + '@radix-ui/react-popover': 1.1.16(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-popper': 1.3.0(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-select': 2.3.0(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-toggle-group': 1.1.12(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-toolbar': 1.1.12(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-tooltip': 1.2.9(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + classnames: 2.5.1 + cm6-theme-basic-light: 0.2.0(@codemirror/language@6.12.4)(@codemirror/state@6.7.1)(@codemirror/view@6.43.8)(@lezer/highlight@1.2.3) + codemirror: 6.0.2 + downshift: 7.6.2(react@19.2.6) + js-yaml: 4.3.0 + lexical: 0.48.0(typescript@5.9.3) + mdast-util-directive: 3.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-frontmatter: 2.0.1 + mdast-util-gfm-strikethrough: 2.0.0 + mdast-util-gfm-table: 2.0.0 + mdast-util-gfm-task-list-item: 2.0.0 + mdast-util-highlight-mark: 1.2.2 + mdast-util-mdx: 3.0.0 + mdast-util-mdx-jsx: 3.2.0 + mdast-util-to-markdown: 2.1.2 + micromark-extension-directive: 3.0.2 + micromark-extension-frontmatter: 2.0.0 + micromark-extension-gfm-strikethrough: 2.1.0 + micromark-extension-gfm-table: 2.1.1 + micromark-extension-gfm-task-list-item: 2.1.0 + micromark-extension-highlight-mark: 1.2.0 + micromark-extension-mdx-jsx: 3.0.2 + micromark-extension-mdx-md: 2.0.0 + micromark-extension-mdxjs: 3.0.0 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + react-hook-form: 7.77.0(react@19.2.6) + unidiff: 1.0.4 + transitivePeerDependencies: + - '@codemirror/language' + - '@lezer/highlight' + - '@types/react' + - '@types/react-dom' + - supports-color + - typescript + - yjs + + '@mdxeditor/gurx@1.2.4(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + '@microsoft/tsdoc@0.15.1': {} '@microsoft/tsdoc@0.16.0': {} @@ -14264,6 +15691,8 @@ snapshots: '@posthog/types@1.394.0': {} + '@preact/signals-core@1.14.4': {} + '@prisma/client@6.19.3(prisma@6.19.3(typescript@5.9.3))(typescript@5.9.3)': optionalDependencies: prisma: 6.19.3(typescript@5.9.3) @@ -14301,7 +15730,7 @@ snapshots: '@puppeteer/browsers@2.13.2': dependencies: - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) extract-zip: 2.0.1 progress: 2.0.3 proxy-agent: 6.5.0 @@ -14314,6 +15743,8 @@ snapshots: - react-native-b4a - supports-color + '@radix-ui/colors@3.0.0': {} + '@radix-ui/number@1.1.2': {} '@radix-ui/primitive@1.1.4': {} @@ -14806,6 +16237,10 @@ snapshots: '@types/react': 18.3.31 '@types/react-dom': 18.3.7(@types/react@18.3.31) + '@radix-ui/react-icons@1.3.2(react@19.2.6)': + dependencies: + react: 19.2.6 + '@radix-ui/react-id@1.1.2(@types/react@18.3.31)(react@18.3.1)': dependencies: '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@18.3.31)(react@18.3.1) @@ -16369,7 +17804,7 @@ snapshots: '@tokenizer/inflate@0.4.1': dependencies: - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) token-types: 6.1.2 transitivePeerDependencies: - supports-color @@ -16668,6 +18103,130 @@ snapshots: - utf-8-validate - vite + '@tria-plc/iamui@file:local-packages/tria-plc-iamui-0.1.1.tgz(a5d0bdee55164ae56064fb273b530e00)': + dependencies: + '@emotion/react': 11.14.0(@types/react@18.3.31)(react@19.2.6) + '@emotion/styled': 11.14.1(@emotion/react@11.14.0(@types/react@18.3.31)(react@19.2.6))(@types/react@18.3.31)(react@19.2.6) + '@hookform/resolvers': 5.4.0(react-hook-form@7.77.0(react@19.2.6)) + '@lottiefiles/react-lottie-player': 3.6.0(react@19.2.6) + '@mantine/charts': 7.17.8(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(recharts@3.8.1(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6)(redux@5.0.1)) + '@mantine/core': 7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@mantine/dates': 7.17.8(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@mantine/hooks': 7.17.8(react@19.2.6) + '@mantine/notifications': 7.17.8(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-accordion': 1.2.13(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-alert-dialog': 1.1.16(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-avatar': 1.1.12(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-checkbox': 1.3.4(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-collapsible': 1.1.13(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-context-menu': 2.3.0(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-dialog': 1.1.16(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-dropdown-menu': 2.1.17(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-hover-card': 1.1.16(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-label': 2.1.9(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-navigation-menu': 1.2.15(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-popover': 1.1.16(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-progress': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-radio-group': 1.4.0(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-scroll-area': 1.2.11(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-select': 2.3.0(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-separator': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-slot': 1.2.5(@types/react@18.3.31)(react@19.2.6) + '@radix-ui/react-switch': 1.3.0(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-tabs': 1.1.14(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-toast': 1.2.16(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-tooltip': 1.2.9(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@react-pdf-viewer/core': 3.12.0(pdfjs-dist@5.4.296)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@react-pdf-viewer/default-layout': 3.12.0(pdfjs-dist@5.4.296)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@react-pdf/renderer': 4.5.1(react@19.2.6) + '@reduxjs/toolkit': 2.12.0(react-redux@9.3.0(@types/react@18.3.31)(react@19.2.6)(redux@5.0.1))(react@19.2.6) + '@tabler/icons-react': 3.44.0(react@19.2.6) + '@tailwindcss/vite': 4.3.0(vite@5.4.21(@types/node@24.13.1)(lightningcss@1.32.0)(terser@5.48.0)) + '@tanstack/react-query': 5.101.0(react@19.2.6) + '@tanstack/react-query-devtools': 5.101.0(@tanstack/react-query@5.101.0(react@19.2.6))(react@19.2.6) + '@tanstack/react-table': 8.21.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@tinymce/tinymce-react': 6.3.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(tinymce@7.9.3) + '@types/dompurify': 3.2.0 + '@types/node': 24.13.1 + '@types/tinymce': 4.6.9 + axios: 1.17.0 + class-variance-authority: 0.7.1 + clsx: 2.1.1 + cmdk: 1.1.1(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + date-fns: 3.6.0 + dayjs: 1.11.21 + dompurify: 3.4.8 + ethiopian-calendar-date-converter: 2.1.6 + ethiopian-calendar-new: 1.1.0 + file-type: 18.7.0 + framer-motion: 12.40.0(@emotion/is-prop-valid@1.4.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + html2canvas: 1.4.1 + i18next: 25.10.10(typescript@5.9.3) + i18next-browser-languagedetector: 8.2.1 + jquery: 3.7.1 + js-cookie: 3.0.8 + jspdf: 3.0.4 + lodash: 4.18.1 + lucide-react: 0.513.0(react@19.2.6) + mantine-react-table: 2.0.0-beta.9(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/dates@7.17.8(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(@tabler/icons-react@3.44.0(react@19.2.6))(clsx@2.1.1)(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + mui-ethiopian-datepicker: 0.3.2(4b3af212eafdf0059f009b005d7e343d) + next-themes: 0.4.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + path: 0.12.7 + pdf-lib: 1.17.1 + qs: 6.15.2 + react: 19.2.6 + react-cookie: 8.1.2(@types/react@18.3.31)(react@19.2.6) + react-css-nocode-editor: 1.0.13(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6) + react-day-picker: 8.10.2(date-fns@3.6.0)(react@19.2.6) + react-dom: 19.2.6(react@19.2.6) + react-dropzone: 14.4.1(react@19.2.6) + react-hook-form: 7.77.0(react@19.2.6) + react-i18next: 15.7.4(i18next@25.10.10(typescript@5.9.3))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.9.3) + react-icons: 5.6.0(react@19.2.6) + react-image-crop: 11.0.10(react@19.2.6) + react-intersection-observer: 9.16.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + react-pdf: 10.4.1(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + react-pdf-html: 2.1.5(@react-pdf/renderer@4.5.1(react@19.2.6))(react@19.2.6) + react-redux: 9.3.0(@types/react@18.3.31)(react@19.2.6)(redux@5.0.1) + react-resizable-panels: 3.0.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + react-router-dom: 7.17.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + react-signature-canvas: 1.1.0-alpha.2(@types/prop-types@15.7.15)(@types/react@18.3.31)(prop-types@15.8.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + recharts: 3.8.1(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6)(redux@5.0.1) + rollup-plugin-visualizer: 7.0.1(rollup@4.61.1) + socket.io-client: 4.8.3 + sonner: 2.0.7(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + tailwind-merge: 3.6.0 + tailwind-scrollbar-hide: 4.0.0(tailwindcss@4.3.0) + tailwindcss: 4.3.0 + tailwindcss-animate: 1.0.7(tailwindcss@4.3.0) + tinymce: 7.9.3 + url: 0.11.4 + vaul: 1.1.2(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + xlsx: 0.18.5 + zod: 3.25.76 + transitivePeerDependencies: + - '@babel/core' + - '@emotion/is-prop-valid' + - '@mui/icons-material' + - '@mui/material' + - '@mui/x-date-pickers' + - '@types/prop-types' + - '@types/react' + - '@types/react-dom' + - bufferutil + - debug + - pdfjs-dist + - prop-types + - react-is + - react-native + - redux + - rolldown + - rollup + - supports-color + - typescript + - utf-8-validate + - vite + '@ts-morph/common@0.27.0': dependencies: fast-glob: 3.3.3 @@ -16779,6 +18338,10 @@ snapshots: '@types/d3-timer@3.0.2': {} + '@types/debug@4.1.13': + dependencies: + '@types/ms': 2.1.0 + '@types/dompurify@3.2.0': dependencies: dompurify: 3.4.8 @@ -16793,6 +18356,10 @@ snapshots: '@types/estree': 1.0.9 '@types/json-schema': 7.0.15 + '@types/estree-jsx@1.0.5': + dependencies: + '@types/estree': 1.0.9 + '@types/estree@1.0.9': {} '@types/express-serve-static-core@4.19.8': @@ -16828,6 +18395,10 @@ snapshots: dependencies: '@types/node': 20.19.42 + '@types/hast@3.0.5': + dependencies: + '@types/unist': 3.0.3 + '@types/hoist-non-react-statics@3.3.7(@types/react@18.3.31)': dependencies: '@types/react': 18.3.31 @@ -16871,6 +18442,10 @@ snapshots: '@types/luxon@3.7.1': {} + '@types/mdast@4.0.4': + dependencies: + '@types/unist': 3.0.3 + '@types/methods@1.1.4': {} '@types/mime@1.3.5': {} @@ -16994,8 +18569,11 @@ snapshots: '@types/tmp@0.2.6': {} - '@types/trusted-types@2.0.7': - optional: true + '@types/trusted-types@2.0.7': {} + + '@types/unist@2.0.11': {} + + '@types/unist@3.0.3': {} '@types/use-sync-external-store@0.0.6': {} @@ -17046,7 +18624,7 @@ snapshots: '@typescript-eslint/types': 8.60.1 '@typescript-eslint/typescript-estree': 8.60.1(typescript@5.9.3) '@typescript-eslint/visitor-keys': 8.60.1 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) eslint: 8.57.1 typescript: 5.9.3 transitivePeerDependencies: @@ -17056,7 +18634,7 @@ snapshots: dependencies: '@typescript-eslint/tsconfig-utils': 8.60.1(typescript@5.9.3) '@typescript-eslint/types': 8.60.1 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) typescript: 5.9.3 transitivePeerDependencies: - supports-color @@ -17075,7 +18653,7 @@ snapshots: '@typescript-eslint/types': 8.60.1 '@typescript-eslint/typescript-estree': 8.60.1(typescript@5.9.3) '@typescript-eslint/utils': 8.60.1(eslint@8.57.1)(typescript@5.9.3) - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) eslint: 8.57.1 ts-api-utils: 2.5.0(typescript@5.9.3) typescript: 5.9.3 @@ -17090,7 +18668,7 @@ snapshots: '@typescript-eslint/tsconfig-utils': 8.60.1(typescript@5.9.3) '@typescript-eslint/types': 8.60.1 '@typescript-eslint/visitor-keys': 8.60.1 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) minimatch: 10.2.5 semver: 7.8.2 tinyglobby: 0.2.17 @@ -17379,7 +18957,7 @@ snapshots: agent-base@6.0.2: dependencies: - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) transitivePeerDependencies: - supports-color @@ -17893,6 +19471,16 @@ snapshots: transitivePeerDependencies: - supports-color + babel-plugin-styled-components@2.3.0(styled-components@5.3.11(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6))(supports-color@5.5.0): + dependencies: + '@babel/helper-annotate-as-pure': 7.29.7 + '@babel/helper-module-imports': 7.29.7(supports-color@5.5.0) + '@babel/plugin-syntax-jsx': 7.29.7(@babel/core@7.29.7) + picomatch: 4.0.4 + styled-components: 5.3.11(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6) + transitivePeerDependencies: + - supports-color + babel-polyfill@6.26.0: dependencies: babel-runtime: 6.26.0 @@ -17929,6 +19517,8 @@ snapshots: core-js: 2.6.12 regenerator-runtime: 0.11.1 + bail@2.0.2: {} + balanced-match@1.0.2: {} balanced-match@4.0.4: {} @@ -18046,7 +19636,7 @@ snapshots: dependencies: bytes: 3.1.2 content-type: 1.0.5 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) http-errors: 2.0.1 iconv-lite: 0.7.2 on-finished: 2.4.1 @@ -18222,6 +19812,8 @@ snapshots: caseless@0.12.0: {} + ccount@2.0.1: {} + cfb@1.2.2: dependencies: adler-32: 1.3.1 @@ -18258,6 +19850,14 @@ snapshots: char-regex@1.0.2: {} + character-entities-html4@2.1.0: {} + + character-entities-legacy@3.0.0: {} + + character-entities@2.0.2: {} + + character-reference-invalid@2.0.1: {} + chardet@2.1.1: {} check-error@2.1.3: {} @@ -18385,6 +19985,13 @@ snapshots: clsx@2.1.1: {} + cm6-theme-basic-light@0.2.0(@codemirror/language@6.12.4)(@codemirror/state@6.7.1)(@codemirror/view@6.43.8)(@lezer/highlight@1.2.3): + dependencies: + '@codemirror/language': 6.12.4 + '@codemirror/state': 6.7.1 + '@codemirror/view': 6.43.8 + '@lezer/highlight': 1.2.3 + cmdk@1.1.1(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6): dependencies: '@radix-ui/react-compose-refs': 1.1.3(@types/react@18.3.31)(react@19.2.6) @@ -18403,6 +20010,16 @@ snapshots: code-point-at@1.1.0: {} + codemirror@6.0.2: + dependencies: + '@codemirror/autocomplete': 6.20.3 + '@codemirror/commands': 6.10.4 + '@codemirror/language': 6.12.4 + '@codemirror/lint': 6.9.7 + '@codemirror/search': 6.7.1 + '@codemirror/state': 6.7.1 + '@codemirror/view': 6.43.8 + codepage@1.15.0: {} collect-v8-coverage@1.0.3: {} @@ -18433,6 +20050,8 @@ snapshots: dependencies: delayed-stream: 1.0.0 + comma-separated-tokens@2.0.3: {} + commander@11.1.0: {} commander@13.1.0: {} @@ -18466,6 +20085,8 @@ snapshots: normalize-path: 3.0.0 readable-stream: 3.6.2 + compute-scroll-into-view@2.0.4: {} + concat-map@0.0.1: {} concat-stream@2.0.0: @@ -18603,6 +20224,8 @@ snapshots: create-require@1.1.1: {} + crelt@1.0.7: {} + cron@4.4.0: dependencies: '@types/luxon': 3.7.1 @@ -18820,6 +20443,10 @@ snapshots: decimal.js@10.6.0: {} + decode-named-character-reference@1.3.0: + dependencies: + character-entities: 2.0.2 + decode-uri-component@0.2.2: {} dedent@1.7.2(babel-plugin-macros@3.1.0): @@ -18902,6 +20529,10 @@ snapshots: detect-node-es@1.1.0: {} + devlop@1.1.0: + dependencies: + dequal: 2.0.3 + devtools-protocol@0.0.1608973: {} dezalgo@1.0.4: @@ -18917,6 +20548,8 @@ snapshots: diff@4.0.4: {} + diff@5.2.2: {} + diff@8.0.4: {} dijkstrajs@1.0.3: {} @@ -18979,6 +20612,15 @@ snapshots: dotenv@17.4.2: {} + downshift@7.6.2(react@19.2.6): + dependencies: + '@babel/runtime': 7.29.7 + compute-scroll-into-view: 2.0.4 + prop-types: 15.8.1 + react: 19.2.6 + react-is: 17.0.2 + tslib: 2.8.1 + dunder-proto@1.0.1: dependencies: call-bind-apply-helpers: 1.0.2 @@ -19043,7 +20685,7 @@ snapshots: engine.io-client@6.6.5: dependencies: '@socket.io/component-emitter': 3.1.2 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) engine.io-parser: 5.2.3 ws: 8.20.1 xmlhttprequest-ssl: 2.1.2 @@ -19063,7 +20705,7 @@ snapshots: base64id: 2.0.0 cookie: 0.7.2 cors: 2.8.6 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) engine.io-parser: 5.2.3 ws: 8.21.0 transitivePeerDependencies: @@ -19249,6 +20891,8 @@ snapshots: escape-string-regexp@4.0.0: {} + escape-string-regexp@5.0.0: {} + escodegen@2.1.0: dependencies: esprima: 4.0.1 @@ -19292,7 +20936,7 @@ snapshots: eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.60.1(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1): dependencies: '@nolyfill/is-core-module': 1.0.39 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) eslint: 8.57.1 get-tsconfig: 4.14.0 is-bun-module: 2.0.0 @@ -19420,7 +21064,7 @@ snapshots: ajv: 6.15.0 chalk: 4.1.2 cross-spawn: 7.0.6 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) doctrine: 3.0.0 escape-string-regexp: 4.0.0 eslint-scope: 7.2.2 @@ -19470,6 +21114,13 @@ snapshots: estraverse@5.3.0: {} + estree-util-is-identifier-name@3.0.0: {} + + estree-util-visit@2.0.0: + dependencies: + '@types/estree-jsx': 1.0.5 + '@types/unist': 3.0.3 + estree-walker@3.0.3: dependencies: '@types/estree': 1.0.9 @@ -19650,7 +21301,7 @@ snapshots: content-type: 1.0.5 cookie: 0.7.2 cookie-signature: 1.2.2 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) depd: 2.0.0 encodeurl: 2.0.0 escape-html: 1.0.3 @@ -19703,7 +21354,7 @@ snapshots: extract-zip@2.0.1: dependencies: - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) get-stream: 5.2.0 yauzl: 2.10.0 optionalDependencies: @@ -19774,6 +21425,10 @@ snapshots: dependencies: reusify: 1.1.0 + fault@2.0.1: + dependencies: + format: 0.2.2 + fb-watchman@2.0.2: dependencies: bser: 2.1.1 @@ -19854,7 +21509,7 @@ snapshots: finalhandler@2.1.1: dependencies: - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) encodeurl: 2.0.0 escape-html: 1.0.3 on-finished: 2.4.1 @@ -19947,6 +21602,8 @@ snapshots: hasown: 2.0.4 mime-types: 2.1.35 + format@0.2.2: {} + formdata-polyfill@4.0.10: dependencies: fetch-blob: 3.2.0 @@ -20100,7 +21757,7 @@ snapshots: dependencies: basic-ftp: 5.3.1 data-uri-to-buffer: 6.0.2 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) transitivePeerDependencies: - supports-color @@ -20306,6 +21963,30 @@ snapshots: dependencies: function-bind: 1.1.2 + hast-util-to-jsx-runtime@2.3.6: + dependencies: + '@types/estree': 1.0.9 + '@types/hast': 3.0.5 + '@types/unist': 3.0.3 + comma-separated-tokens: 2.0.3 + devlop: 1.1.0 + estree-util-is-identifier-name: 3.0.0 + hast-util-whitespace: 3.0.0 + mdast-util-mdx-expression: 2.0.1 + mdast-util-mdx-jsx: 3.2.0 + mdast-util-mdxjs-esm: 2.0.1 + property-information: 7.2.0 + space-separated-tokens: 2.0.2 + style-to-js: 1.1.21 + unist-util-position: 5.0.0 + vfile-message: 4.0.3 + transitivePeerDependencies: + - supports-color + + hast-util-whitespace@3.0.0: + dependencies: + '@types/hast': 3.0.5 + he@1.2.0: {} headers-polyfill@5.0.1: @@ -20365,6 +22046,8 @@ snapshots: is-self-closing: 1.0.1 kind-of: 6.0.3 + html-url-attributes@3.0.1: {} + html2canvas@1.4.1: dependencies: css-line-break: 2.1.0 @@ -20381,7 +22064,7 @@ snapshots: http-proxy-agent@7.0.2: dependencies: agent-base: 7.1.4 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) transitivePeerDependencies: - supports-color @@ -20394,14 +22077,14 @@ snapshots: https-proxy-agent@5.0.1: dependencies: agent-base: 6.0.2 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) transitivePeerDependencies: - supports-color https-proxy-agent@7.0.6: dependencies: agent-base: 7.1.4 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) transitivePeerDependencies: - supports-color @@ -20486,6 +22169,8 @@ snapshots: ini@4.1.1: {} + inline-style-parser@0.2.7: {} + input-format@0.3.14(react-dom@19.2.6(react@19.2.6))(react@19.2.6): dependencies: prop-types: 15.8.1 @@ -20528,6 +22213,13 @@ snapshots: dependencies: hasown: 2.0.4 + is-alphabetical@2.0.1: {} + + is-alphanumerical@2.0.1: + dependencies: + is-alphabetical: 2.0.1 + is-decimal: 2.0.1 + is-arguments@1.2.0: dependencies: call-bound: 1.0.4 @@ -20589,6 +22281,8 @@ snapshots: call-bound: 1.0.4 has-tostringtag: 1.0.2 + is-decimal@2.0.1: {} + is-descriptor@0.1.8: dependencies: is-accessor-descriptor: 1.0.2 @@ -20645,6 +22339,8 @@ snapshots: dependencies: is-extglob: 2.1.1 + is-hexadecimal@2.0.1: {} + is-in-ssh@1.0.0: {} is-inside-container@1.0.0: @@ -20794,6 +22490,8 @@ snapshots: isobject@3.0.1: {} + isomorphic.js@0.2.5: {} + isstream@0.1.2: {} istanbul-lib-coverage@3.2.2: {} @@ -20826,7 +22524,7 @@ snapshots: istanbul-lib-source-maps@4.0.1: dependencies: - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) istanbul-lib-coverage: 3.2.2 source-map: 0.6.1 transitivePeerDependencies: @@ -21221,6 +22919,10 @@ snapshots: dependencies: argparse: 2.0.1 + js-yaml@4.3.0: + dependencies: + argparse: 2.0.1 + jsbn@0.1.1: {} jsdom@25.0.1: @@ -21399,6 +23101,16 @@ snapshots: prelude-ls: 1.2.1 type-check: 0.4.0 + lexical@0.48.0(typescript@5.9.3): + dependencies: + '@lexical/internal': 0.48.0(typescript@5.9.3) + optionalDependencies: + typescript: 5.9.3 + + lib0@0.2.117: + dependencies: + isomorphic.js: 0.2.5 + libphonenumber-js@1.13.6: {} libreoffice-convert@1.8.1: @@ -21472,7 +23184,7 @@ snapshots: dependencies: chalk: 5.6.2 commander: 13.1.0 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) execa: 8.0.1 lilconfig: 3.1.3 listr2: 8.3.3 @@ -21648,6 +23360,8 @@ snapshots: isobject: 3.0.1 log-utils: 0.2.1 + longest-streak@3.1.0: {} + loose-envify@1.4.0: dependencies: js-tokens: 4.0.0 @@ -21730,8 +23444,165 @@ snapshots: dependencies: object-visit: 1.0.1 + markdown-table@3.0.4: {} + math-intrinsics@1.1.0: {} + mdast-util-directive@3.1.0: + dependencies: + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + ccount: 2.0.1 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + parse-entities: 4.0.2 + stringify-entities: 4.0.4 + unist-util-visit-parents: 6.0.2 + transitivePeerDependencies: + - supports-color + + mdast-util-from-markdown@2.0.3: + dependencies: + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + decode-named-character-reference: 1.3.0 + devlop: 1.1.0 + mdast-util-to-string: 4.0.0 + micromark: 4.0.2 + micromark-util-decode-numeric-character-reference: 2.0.2 + micromark-util-decode-string: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + unist-util-stringify-position: 4.0.0 + transitivePeerDependencies: + - supports-color + + mdast-util-frontmatter@2.0.1: + dependencies: + '@types/mdast': 4.0.4 + devlop: 1.1.0 + escape-string-regexp: 5.0.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + micromark-extension-frontmatter: 2.0.0 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-strikethrough@2.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-table@2.0.0: + dependencies: + '@types/mdast': 4.0.4 + devlop: 1.1.0 + markdown-table: 3.0.4 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-task-list-item@2.0.0: + dependencies: + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-highlight-mark@1.2.2: + dependencies: + micromark-extension-highlight-mark: 1.2.0 + + mdast-util-mdx-expression@2.0.1: + dependencies: + '@types/estree-jsx': 1.0.5 + '@types/hast': 3.0.5 + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-mdx-jsx@3.2.0: + dependencies: + '@types/estree-jsx': 1.0.5 + '@types/hast': 3.0.5 + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + ccount: 2.0.1 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + parse-entities: 4.0.2 + stringify-entities: 4.0.4 + unist-util-stringify-position: 4.0.0 + vfile-message: 4.0.3 + transitivePeerDependencies: + - supports-color + + mdast-util-mdx@3.0.0: + dependencies: + mdast-util-from-markdown: 2.0.3 + mdast-util-mdx-expression: 2.0.1 + mdast-util-mdx-jsx: 3.2.0 + mdast-util-mdxjs-esm: 2.0.1 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-mdxjs-esm@2.0.1: + dependencies: + '@types/estree-jsx': 1.0.5 + '@types/hast': 3.0.5 + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-phrasing@4.1.0: + dependencies: + '@types/mdast': 4.0.4 + unist-util-is: 6.0.1 + + mdast-util-to-hast@13.2.1: + dependencies: + '@types/hast': 3.0.5 + '@types/mdast': 4.0.4 + '@ungap/structured-clone': 1.3.1 + devlop: 1.1.0 + micromark-util-sanitize-uri: 2.0.1 + trim-lines: 3.0.1 + unist-util-position: 5.0.0 + unist-util-visit: 5.1.0 + vfile: 6.0.3 + + mdast-util-to-markdown@2.1.2: + dependencies: + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + longest-streak: 3.1.0 + mdast-util-phrasing: 4.1.0 + mdast-util-to-string: 4.0.0 + micromark-util-classify-character: 2.0.1 + micromark-util-decode-string: 2.0.1 + unist-util-visit: 5.1.0 + zwitch: 2.0.4 + + mdast-util-to-string@4.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdn-data@2.0.14: {} media-engine@1.0.3: {} @@ -21762,6 +23633,263 @@ snapshots: methods@1.1.2: {} + micromark-core-commonmark@2.0.3: + dependencies: + decode-named-character-reference: 1.3.0 + devlop: 1.1.0 + micromark-factory-destination: 2.0.1 + micromark-factory-label: 2.0.1 + micromark-factory-space: 2.0.1 + micromark-factory-title: 2.0.1 + micromark-factory-whitespace: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-chunked: 2.0.1 + micromark-util-classify-character: 2.0.1 + micromark-util-html-tag-name: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-subtokenize: 2.1.0 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-directive@3.0.2: + dependencies: + devlop: 1.1.0 + micromark-factory-space: 2.0.1 + micromark-factory-whitespace: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + parse-entities: 4.0.2 + + micromark-extension-frontmatter@2.0.0: + dependencies: + fault: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-strikethrough@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-util-chunked: 2.0.1 + micromark-util-classify-character: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-table@2.1.1: + dependencies: + devlop: 1.1.0 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-task-list-item@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-highlight-mark@1.2.0: + dependencies: + micromark-util-chunked: 2.0.1 + micromark-util-classify-character: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + uvu: 0.5.6 + + micromark-extension-mdx-expression@3.0.1: + dependencies: + '@types/estree': 1.0.9 + devlop: 1.1.0 + micromark-factory-mdx-expression: 2.0.3 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-events-to-acorn: 2.0.3 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-mdx-jsx@3.0.2: + dependencies: + '@types/estree': 1.0.9 + devlop: 1.1.0 + estree-util-is-identifier-name: 3.0.0 + micromark-factory-mdx-expression: 2.0.3 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-events-to-acorn: 2.0.3 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + vfile-message: 4.0.3 + + micromark-extension-mdx-md@2.0.0: + dependencies: + micromark-util-types: 2.0.2 + + micromark-extension-mdxjs-esm@3.0.0: + dependencies: + '@types/estree': 1.0.9 + devlop: 1.1.0 + micromark-core-commonmark: 2.0.3 + micromark-util-character: 2.1.1 + micromark-util-events-to-acorn: 2.0.3 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + unist-util-position-from-estree: 2.0.0 + vfile-message: 4.0.3 + + micromark-extension-mdxjs@3.0.0: + dependencies: + acorn: 8.16.0 + acorn-jsx: 5.3.2(acorn@8.16.0) + micromark-extension-mdx-expression: 3.0.1 + micromark-extension-mdx-jsx: 3.0.2 + micromark-extension-mdx-md: 2.0.0 + micromark-extension-mdxjs-esm: 3.0.0 + micromark-util-combine-extensions: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-destination@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-label@2.0.1: + dependencies: + devlop: 1.1.0 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-mdx-expression@2.0.3: + dependencies: + '@types/estree': 1.0.9 + devlop: 1.1.0 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-events-to-acorn: 2.0.3 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + unist-util-position-from-estree: 2.0.0 + vfile-message: 4.0.3 + + micromark-factory-space@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-types: 2.0.2 + + micromark-factory-title@2.0.1: + dependencies: + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-whitespace@2.0.1: + dependencies: + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-character@2.1.1: + dependencies: + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-chunked@2.0.1: + dependencies: + micromark-util-symbol: 2.0.1 + + micromark-util-classify-character@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-combine-extensions@2.0.1: + dependencies: + micromark-util-chunked: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-decode-numeric-character-reference@2.0.2: + dependencies: + micromark-util-symbol: 2.0.1 + + micromark-util-decode-string@2.0.1: + dependencies: + decode-named-character-reference: 1.3.0 + micromark-util-character: 2.1.1 + micromark-util-decode-numeric-character-reference: 2.0.2 + micromark-util-symbol: 2.0.1 + + micromark-util-encode@2.0.1: {} + + micromark-util-events-to-acorn@2.0.3: + dependencies: + '@types/estree': 1.0.9 + '@types/unist': 3.0.3 + devlop: 1.1.0 + estree-util-visit: 2.0.0 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + vfile-message: 4.0.3 + + micromark-util-html-tag-name@2.0.1: {} + + micromark-util-normalize-identifier@2.0.1: + dependencies: + micromark-util-symbol: 2.0.1 + + micromark-util-resolve-all@2.0.1: + dependencies: + micromark-util-types: 2.0.2 + + micromark-util-sanitize-uri@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-encode: 2.0.1 + micromark-util-symbol: 2.0.1 + + micromark-util-subtokenize@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-util-chunked: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-symbol@2.0.1: {} + + micromark-util-types@2.0.2: {} + + micromark@4.0.2: + dependencies: + '@types/debug': 4.1.13 + debug: 4.4.3(supports-color@8.1.1) + decode-named-character-reference: 1.3.0 + devlop: 1.1.0 + micromark-core-commonmark: 2.0.3 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-chunked: 2.0.1 + micromark-util-combine-extensions: 2.0.1 + micromark-util-decode-numeric-character-reference: 2.0.2 + micromark-util-encode: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-sanitize-uri: 2.0.1 + micromark-util-subtokenize: 2.1.0 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + transitivePeerDependencies: + - supports-color + micromatch@3.1.10: dependencies: arr-diff: 4.0.0 @@ -22269,7 +24397,7 @@ snapshots: dependencies: '@tootallnate/quickjs-emscripten': 0.23.0 agent-base: 7.1.4 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) get-uri: 6.0.5 http-proxy-agent: 7.0.2 https-proxy-agent: 7.0.6 @@ -22297,6 +24425,16 @@ snapshots: dependencies: callsites: 3.1.0 + parse-entities@4.0.2: + dependencies: + '@types/unist': 2.0.11 + character-entities-legacy: 3.0.0 + character-reference-invalid: 2.0.1 + decode-named-character-reference: 1.3.0 + is-alphanumerical: 2.0.1 + is-decimal: 2.0.1 + is-hexadecimal: 2.0.1 + parse-json@5.2.0: dependencies: '@babel/code-frame': 7.29.7 @@ -22599,6 +24737,8 @@ snapshots: object-assign: 4.1.1 react-is: 16.13.1 + property-information@7.2.0: {} + proxy-addr@2.0.7: dependencies: forwarded: 0.2.0 @@ -22607,7 +24747,7 @@ snapshots: proxy-agent@6.5.0: dependencies: agent-base: 7.1.4 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) http-proxy-agent: 7.0.2 https-proxy-agent: 7.0.6 lru-cache: 7.18.3 @@ -22636,7 +24776,7 @@ snapshots: dependencies: '@puppeteer/browsers': 2.13.2 chromium-bidi: 14.0.0(devtools-protocol@0.0.1608973) - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) devtools-protocol: 0.0.1608973 typed-query-selector: 2.12.2 webdriver-bidi-protocol: 0.4.1 @@ -22889,6 +25029,15 @@ snapshots: - '@babel/core' - react-is + react-css-nocode-editor@1.0.13(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6): + dependencies: + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + styled-components: 5.3.11(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6) + transitivePeerDependencies: + - '@babel/core' + - react-is + react-day-picker@8.10.2(date-fns@3.6.0)(react@19.2.6): dependencies: date-fns: 3.6.0 @@ -22980,10 +25129,30 @@ snapshots: react-is@16.13.1: {} + react-is@17.0.2: {} + react-is@18.3.1: {} react-is@19.2.7: {} + react-markdown@9.1.0(@types/react@18.3.31)(react@19.2.6): + dependencies: + '@types/hast': 3.0.5 + '@types/mdast': 4.0.4 + '@types/react': 18.3.31 + devlop: 1.1.0 + hast-util-to-jsx-runtime: 2.3.6 + html-url-attributes: 3.0.1 + mdast-util-to-hast: 13.2.1 + react: 19.2.6 + remark-parse: 11.0.0 + remark-rehype: 11.1.2 + unified: 11.0.5 + unist-util-visit: 5.1.0 + vfile: 6.0.3 + transitivePeerDependencies: + - supports-color + react-number-format@5.4.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: react: 18.3.1 @@ -23327,6 +25496,23 @@ snapshots: dependencies: isobject: 2.1.0 + remark-parse@11.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-from-markdown: 2.0.3 + micromark-util-types: 2.0.2 + unified: 11.0.5 + transitivePeerDependencies: + - supports-color + + remark-rehype@11.1.2: + dependencies: + '@types/hast': 3.0.5 + '@types/mdast': 4.0.4 + mdast-util-to-hast: 13.2.1 + unified: 11.0.5 + vfile: 6.0.3 + remarkable@1.7.4: dependencies: argparse: 1.0.10 @@ -23462,7 +25648,7 @@ snapshots: router@2.2.0: dependencies: - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) depd: 2.0.0 is-promise: 4.0.0 parseurl: 1.3.3 @@ -23494,6 +25680,10 @@ snapshots: dependencies: tslib: 2.8.1 + sade@1.8.1: + dependencies: + mri: 1.2.0 + safe-array-concat@1.1.4: dependencies: call-bind: 1.0.9 @@ -23580,7 +25770,7 @@ snapshots: send@1.2.1: dependencies: - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) encodeurl: 2.0.0 escape-html: 1.0.3 etag: 1.8.1 @@ -23796,7 +25986,7 @@ snapshots: socket.io-adapter@2.5.8: dependencies: - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) ws: 8.21.0 transitivePeerDependencies: - bufferutil @@ -23806,7 +25996,7 @@ snapshots: socket.io-client@4.8.3: dependencies: '@socket.io/component-emitter': 3.1.2 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) engine.io-client: 6.6.5 socket.io-parser: 4.2.6 transitivePeerDependencies: @@ -23817,7 +26007,7 @@ snapshots: socket.io-parser@4.2.6: dependencies: '@socket.io/component-emitter': 3.1.2 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) transitivePeerDependencies: - supports-color @@ -23826,7 +26016,7 @@ snapshots: accepts: 1.3.8 base64id: 2.0.0 cors: 2.8.6 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) engine.io: 6.6.9 socket.io-adapter: 2.5.8 socket.io-parser: 4.2.6 @@ -23838,7 +26028,7 @@ snapshots: socks-proxy-agent@8.0.5: dependencies: agent-base: 7.1.4 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) socks: 2.8.9 transitivePeerDependencies: - supports-color @@ -23883,6 +26073,8 @@ snapshots: source-map@0.7.6: {} + space-separated-tokens@2.0.2: {} + split-on-first@1.1.0: {} split-string@3.1.0: @@ -24053,6 +26245,11 @@ snapshots: dependencies: safe-buffer: 5.2.1 + stringify-entities@4.0.4: + dependencies: + character-entities-html4: 2.1.0 + character-entities-legacy: 3.0.0 + stringify-object@5.0.0: dependencies: get-own-enumerable-keys: 1.0.0 @@ -24096,8 +26293,18 @@ snapshots: '@tokenizer/token': 0.3.0 peek-readable: 5.4.2 + style-mod@4.1.3: {} + style-object-to-css-string@1.1.3: {} + style-to-js@1.1.21: + dependencies: + style-to-object: 1.0.14 + + style-to-object@1.0.14: + dependencies: + inline-style-parser: 0.2.7 + styled-components@5.3.11(@babel/core@7.29.7)(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6): dependencies: '@babel/helper-module-imports': 7.29.7(supports-color@5.5.0) @@ -24116,6 +26323,24 @@ snapshots: transitivePeerDependencies: - '@babel/core' + styled-components@5.3.11(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6): + dependencies: + '@babel/helper-module-imports': 7.29.7(supports-color@5.5.0) + '@babel/traverse': 7.29.7(supports-color@5.5.0) + '@emotion/is-prop-valid': 1.4.0 + '@emotion/stylis': 0.8.5 + '@emotion/unitless': 0.7.5 + babel-plugin-styled-components: 2.3.0(styled-components@5.3.11(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6))(supports-color@5.5.0) + css-to-react-native: 3.2.0 + hoist-non-react-statics: 3.3.2 + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + react-is: 19.2.7 + shallowequal: 1.1.0 + supports-color: 5.5.0 + transitivePeerDependencies: + - '@babel/core' + styled-jsx@5.1.1(babel-plugin-macros@3.1.0)(react@18.3.1): dependencies: client-only: 0.0.1 @@ -24141,7 +26366,7 @@ snapshots: dependencies: component-emitter: 1.3.1 cookiejar: 2.1.4 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) fast-safe-stringify: 2.1.1 form-data: 4.0.5 formidable: 3.5.4 @@ -24441,6 +26666,10 @@ snapshots: trim-canvas@0.1.2: {} + trim-lines@3.0.1: {} + + trough@2.2.0: {} + ts-api-utils@2.5.0(typescript@5.9.3): dependencies: typescript: 5.9.3 @@ -24650,7 +26879,7 @@ snapshots: app-root-path: 3.1.0 buffer: 6.0.3 dayjs: 1.11.21 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) dedent: 1.7.2(babel-plugin-macros@3.1.0) dotenv: 16.6.1 glob: 10.5.0 @@ -24674,7 +26903,7 @@ snapshots: app-root-path: 3.1.0 buffer: 6.0.3 dayjs: 1.11.21 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) dedent: 1.7.2(babel-plugin-macros@3.1.0) dotenv: 16.6.1 glob: 10.5.0 @@ -24727,6 +26956,20 @@ snapshots: unicorn-magic@0.3.0: {} + unidiff@1.0.4: + dependencies: + diff: 5.2.2 + + unified@11.0.5: + dependencies: + '@types/unist': 3.0.3 + bail: 2.0.2 + devlop: 1.1.0 + extend: 3.0.2 + is-plain-obj: 4.1.0 + trough: 2.2.0 + vfile: 6.0.3 + union-value@1.0.1: dependencies: arr-union: 3.1.0 @@ -24734,6 +26977,33 @@ snapshots: is-extendable: 0.1.1 set-value: 2.0.1 + unist-util-is@6.0.1: + dependencies: + '@types/unist': 3.0.3 + + unist-util-position-from-estree@2.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-position@5.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-stringify-position@4.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-visit-parents@6.0.2: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + + unist-util-visit@5.1.0: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + unist-util-visit-parents: 6.0.2 + universal-cookie@8.1.2: dependencies: cookie: 1.1.1 @@ -24905,6 +27175,13 @@ snapshots: uuid@8.3.2: {} + uvu@0.5.6: + dependencies: + dequal: 2.0.3 + diff: 5.2.2 + kleur: 4.1.5 + sade: 1.8.1 + v8-compile-cache-lib@3.0.1: {} v8-to-istanbul@9.3.0: @@ -24934,6 +27211,16 @@ snapshots: core-util-is: 1.0.2 extsprintf: 1.3.0 + vfile-message@4.0.3: + dependencies: + '@types/unist': 3.0.3 + unist-util-stringify-position: 4.0.0 + + vfile@6.0.3: + dependencies: + '@types/unist': 3.0.3 + vfile-message: 4.0.3 + victory-vendor@36.9.2: dependencies: '@types/d3-array': 3.2.2 @@ -24977,7 +27264,7 @@ snapshots: vite-node@2.1.9(@types/node@22.20.1)(lightningcss@1.32.0)(terser@5.48.0): dependencies: cac: 6.7.14 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) es-module-lexer: 1.7.0 pathe: 1.1.2 vite: 5.4.21(@types/node@22.20.1)(lightningcss@1.32.0)(terser@5.48.0) @@ -24995,7 +27282,7 @@ snapshots: vite-node@2.1.9(@types/node@24.13.1)(lightningcss@1.32.0)(terser@5.48.0): dependencies: cac: 6.7.14 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) es-module-lexer: 1.7.0 pathe: 1.1.2 vite: 5.4.21(@types/node@24.13.1)(lightningcss@1.32.0)(terser@5.48.0) @@ -25042,7 +27329,7 @@ snapshots: '@vitest/spy': 2.1.9 '@vitest/utils': 2.1.9 chai: 5.3.3 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) expect-type: 1.3.0 magic-string: 0.30.21 pathe: 1.1.2 @@ -25078,7 +27365,7 @@ snapshots: '@vitest/spy': 2.1.9 '@vitest/utils': 2.1.9 chai: 5.3.3 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) expect-type: 1.3.0 magic-string: 0.30.21 pathe: 1.1.2 @@ -25119,6 +27406,8 @@ snapshots: strip-ansi: 3.0.1 wrap-ansi: 2.1.0 + w3c-keyname@2.2.8: {} + w3c-xmlserializer@5.0.0: dependencies: xml-name-validator: 5.0.0 @@ -25411,6 +27700,10 @@ snapshots: year@0.2.1: {} + yjs@13.6.32: + dependencies: + lib0: 0.2.117 + yn@3.1.1: {} yocto-queue@0.1.0: {} @@ -25454,3 +27747,5 @@ snapshots: immer: 11.1.8 react: 19.2.6 use-sync-external-store: 1.6.0(react@19.2.6) + + zwitch@2.0.4: {} diff --git a/portal-content-contact.png b/portal-content-contact.png new file mode 100644 index 0000000000000000000000000000000000000000..dfbf73fc54ddd3694ebb8b876aff7783d33bbc01 GIT binary patch literal 74931 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

    %*LS;`_1bOW2X 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 b&1Yy^L0rhie(XNf6|_%MN|I$CjlTZ}c}nU` literal 0 HcmV?d00001 From 229d9c66513a953d2a7d13ba835e428a545bd746 Mon Sep 17 00:00:00 2001 From: Marshal Date: Sat, 8 Aug 2026 10:47:48 +0000 Subject: [PATCH 030/276] feat(freight-web): restrict booking currency to ETB and portal ETB payments to CBE bill only --- .../contracts/GlCreateBookingForm.tsx | 14 ++------- .../src/pages/bookings/NewBookingPage.tsx | 5 +-- .../portal/public/assets/cbe.png | Bin 0 -> 280872 bytes .../components/PaymentMethodModal.tsx | 29 ++++++++++-------- .../src/pages/bookings/EditBookingPage.tsx | 4 +-- .../pages/bookings/new-booking-form/schema.ts | 8 ++--- .../new-booking-form/step8-review.tsx | 2 +- .../src/pages/contracts/NewShipmentPage.tsx | 12 +++----- .../CurrencySelector/CurrencySelector.tsx | 9 ++---- 9 files changed, 31 insertions(+), 52 deletions(-) create mode 100644 apps/edr-freight-web/portal/public/assets/cbe.png diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx index 42ecbce61..5502886d3 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx @@ -276,9 +276,8 @@ export default function GlCreateBookingForm() { const [trainScheduleId, setTrainScheduleId] = useState(""); const [contractRouteId, setContractRouteId] = useState(null); const [notes, setNotes] = useState(""); - // The customer states the billing currency on their shipment request — GL - // books in it. Intercity is always ETB (the API enforces this too). - const [paymentCurrency, setPaymentCurrency] = useState<"USD" | "ETB">("USD"); + // ponytail: ETB-only for now — widen back to "USD" | "ETB" when multi-currency billing returns. + const [paymentCurrency, setPaymentCurrency] = useState<"USD" | "ETB">("ETB"); // What the containers carry — captured per booking (moved off the contract). const [cargoDescription, setCargoDescription] = useState(""); const [containerLines, setContainerLines] = useState([]); @@ -449,9 +448,6 @@ export default function GlCreateBookingForm() { } if (bookingRequest.contractRouteId) setContractRouteId(bookingRequest.contractRouteId); - if (bookingRequest.paymentCurrency === "USD" || bookingRequest.paymentCurrency === "ETB") { - setPaymentCurrency(bookingRequest.paymentCurrency); - } if (bookingRequest.notes) setNotes(bookingRequest.notes); }, [bookingRequest, prefilled]); @@ -1761,11 +1757,7 @@ export default function GlCreateBookingForm() { Billing currency - {isIntercity - ? "Intercity shipments are invoiced in ETB." - : bookingRequest?.paymentCurrency - ? "Requested by the customer on their shipment request." - : "The contract is quoted in USD — pick the currency this shipment is invoiced in."} + Shipments are invoiced in ETB. - - - - - - - Last Name - - - - - - -

    - -
    - - Email - - - - - - - -
    - - )} - - {/* COMPANY */} - {step === "company" && ( - <> - - - Company Name - - - - - - - -
    - - - Company Email - - - - - - - - -
    - -
    - - - Company Location / Country - - - - - - - - - - Company Address - - - - - - -
    - - )} - - {/* REPRESENTATIVE */} - {step === - "representative" && ( - <> - - - Company Representative Person - Name - - - - - - - -
    - - - Representative Email - - - - - - - - -
    - - )} - - - {/* FOOTER */} -
    - - - -
    - - - ); -} - -function StepIcon({ - icon, - active, - completed, -}: { - icon: React.ReactNode; - active: boolean; - completed: boolean; -}) { - return ( -
    - {completed ? ( - - ) : ( - icon - )} -
    - ); -} \ No newline at end of file diff --git a/apps/edr-freight-web/portal/src/pages/customers/on_boarding/ImportExportOnBoarding.tsx b/apps/edr-freight-web/portal/src/pages/customers/on_boarding/ImportExportOnBoarding.tsx deleted file mode 100644 index 33a28f58d..000000000 --- a/apps/edr-freight-web/portal/src/pages/customers/on_boarding/ImportExportOnBoarding.tsx +++ /dev/null @@ -1,711 +0,0 @@ -import { useState } from "react"; -import { useForm } from "react-hook-form"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { z } from "zod"; - -import { - ArrowLeft, - ArrowRight, - Building2, - User, - FileText, - CheckCircle2, -} from "lucide-react"; - -import { ControlledPhoneField, isValidPhone } from "@/components/PhoneField"; - -import { - Button, - Field, - FieldError, - FieldGroup, - FieldLabel, - Input, -} from "@edr/ui-common"; - -type OnboardingStep = - | "personal" - | "company" - | "personnel" - | "poa"; - -const onboardingSchema = z.object({ - // PERSONAL - firstName: z.string().min(1, "First name is required"), - lastName: z.string().min(1, "Last name is required"), - email: z.string().email("Invalid email address"), - phoneNumber: z - .string() - .min(1, "Phone number is required") - .refine(isValidPhone, "Enter a valid phone number"), - - // COMPANY - companyName: z.string().min(1, "Company name is required"), - companyEmail: z.string().email("Invalid email address"), - companyPhone: z - .string() - .min(1, "Company phone is required") - .refine(isValidPhone, "Enter a valid phone number"), - companyLocation: z.string().min(1, "Location is required"), - companyAddress: z.string().min(1, "Address is required"), - - // LEGAL - tinNumber: z.string().regex(/^\d{10}$/, { - message: "TIN must be exactly 10 digits", - }), - - vatNumber: z.string().min(1, "VAT number is required"), - - fanNumber: z.string().regex(/^\d{16}$/, { - message: "FAN must be exactly 16 digits", - }), - - // CONTACT PERSON - contactPersonName: z - .string() - .min(1, "Contact person name is required"), - - contactPersonPhone: z - .string() - .min(1, "Contact person phone is required") - .refine(isValidPhone, "Enter a valid phone number"), - - // GENERAL MANAGER - generalManagerName: z - .string() - .min(1, "General manager name is required"), - - generalManagerEmail: z - .string() - .email("Invalid email"), - - generalManagerPhone: z - .string() - .min(1, "General manager phone is required") - .refine(isValidPhone, "Enter a valid phone number"), - - // OPTIONAL POA - poaName: z.string().optional(), - poaPhone: z - .string() - .optional() - .refine((v) => !v || isValidPhone(v), "Enter a valid phone number"), - poaAddress: z.string().optional(), - poaEmail: z.string().optional(), - poaLocation: z.string().optional(), -}); - -type FormData = z.infer; - -const stepFields: Record< - OnboardingStep, - (keyof FormData)[] -> = { - personal: [ - "firstName", - "lastName", - "email", - "phoneNumber", - ], - - company: [ - "companyName", - "companyEmail", - "companyPhone", - "companyLocation", - "companyAddress", - "tinNumber", - "vatNumber", - "fanNumber", - ], - - personnel: [ - "contactPersonName", - "contactPersonPhone", - "generalManagerName", - "generalManagerEmail", - "generalManagerPhone", - ], - - poa: [], -}; - -export default function ImportExportOnBoarding() { - const [step, setStep] = - useState("personal"); - - const { - register, - control, - handleSubmit, - trigger, - formState: { errors, isSubmitting }, - } = useForm({ - resolver: zodResolver(onboardingSchema), - - defaultValues: { - phoneNumber: "", - companyPhone: "", - contactPersonPhone: "", - generalManagerPhone: "", - poaPhone: "", - }, - }); - - const nextStep = async () => { - if (step === "poa") { - handleSubmit(onSubmit)(); - return; - } - - const isValid = await trigger(stepFields[step]); - - if (!isValid) return; - - if (step === "personal") { - setStep("company"); - } else if (step === "company") { - setStep("personnel"); - } else { - setStep("poa"); - } - }; - - const prevStep = () => { - if (step === "company") { - setStep("personal"); - } else if (step === "personnel") { - setStep("company"); - } else if (step === "poa") { - setStep("personnel"); - } - }; - - const onSubmit = async (data: FormData) => { - console.log(data); - }; - - return ( - <> - {/* STEP HEADER */} -
    -
    -
    - - } - active={step === "personal"} - completed={ - step !== "personal" - } - /> - - } - active={step === "company"} - completed={ - step === "personnel" || - step === "poa" - } - /> - - } - active={step === "personnel"} - completed={step === "poa"} - /> - - } - active={step === "poa"} - completed={false} - /> -
    - -

    - {step === "personal" && - "Step 1 of 4 — Personal Information"} - - {step === "company" && - "Step 2 of 4 — Company Information"} - - {step === "personnel" && - "Step 3 of 4 — Personnel Information"} - - {step === "poa" && - "Step 4 of 4 — Power of Attorney"} -

    -
    - -
    - - {/* PERSONAL */} - {step === "personal" && ( - <> -
    - - - First Name - - - - - - - - - - Last Name - - - - - - -
    - -
    - - Email - - - - - - - -
    - - )} - - {/* COMPANY */} - {step === "company" && ( - <> - - - Company Name - - - - - - - -
    - - - Company Email - - - - - - - - -
    - -
    - - - Company Location - - - - - - - - - - Company Address - - - - - - -
    - -
    - - - TIN Number - - - - - - - - - - VAT Number - - - - - - - - - - FAN Number - - - - - - -
    - - )} - - {/* PERSONNEL */} - {step === "personnel" && ( - <> -
    -

    - Contact Person -

    - -
    - - - Contact Person Name - - - - - - - - -
    -
    - -
    - -
    -

    - General Manager -

    - -
    - - - General Manager Name - - - - - - - - - - General Manager Email - - - - - - - - -
    -
    - - )} - - {/* POA */} - {step === "poa" && ( - <> -

    - Power of Attorney details are - optional. -

    - - - PoA Name - - - - -
    - - - PoA Email - - - - - - -
    - -
    - - - PoA Location - - - - - - - - PoA Address - - - - -
    - - )} -
    - - {/* FOOTER */} -
    - - - -
    -
    - - ); -} - -function StepIcon({ - icon, - active, - completed, -}: { - icon: React.ReactNode; - active: boolean; - completed: boolean; -}) { - return ( -
    - {completed ? ( - - ) : ( - icon - )} -
    - ); -} \ No newline at end of file diff --git a/apps/edr-freight-web/portal/src/pages/customers/on_boarding/TransportrOnBoarding.tsx b/apps/edr-freight-web/portal/src/pages/customers/on_boarding/TransportrOnBoarding.tsx deleted file mode 100644 index 77b5d4ecd..000000000 --- a/apps/edr-freight-web/portal/src/pages/customers/on_boarding/TransportrOnBoarding.tsx +++ /dev/null @@ -1,288 +0,0 @@ -import { useState } from "react"; -import { useForm } from "react-hook-form"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { z } from "zod"; - -import { - ArrowLeft, - ArrowRight, - User, - Truck, - CheckCircle2, -} from "lucide-react"; - -import { ControlledPhoneField, isValidPhone } from "@/components/PhoneField"; - -import { - Button, - Field, - FieldError, - FieldGroup, - FieldLabel, - Input, -} from "@edr/ui-common"; - -type Step = "personal" | "transport"; - -const schema = z.object({ - // PERSONAL - firstName: z.string().min(1), - lastName: z.string().min(1), - email: z.string().email(), - phoneNumber: z - .string() - .min(1) - .refine(isValidPhone, "Enter a valid phone number"), - - // TRANSPORT - fanNumber: z.string().min(1), - tinNumber: z.string().regex(/^\d{10}$/, "TIN must be exactly 10 digits"), - - truckType: z.enum([ - "Casoni", - "Truck Trailer", - "High Bed", - "Low Bed", - "Others", - ]), - - plateNumber: z.string().min(1), - - plateNumber2: z.string().optional(), - - vehicleModel: z.string().min(1), - yearOfManufacturing: z.string().min(1), -}); - -type FormData = z.infer; - -const stepFields: Record = { - personal: [ - "firstName", - "lastName", - "email", - "phoneNumber", - ], - transport: [ - "fanNumber", - "tinNumber", - "truckType", - "plateNumber", - "plateNumber2", - "vehicleModel", - "yearOfManufacturing", - ], -}; - -export default function TransporterOnboarding() { - const [step, setStep] = useState("personal"); - - const { - register, - control, - handleSubmit, - trigger, - watch, - formState: { errors, isSubmitting }, - } = useForm({ - resolver: zodResolver(schema), - defaultValues: { - phoneNumber: "", - }, - }); - - const truckType = watch("truckType"); - - const nextStep = async () => { - const valid = await trigger(stepFields[step]); - if (!valid) return; - - if (step === "personal") setStep("transport"); - else handleSubmit(onSubmit)(); - }; - - const prevStep = () => { - if (step === "transport") setStep("personal"); - }; - - const onSubmit = (data: FormData) => { - console.log("TRANSPORTER:", data); - }; - - return ( - <> - {/* STEPPER */} -
    -
    -
    - - } - active={step === "personal"} - completed={step !== "personal"} - /> - - } - active={step === "transport"} - completed={false} - /> -
    - -

    - {step === "personal" && "Step 1 of 2 — Personal Information"} - {step === "transport" && "Step 2 of 2 — Transport Information"} -

    -
    - - {/* FORM */} -
    - - - {/* PERSONAL */} - {step === "personal" && ( - <> -
    - - First Name - - - - - - Last Name - - - -
    - -
    - - Email - - - - - -
    - - )} - - {/* TRANSPORT */} - {step === "transport" && ( - <> -
    - - FAN Number - - - - - - TIN Number - - - -
    - - - Truck Type - - - - -
    - - Plate Number - - - - - {truckType === "Casoni" && ( - - Second Plate Number (Casoni) - - - - )} -
    - -
    - - Vehicle Model - - - - - - Year of Manufacturing - - - -
    - - )} - -
    - - {/* FOOTER */} -
    - - - -
    -
    - - ); -} - -function StepIcon({ - icon, - active, - completed, -}: { - icon: React.ReactNode; - active: boolean; - completed: boolean; -}) { - return ( -
    - {completed ? : icon} -
    - ); -} \ No newline at end of file diff --git a/apps/edr-freight-web/portal/src/pages/settings/TabCompanyProfile.tsx b/apps/edr-freight-web/portal/src/pages/settings/TabCompanyProfile.tsx index 2ff16f445..519a2b95c 100644 --- a/apps/edr-freight-web/portal/src/pages/settings/TabCompanyProfile.tsx +++ b/apps/edr-freight-web/portal/src/pages/settings/TabCompanyProfile.tsx @@ -1,23 +1,22 @@ -import { ControlledPhoneField, isValidPhone } from "@/components/PhoneField"; import { api } from "@/services/api"; import type { - CompanyProfileInput, - CreateCompanyPayload, + CompanyProfileInput, + CreateCompanyPayload, } from "@/services/companies.service"; import type { AuthUser } from "@/types/auth"; import type { ProfileResponse } from "@/types/profile"; import { extractApiError } from "@/utils/result"; import { zodResolver } from "@hookform/resolvers/zod"; import { - Button, - Card, - Group, - Select, - SimpleGrid, - Stack, - Text, - TextInput, - Title, + Button, + Card, + Group, + Select, + SimpleGrid, + Stack, + Text, + TextInput, + Title, } from "@mantine/core"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { Building2, CheckCircle2, Save, XCircle } from "lucide-react"; @@ -27,7 +26,9 @@ import { z } from "zod"; import { ETHIOPIAN_REGIONS, type CompanyRegistrationData } from "@edr/types"; import OnboardingRoleSelect from "./OnboardingRoleSelect"; import FaydaVerifyPanel from "@/components/FaydaVerifyPanel"; -import ETradeInfo, { type ETradeStatus } from "@/components/onboarding/ETradeInfo"; +import ETradeInfo, { + type ETradeStatus, +} from "@/components/onboarding/ETradeInfo"; import { ReadOnlyField } from "@/pages/accounts/companyProfileForm/ReadOnlyField"; import StepSection from "@/pages/accounts/companyProfileForm/StepSection"; import { ETRADE_BUNDLE_FIELDS as SHARED_ETRADE_FIELDS } from "@/pages/accounts/companyProfileForm/schema"; @@ -39,11 +40,6 @@ import { export const COMPANY_PROFILE_SCHEMA = z.object({ companyName: z.string().min(1, "Company name is required"), - companyEmail: z.string().email("Invalid email address"), - companyPhone: z - .string() - .min(1, "Company phone is required") - .refine(isValidPhone, "Enter a valid phone number"), companyLocation: z.string().min(1, "Location is required"), // Derived from the eTrade address parts (region/zone/woreda/kebele/houseNo); // no standalone input. @@ -108,8 +104,6 @@ export default function TabCompanyProfile({ if (profile) { return { companyName: profile.companyName, - companyEmail: profile.companyEmail ?? "", - companyPhone: profile.companyPhone ?? "", companyLocation: profile.companyLocation, companyAddress: profile.companyAddress ?? "", tinNumber: profile.tinNumber, @@ -130,8 +124,6 @@ export default function TabCompanyProfile({ } return { companyName: "", - companyEmail: "", - companyPhone: "", companyLocation: "", companyAddress: "", tinNumber: "", @@ -173,31 +165,6 @@ export default function TabCompanyProfile({ ); const verifiedIdentity = identity?.faydaRequired === true; - // companyEmail/companyPhone are the owner's verified contact details, never - // typed — same derivation as the onboarding wizard, just fed from the saved - // profile instead of an in-progress form. `firstValid*` rather than `??`: - // these claims are optional AND unreliable — eTrade's registered phone is - // free text that arrives as things like "09 " — and `??` stops at the - // first non-null, so junk became a read-only field the customer could not - // fix and a 400 on save. When nothing usable can be derived the fields below - // become editable instead of blocking. - const derivedEmail = firstValidEmail(identity?.owner.email, user?.email); - const derivedPhone = firstValidPhone( - identity?.owner.phone, - profile?.etradePhone, - user?.phoneNumber, - ); - - useEffect(() => { - if (derivedEmail) setValue("companyEmail", derivedEmail); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [derivedEmail]); - - useEffect(() => { - if (derivedPhone) setValue("companyPhone", derivedPhone); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [derivedPhone]); - // companyAddress is composed from the (locked) eTrade address parts, not // typed directly. const region = watch("region"); @@ -221,7 +188,9 @@ export default function TabCompanyProfile({ }); } setValue("licenceNumber", data.licenceNumber, { shouldDirty: true }); - setValue("statusDescription", data.statusDescription, { shouldDirty: true }); + setValue("statusDescription", data.statusDescription, { + shouldDirty: true, + }); setValue("dateRegistered", data.dateRegistered, { shouldDirty: true }); setValue("renewedFrom", data.renewedFrom, { shouldDirty: true }); setValue("renewalDate", data.renewalDate, { shouldDirty: true }); @@ -260,8 +229,6 @@ export default function TabCompanyProfile({ if (dirtyFields.tinNumber) etradeBundle.tin = data.tinNumber; const base = { - companyEmail: data.companyEmail, - companyPhone: data.companyPhone, companyLocation: data.companyLocation, companyAddress: data.companyAddress, vatNumber: data.vatNumber ?? "", @@ -329,8 +296,11 @@ export default function TabCompanyProfile({ (mutation.isError ? extractApiError(mutation.error).message : null); const pendingOwnerReview = Boolean( - (profile?.pendingChanges as { faydaIdentity?: Record } | null) - ?.faydaIdentity?.ownerFaydaSub, + ( + profile?.pendingChanges as { + faydaIdentity?: Record; + } | null + )?.faydaIdentity?.ownerFaydaSub, ); // During onboarding the role selection gates the form: nothing else shows @@ -346,185 +316,163 @@ export default function TabCompanyProfile({ /> ) : null} {showForm && ( - - - - Company Profile - - - {isCreate - ? "Enter your company registration details to get started" - : "Your registration and identity come from eTrade and Fayda — re-verify to refresh them."} - - -
    - - - - - - {identity && ( - 0 - ? "done" - : identity.passportRequired - ? "blocked" - : "todo" - } - > - - {identity.passportRequired && ( - - )} - {/* Read-only while the verified owner (or eTrade, or the account) - supplies them. Fayda's email/phone claims are optional, so - when nothing can be derived these become typeable — the API - requires both, and showing an empty read-only field is a save - that can never succeed. */} - - {derivedEmail ? ( - - ) : ( - - )} - {derivedPhone ? ( - - ) : ( - - )} - - - )} - - - - {tinVerified && ( - - )} - - - - - - - - {mutation.isSuccess && !isCreate && ( - - - - Saved successfully - - - )} - {saveErrorMessage && ( - - - - {saveErrorMessage} - - - )} + + + + Company Profile - - {!isCreate && ( - - )} - - - - -
    + + {mutation.isSuccess && !isCreate && ( + + + + Saved successfully + + + )} + {saveErrorMessage && ( + + + + {saveErrorMessage} + + + )} + + + {!isCreate && ( + + )} + + + + + )} ); @@ -545,7 +493,9 @@ function EtradeLockedCard({ tin: string; register: ReturnType>["register"]; watch: ReturnType>["watch"]; - errors: ReturnType>["formState"]["errors"]; + errors: ReturnType< + typeof useForm + >["formState"]["errors"]; control: ReturnType>["control"]; }) { const companyName = watch("companyName"); @@ -562,10 +512,19 @@ function EtradeLockedCard({ - + - + @@ -576,10 +535,34 @@ function EtradeLockedCard({ ) : ( )} - - - - + + + + ); @@ -596,7 +579,9 @@ function LockedField({ name: keyof CompanyProfileFormData; register: ReturnType>["register"]; watch: ReturnType>["watch"]; - errors: ReturnType>["formState"]["errors"]; + errors: ReturnType< + typeof useForm + >["formState"]["errors"]; }) { const value = watch(name) as string | undefined; // A value that fails validation unlocks too — rendering a rejected value diff --git a/apps/edr-freight-web/portal/src/services/companies.service.ts b/apps/edr-freight-web/portal/src/services/companies.service.ts index 5fba278dd..52f368ca6 100644 --- a/apps/edr-freight-web/portal/src/services/companies.service.ts +++ b/apps/edr-freight-web/portal/src/services/companies.service.ts @@ -194,11 +194,11 @@ export interface OnboardingRequirements { export interface CompanyProfileInput { type: - | "importer" - | "exporter" - | "freight_forwarder" - | "dj_freight_forwarder" - | "transporter"; + | "importer" + | "exporter" + | "freight_forwarder" + | "dj_freight_forwarder" + | "transporter"; businessLicense?: string; } @@ -206,8 +206,6 @@ export interface CreateCompanyPayload { companyType?: string; nationality?: CompanyNationality; companyName: string; - companyEmail?: string; - companyPhone?: string; companyLocation?: string; companyAddress?: string; tin?: string; @@ -460,9 +458,9 @@ export const companiesService = { /** The current company's open profile change request (pending/rejected), or null. */ getChangeRequest: async (): Promise => { - const response = await client.get>( - URL_CONSTANTS.COMPANIES_API.PROFILE_CHANGE_REQUEST, - ); + const response = await client.get< + ApiResponse + >(URL_CONSTANTS.COMPANIES_API.PROFILE_CHANGE_REQUEST); return unwrap(response.data); }, diff --git a/apps/edr-freight-web/portal/src/types/profile.ts b/apps/edr-freight-web/portal/src/types/profile.ts index 8f126ea1a..732dd13cf 100644 --- a/apps/edr-freight-web/portal/src/types/profile.ts +++ b/apps/edr-freight-web/portal/src/types/profile.ts @@ -7,8 +7,6 @@ export interface ProfileResponse { companyType: string; nationality: string | null; companyProfiles: CompanyProfileResponse[]; - companyEmail: string | null; - companyPhone: string | null; companyLocation: string; companyAddress: string | null; tinNumber: string; @@ -68,8 +66,6 @@ export interface ProfileResponse { export interface UpdateProfilePayload { nationality?: "ethiopian" | "foreign"; companyName?: string; - companyEmail?: string; - companyPhone?: string; companyLocation?: string; companyAddress?: string; tin?: string; diff --git a/packages/types/src/freight/index.ts b/packages/types/src/freight/index.ts index 3041f02d5..7ff14721c 100644 --- a/packages/types/src/freight/index.ts +++ b/packages/types/src/freight/index.ts @@ -1,5 +1,9 @@ import type { BaseEntity } from "../common"; -import { ClearanceNextAction, ContractDocPhase, IClearanceMilestone } from "./contracts"; +import { + ClearanceNextAction, + ContractDocPhase, + IClearanceMilestone, +} from "./contracts"; export * from "./dropdown_settings"; export * from "./file_upload_settings"; @@ -183,7 +187,7 @@ export enum InvoiceSource { FirstMile = "firstmile", LastMile = "lastmile", /** Customs clearance service fee — billed on the booking invoice with the freight. */ - Clearance = "clearance" + Clearance = "clearance", } export enum SchedulingStatus { @@ -444,8 +448,6 @@ export interface ICustomer extends BaseEntity { email: string; phone: string; companyName: string; - companyEmail: string; - companyPhone: string; companyLocation: string; companyAddress: string; contactPersonName: string; @@ -471,8 +473,6 @@ export interface CreateCustomerDto { email: string; phone: string; companyName: string; - companyEmail: string; - companyPhone: string; companyLocation: string; companyAddress: string; contactPersonName: string; @@ -758,7 +758,10 @@ export interface IBooking extends BaseEntity { */ isSplit?: boolean; /** What this booking carried before it was reduced by a split (bulk tons / units per size). */ - preSplitQuantities?: { bulkTons?: number; bySize?: Record } | null; + preSplitQuantities?: { + bulkTons?: number; + bySize?: Record; + } | null; } export interface PricingBreakdownLineItem { From fb6f8afc0ca5b6558b94ac26d3f362394292759d Mon Sep 17 00:00:00 2001 From: Marshal Date: Sat, 8 Aug 2026 15:14:10 +0000 Subject: [PATCH 040/276] add usd for import --- .../contracts/GlCreateBookingForm.tsx | 8 ++++- .../src/pages/bookings/EditBookingPage.tsx | 6 +++- .../payment-currency-field.tsx | 20 ++++++++---- .../pages/bookings/new-booking-form/schema.ts | 9 ++++-- .../new-booking-form/step2-service-type.tsx | 6 +++- .../src/pages/contracts/NewShipmentPage.tsx | 8 ++++- .../CurrencySelector/CurrencySelector.tsx | 32 +++++++++++++------ 7 files changed, 67 insertions(+), 22 deletions(-) diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx index 5502886d3..67e84a09f 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx @@ -348,6 +348,9 @@ export default function GlCreateBookingForm() { // Intercity shipments ride a passing import/export train staff pick at // finalize time — no shipment day is chosen and no window gate applies. const isIntercity = contract?.tradeDirection === "DOMESTIC"; + // USD billing is offered on import traffic only — export and domestic + // shipments are always invoiced in ETB. + const isImport = contract?.tradeDirection === "IMPORT"; // ONE_TIME split-remainder mode: a previous booking on this contract was // split on train capacity, so the capacity endpoint reports the outstanding @@ -1757,12 +1760,15 @@ export default function GlCreateBookingForm() { Billing currency - Shipments are invoiced in ETB. + {isImport + ? "Import shipments may be invoiced in ETB or USD. USD is paid by bank transfer, not online." + : "Shipments are invoiced in ETB."} diff --git a/apps/edr-freight-web/portal/src/pages/bookings/EditBookingPage.tsx b/apps/edr-freight-web/portal/src/pages/bookings/EditBookingPage.tsx index 85709f1d4..26a59b405 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/EditBookingPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/EditBookingPage.tsx @@ -683,7 +683,11 @@ export default function EditBookingPage() { /> - + {/* USD billing is import-only; export/intercity stay ETB. */} + {(selectedService?.includesFirstMile || selectedService?.includesLastMile || diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/payment-currency-field.tsx b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/payment-currency-field.tsx index d874fb783..fb91b515b 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/payment-currency-field.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/payment-currency-field.tsx @@ -19,14 +19,25 @@ const CURRENCY_ICONS: Record< export function PaymentCurrencyField({ control, + allowUsd = false, }: { control: Control; + /** + * Offer USD alongside ETB. Import shipments only — export and domestic + * traffic is always invoiced in ETB. + */ + allowUsd?: boolean; }) { + const options = allowUsd + ? PAYMENT_CURRENCY_OPTIONS + : PAYMENT_CURRENCY_OPTIONS.filter((o) => o.value !== "USD"); return ( Payment currency - Choose the currency for your freight quote and invoices. + {allowUsd + ? "Choose the currency for your freight quote and invoices. USD is paid by bank transfer, not online." + : "Choose the currency for your freight quote and invoices."} - {PAYMENT_CURRENCY_OPTIONS.map((option) => { + {options.map((option) => { const Icon = CURRENCY_ICONS[option.value].icon; const selected = field.value === option.value; return ( @@ -97,10 +108,7 @@ export function PaymentCurrencyField({ {/* Description for the active currency, kept subtle. */} - { - PAYMENT_CURRENCY_OPTIONS.find((o) => o.value === field.value) - ?.description - } + {options.find((o) => o.value === field.value)?.description}
    diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/schema.ts b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/schema.ts index 08bb5236d..27b669d72 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/schema.ts +++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/schema.ts @@ -81,11 +81,16 @@ export const PAYMENT_CURRENCY_OPTIONS: Array<{ label: string; description: string; }> = [ - // ponytail: ETB-only for now — re-add the USD option when multi-currency billing returns. { value: "ETB", label: "ETB", - description: "Ethiopian Birr — local pricing and invoicing.", + description: "Ethiopian Birr — pay online through the payment gateway.", + }, + // Import shipments only; the field is hidden on export/domestic traffic. + { + value: "USD", + label: "USD", + description: "US Dollar — paid by bank transfer, slip sent to Finance.", }, ]; diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step2-service-type.tsx b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step2-service-type.tsx index 47e80e7e2..2c51cbb62 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step2-service-type.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step2-service-type.tsx @@ -133,7 +133,11 @@ export function Step2ServiceType({ )} /> - + {/* USD billing is import-only; export/intercity stay ETB. */} + {showServiceSections && ( diff --git a/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx b/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx index c65ccea2a..a5b29a299 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx @@ -1215,6 +1215,9 @@ function ScheduleStep({ })(); const isIntercity = contract.tradeDirection === "DOMESTIC"; + // USD billing is offered on import traffic only — export and domestic + // shipments are always invoiced in ETB. + const isImport = contract.tradeDirection === "IMPORT"; const { data: availableDays, isLoading } = useQuery({ ...api.bookings.getAvailableDaysForCargo.queryOptions({ input: @@ -1310,12 +1313,15 @@ function ScheduleStep({ Billing currency * - Shipments are invoiced in ETB. + {isImport + ? "Import shipments may be invoiced in ETB or USD. USD is paid by bank transfer, not online." + : "Shipments are invoiced in ETB."} field.onChange(v)} error={fieldState.error?.message} + allowUsd={isImport} /> )} diff --git a/packages/ui-common/src/components/CurrencySelector/CurrencySelector.tsx b/packages/ui-common/src/components/CurrencySelector/CurrencySelector.tsx index 5ce3d1272..f25a7b015 100644 --- a/packages/ui-common/src/components/CurrencySelector/CurrencySelector.tsx +++ b/packages/ui-common/src/components/CurrencySelector/CurrencySelector.tsx @@ -8,17 +8,27 @@ export interface CurrencySelectorProps { disabled?: boolean; /** Validation error shown under the cards. */ error?: string; + /** + * Offer USD alongside ETB. Import shipments only — export and domestic + * traffic is invoiced in ETB, so the option stays hidden everywhere else. + * USD is settled by bank transfer, never through the online gateway. + */ + allowUsd?: boolean; } -// ponytail: ETB-only for now — restore the USD entry when multi-currency billing returns. -const OPTIONS = [ - { - code: "ETB", - symbol: "Br", - name: "Ethiopian Birr", - hint: "All shipments are invoiced in ETB", - }, -] as const; +const ETB_OPTION = { + code: "ETB", + symbol: "Br", + name: "Ethiopian Birr", + hint: "Pay online through the payment gateway", +} as const; + +const USD_OPTION = { + code: "USD", + symbol: "$", + name: "US Dollar", + hint: "Paid by bank transfer — send the slip to Finance", +} as const; /** * Card-style USD/ETB billing-currency picker. Renders unselected when `value` @@ -29,7 +39,9 @@ export function CurrencySelector({ onChange, disabled = false, error, + allowUsd = false, }: CurrencySelectorProps) { + const options = allowUsd ? [ETB_OPTION, USD_OPTION] : [ETB_OPTION]; return ( - {OPTIONS.map((o) => { + {options.map((o) => { const selected = value === o.code; return ( + + ); +} + +export default DocumentRail; diff --git a/apps/edr-freight-web/backoffice/src/pages/portal_content/EditorPane.tsx b/apps/edr-freight-web/backoffice/src/pages/portal_content/EditorPane.tsx new file mode 100644 index 000000000..8c2602867 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/portal_content/EditorPane.tsx @@ -0,0 +1,98 @@ +import { Box, Button, Group, Stack, Text, TextInput } from "@mantine/core"; +import { Trash2 } from "lucide-react"; +import type { ReactNode } from "react"; + +interface EditorPaneProps { + /** Large borderless field at the top — the page's own name. */ + title: string; + titlePlaceholder: string; + onTitleChange: (next: string) => void; + onRemove: () => void; + removeLabel: string; + children: ReactNode; +} + +/** + * The open page. One title, one body, one Remove — everything else about the + * document lives in the rail, so this pane stays as close to a blank sheet as + * the feature set allows. + */ +export function EditorPane({ + title, + titlePlaceholder, + onTitleChange, + onRemove, + removeLabel, + children, +}: EditorPaneProps) { + return ( + + onTitleChange(e.currentTarget.value)} + variant="unstyled" + styles={{ + input: { + fontSize: "1.6rem", + fontWeight: 700, + lineHeight: 1.3, + height: "auto", + padding: 0, + }, + }} + /> + + + {children} + + + + + + + ); +} + +/** Shown when a document has no pages yet. */ +export function EmptyPane({ message }: { message: string }) { + return ( + + {message} + + ); +} + +export default EditorPane; diff --git a/apps/edr-freight-web/backoffice/src/pages/portal_content/FaqEditor.tsx b/apps/edr-freight-web/backoffice/src/pages/portal_content/FaqEditor.tsx deleted file mode 100644 index b5a9a7fca..000000000 --- a/apps/edr-freight-web/backoffice/src/pages/portal_content/FaqEditor.tsx +++ /dev/null @@ -1,242 +0,0 @@ -import type { PortalFaqContent, PortalFaqGroup } from "@edr/types"; -import { - Accordion, - Badge, - Button, - Card, - Group, - Stack, - Switch, - TextInput, -} from "@mantine/core"; -import { Plus } from "lucide-react"; - -import { AccordionRow, excerpt } from "./AccordionRow"; -import { moveAt, newId, removeAt, replaceAt } from "./array-helpers"; -import { MarkdownEditor, MarkdownHint } from "./MarkdownEditor"; - -interface FaqEditorProps { - value: PortalFaqContent; - onChange: (next: PortalFaqContent) => void; -} - -const EMPTY_FOOTER = { - heading: "Still need a hand?", - body: "", - ctaLabel: "Go to Help & Support", - ctaTo: "/help", -}; - -export function FaqEditor({ value, onChange }: FaqEditorProps) { - const setGroups = (groups: PortalFaqGroup[]) => onChange({ ...value, groups }); - - const setGroup = (index: number, next: PortalFaqGroup) => - setGroups(replaceAt(value.groups, index, next)); - - return ( - - - - - onChange({ ...value, title: e.currentTarget.value }) - } - /> - - onChange({ ...value, subtitle: e.currentTarget.value }) - } - /> - - - - - - - {value.groups.map((group, groupIndex) => ( - setGroups(moveAt(value.groups, groupIndex, delta))} - onRemove={() => setGroups(removeAt(value.groups, groupIndex))} - > - - - setGroup(groupIndex, { - ...group, - title: e.currentTarget.value, - }) - } - /> - - - {group.items.map((item, itemIndex) => ( - - setGroup(groupIndex, { - ...group, - items: moveAt(group.items, itemIndex, delta), - }) - } - onRemove={() => - setGroup(groupIndex, { - ...group, - items: removeAt(group.items, itemIndex), - }) - } - > - - - setGroup(groupIndex, { - ...group, - items: replaceAt(group.items, itemIndex, { - ...item, - question: e.currentTarget.value, - }), - }) - } - /> - - setGroup(groupIndex, { - ...group, - items: replaceAt(group.items, itemIndex, { - ...item, - answer, - }), - }) - } - /> - - - ))} - - - - - - ))} - - - - - - - - - onChange({ - ...value, - footer: e.currentTarget.checked ? EMPTY_FOOTER : null, - }) - } - /> - {!value.footer && Hidden} - - - {value.footer && ( - <> - - onChange({ - ...value, - footer: { ...value.footer!, heading: e.currentTarget.value }, - }) - } - /> - - onChange({ ...value, footer: { ...value.footer!, body } }) - } - /> - - - onChange({ - ...value, - footer: { - ...value.footer!, - ctaLabel: e.currentTarget.value, - }, - }) - } - /> - - onChange({ - ...value, - footer: { ...value.footer!, ctaTo: e.currentTarget.value }, - }) - } - /> - - - )} - - - - ); -} - -export default FaqEditor; diff --git a/apps/edr-freight-web/backoffice/src/pages/portal_content/FaqWorkspace.tsx b/apps/edr-freight-web/backoffice/src/pages/portal_content/FaqWorkspace.tsx new file mode 100644 index 000000000..79029abf9 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/portal_content/FaqWorkspace.tsx @@ -0,0 +1,227 @@ +import type { PortalFaqContent, PortalFaqGroup } from "@edr/types"; +import { Button, Group, Stack, Text, TextInput } from "@mantine/core"; +import { Plus } from "lucide-react"; +import { useEffect, useState } from "react"; + +import { moveAt, newId, removeAt, replaceAt } from "./array-helpers"; +import { DocumentRail, type RailItem } from "./DocumentRail"; +import { EditorPane, EmptyPane } from "./EditorPane"; +import { MarkdownEditor } from "./MarkdownEditor"; + +interface FaqWorkspaceProps { + value: PortalFaqContent; + onChange: (next: PortalFaqContent) => void; + /** Reseed counter — see the `key` on the editor below. */ + seed: number; +} + +/** + * The FAQ is two levels deep, so the rail shows each group as a heading with + * its questions beneath — the same outline a customer sees on the page. Picking + * a question opens it; picking the group's own row renames or removes it. + */ +export function FaqWorkspace({ + value, + onChange, + seed, +}: FaqWorkspaceProps) { + const groups = value.groups ?? []; + + const [selectedId, setSelectedId] = useState( + groups[0]?.items[0]?.id ?? groups[0]?.id ?? null, + ); + + const setGroups = (next: PortalFaqGroup[]) => + onChange({ ...value, groups: next }); + + // Every selectable row, in display order: the group's own row, then its + // questions. Flattening once keeps selection and reordering simple. + const rows: RailItem[] = groups.flatMap((group) => [ + { id: group.id, label: group.title || "Untitled group", heading: "Group" }, + ...group.items.map((item) => ({ + id: item.id, + label: item.question, + indented: true, + })), + ]); + + useEffect(() => { + if (!rows.some((row) => row.id === selectedId)) { + setSelectedId(rows[0]?.id ?? null); + } + }, [rows, selectedId]); + + const groupIndex = groups.findIndex((group) => group.id === selectedId); + const selectedGroup = groupIndex >= 0 ? groups[groupIndex] : null; + + const ownerIndex = groups.findIndex((group) => + group.items.some((item) => item.id === selectedId), + ); + const owner = ownerIndex >= 0 ? groups[ownerIndex] : null; + const itemIndex = + owner?.items.findIndex((item) => item.id === selectedId) ?? -1; + const selectedItem = owner && itemIndex >= 0 ? owner.items[itemIndex] : null; + + const move = (id: string, delta: number) => { + const asGroup = groups.findIndex((group) => group.id === id); + if (asGroup >= 0) { + setGroups(moveAt(groups, asGroup, delta)); + return; + } + + const at = groups.findIndex((group) => + group.items.some((item) => item.id === id), + ); + if (at < 0) return; + const within = groups[at].items.findIndex((item) => item.id === id); + setGroups( + replaceAt(groups, at, { + ...groups[at], + items: moveAt(groups[at].items, within, delta), + }), + ); + }; + + const canMove = (id: string, delta: number) => { + const asGroup = groups.findIndex((group) => group.id === id); + if (asGroup >= 0) { + const target = asGroup + delta; + return target >= 0 && target < groups.length; + } + + const at = groups.findIndex((group) => + group.items.some((item) => item.id === id), + ); + if (at < 0) return false; + const within = groups[at].items.findIndex((item) => item.id === id); + const target = within + delta; + return target >= 0 && target < groups[at].items.length; + }; + + const addQuestion = () => { + // Add into the group the author is currently in, or the last one. + const target = owner ?? selectedGroup ?? groups[groups.length - 1]; + if (!target) return; + + const at = groups.findIndex((group) => group.id === target.id); + const question = { id: newId(), question: "", answer: "" }; + setGroups( + replaceAt(groups, at, { + ...target, + items: [...target.items, question], + }), + ); + setSelectedId(question.id); + }; + + const addGroup = () => { + const group = { id: newId(), title: "New group", items: [] }; + setGroups([...groups, group]); + setSelectedId(group.id); + }; + + return ( + + + + + + + {selectedItem && owner ? ( + + setGroups( + replaceAt(groups, ownerIndex, { + ...owner, + items: replaceAt(owner.items, itemIndex, { + ...selectedItem, + question, + }), + }), + ) + } + onRemove={() => + setGroups( + replaceAt(groups, ownerIndex, { + ...owner, + items: removeAt(owner.items, itemIndex), + }), + ) + } + removeLabel="Delete this question" + > + + setGroups( + replaceAt(groups, ownerIndex, { + ...owner, + items: replaceAt(owner.items, itemIndex, { + ...selectedItem, + answer, + }), + }), + ) + } + /> + + ) : selectedGroup ? ( + + setGroups(replaceAt(groups, groupIndex, { ...selectedGroup, title })) + } + onRemove={() => setGroups(removeAt(groups, groupIndex))} + removeLabel="Delete this group and its questions" + > + + + A group is just a heading on the FAQ page. It holds{" "} + {selectedGroup.items.length} question + {selectedGroup.items.length === 1 ? "" : "s"} — pick one on the + left to edit it. + + + setGroups( + replaceAt(groups, groupIndex, { + ...selectedGroup, + title: e.currentTarget.value, + }), + ) + } + /> + + + ) : ( + + )} + + ); +} + +export default FaqWorkspace; diff --git a/apps/edr-freight-web/backoffice/src/pages/portal_content/HelpEditor.tsx b/apps/edr-freight-web/backoffice/src/pages/portal_content/HelpEditor.tsx deleted file mode 100644 index e7b648b43..000000000 --- a/apps/edr-freight-web/backoffice/src/pages/portal_content/HelpEditor.tsx +++ /dev/null @@ -1,125 +0,0 @@ -import type { PortalHelpContent, PortalHelpSection } from "@edr/types"; -import { Accordion, Button, Card, Divider, Stack, TextInput } from "@mantine/core"; -import { Plus } from "lucide-react"; - -import { AccordionRow, excerpt } from "./AccordionRow"; -import { moveAt, newId, removeAt, replaceAt } from "./array-helpers"; -import { MarkdownEditor, MarkdownHint } from "./MarkdownEditor"; -import { MediaManager } from "./MediaManager"; - -interface HelpEditorProps { - value: PortalHelpContent; - onChange: (next: PortalHelpContent) => void; -} - -/** - * The help page is built, not filled in: an ordered list of sections, each a - * heading plus free markdown plus any images or videos. Nothing about the page - * is fixed except its title, so support can add, reorder or drop a section - * without a code change. - */ -export function HelpEditor({ value, onChange }: HelpEditorProps) { - // A row written before the free-form conversion has no `sections` at all. - // Tolerate it rather than crashing the tab: the migration rewrites it, but - // an environment can be mid-deploy. - const sections = value.sections ?? []; - - const setSections = (next: PortalHelpSection[]) => - onChange({ ...value, sections: next }); - - return ( - - - - - onChange({ ...value, title: e.currentTarget.value }) - } - /> - - onChange({ ...value, subtitle: e.currentTarget.value }) - } - /> - - - - - - - {sections.map((section, index) => ( - setSections(moveAt(sections, index, delta))} - onRemove={() => setSections(removeAt(sections, index))} - > - - - setSections( - replaceAt(sections, index, { - ...section, - heading: e.currentTarget.value, - }), - ) - } - /> - - - setSections( - replaceAt(sections, index, { ...section, body }), - ) - } - /> - - - - - setSections( - replaceAt(sections, index, { ...section, media }), - ) - } - /> - - - ))} - - - - - ); -} - -export default HelpEditor; diff --git a/apps/edr-freight-web/backoffice/src/pages/portal_content/LegalDocEditor.tsx b/apps/edr-freight-web/backoffice/src/pages/portal_content/LegalDocEditor.tsx deleted file mode 100644 index b24bf9be4..000000000 --- a/apps/edr-freight-web/backoffice/src/pages/portal_content/LegalDocEditor.tsx +++ /dev/null @@ -1,110 +0,0 @@ -import type { PortalLegalContent } from "@edr/types"; -import { Accordion, Button, Card, Group, Stack, TextInput } from "@mantine/core"; -import { Plus } from "lucide-react"; - -import { AccordionRow, excerpt } from "./AccordionRow"; -import { moveAt, newId, removeAt, replaceAt } from "./array-helpers"; -import { MarkdownEditor, MarkdownHint } from "./MarkdownEditor"; - -interface LegalDocEditorProps { - value: PortalLegalContent; - onChange: (next: PortalLegalContent) => void; -} - -/** Shared by the Privacy and Terms tabs — the two documents have one shape. */ -export function LegalDocEditor({ value, onChange }: LegalDocEditorProps) { - const setSections = (sections: PortalLegalContent["sections"]) => - onChange({ ...value, sections }); - - return ( - - - - - - onChange({ ...value, title: e.currentTarget.value }) - } - /> - - onChange({ ...value, lastUpdated: e.currentTarget.value }) - } - /> - - - - onChange({ ...value, subtitle: e.currentTarget.value }) - } - /> - - - - - - - {value.sections.map((section, index) => ( - setSections(moveAt(value.sections, index, delta))} - onRemove={() => setSections(removeAt(value.sections, index))} - > - - - setSections( - replaceAt(value.sections, index, { - ...section, - heading: e.currentTarget.value, - }), - ) - } - /> - - - setSections( - replaceAt(value.sections, index, { ...section, body }), - ) - } - /> - - - ))} - - - - - ); -} - -export default LegalDocEditor; diff --git a/apps/edr-freight-web/backoffice/src/pages/portal_content/Markdown.tsx b/apps/edr-freight-web/backoffice/src/pages/portal_content/Markdown.tsx index 53a6937e8..3a70754a5 100644 --- a/apps/edr-freight-web/backoffice/src/pages/portal_content/Markdown.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/portal_content/Markdown.tsx @@ -1,22 +1,101 @@ -import ReactMarkdown from "react-markdown"; +import { isPortalVideoSrc, PORTAL_MEDIA_URI_SCHEME } from "@edr/types"; +import { Text } from "@mantine/core"; +import { useEffect, useState } from "react"; +import ReactMarkdown, { defaultUrlTransform } from "react-markdown"; +import { resolvePreview } from "./MarkdownEditor"; // Same preflight fix the editor needs — Mantine's `Typography` defines its list // and margin rules with `:where()`, which Tailwind's preflight outranks, so // bullets rendered without markers here too. import "./markdown-editor.css"; /** - * Read-only markdown rendering for the version-history preview. Editing goes - * through `MarkdownEditor` (MDXEditor); this is only for showing what an old - * version said. + * Renders one embedded picture or video. Stored copy holds `minio:`, so + * the URL has to be signed before it can be shown; until it resolves the slot + * stays empty rather than flashing a broken image. + */ +function Embed({ src, alt }: { src: string; alt?: string }) { + const [resolved, setResolved] = useState( + src.startsWith(PORTAL_MEDIA_URI_SCHEME) ? null : src, + ); + + useEffect(() => { + let active = true; + void resolvePreview(src).then((url) => { + if (active) setResolved(url); + }); + return () => { + active = false; + }; + }, [src]); + + if (!resolved) return null; + + // Spans, not
    : markdown wraps an image in a paragraph, and a + //
    inside a

    is invalid HTML the browser silently re-parents — + // which dropped every embed after the first. + return ( + + {isPortalVideoSrc(resolved) ? ( + + ); +} + +/** + * Read-only markdown rendering, used by the Preview toggle and the version + * history. Editing goes through `MarkdownEditor` (MDXEditor). * * Same options as the portal's renderer — no `rehype-raw`, no custom * `urlTransform` — so neither app grows an HTML-injection surface. */ +/** + * Module scope on purpose. Declared inline, this object is rebuilt on every + * render, so React sees a brand-new component type for `img` and remounts + * `Embed` each time — which threw away the URL it had just resolved and left + * every uploaded picture and video permanently blank. + */ +const COMPONENTS = { + img: ({ src, alt }: { src?: string; alt?: string }) => + typeof src === "string" ? : null, +}; + +/** + * Lets our own `minio:` refs through, and defers everything else to + * react-markdown's default vetting (which drops `javascript:` and friends). + * + * Needed because the default transform allows only http/https/mailto/tel, so + * an unresolved `minio:` ref was silently blanked and every uploaded picture + * rendered as an empty paragraph. Only the backoffice needs this: the public + * bundle has already had these refs replaced with signed https URLs, so the + * portal's renderer keeps the stock transform untouched. + */ +const urlTransform = (url: string) => + url.startsWith(PORTAL_MEDIA_URI_SCHEME) ? url : defaultUrlTransform(url); + export function Markdown({ children }: { children: string }) { return (

    - {children} + + {children} +
    ); } diff --git a/apps/edr-freight-web/backoffice/src/pages/portal_content/MarkdownEditor.tsx b/apps/edr-freight-web/backoffice/src/pages/portal_content/MarkdownEditor.tsx index 83ca7ef46..b053905cf 100644 --- a/apps/edr-freight-web/backoffice/src/pages/portal_content/MarkdownEditor.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/portal_content/MarkdownEditor.tsx @@ -1,13 +1,13 @@ import { PORTAL_MEDIA_URI_SCHEME } from "@edr/types"; -import { Box, Stack, Text } from "@mantine/core"; +import { Box, Button, Group, SegmentedControl, Stack, Text } from "@mantine/core"; import { BlockTypeSelect, BoldItalicUnderlineToggles, CreateLink, - InsertImage, InsertThematicBreak, ListsToggle, MDXEditor, + type MDXEditorMethods, UndoRedo, headingsPlugin, imagePlugin, @@ -19,18 +19,20 @@ import { thematicBreakPlugin, toolbarPlugin, } from "@mdxeditor/editor"; +import { ImagePlus } from "lucide-react"; +import { useRef, useState } from "react"; import "@mdxeditor/editor/style.css"; import { portalContentService } from "@/services/portal-content.service"; +import { Markdown } from "./Markdown"; +import { MediaDialog } from "./MediaDialog"; // Undoes Tailwind's preflight inside the editor's content area — see the file. import "./markdown-editor.css"; interface MarkdownEditorProps { - label: string; value: string; onChange: (next: string) => void; - description?: string; } /** @@ -40,12 +42,12 @@ interface MarkdownEditorProps { const previewCache = new Map>(); /** - * Inserted images are stored as `minio:`, never as the signed URL the - * upload returns: a presigned URL expires, so persisting one would leave every - * embedded image broken a few hours later. `imagePreviewHandler` resolves the - * ref back to a temporary URL purely for display, on both sides of the wire. + * Inserted media is stored as `minio:`, never as the signed URL the upload + * returns: a presigned URL expires, so persisting one would leave every + * embedded picture broken a few hours later. This resolves the ref back to a + * temporary URL purely for display. */ -function resolvePreview(url: string): Promise { +export function resolvePreview(url: string): Promise { if (!url.startsWith(PORTAL_MEDIA_URI_SCHEME)) return Promise.resolve(url); const key = url.slice(PORTAL_MEDIA_URI_SCHEME.length); @@ -59,82 +61,124 @@ function resolvePreview(url: string): Promise { return pending; } -export function MarkdownEditor({ - label, - value, - onChange, - description, -}: MarkdownEditorProps) { - return ( - - - {label} - - {description && ( - - {description} - - )} +export function MarkdownEditor({ value, onChange }: MarkdownEditorProps) { + const editorRef = useRef(null); + const [mediaOpen, setMediaOpen] = useState(false); + const [mode, setMode] = useState<"write" | "preview">("write"); - - { - if (!initialMarkdownNormalize) onChange(markdown); - }} - plugins={[ - headingsPlugin(), - listsPlugin(), - quotePlugin(), - linkPlugin(), - linkDialogPlugin(), - thematicBreakPlugin(), - imagePlugin({ - imageUploadHandler: async (file) => { - const { key } = await portalContentService.uploadMedia(file); - return `${PORTAL_MEDIA_URI_SCHEME}${key}`; - }, - imagePreviewHandler: resolvePreview, - }), - markdownShortcutPlugin(), - toolbarPlugin({ - toolbarContents: () => ( - <> - - - - - - - - - ), - }), + return ( + + + + Page text + + setMode(next as "write" | "preview")} + data={[ + { label: "Write", value: "write" }, + { label: "Preview", value: "preview" }, ]} /> - + + + {mode === "write" ? ( + + { + if (!initialMarkdownNormalize) onChange(markdown); + }} + plugins={[ + headingsPlugin(), + listsPlugin(), + quotePlugin(), + linkPlugin(), + linkDialogPlugin(), + thematicBreakPlugin(), + // Kept for rendering existing images; its own insert button is + // replaced by the one below, which also handles video. + imagePlugin({ imagePreviewHandler: resolvePreview }), + markdownShortcutPlugin(), + toolbarPlugin({ + toolbarContents: () => ( + <> + + {/* No underline: markdown has none, so MDXEditor emits a + raw tag — and the portal's renderer drops raw HTML + by design, so the author's emphasis would silently + vanish for the customer. */} + + + + + + + + ), + }), + ]} + /> + + ) : ( + + {/* The same renderer the customer gets — the only place an author can + watch an embedded video actually play before publishing. */} + {value || "_This page is empty._"} + + )} + + setMediaOpen(false)} + onInsert={(markdown) => { + editorRef.current?.insertMarkdown(`\n\n${markdown}\n\n`); + // Inserting through the ref bypasses onChange, so push it ourselves. + const next = editorRef.current?.getMarkdown(); + if (next !== undefined) onChange(next); + }} + /> ); } -/** Reminder of the substitution tokens, rendered once per tab. */ +/** Reminder of the substitution tokens, shown once per document. */ export function MarkdownHint() { return ( - Placeholders resolve from the Contact tab, so one edit there updates every - page: {"{{supportEmail}}"} · {"{{supportPhone}}"}{" "} - · {"{{supportOffice}}"} · {"{{supportHours}}"} ·{" "} - {"{{supportPhoneTel}}"} (inside a tel: link). + Type {"{{supportEmail}}"}, {"{{supportPhone}}"},{" "} + {"{{supportOffice}}"} or {"{{supportHours}}"}{" "} + anywhere and it fills in from the Contact tab — change it once there and + every page updates. ); } diff --git a/apps/edr-freight-web/backoffice/src/pages/portal_content/MediaDialog.tsx b/apps/edr-freight-web/backoffice/src/pages/portal_content/MediaDialog.tsx new file mode 100644 index 000000000..eae3c6f52 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/portal_content/MediaDialog.tsx @@ -0,0 +1,230 @@ +import { PORTAL_MEDIA_URI_SCHEME, SUPPORT_MEDIA_MAX_BYTES } from "@edr/types"; +import { + Box, + Button, + Group, + Loader, + Modal, + Progress, + Stack, + Text, + TextInput, +} from "@mantine/core"; +import { Film, Image as ImageIcon, UploadCloud } from "lucide-react"; +import { useRef, useState } from "react"; +import toast from "react-hot-toast"; + +import { portalContentService } from "@/services/portal-content.service"; + +interface MediaDialogProps { + opened: boolean; + onClose: () => void; + /** Receives the markdown to drop at the cursor. */ + onInsert: (markdown: string) => void; +} + +const MAX_MB = Math.round(SUPPORT_MEDIA_MAX_BYTES / (1024 * 1024)); + +/** + * Adds a picture or video to the text — drop a file, or browse for one. + * + * Replaces MDXEditor's built-in image dialog, which asks for a URL, only takes + * images, and leaves an author who just wants to show a screenshot with nothing + * to do. There is deliberately no "paste a link" field: everything lives in the + * platform, so nothing an editor inserts can rot because someone else's server + * moved a file. + * + * Uploads return an object key; the markdown stores `minio:` and the API + * signs it fresh on every read. + */ +export function MediaDialog({ opened, onClose, onInsert }: MediaDialogProps) { + const inputRef = useRef(null); + const [uploading, setUploading] = useState(false); + const [progress, setProgress] = useState(null); + const [dragging, setDragging] = useState(false); + const [uploaded, setUploaded] = useState<{ + key: string; + kind: "image" | "video"; + url: string; + } | null>(null); + const [caption, setCaption] = useState(""); + + const reset = () => { + setUploaded(null); + setCaption(""); + setDragging(false); + if (inputRef.current) inputRef.current.value = ""; + }; + + const close = () => { + reset(); + onClose(); + }; + + const upload = async (file: File) => { + if (file.size > SUPPORT_MEDIA_MAX_BYTES) { + toast.error(`That file is over ${MAX_MB} MB.`); + return; + } + + setUploading(true); + setProgress(0); + try { + setUploaded(await portalContentService.uploadMedia(file, setProgress)); + } catch (error) { + const err = error as { + code?: string; + response?: { data?: { message?: string } }; + }; + const message = + err.code === "ECONNABORTED" + ? "That upload timed out. Check your connection and try again." + : (err.response?.data?.message ?? "Upload failed"); + toast.error(Array.isArray(message) ? message.join(", ") : message); + } finally { + setUploading(false); + setProgress(null); + if (inputRef.current) inputRef.current.value = ""; + } + }; + + const insert = () => { + if (!uploaded) return; + // The caption doubles as alt text, so it is worth prompting for. + onInsert(`![${caption}](${PORTAL_MEDIA_URI_SCHEME}${uploaded.key})`); + close(); + }; + + return ( + + + {!uploaded ? ( + { + e.preventDefault(); + setDragging(true); + }} + onDragLeave={() => setDragging(false)} + onDrop={(e) => { + e.preventDefault(); + setDragging(false); + const file = e.dataTransfer.files?.[0]; + if (file) void upload(file); + }} + onClick={() => inputRef.current?.click()} + style={{ + border: `2px dashed var(--mantine-color-${dragging ? "green" : "gray"}-4)`, + background: dragging + ? "var(--mantine-color-green-0)" + : "var(--mantine-color-gray-0)", + borderRadius: "var(--mantine-radius-md)", + padding: "2.5rem 1.5rem", + textAlign: "center", + cursor: "pointer", + transition: "background 120ms, border-color 120ms", + }} + > + {uploading ? ( + + + + {progress === null + ? "Uploading…" + : `Uploading… ${progress}%`} + + {progress !== null && ( + + )} + + ) : ( + + + Drop a file here, or click to browse + + Pictures (PNG, JPG, GIF) and videos (MP4, WebM) up to {MAX_MB} MB + + + )} + + ) : ( + + + {uploaded.kind === "video" ? ( + + + + {uploaded.kind === "video" ? ( + + ) : ( + + )} + + {uploaded.kind === "video" ? "Video" : "Picture"} uploaded + + + + setCaption(e.currentTarget.value)} + autoFocus + /> + + )} + + { + const file = e.currentTarget.files?.[0]; + if (file) void upload(file); + }} + /> + + + + + + + + ); +} + +export default MediaDialog; diff --git a/apps/edr-freight-web/backoffice/src/pages/portal_content/MediaManager.tsx b/apps/edr-freight-web/backoffice/src/pages/portal_content/MediaManager.tsx deleted file mode 100644 index a80bd3b55..000000000 --- a/apps/edr-freight-web/backoffice/src/pages/portal_content/MediaManager.tsx +++ /dev/null @@ -1,122 +0,0 @@ -import type { PortalMedia } from "@edr/types"; -import { - ActionIcon, - Button, - Group, - Paper, - Stack, - Text, - TextInput, - Tooltip, -} from "@mantine/core"; -import { Film, Image as ImageIcon, Trash2, Upload } from "lucide-react"; -import { useRef, useState } from "react"; -import toast from "react-hot-toast"; - -import { portalContentService } from "@/services/portal-content.service"; - -import { newId, removeAt, replaceAt } from "./array-helpers"; - -interface MediaManagerProps { - value: PortalMedia[]; - onChange: (next: PortalMedia[]) => void; -} - -/** - * Attachments for one help section. Uploads store the MinIO object *key*; the - * signed URL the upload returns is short-lived and is never persisted, so the - * list shows the key rather than pretending to be a gallery. - */ -export function MediaManager({ value, onChange }: MediaManagerProps) { - const inputRef = useRef(null); - const [uploading, setUploading] = useState(false); - - const upload = async (file: File) => { - setUploading(true); - try { - const { key, kind } = await portalContentService.uploadMedia(file); - onChange([...value, { id: newId(), kind, src: key, caption: null }]); - } catch (error) { - const message = - (error as { response?: { data?: { message?: string } } })?.response?.data - ?.message ?? "Upload failed"; - toast.error(Array.isArray(message) ? message.join(", ") : message); - } finally { - setUploading(false); - if (inputRef.current) inputRef.current.value = ""; - } - }; - - return ( - - - Attachments - - - {value.map((item, index) => ( - - - {item.kind === "video" ? ( - - ) : ( - - )} - - - - {item.src} - - - onChange( - replaceAt(value, index, { - ...item, - caption: e.currentTarget.value || null, - }), - ) - } - /> - - - - onChange(removeAt(value, index))} - > - - - - - - ))} - - { - const file = e.currentTarget.files?.[0]; - if (file) void upload(file); - }} - /> - - - - ); -} - -export default MediaManager; diff --git a/apps/edr-freight-web/backoffice/src/pages/portal_content/PortalContentPage.tsx b/apps/edr-freight-web/backoffice/src/pages/portal_content/PortalContentPage.tsx index 17d5d190f..4fc51174d 100644 --- a/apps/edr-freight-web/backoffice/src/pages/portal_content/PortalContentPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/portal_content/PortalContentPage.tsx @@ -26,9 +26,9 @@ import { useUpdatePortalDoc, } from "@/hooks/portal-content/usePortalContentAdmin"; -import { FaqEditor } from "./FaqEditor"; -import { HelpEditor } from "./HelpEditor"; -import { LegalDocEditor } from "./LegalDocEditor"; +import { FaqWorkspace } from "./FaqWorkspace"; +import { MarkdownHint } from "./MarkdownEditor"; +import { SectionWorkspace } from "./SectionWorkspace"; import { VersionHistoryModal } from "./VersionHistoryModal"; const TABS: { slug: SupportDocSlug; label: string }[] = [ @@ -89,6 +89,14 @@ function DocumentTab({ slug }: { slug: SupportDocSlug }) { const [note, setNote] = useState(""); const [historyOpen, setHistoryOpen] = useState(false); + /** + * Bumped every time the draft is replaced wholesale rather than edited — + * initial load, save, restore, Reset. The editors key off it to remount, + * because MDXEditor reads its markdown only on mount and would otherwise + * keep showing text the draft no longer holds. + */ + const [seed, setSeed] = useState(0); + // Reseed only when the server's version number moves (load, save, restore). // Keying off `data` itself would let a background refetch wipe edits that are // still in progress. @@ -98,6 +106,7 @@ function DocumentTab({ slug }: { slug: SupportDocSlug }) { seededVersion.current = data.version; setDraft(data.payload); setNote(""); + setSeed((n) => n + 1); } }, [data]); @@ -108,6 +117,7 @@ function DocumentTab({ slug }: { slug: SupportDocSlug }) { const reset = () => { setDraft(data.payload); setNote(""); + setSeed((n) => n + 1); }; return ( @@ -174,7 +184,12 @@ function DocumentTab({ slug }: { slug: SupportDocSlug }) { - + void; + /** Reseed counter; bumping it remounts the editors. */ + seed: number; }) { switch (slug) { case "CONTACT": @@ -204,23 +222,98 @@ function DocumentEditor({ onChange={onChange} /> ); - case "HELP": - return ( - - ); case "FAQ": - return ; - case "PRIVACY": - case "TERMS": return ( - ); + case "HELP": + case "PRIVACY": + case "TERMS": { + // All three are a title, a subtitle and a list of pages, so they share + // one workspace. `sections` is guarded because a row written before the + // free-form conversion has none. + const doc = value as PortalHelpContent | PortalLegalContent; + return ( + + ); + } } } +/** + * Title, subtitle and (for the legal documents) the "last updated" line, above + * the page list. These three fields describe the whole document, so they sit + * apart from the page being edited. + */ +function DocumentShell({ + value, + onChange, + showLastUpdated, + seed, +}: { + value: PortalHelpContent | PortalLegalContent; + onChange: (next: SupportDocPayload) => void; + showLastUpdated: boolean; + seed: number; +}) { + const legal = value as PortalLegalContent; + + return ( + + + + + + onChange({ ...value, title: e.currentTarget.value }) + } + /> + {showLastUpdated && ( + + onChange({ ...legal, lastUpdated: e.currentTarget.value }) + } + /> + )} + + + + onChange({ ...value, subtitle: e.currentTarget.value }) + } + /> + + + + + + onChange({ ...value, sections })} + seed={seed} + /> + + ); +} + /** * Four fields, so no separate file. These values feed the help page's contact * cards and resolve the `{{supportEmail}}`-style placeholders used throughout diff --git a/apps/edr-freight-web/backoffice/src/pages/portal_content/SectionWorkspace.tsx b/apps/edr-freight-web/backoffice/src/pages/portal_content/SectionWorkspace.tsx new file mode 100644 index 000000000..b59e12660 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/portal_content/SectionWorkspace.tsx @@ -0,0 +1,107 @@ +import type { PortalDocSection } from "@edr/types"; +import { Group, Stack } from "@mantine/core"; +import { useEffect, useState } from "react"; + +import { moveAt, newId, removeAt, replaceAt } from "./array-helpers"; +import { DocumentRail } from "./DocumentRail"; +import { EditorPane, EmptyPane } from "./EditorPane"; +import { MarkdownEditor } from "./MarkdownEditor"; + +interface SectionWorkspaceProps { + sections: PortalDocSection[]; + onChange: (next: PortalDocSection[]) => void; + /** Reseed counter — see the `key` on the editor below. */ + seed: number; +} + +/** + * Page-at-a-time editing for any document that is a list of sections — the help + * page and both legal documents. The list on the left is the document's table + * of contents; the pane on the right is the page you are working on. + */ +export function SectionWorkspace({ + sections, + onChange, + seed, +}: SectionWorkspaceProps) { + const [selectedId, setSelectedId] = useState( + sections[0]?.id ?? null, + ); + + // Keep a valid selection when the open page is deleted, or when the tab is + // reseeded after a save or a restore. + useEffect(() => { + if (!sections.some((section) => section.id === selectedId)) { + setSelectedId(sections[0]?.id ?? null); + } + }, [sections, selectedId]); + + const index = sections.findIndex((section) => section.id === selectedId); + const selected = index >= 0 ? sections[index] : null; + + const addSection = () => { + const section = { id: newId(), heading: "", body: "" }; + onChange([...sections, section]); + setSelectedId(section.id); + }; + + return ( + + ({ + id: section.id, + label: section.heading, + }))} + selectedId={selectedId} + onSelect={setSelectedId} + onMove={(id, delta) => + onChange( + moveAt( + sections, + sections.findIndex((section) => section.id === id), + delta, + ), + ) + } + canMove={(id, delta) => { + const at = sections.findIndex((section) => section.id === id); + const target = at + delta; + return target >= 0 && target < sections.length; + }} + addLabel="Add a page" + onAdd={addSection} + emptyLabel="This document has no pages yet." + /> + + {selected ? ( + + onChange(replaceAt(sections, index, { ...selected, heading })) + } + onRemove={() => onChange(removeAt(sections, index))} + removeLabel="Delete this page" + > + + + onChange(replaceAt(sections, index, { ...selected, body })) + } + /> + + + ) : ( + + )} + + ); +} + +export default SectionWorkspace; diff --git a/apps/edr-freight-web/backoffice/src/pages/portal_content/version-preview.ts b/apps/edr-freight-web/backoffice/src/pages/portal_content/version-preview.ts index 465c91aba..42e7fb8ab 100644 --- a/apps/edr-freight-web/backoffice/src/pages/portal_content/version-preview.ts +++ b/apps/edr-freight-web/backoffice/src/pages/portal_content/version-preview.ts @@ -39,11 +39,11 @@ export function summarizeVersion( return [ { label: "Title", body: help.title }, { label: "Subtitle", body: help.subtitle }, - ...help.sections.map((section) => ({ + // Pictures and videos live in the body, so they render in the preview + // alongside the text they belong to — nothing to summarise separately. + ...(help.sections ?? []).map((section) => ({ label: section.heading, - body: section.media.length - ? `${section.body}\n\n_${section.media.length} attachment${section.media.length === 1 ? "" : "s"}: ${section.media.map((m) => m.src).join(", ")}_` - : section.body, + body: section.body, })), ]; } diff --git a/apps/edr-freight-web/backoffice/src/services/portal-content.service.ts b/apps/edr-freight-web/backoffice/src/services/portal-content.service.ts index 0f42b2a5a..a1bbb3260 100644 --- a/apps/edr-freight-web/backoffice/src/services/portal-content.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/portal-content.service.ts @@ -64,6 +64,7 @@ export const portalContentService = { */ async uploadMedia( file: File, + onProgress?: (percent: number | null) => void, ): Promise<{ key: string; kind: PortalMediaKind; url: string }> { const form = new FormData(); form.append("file", file); @@ -72,7 +73,15 @@ export const portalContentService = { key: string; kind: PortalMediaKind; url: string; - }>(`${ROOT}/media`, form); + }>(`${ROOT}/media`, form, { + // A video is big enough that a stalled connection would otherwise sit on + // a spinner forever with nothing to tell the author it had failed. + timeout: 2 * 60 * 1000, + onUploadProgress: (event) => + onProgress?.( + event.total ? Math.round((event.loaded / event.total) * 100) : null, + ), + }); return data; }, diff --git a/apps/edr-freight-web/portal/src/pages/support/DocShell.tsx b/apps/edr-freight-web/portal/src/pages/support/DocShell.tsx index 4808f5d52..b4646e0cc 100644 --- a/apps/edr-freight-web/portal/src/pages/support/DocShell.tsx +++ b/apps/edr-freight-web/portal/src/pages/support/DocShell.tsx @@ -20,6 +20,8 @@ interface DocShellProps { meta?: string; /** Path of the current page, so it is not linked to itself. */ current: string; + /** Contents column, mirroring the page list editors see in the backoffice. */ + sidebar?: ReactNode; children: ReactNode; } @@ -33,12 +35,13 @@ export function DocShell({ subtitle, meta, current, + sidebar, children, }: DocShellProps) { return (
    -
    +
    @@ -56,7 +59,7 @@ export function DocShell({
    -
    +

    {title}

    {subtitle} @@ -65,11 +68,18 @@ export function DocShell({

    {meta}

    )} -
    {children}
    + {sidebar ? ( +
    + {sidebar} +
    {children}
    +
    + ) : ( +
    {children}
    + )}
    -
    +
    © 2026 EDR Freight. All rights reserved.
    - ); -} - export default function HelpPage() { // Never undefined — see TermsPage. const { data } = usePortalContent(); const help = data!.help; + const sections = help.sections ?? []; + + const navigate = useNavigate(); + const { hash } = useLocation(); + + // The open page lives in the URL, so a link to a specific topic works and + // Back steps between topics. + const fromHash = hash ? decodeURIComponent(hash.slice(1)) : ""; + const [selectedId, setSelectedId] = useState( + () => fromHash || sections[0]?.id || "", + ); + + useEffect(() => { + const valid = sections.some((section) => section.id === selectedId); + if (!valid) setSelectedId(fromHash || sections[0]?.id || ""); + }, [sections, selectedId, fromHash]); + + useEffect(() => { + if (fromHash && fromHash !== selectedId) setSelectedId(fromHash); + }, [fromHash]); // eslint-disable-line react-hooks/exhaustive-deps + + const selected = + sections.find((section) => section.id === selectedId) ?? sections[0]; return ( - -
    - {help.sections.map((section) => ( -
    -

    - {section.heading} -

    - - {section.body} - - {section.media.map((item) => ( - - ))} -
    - ))} -
    + ({ + id: section.id, + label: section.heading, + }))} + activeId={selected?.id ?? null} + onSelect={(id) => { + setSelectedId(id); + navigate(`#${id}`); + window.scrollTo({ top: 0, behavior: "smooth" }); + }} + /> + } + > + {selected && ( +
    +

    + {selected.heading} +

    + {selected.body} +
    + )}
    ); } diff --git a/apps/edr-freight-web/portal/src/pages/support/Markdown.tsx b/apps/edr-freight-web/portal/src/pages/support/Markdown.tsx index 11924cf23..646187f6c 100644 --- a/apps/edr-freight-web/portal/src/pages/support/Markdown.tsx +++ b/apps/edr-freight-web/portal/src/pages/support/Markdown.tsx @@ -1,5 +1,8 @@ +import { isPortalVideoSrc } from "@edr/types"; import ReactMarkdown from "react-markdown"; +import { safeMediaSrc } from "./portal-content"; + /** * Renders admin-authored markdown from the support-content API. * @@ -10,7 +13,9 @@ import ReactMarkdown from "react-markdown"; * Two things must stay absent for that to hold: * - `rehype-raw`, which would start rendering raw HTML embedded in the copy; * - a custom `urlTransform`, which would override the built-in stripping of - * `javascript:` and `data:` hrefs. + * `javascript:` and `data:` hrefs. That transform is now the *only* thing + * vetting embedded media URLs, since they are authored inside the markdown + * rather than validated field-by-field on the API. * * `remark-gfm` is also left out: tables and strikethrough are not used in the * legal or FAQ copy, and CommonMark already covers lists, emphasis and links. @@ -51,17 +56,55 @@ export function Markdown({ children }: { children: string }) { h3: ({ children: content }) => (

    {content}

    ), - // Images embedded by the editor. The API has already resolved these to - // signed URLs; react-markdown's default urlTransform still guards the - // scheme. - img: ({ src, alt }) => ( - {alt - ), + /** + * Images *and* videos: both are written with markdown's image syntax, + * and the element is chosen from the file extension. `src` here has + * already been through react-markdown's url transform; `safeMediaSrc` + * narrows it further to same-origin paths and https. + */ + img: ({ src, alt }) => { + const resolved = + typeof src === "string" ? safeMediaSrc(src) : null; + if (!resolved) return null; + + // Spans, not
    /
    : markdown wraps an image in a + // paragraph, and a
    inside a

    is invalid HTML that the + // browser silently re-parents, which drops sibling content. + const caption = alt ? ( + + {alt} + + ) : null; + + if (isPortalVideoSrc(resolved)) { + return ( + + {/* preload="metadata" so a large file is not pulled on every + visit; the browser fetches it once playback starts. */} + + {caption} + + ); + } + + return ( + + {alt + {caption} + + ); + }, }} > {children} diff --git a/apps/edr-freight-web/portal/src/pages/support/PrivacyPolicyPage.tsx b/apps/edr-freight-web/portal/src/pages/support/PrivacyPolicyPage.tsx index 07e79d207..a924d6514 100644 --- a/apps/edr-freight-web/portal/src/pages/support/PrivacyPolicyPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/support/PrivacyPolicyPage.tsx @@ -1,11 +1,15 @@ import { usePortalContent } from "@/hooks/usePortalContent"; import { DocSections, DocShell } from "./DocShell"; +import { DocSidebar, useActiveSection } from "./DocSidebar"; export default function PrivacyPolicyPage() { // Never undefined — see TermsPage. const { data } = usePortalContent(); const privacy = data!.privacy; + const sections = privacy.sections ?? []; + + const activeId = useActiveSection(sections.map((section) => section.id)); return ( ({ + id: section.id, + label: section.heading, + }))} + activeId={activeId} + /> + } > - + ); } diff --git a/apps/edr-freight-web/portal/src/pages/support/TermsPage.tsx b/apps/edr-freight-web/portal/src/pages/support/TermsPage.tsx index 6302e838d..671882ff1 100644 --- a/apps/edr-freight-web/portal/src/pages/support/TermsPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/support/TermsPage.tsx @@ -1,12 +1,18 @@ import { usePortalContent } from "@/hooks/usePortalContent"; import { DocSections, DocShell } from "./DocShell"; +import { DocSidebar, useActiveSection } from "./DocSidebar"; export default function TermsPage() { // Never undefined — the hook seeds it with the shipped copy, so this public // page renders instantly and survives the API being unreachable. const { data } = usePortalContent(); const terms = data!.terms; + const sections = terms.sections ?? []; + + // A contents list, not a pager: the whole document stays on one page so it + // can be searched, printed and deep-linked to a clause. + const activeId = useActiveSection(sections.map((section) => section.id)); return ( ({ + id: section.id, + label: section.heading, + }))} + activeId={activeId} + /> + } > - + ); } diff --git a/apps/edr-freight-web/portal/src/pages/support/portal-content.test.ts b/apps/edr-freight-web/portal/src/pages/support/portal-content.test.ts index 2523ca3ae..07b2c09b6 100644 --- a/apps/edr-freight-web/portal/src/pages/support/portal-content.test.ts +++ b/apps/edr-freight-web/portal/src/pages/support/portal-content.test.ts @@ -67,11 +67,16 @@ describe("withSupportVars", () => { }); describe("safeMediaSrc", () => { - it("keeps same-origin paths and https sources", () => { + it("keeps same-origin paths and http(s) sources", () => { expect(safeMediaSrc("/assets/guide.webm")).toBe("/assets/guide.webm"); expect(safeMediaSrc("https://minio.internal/support-content/a.png?sig=x")).toBe( "https://minio.internal/support-content/a.png?sig=x", ); + // Signed object-store URLs are plain http in dev — rejecting them hid + // every upload. + expect(safeMediaSrc("http://localhost:9000/fhc/support-content/a.mp4")).toBe( + "http://localhost:9000/fhc/support-content/a.mp4", + ); }); it("drops javascript: and protocol-relative sources", () => { diff --git a/apps/edr-freight-web/portal/src/pages/support/portal-content.ts b/apps/edr-freight-web/portal/src/pages/support/portal-content.ts index 06bfdbce9..66c5910f8 100644 --- a/apps/edr-freight-web/portal/src/pages/support/portal-content.ts +++ b/apps/edr-freight-web/portal/src/pages/support/portal-content.ts @@ -90,14 +90,17 @@ export function withSupportVars( } /** - * Accepts only a same-origin path or an https URL for an attached image or - * video, returning null for anything else so the caller renders nothing. + * Accepts a same-origin path or an http(s) URL for an attached image or video, + * returning null for anything else so the caller renders nothing. * - * `(?!\/)` rejects protocol-relative `//host/...`, which would otherwise pass - * as a path. An ``/`` src is not a navigation, so a `javascript:` - * URL would not execute anyway — but the guard is cheaper than re-deriving - * that every time someone reads this file. + * The point is to exclude `javascript:` and `data:`, not to require TLS: the + * signed MinIO URLs the API hands back are plain http wherever the object + * store is (dev, and any deployment terminating TLS elsewhere), and rejecting + * those made every uploaded picture and video vanish from the page. + * + * `(?!\/)` still rejects protocol-relative `//host/...`, which would otherwise + * pass as a path. */ export function safeMediaSrc(src: string): string | null { - return /^(https:\/\/|\/(?!\/))/.test(src) ? src : null; + return /^(https?:\/\/|\/(?!\/))/.test(src) ? src : null; } diff --git a/packages/types/src/freight/portal-content.defaults.ts b/packages/types/src/freight/portal-content.defaults.ts index 30ae3cf79..bd3ffe204 100644 --- a/packages/types/src/freight/portal-content.defaults.ts +++ b/packages/types/src/freight/portal-content.defaults.ts @@ -36,40 +36,29 @@ export const SUPPORT_CONTENT_DEFAULTS: SupportDocPayloadMap = { { id: "help-walkthrough", heading: "Portal walkthrough", - body: "A guided tour of the portal — registering your company, raising a booking against a contract, and settling an invoice.", - media: [ - { - id: "help-walkthrough-video", - kind: "video", - // Ships with the app rather than MinIO, so it is used verbatim. - src: "/assets/edr-portal-guide.webm", - caption: null, - }, - ], + // The video ships with the app rather than MinIO, so its path is used + // verbatim; the renderer picks

    - Select containers to return: - - {truck.containers.map((container) => ( - { - if (e.currentTarget.checked) { - setSelectedContainers([...selectedContainers, container.containerNumber]); - } else { - setSelectedContainers(selectedContainers.filter(c => c !== container.containerNumber)); - } - }} - /> - ))} - -
    - -