From 8ba8376f45f0e035ab5618b07c4f6da5f1191870 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Sat, 15 Aug 2026 08:39:59 +0000 Subject: [PATCH 01/43] feat(filters): migrate FleetResourcePage to the pill filter bar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The real live fleet page — FleetCrudPages.tsx (previous commit) turned out to be dead code; every fleet route (locomotives/trains/wagons/ containers/cargoes/vehicles/drivers) actually renders this one, config-driven off resources.ts. Highest-leverage single file in the remaining inventory: 7 routes at once. - filterDefs are built per slug from `config.listFilters` (status/yard/ wagon type/train…, wired to real server params via each def's default `{key: value}` toParams) plus a "Registered" date range. The 3 slugs with no server list filters (trains/containers/cargoes) fall back to a plain client-only Status filter off a fixed enum, not "whatever status exists in the currently-loaded rows" — the latter would be circular (filterDefs feeds useFilters, which feeds the query that produces those rows). - serverListFilters/pagedFilters derive straight from `controls.params` instead of hand-picking each field off local state — the def keys already match the API's param names, so this is mostly free. - filteredRows keeps the original's exact "usesServerListFilters ⇒ trust the server, don't re-check client-side" short-circuit — some server list filters (a wagon's `trainNumber` matches either of two different columns) aren't expressible as a plain client-side field equality, and re-applying them would silently break. - Pagination moved from a local `usePagination()` (component state) to useFilters' URL-backed page/pageSize — the whole point of this pass. DataTable takes `controls.tableProps(total)` directly; FleetCardGrid (not a DataTable) is fed the same pieces by hand. - The two manual "reset on slug/filter change" effects are dropped — a same-app nav Link to a bare path already clears the query string, and useFilters already deletes `page` on every filter/search change. - FleetToolbar's view-mode SegmentedControl moves into FilterBar's `children` slot; its search/filters props are no longer used here. --- .../src/pages/fleet/FleetResourcePage.tsx | 399 ++++++++---------- 1 file changed, 168 insertions(+), 231 deletions(-) diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/FleetResourcePage.tsx b/apps/edr-freight-web/backoffice/src/pages/fleet/FleetResourcePage.tsx index d837ee712..23dc6b76b 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/FleetResourcePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/FleetResourcePage.tsx @@ -1,7 +1,5 @@ import type { ColumnDef } from "@edr/ui-common"; -import { Box, Button, Card, Container, Group, Modal, Select, Stack, Text, TextInput, Title } from "@mantine/core"; -import { DatePickerInput } from "@mantine/dates"; -import { getDateRangePresets } from "@/components/common/dateRangePresets"; +import { Box, Button, Card, Container, Group, Modal, SegmentedControl, Select, Stack, Text, TextInput, Title } from "@mantine/core"; import { keepPreviousData, useMutation, useQuery } from "@tanstack/react-query"; import { api } from "@/services/api"; @@ -13,7 +11,7 @@ import { FREIGHT_PERMS, } from "@/lib/permissions"; import Breadcrumbs from "@/components/ui/Breadcrumbs"; -import { Inbox, Plus, Warehouse } from "lucide-react"; +import { Inbox, LayoutGrid, Plus, Table2, Warehouse } from "lucide-react"; import { useEffect, useMemo, useState } from "react"; import { Link, Navigate, useLocation } from "react-router-dom"; @@ -21,13 +19,12 @@ import FleetCardGrid from "@/components/fleet/FleetCardGrid"; import FleetFormDialog from "@/components/fleet/FleetFormDialog"; import FleetHistoryModal from "@/components/fleet/FleetHistoryModal"; import FleetRecordActions from "@/components/fleet/FleetRecordActions"; -import FleetToolbar from "@/components/fleet/FleetToolbar"; import { matchesDayRange } from "@/hooks/useListControls"; import WagonMovementHistoryModal from "@/components/fleet/WagonMovementHistoryModal"; import WagonStatusActions from "@/components/wagons/WagonStatusActions"; import WagonYardWorkspaceModal from "@/components/wagons/WagonYardWorkspaceModal"; import { formatFleetCell, registerFleetOptionLabels } from "@/components/fleet/fleetFormat"; -import { useFleetViewMode } from "@/components/fleet/useFleetViewMode"; +import { useFleetViewMode, type FleetViewMode } from "@/components/fleet/useFleetViewMode"; import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles"; import { useToast } from "@/hooks/use-toast"; import { @@ -43,11 +40,46 @@ import { type FleetListFilters, type FleetRecord, } from "@/services/fleet/fleet.service"; -import { DataTable, DataTableFooter, usePagination } from "@edr/ui-common"; -import { useDebouncedValue } from "@mantine/hooks"; +import { DataTable, DataTableFooter } from "@edr/ui-common"; +import { dateRangeParams, FilterBar, useFilters, type FilterDef, type FilterOption } from "@/components/filters"; const DEFAULT_SLUG: FleetResourceSlug = "locomotives"; +const SERVER_FILTERED_SLUGS: FleetResourceSlug[] = ["wagons", "locomotives", "vehicles", "drivers"]; + +// trains/containers/cargoes have no server-side `listFilters` config (see +// resources.ts) — they get a plain client-only Status filter instead, off a +// fixed enum rather than "whatever status happens to exist in the currently +// loaded rows" (which would create a circular dependency: filterDefs feeds +// useFilters, which feeds the query that produces those rows). +const TRAIN_STATUS_OPTIONS: FilterOption[] = [ + { value: "AVAILABLE", label: "Available" }, + { value: "SCHEDULED", label: "Scheduled" }, + { value: "IN_SERVICE", label: "In service" }, + { value: "UNDER_MAINTENANCE", label: "Under maintenance" }, + { value: "OUT_OF_SERVICE", label: "Out of service" }, + { value: "DEACTIVATED", label: "Deactivated" }, +]; +const CONTAINER_STATUS_OPTIONS: FilterOption[] = [ + { value: "AVAILABLE", label: "Available" }, + { value: "LOADED", label: "Loaded" }, + { value: "IN_TRANSIT", label: "In transit" }, + { value: "MAINTENANCE", label: "Maintenance" }, + { value: "DAMAGED", label: "Damaged" }, +]; +const CARGO_STATUS_OPTIONS: FilterOption[] = [ + { value: "PENDING", label: "Pending" }, + { value: "LOADED", label: "Loaded" }, + { value: "IN_TRANSIT", label: "In transit" }, + { value: "DELIVERED", label: "Delivered" }, + { value: "UNLOADED", label: "Unloaded" }, +]; +const FALLBACK_STATUS_OPTIONS: Partial> = { + trains: TRAIN_STATUS_OPTIONS, + containers: CONTAINER_STATUS_OPTIONS, + cargoes: CARGO_STATUS_OPTIONS, +}; + const FleetResourcePage = () => { const location = useLocation(); const slug = getFleetSlugFromPath(location.pathname) ?? DEFAULT_SLUG; @@ -70,18 +102,9 @@ const FleetResourcePage = () => { hasPermission(user, FREIGHT_PERMS.wagons.transferFulfill) || hasPermission(user, FREIGHT_PERMS.wagons.transferHistoryAll); - const { pagination, setPagination } = usePagination({ pageSize: 10 }); - const [search, setSearch] = useState(""); - const [debouncedSearch] = useDebouncedValue(search, 300); // Wagons and locomotives page in the database; the rest still list in full // and page in the browser (see `pagedHandlers` in fleet.service). const serverPaged = isFleetServerPaginated(slug); - const [statusFilter, setStatusFilter] = useState("ALL"); - // Registration date range. Server-side list filters (status/yard/train) are - // applied by the API; this narrows what comes back, alongside search. - const [dateFrom, setDateFrom] = useState(null); - const [dateTo, setDateTo] = useState(null); - const [listFilterValues, setListFilterValues] = useState>({}); const [formOpen, setFormOpen] = useState(false); const [editing, setEditing] = useState(null); const [removeTarget, setRemoveTarget] = useState(null); @@ -95,77 +118,6 @@ const FleetResourcePage = () => { const [wagonWorkspaceOpen, setWagonWorkspaceOpen] = useState(false); const { viewMode, setViewMode } = useFleetViewMode(slug); - const serverListFilters = useMemo((): FleetListFilters | undefined => { - const serverFilteredSlugs: FleetResourceSlug[] = ["wagons", "locomotives", "vehicles", "drivers"]; - if (!serverFilteredSlugs.includes(slug)) return undefined; - const filters: FleetListFilters = {}; - const status = listFilterValues.status; - const currentYardId = listFilterValues.currentYardId; - const availability = listFilterValues.availability; - const trainNumber = listFilterValues.trainNumber; - const trainId = listFilterValues.trainId; - if (status && status !== "ALL") { - (filters as { status?: string }).status = status; - } - if (currentYardId && currentYardId !== "ALL") { - filters.currentYardId = currentYardId; - } - if (availability && availability !== "ALL") { - (filters as { availability?: string }).availability = availability; - } - if (trainNumber && trainNumber !== "ALL") { - (filters as { trainNumber?: string }).trainNumber = trainNumber; - } - if (trainId && trainId !== "ALL") { - filters.trainId = trainId; - } - // Wagons only: narrow the fleet to one wagon type (the API filters on it). - const wagonTypeId = listFilterValues.wagonTypeId; - if (wagonTypeId && wagonTypeId !== "ALL") { - (filters as { wagonTypeId?: string }).wagonTypeId = wagonTypeId; - } - // The plain locomotives list has no server-side search — its page window - // does, so the term is only sent on the paginated path. - if ((serverPaged || slug !== "locomotives") && debouncedSearch.trim()) { - filters.search = debouncedSearch.trim(); - } - return filters; - }, [slug, listFilterValues, debouncedSearch, serverPaged]); - - // On the server-paged path the page window, the search and the registration - // date range are all resolved by the API — nothing is filtered client-side. - const pagedFilters = useMemo( - (): FleetListFilters => ({ - ...serverListFilters, - page: pagination.pageIndex + 1, - pageSize: pagination.pageSize, - ...(dateFrom ? { createdFrom: dateFrom } : {}), - ...(dateTo ? { createdTo: dateTo } : {}), - }), - [serverListFilters, pagination.pageIndex, pagination.pageSize, dateFrom, dateTo], - ); - - const listQuery = useQuery({ - ...api.fleet.list.queryOptions({ input: { slug, filters: serverListFilters } }), - enabled: !serverPaged, - }); - const pagedQuery = useQuery({ - ...api.fleet.listPaged.queryOptions({ input: { slug, filters: pagedFilters } }), - enabled: serverPaged, - placeholderData: keepPreviousData, - }); - - const activeQuery = serverPaged ? pagedQuery : listQuery; - const { isLoading, isError, error } = activeQuery; - const allRows = useMemo( - () => (serverPaged ? (pagedQuery.data?.items ?? []) : (listQuery.data ?? [])), - [serverPaged, pagedQuery.data, listQuery.data], - ); - const create = useMutation(api.fleet.create.mutationOptions()); - const update = useMutation(api.fleet.update.mutationOptions()); - const remove = useMutation(api.fleet.remove.mutationOptions()); - const purge = useMutation(api.fleet.purge.mutationOptions()); - const { data: wagonTypes = [], isLoading: wagonTypesLoading } = useQuery( api.wagonTypes.list.queryOptions(), ); @@ -202,40 +154,8 @@ const FleetResourcePage = () => { enabled: slug === "wagons", }); - useEffect(() => { - setPagination((prev) => ({ pageIndex: 0, pageSize: prev.pageSize })); - setSearch(""); - setStatusFilter("ALL"); - setListFilterValues({}); - }, [slug, setPagination]); - - useEffect(() => { - setPagination((prev) => ({ pageIndex: 0, pageSize: prev.pageSize })); - }, [search, listFilterValues, dateFrom, dateTo, setPagination]); - - const hasStatusColumn = Boolean(config?.columns.some((col) => col.accessorKey === "status")); const usesServerListFilters = Boolean(config?.listFilters?.length); - const statusFilterOptions = useMemo(() => { - if (!hasStatusColumn || usesServerListFilters) return []; - if (slug === "vehicles" || slug === "drivers") { - return [ - { value: "ALL", label: "All statuses" }, - { value: "ACTIVE", label: "Active" }, - { value: "INACTIVE", label: "Inactive" }, - ]; - } - const statuses = new Set( - allRows - .map((row) => String((row as unknown as Record).status ?? "")) - .filter(Boolean), - ); - return [ - { value: "ALL", label: "All statuses" }, - ...[...statuses].sort().map((status) => ({ value: status, label: status })), - ]; - }, [allRows, hasStatusColumn, usesServerListFilters, slug]); - const dynamicOptions = useMemo(() => { const wagonTypeOpts = (wagonTypes as Array<{ id: string; code: string; name?: string }>).map( (t) => ({ value: t.id, label: `${t.code}${t.name ? ` - ${t.name}` : ""}` }), @@ -291,25 +211,78 @@ const FleetResourcePage = () => { }; }, [wagonTypes, containerTypes, cargoTypes, truckTypes, wagons, containers, yards, trains]); - const listFilterSelects = useMemo(() => { - if (!config?.listFilters?.length) return null; - return config.listFilters.map((filter) => { - const dynamicOpts = filter.dynamicOptions - ? (dynamicOptions[filter.dynamicOptions] ?? []) - : []; - const staticOpts = - filter.options?.map((opt) => ({ value: opt.value, label: opt.label })) ?? []; - const opts = filter.dynamicOptions ? dynamicOpts : staticOpts; - return { - ...filter, - value: listFilterValues[filter.key] ?? "ALL", - data: [ - { value: "ALL", label: filter.allLabel ?? `All ${filter.label.toLowerCase()}` }, - ...opts, - ], - }; - }); - }, [config?.listFilters, listFilterValues, dynamicOptions]); + // One pill per configured server list filter (status/yard/wagon type/train…), + // built off `config.listFilters` — same source the old plain ` { - setListFilterValues((prev) => ({ - ...prev, - [filter.key]: value ?? "ALL", - })); - setPagination((prev) => ({ ...prev, pageIndex: 0 })); - }} - size="sm" - radius="lg" - w={200} - searchable={filter.data.length > 8} - comboboxProps={{ withinPortal: true }} - styles={{ input: { borderColor: "var(--mantine-color-gray-3)" } }} - /> - ))} - - ) : hasStatusColumn && statusFilterOptions.length > 1 ? ( - - Status: - - {[{ value: "ALL", label: "All" }, ...statusFilterOptions].map((option) => ( - - ))} - - - ) : null} - - } - /> + viewId={`fleet-${slug}`} + > + setViewMode(value as FleetViewMode)} + size="sm" + radius="lg" + data={[ + { + value: "table", + label: ( + + + Table + + ), + }, + { + value: "cards", + label: ( + + + Cards + + ), + }, + ]} + styles={{ root: { background: "var(--mantine-color-gray-1)" } }} + /> + {viewMode === "table" ? ( @@ -696,18 +643,8 @@ const FleetResourcePage = () => { : undefined } emptyMessage={`No ${itemLabel} found`} - pagination={{ - pageIndex: pagination.pageIndex, - pageSize: pagination.pageSize, - pageCount, - totalCount, - }} - tableOptions={{ - manualPagination: true, - pageCount, - state: { pagination }, - onPaginationChange: setPagination, - }} + pagination={dtPagination} + tableOptions={dtTableOptions} containerClassName="border-0 shadow-none bg-transparent" footer={({ table, pagination: footerPagination }) => ( { rows={pagedRows} status={tableStatus} emptyMessage={`No ${itemLabel} found`} - pagination={pagination} + pagination={{ pageIndex: controls.page - 1, pageSize: controls.pageSize }} pageCount={pageCount} totalCount={totalCount} - onPaginationChange={setPagination} + onPaginationChange={dtTableOptions!.onPaginationChange!} onEdit={ canUpdate ? (record) => { From aae0066a5d9ea4eed84ecaa9e317bfba3cbe16ca Mon Sep 17 00:00:00 2001 From: Nathnael Date: Sat, 15 Aug 2026 08:42:14 +0000 Subject: [PATCH 02/43] feat(filters): migrate ClearanceDocumentsPage to the pill filter bar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mechanical Family-A port — same server contract BookingRequestsPage already uses (tradeDirection/freightType/isGovernment/createdFrom/ createdTo/search/page/pageSize all already accepted). One wrinkle: this hub has no true "unfiltered" state — it always scopes to a fixed 4-status baseline (customsClearingEnabled=false self-clearance bookings), with the old status Select's "All statuses" row just being that baseline spelled out as an option. Modeled as a normal optional Status enum pill (4 individual statuses, no synthetic "All" entry) whose absence falls back to the baseline in the query build, not in the filter defs themselves — `customsClearingEnabled` stays a fixed, non-user-facing param the same way. --- .../contracts/ClearanceDocumentsPage.tsx | 249 +++++------------- 1 file changed, 62 insertions(+), 187 deletions(-) diff --git a/apps/edr-freight-web/backoffice/src/pages/contracts/ClearanceDocumentsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/contracts/ClearanceDocumentsPage.tsx index 029c53dd2..766f7c897 100644 --- a/apps/edr-freight-web/backoffice/src/pages/contracts/ClearanceDocumentsPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/contracts/ClearanceDocumentsPage.tsx @@ -3,34 +3,28 @@ import { ActionIcon, Box, Card, - Group, - Select, Stack, Text, - TextInput, ThemeIcon, } from "@mantine/core"; -import { DatePickerInput } from "@mantine/dates"; -import { getDateRangePresets } from "@/components/common/dateRangePresets"; -import { useDebouncedValue } from "@mantine/hooks"; import { keepPreviousData, useQuery } from "@tanstack/react-query"; -import { FileText, Inbox, RefreshCw, Search, User, X } from "lucide-react"; -import { useCallback, useMemo, useState } from "react"; +import { FileText, Inbox, RefreshCw, User } from "lucide-react"; +import { useMemo } from "react"; import { useNavigate } from "react-router-dom"; import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge"; import { ContractReferenceLink } from "@/components/bookings/ContractReferenceLink"; import { bookingTable } from "@/components/bookings/booking-ui.styles"; import { PageContainer, PageHeader } from "@/components/page"; -import { bookingsService } from "@/services/bookings.service"; +import { bookingsService, type BookingListFilter } from "@/services/bookings.service"; import type { BookingDetail } from "@/types/booking"; import { Badge, DataTable, DataTableFooter, - usePagination, type ColumnDef, } from "@edr/ui-common"; +import { dateRangeParams, FilterBar, useFilters, type FilterDef } from "@/components/filters"; /** * Operations "Clearance Documents" hub — the worklist for self-clearance @@ -43,16 +37,14 @@ import { const PAGE_SIZE = 10; /** - * Status filter options (values = `statuses` param). FULLY_EXECUTED is the - * post-approval status of intercity (domestic) bookings — kept in the list as - * history, otherwise an approved intercity row vanishes from the hub. + * The hub's baseline scope — FULLY_EXECUTED is the post-approval status of + * intercity (domestic) bookings, kept in as history so an approved intercity + * row doesn't just vanish. Sent whenever the Status pill has no narrower pick. */ -const BOOKING_STATUS_OPTIONS = [ - { - value: - "AWAITING_DOCUMENTS,DOCUMENTS_UNDER_REVIEW,CLEARANCE_READY,FULLY_EXECUTED", - label: "All statuses", - }, +const DEFAULT_STATUSES = + "AWAITING_DOCUMENTS,DOCUMENTS_UNDER_REVIEW,CLEARANCE_READY,FULLY_EXECUTED"; + +const STATUS_OPTIONS = [ { value: "AWAITING_DOCUMENTS", label: "Awaiting documents" }, { value: "DOCUMENTS_UNDER_REVIEW", label: "Under review" }, { value: "CLEARANCE_READY", label: "Clearance ready" }, @@ -75,72 +67,58 @@ const OWNERSHIP_OPTIONS = [ { value: "false", label: "Private" }, ]; -function startOfDayIso(d: Date): string { - const x = new Date(d); - x.setHours(0, 0, 0, 0); - return x.toISOString(); -} - -function endOfDayIso(d: Date): string { - const x = new Date(d); - x.setHours(23, 59, 59, 999); - return x.toISOString(); -} - export default function ClearanceDocumentsPage() { const navigate = useNavigate(); - const [query, setQuery] = useState(""); - const [debouncedQuery] = useDebouncedValue(query, 300); - const [bookingStatuses, setBookingStatuses] = useState( - BOOKING_STATUS_OPTIONS[0].value, - ); const { filterOptions } = useMyTradeAccess(); - const [directionFilter, setDirectionFilter] = useState(null); - const [freightTypeFilter, setFreightTypeFilter] = useState(null); - const [ownershipFilter, setOwnershipFilter] = useState(null); - const [createdFrom, setCreatedFrom] = useState(null); - const [createdTo, setCreatedTo] = useState(null); - const { pagination, setPagination } = usePagination({ pageSize: PAGE_SIZE }); - const search = debouncedQuery.trim() || undefined; + const filterDefs: FilterDef[] = useMemo( + () => [ + { + key: "status", + label: "Status", + type: "enum", + multiple: false, + options: STATUS_OPTIONS, + // No pick ⇒ no `statuses` param at all; the query fills in + // DEFAULT_STATUSES itself, same as the old Select's "All statuses" row. + toParams: ({ v }) => ({ statuses: v[0] }), + }, + { + key: "tradeDirection", + label: "Direction", + type: "enum", + multiple: false, + options: filterOptions(TRADE_DIRECTION_OPTIONS), + }, + { key: "freightType", label: "Freight", type: "enum", multiple: false, options: FREIGHT_TYPE_OPTIONS }, + { key: "isGovernment", label: "Ownership", type: "enum", multiple: false, options: OWNERSHIP_OPTIONS }, + { + key: "created", + label: "Created", + type: "date", + toParams: dateRangeParams("createdFrom", "createdTo"), + }, + ], + [filterOptions], + ); - const resetPage = useCallback(() => { - setPagination({ pageIndex: 0, pageSize: PAGE_SIZE }); - }, [setPagination]); + const controls = useFilters(filterDefs, { pageSize: PAGE_SIZE }); - const page = pagination.pageIndex + 1; + const filter: BookingListFilter = useMemo( + () => ({ + ...(controls.params as unknown as BookingListFilter), + // Self-clearance instances carry bookingType=ONE_TIME whatever their + // contract kind, so customsClearingEnabled=false + the status scope + // above are what isolate exactly this worklist. + customsClearingEnabled: "false", + statuses: (controls.params.statuses as string | undefined) ?? DEFAULT_STATUSES, + }), + [controls.params], + ); const bookingsQuery = useQuery({ - queryKey: [ - "clearance-documents", - "bookings", - bookingStatuses, - directionFilter, - freightTypeFilter, - ownershipFilter, - createdFrom, - createdTo, - page, - search, - ], - queryFn: () => - // Self-clearance instances carry bookingType=ONE_TIME whatever their - // contract kind, so customsClearingEnabled=false + the three per-booking - // clearance statuses are what isolate exactly this worklist. - bookingsService.list({ - statuses: bookingStatuses, - customsClearingEnabled: "false", - page, - pageSize: PAGE_SIZE, - search, - ...(directionFilter ? { tradeDirection: directionFilter } : {}), - ...(freightTypeFilter ? { freightType: freightTypeFilter } : {}), - ...(ownershipFilter - ? { isGovernment: ownershipFilter as "true" | "false" } - : {}), - ...(createdFrom ? { createdFrom: startOfDayIso(createdFrom) } : {}), - ...(createdTo ? { createdTo: endOfDayIso(createdTo) } : {}), - }), + queryKey: ["clearance-documents", "bookings", filter], + queryFn: () => bookingsService.list(filter), placeholderData: keepPreviousData, }); @@ -233,7 +211,6 @@ export default function ClearanceDocumentsPage() { ); const total = bookingsQuery.data?.total ?? 0; - const pageCount = Math.max(1, Math.ceil(total / PAGE_SIZE)); const showEmpty = !bookingsQuery.isLoading && !bookingsQuery.isError && bookingRows.length === 0; const tableStatus = bookingsQuery.isLoading @@ -265,103 +242,12 @@ export default function ClearanceDocumentsPage() { - - } - value={query} - onChange={(e) => { - setQuery(e.target.value); - resetPage(); - }} - rightSection={ - query && ( - { - setQuery(""); - resetPage(); - }} - > - - - ) - } - style={{ flex: 1, minWidth: "200px" }} - radius="lg" - /> - { - setDirectionFilter(v); - resetPage(); - }} - clearable - radius="lg" - style={{ minWidth: 130 }} - aria-label="Filter by direction" - /> - { - setOwnershipFilter(v); - resetPage(); - }} - clearable - radius="lg" - style={{ minWidth: 140 }} - aria-label="Filter by ownership" - /> - { - setCreatedFrom(from ? new Date(from) : null); - setCreatedTo(to ? new Date(to) : null); - resetPage(); - }} - presets={getDateRangePresets()} - clearable - radius="lg" - style={{ minWidth: 220 }} - aria-label="Created date range" - /> - + {showEmpty ? ( @@ -384,18 +270,7 @@ export default function ClearanceDocumentsPage() { state: { from: "/dashboard/contracts/clearance-documents" }, }) } - pagination={{ - pageIndex: pagination.pageIndex, - pageSize: pagination.pageSize, - pageCount, - totalCount: total, - }} - tableOptions={{ - state: { pagination }, - onPaginationChange: setPagination, - manualPagination: true, - pageCount, - }} + {...controls.tableProps(total)} containerClassName="border-0 shadow-none bg-transparent [&_th]:max-w-[100px] [&_td]:max-w-[100px] [&_td]:break-words" footer={DataTableFooter} /> From b9e000729d8926f20d09c71547c931db7408e2cd Mon Sep 17 00:00:00 2001 From: Abubeker Yasin Date: Mon, 17 Aug 2026 09:47:36 +0300 Subject: [PATCH 03/43] fix: ( payments ) restrict force-confirm to tickets:generate permission --- .../src/modules/payments/payments.controller.ts | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) 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 9c1bfa65d..858d108fe 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.controller.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.controller.ts @@ -211,17 +211,14 @@ export class PaymentsController { } @Post(":bookingId/force-confirm") - @PassengerStaff([ - PASSENGER_PERMS.payments.manage, - PASSENGER_PERMS.payments.manageMethods, - PASSENGER_PERMS.admin, - ]) + @PassengerStaff([PASSENGER_PERMS.tickets.generate, PASSENGER_PERMS.admin]) @ApiBearerAuth("IAM-auth") @ApiOperation({ - summary: "Force-confirm payment & generate ticket (back-office only)", + summary: "Force-confirm payment & generate ticket (ticket-generate permission)", description: "Marks the payment as SUCCEEDED, confirms the booking, and generates the ticket. " + - "Use when a vendor payment completed but the webhook was never delivered. Idempotent.", + "Use when a vendor payment completed but the webhook was never delivered. Idempotent. " + + "Requires `edr_passenger_app:tickets:generate` (admins bypass).", }) forceConfirm( @Param("bookingId") bookingId: string, From b453b81ff530ee6536ee49451094c42fc905a909 Mon Sep 17 00:00:00 2001 From: Abubeker Yasin Date: Mon, 17 Aug 2026 09:55:02 +0300 Subject: [PATCH 04/43] feat: ( backoffice ) hide Generate Ticket action without tickets:generate --- apps/edr-passenger-web/backoffice/src/app/bookings/page.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/edr-passenger-web/backoffice/src/app/bookings/page.tsx b/apps/edr-passenger-web/backoffice/src/app/bookings/page.tsx index a1909d6e8..fffe3722c 100644 --- a/apps/edr-passenger-web/backoffice/src/app/bookings/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/bookings/page.tsx @@ -31,6 +31,9 @@ const SectionHeader = ({ title }: { title: string }) => ( function BookingsPageContent() { const canManage = usePermission(PERMS.bookings.manage); + // Mirrors the API guard on POST /payments/:bookingId/force-confirm — + // tickets:generate, with the usual super-admin / org-admin bypass. + const canGenerateTicket = usePermission(PERMS.tickets.generate); const [filters, setFilters] = useState({ page: 1, pageSize: 20, search: '', status: '' }); const [extraFilters, setExtraFilters] = useState({ bookingType: '', dateFrom: '', dateTo: '', paymentStatus: '', providerTxnId: '' }); const [showExtraFilters, setShowExtraFilters] = useState(false); @@ -298,7 +301,7 @@ function BookingsPageContent() { const actions = [ { label: 'View Details', onClick: (b: any) => setSelectedBooking(b), variant: 'secondary' as const, icon: Eye }, - { label: 'Generate Ticket', onClick: (b: any) => { setGenerateTicketForm({ paymentReference: '', paymentMethod: '', notes: '' }); setGenerateTicketTouched({ paymentReference: false, paymentMethod: false }); setGenerateTicketBooking(b); }, variant: 'secondary' as const, icon: Ticket, show: (b: any) => !(b.status === 'CONFIRMED' && b.paymentIntent?.status === 'SUCCEEDED') }, + { label: 'Generate Ticket', onClick: (b: any) => { setGenerateTicketForm({ paymentReference: '', paymentMethod: '', notes: '' }); setGenerateTicketTouched({ paymentReference: false, paymentMethod: false }); setGenerateTicketBooking(b); }, variant: 'secondary' as const, icon: Ticket, show: (b: any) => canGenerateTicket && !(b.status === 'CONFIRMED' && b.paymentIntent?.status === 'SUCCEEDED') }, { label: 'Delete', onClick: (b: any) => { setDeleteError(null); setDeleteCascade(false); setDeleteCascadeChecked(false); setBookingToDelete(b); setDeleteConfirmOpen(true); }, variant: 'danger' as const, icon: Trash2 }, ]; From 41080c1650f88bd9ca64fedb8b91686d7a7d12cc Mon Sep 17 00:00:00 2001 From: Abubeker Yasin Date: Mon, 17 Aug 2026 10:22:52 +0300 Subject: [PATCH 05/43] feat: ( backoffice ) gate Master Data pages and sidebar on view permissions --- .../backoffice/src/app/classes/page.tsx | 12 +++++++++++- .../backoffice/src/app/coaches/page.tsx | 12 +++++++++++- .../backoffice/src/app/routes/page.tsx | 12 +++++++++++- .../backoffice/src/app/schedules/page.tsx | 12 +++++++++++- .../backoffice/src/app/seats/page.tsx | 11 ++++++++++- .../backoffice/src/app/stations/page.tsx | 12 +++++++++++- .../backoffice/src/app/trains/page.tsx | 12 +++++++++++- .../backoffice/src/components/layout/Sidebar.tsx | 14 +++++++------- 8 files changed, 83 insertions(+), 14 deletions(-) diff --git a/apps/edr-passenger-web/backoffice/src/app/classes/page.tsx b/apps/edr-passenger-web/backoffice/src/app/classes/page.tsx index af2cab29b..ed6f71acb 100644 --- a/apps/edr-passenger-web/backoffice/src/app/classes/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/classes/page.tsx @@ -10,8 +10,10 @@ import Modal from '@/components/ui/Modal'; import ConfirmDialog from '@/components/ui/ConfirmDialog'; import { seatClassesApi, apiClient } from '@/lib/api'; import { formatCurrency } from '@/lib/utils'; +import { PermissionGuard } from '@/components/layout/PermissionGuard'; +import { PERMS } from '@/lib/permissions'; -export default function ClassesPage() { +function ClassesPageContent() { const [filters, setFilters] = useState({ search: '' }); const [showModal, setShowModal] = useState(false); const [editingClass, setEditingClass] = useState(null); @@ -352,3 +354,11 @@ export default function ClassesPage() { ); } + +export default function ClassesPage() { + return ( + + + + ); +} diff --git a/apps/edr-passenger-web/backoffice/src/app/coaches/page.tsx b/apps/edr-passenger-web/backoffice/src/app/coaches/page.tsx index acb0deefa..408ebb352 100644 --- a/apps/edr-passenger-web/backoffice/src/app/coaches/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/coaches/page.tsx @@ -10,6 +10,8 @@ import ConfirmDialog from '@/components/ui/ConfirmDialog'; import { fleetApi, apiClient } from '@/lib/api'; import Pagination from '@/components/ui/Pagination'; import { usePagination } from '@/lib/use-pagination'; +import { PermissionGuard } from '@/components/layout/PermissionGuard'; +import { PERMS } from '@/lib/permissions'; type Tab = 'types' | 'coaches' | 'utilization'; @@ -142,7 +144,7 @@ const renderBedVisualization = (coach: any) => { ); }; -export default function CoachesPage() { +function CoachesPageContent() { const [activeTab, setActiveTab] = useState('coaches'); const [search, setSearch] = useState(''); const [showModal, setShowModal] = useState(false); @@ -929,3 +931,11 @@ export default function CoachesPage() { ); } + +export default function CoachesPage() { + return ( + + + + ); +} diff --git a/apps/edr-passenger-web/backoffice/src/app/routes/page.tsx b/apps/edr-passenger-web/backoffice/src/app/routes/page.tsx index 251d034b1..a44d78546 100644 --- a/apps/edr-passenger-web/backoffice/src/app/routes/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/routes/page.tsx @@ -11,6 +11,8 @@ import ConfirmDialog from '@/components/ui/ConfirmDialog'; import { routesApi } from '@/lib/api/routes'; import { stationsApi, fleetApi, routeCoachTemplatesApi } from '@/lib/api'; import { eatLocalToISO, isoToEATLocal } from '@/lib/timezone'; +import { PermissionGuard } from '@/components/layout/PermissionGuard'; +import { PERMS } from '@/lib/permissions'; interface RouteStop { stationId: string; @@ -166,7 +168,7 @@ function RouteCoachesTab({ routes }: { routes: any[] }) { ); } -export default function RoutesPage() { +function RoutesPageContent() { const [activeTab, setActiveTab] = useState('routes'); const [showModal, setShowModal] = useState(false); const [editingRoute, setEditingRoute] = useState(null); @@ -922,3 +924,11 @@ export default function RoutesPage() { ); } + +export default function RoutesPage() { + return ( + + + + ); +} diff --git a/apps/edr-passenger-web/backoffice/src/app/schedules/page.tsx b/apps/edr-passenger-web/backoffice/src/app/schedules/page.tsx index 35d0290ea..7738f4b71 100644 --- a/apps/edr-passenger-web/backoffice/src/app/schedules/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/schedules/page.tsx @@ -14,6 +14,8 @@ import { usePagination } from '@/lib/use-pagination'; import { formatDateTime } from '@/lib/utils'; import { eatLocalToISO, isoToEATLocal } from '@/lib/timezone'; import DateTimePicker from '@/components/ui/DateTimePicker'; +import { PermissionGuard } from '@/components/layout/PermissionGuard'; +import { PERMS } from '@/lib/permissions'; interface Schedule { id: string; @@ -52,7 +54,7 @@ interface Coach { coachType?: { name: string }; } -export default function SchedulesPage() { +function SchedulesPageContent() { const [showModal, setShowModal] = useState(false); const [showAddModal, setShowAddModal] = useState(false); const [showEditModal, setShowEditModal] = useState(false); @@ -1248,3 +1250,11 @@ export default function SchedulesPage() { ); } + +export default function SchedulesPage() { + return ( + + + + ); +} diff --git a/apps/edr-passenger-web/backoffice/src/app/seats/page.tsx b/apps/edr-passenger-web/backoffice/src/app/seats/page.tsx index c2d3ef2db..86d61eff0 100644 --- a/apps/edr-passenger-web/backoffice/src/app/seats/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/seats/page.tsx @@ -6,6 +6,7 @@ import { seatsApi, schedulesApi, fleetApi, routeCoachTemplatesApi, bookingsApi } import { routesApi } from '@/lib/api/routes'; import { usePermissionStrict } from '@/lib/use-permission'; import { PERMS } from '@/lib/permissions'; +import { PermissionGuard } from '@/components/layout/PermissionGuard'; import Modal from '@/components/ui/Modal'; import ActionButton from '@/components/ui/ActionButton' import { Armchair, Lock, Unlock, Bed, X, RotateCcw, ChevronDown, Train, Wrench, Ticket as TicketIcon } from 'lucide-react'; @@ -15,7 +16,7 @@ import { SeatBlockReasonCategory, } from '@edr/types'; -export default function SeatsPage() { +function SeatsPageContent() { const [activeTab, setActiveTab] = useState<'route' | 'schedule'>('route'); const [selectedSchedule, setSelectedSchedule] = useState(''); const [selectedRoute, setSelectedRoute] = useState(''); @@ -1491,3 +1492,11 @@ function SeatIcon({ ); } + +export default function SeatsPage() { + return ( + + + + ); +} diff --git a/apps/edr-passenger-web/backoffice/src/app/stations/page.tsx b/apps/edr-passenger-web/backoffice/src/app/stations/page.tsx index 032a978a4..420bc3434 100644 --- a/apps/edr-passenger-web/backoffice/src/app/stations/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/stations/page.tsx @@ -11,8 +11,10 @@ import ConfirmDialog from '@/components/ui/ConfirmDialog'; import { stationsApi } from '@/lib/api'; import Pagination from '@/components/ui/Pagination'; import { usePagination } from '@/lib/use-pagination'; +import { PermissionGuard } from '@/components/layout/PermissionGuard'; +import { PERMS } from '@/lib/permissions'; -export default function StationsPage() { +function StationsPageContent() { const [filters, setFilters] = useState({ search: '', country: '', operational: '' }); const [showModal, setShowModal] = useState(false); const [editingStation, setEditingStation] = useState(null); @@ -379,3 +381,11 @@ export default function StationsPage() { ); } + +export default function StationsPage() { + return ( + + + + ); +} diff --git a/apps/edr-passenger-web/backoffice/src/app/trains/page.tsx b/apps/edr-passenger-web/backoffice/src/app/trains/page.tsx index 527aebdc7..e1be87ff3 100644 --- a/apps/edr-passenger-web/backoffice/src/app/trains/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/trains/page.tsx @@ -13,8 +13,10 @@ import Pagination from '@/components/ui/Pagination'; import { usePagination } from '@/lib/use-pagination'; import { Train as TrainType } from '@/types'; import { formatDate } from '@/lib/utils'; +import { PermissionGuard } from '@/components/layout/PermissionGuard'; +import { PERMS } from '@/lib/permissions'; -export default function TrainsPage() { +function TrainsPageContent() { const [showModal, setShowModal] = useState(false); const [editingTrain, setEditingTrain] = useState(null); const [search, setSearch] = useState(''); @@ -366,3 +368,11 @@ export default function TrainsPage() { ); } + +export default function TrainsPage() { + return ( + + + + ); +} diff --git a/apps/edr-passenger-web/backoffice/src/components/layout/Sidebar.tsx b/apps/edr-passenger-web/backoffice/src/components/layout/Sidebar.tsx index 616488917..1f33c417e 100644 --- a/apps/edr-passenger-web/backoffice/src/components/layout/Sidebar.tsx +++ b/apps/edr-passenger-web/backoffice/src/components/layout/Sidebar.tsx @@ -81,13 +81,13 @@ const navigationSections: { title: string; items: NavItem[] }[] = [ { title: 'Master Data', items: [ - { name: 'Stations', href: '/stations', icon: MapPin }, - { name: 'Trains', href: '/trains', icon: Train }, - { name: 'Coaches', href: '/coaches', icon: Grid3x3 }, - { name: 'Seats', href: '/seats', icon: Armchair }, - { name: 'Classes', href: '/classes', icon: Settings }, - { name: 'Routes', href: '/routes', icon: Route }, - { name: 'Schedules', href: '/schedules', icon: Calendar }, + { name: 'Stations', href: '/stations', icon: MapPin, permission: PERMS.stations.view }, + { name: 'Trains', href: '/trains', icon: Train, permission: PERMS.trains.view }, + { name: 'Coaches', href: '/coaches', icon: Grid3x3, permission: PERMS.coaches.view }, + { name: 'Seats', href: '/seats', icon: Armchair, permission: PERMS.seats.view }, + { name: 'Classes', href: '/classes', icon: Settings, permission: PERMS.classes.view }, + { name: 'Routes', href: '/routes', icon: Route, permission: PERMS.routes.view }, + { name: 'Schedules', href: '/schedules', icon: Calendar, permission: PERMS.schedules.view }, ] }, { From 562ebf485cb52359cde23fcb583bf1006f8eb5c5 Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Sun, 16 Aug 2026 19:15:04 +0000 Subject: [PATCH 06/43] fix(eims): thermal page-length used viewport height, not content height MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit scrollHeight is defined as the larger of an element's content height and its own (viewport) height — for a receipt shorter than the placeholder 1123px viewport, it silently returned the viewport height back, producing a correctly-formatted but page-length-tall PDF with a huge trailing blank strip below the real content. Found by actually rendering one and looking at it, not caught by unit tests (buildThermalHtml is pure string output, never exercises page.pdf() sizing). Fix: use a deliberately tiny (100px) viewport height for the thermal measurement pass, forcing content to overflow it so scrollHeight always reflects the receipt's real height. Also round the computed mm value before templating it into the CSS length string. Co-Authored-By: Claude Sonnet 5 --- .../modules/billing/documents/pdf-render.service.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/apps/edr-freight-api/src/modules/billing/documents/pdf-render.service.ts b/apps/edr-freight-api/src/modules/billing/documents/pdf-render.service.ts index 21676c160..3767637a7 100644 --- a/apps/edr-freight-api/src/modules/billing/documents/pdf-render.service.ts +++ b/apps/edr-freight-api/src/modules/billing/documents/pdf-render.service.ts @@ -84,7 +84,13 @@ export class PdfRenderService { const page = await browser.newPage(); const thermal = opts.thermal ?? false; const viewportWidth = thermal ? Math.round((THERMAL_PAGE_WIDTH_MM / 25.4) * 96) : 794; - await page.setViewport({ width: viewportWidth, height: 1123, deviceScaleFactor: 1 }); + // Thermal viewport height is deliberately tiny (not a real page height at all): scrollHeight + // is defined as the LARGER of the content's height and the viewport's own height, so a + // receipt shorter than the viewport would otherwise report the viewport height back, not + // its true content height — a real page-length trailing blank space bug, not theoretical + // (confirmed by actually rendering one). A short viewport forces content to overflow it, + // so scrollHeight always reflects the content, never the viewport. + await page.setViewport({ width: viewportWidth, height: thermal ? 100 : 1123, deviceScaleFactor: 1 }); await page.setContent(preparedHtml, { waitUntil: "load", timeout: 60_000 }); await page.emulateMediaType("print"); await new Promise((resolve) => setTimeout(resolve, 250)); @@ -154,7 +160,7 @@ export class PdfRenderService { // browser context regardless, same as the closure form would be. const scrollPx = (await page.evaluate("document.documentElement.scrollHeight")) as number; const contentMm = (scrollPx / 96) * 25.4 + THERMAL_MARGIN_MM * 2 + THERMAL_FEED_MM; - return Math.min(THERMAL_MAX_HEIGHT_MM, contentMm); + return Math.min(THERMAL_MAX_HEIGHT_MM, Math.round(contentMm * 100) / 100); } private injectPdfPrintStyles(html: string): string { From 09bc7c74d73e2262e83157ab7cb493b114f83e28 Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Mon, 17 Aug 2026 07:18:39 +0000 Subject: [PATCH 07/43] feat(eims): derive buyer City/Country from company profile, not global config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit City: EimsMapperContext.buyerCity was declared but never wired anywhere — always null, silently, for every buyer. No dedicated city column on Company; derives from Zone via a new EIMS_BUYER_CITY_CODES map, same lookup mechanism as Region/Wereda but optional (an unmapped zone resolves to null rather than throwing) — MoR has already accepted a live filing with City null. Country: previously a single flat EIMS_BUYER_COUNTRY_CODE applied to every buyer regardless of Company.country. Now reads company.country, resolved via a new EIMS_BUYER_COUNTRY_CODES name-to-code map; the flat env var becomes a domestic-only fallback (applies only when country is empty/Ethiopia), so an unmapped foreign buyer fails locally instead of silently filing as Ethiopia. Co-Authored-By: Claude Sonnet 5 --- .../edr-freight-api/src/config/eims.config.ts | 22 ++++++ .../billing/eims-invoice.mapper.spec.ts | 53 ++++++++++++- .../modules/billing/eims-invoice.mapper.ts | 78 ++++++++++++++++--- .../src/modules/eims/eims-invoice-context.ts | 2 + .../src/modules/eims/eims-test-fixtures.ts | 2 + 5 files changed, 147 insertions(+), 10 deletions(-) diff --git a/apps/edr-freight-api/src/config/eims.config.ts b/apps/edr-freight-api/src/config/eims.config.ts index cf77072e0..c5565cbc1 100644 --- a/apps/edr-freight-api/src/config/eims.config.ts +++ b/apps/edr-freight-api/src/config/eims.config.ts @@ -80,7 +80,19 @@ export interface EimsInvoiceConfig { paymentMode: string; paymentTerm: string; unitDefault: string; + /** + * Domestic fallback only — used when the buyer's `Company.country` is empty or "Ethiopia" (the + * column's own default) and not already listed in `buyerCountryCodes`. A genuinely foreign + * buyer must be in `buyerCountryCodes` by name or the mapping fails locally; this value is never + * applied to them, so an unconfigured foreign country can't silently be filed as Ethiopia. + */ buyerCountryCode: string | null; + /** + * Country name → MoR code, from `EIMS_BUYER_COUNTRY_CODES` ("Ethiopia=231,Djibouti=071"). Format + * unconfirmed (unlike Region/Wereda, MoR has never named a Country regex), so — unlike them — + * this is not validated against a fixed digit pattern, only looked up by name. + */ + buyerCountryCodes: Record; /** * Buyer region name → MoR numeric code, from `EIMS_BUYER_REGION_CODES` * ("Addis Ababa=13,Oromia=4"). A buyer whose region is neither a code nor in this map fails @@ -89,6 +101,14 @@ export interface EimsInvoiceConfig { buyerRegionCodes: Record; /** Same mechanism as `buyerRegionCodes`, for `EIMS_BUYER_WEREDA_CODES` ("Yeka=574"). */ buyerWeredaCodes: Record; + /** + * Buyer *zone* name → MoR City code, from `EIMS_BUYER_CITY_CODES` ("Kirkos=101"). `Company` has + * no dedicated city column — Zone is the closest match in EDR's own data. Optional, unlike + * Region/Wereda: MoR has never required City on a live buyer (confirmed — filing already + * succeeds with it null), so an unmapped zone falls back to null rather than failing the + * mapping. + */ + buyerCityCodes: Record; /** * Per-`chargeType` tax treatment, e.g. `EIMS_TAX_CODE_BY_CHARGE_TYPE=RAIL_FREIGHT=VAT0` + * `EIMS_TAX_RATE_BY_CHARGE_TYPE=RAIL_FREIGHT=0`. A charge type not listed here falls back to @@ -208,8 +228,10 @@ export default registerAs("eims", (): EimsConfig => { paymentTerm: process.env.EIMS_PAYMENT_TERM ?? "", unitDefault: process.env.EIMS_UNIT_DEFAULT ?? "", buyerCountryCode: process.env.EIMS_BUYER_COUNTRY_CODE || null, + buyerCountryCodes: parseCodeMap(process.env.EIMS_BUYER_COUNTRY_CODES), buyerRegionCodes: parseCodeMap(process.env.EIMS_BUYER_REGION_CODES), buyerWeredaCodes: parseCodeMap(process.env.EIMS_BUYER_WEREDA_CODES), + buyerCityCodes: parseCodeMap(process.env.EIMS_BUYER_CITY_CODES), taxCodeByChargeType: parseCodeMap(process.env.EIMS_TAX_CODE_BY_CHARGE_TYPE), taxRateByChargeType: parseCodeMap(process.env.EIMS_TAX_RATE_BY_CHARGE_TYPE), exciseByChargeType: parseCodeMap(process.env.EIMS_EXCISE_BY_CHARGE_TYPE), 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 index 7406613fc..6d58a7bec 100644 --- 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 @@ -60,8 +60,11 @@ const context = (over: Partial = {}): EimsMapperContext => ({ unitDefault: "PCS", incomeWithholdValue: 0, transactionWithholdValue: 0, + buyerCountryCode: "231", // test-only, not a confirmed real MoR code + buyerCountryCodes: {}, buyerRegionCodes: { "Addis Ababa": "13" }, buyerWeredaCodes: {}, + buyerCityCodes: {}, ...over, }); @@ -92,6 +95,9 @@ describe("toEimsInvoice", () => { expect(doc.BuyerDetails).toEqual({ City: null, + // company.country is "Ethiopia" (the domestic default) — resolves to context's flat + // buyerCountryCode fallback, not null, per resolveCountryCode. + Country: "231", Email: "buyer@abc.et", HouseNumber: "NEW", IdNumber: null, @@ -100,7 +106,6 @@ describe("toEimsInvoice", () => { LegalName: "ABC Trading PLC", Phone: "0912345678", Region: "13", - Country: null, Zone: "SHA", Kebele: "03", VatNumber: "123475885858", @@ -335,6 +340,52 @@ describe("toEimsInvoice — MoR field constraints", () => { ).toThrow(/buyer Wereda "Yeka".*EIMS_BUYER_WEREDA_CODES/); }); + it("derives City from the buyer's zone via the city code map", () => { + const doc = toEimsInvoice( + invoice({ company: { ...invoice().company!, zone: "Kirkos" } }), + seller, + context({ buyerCityCodes: { Kirkos: "101" } }), + ); + expect(doc.BuyerDetails.City).toBe("101"); + }); + + it("leaves City null (not a throw) when the buyer's zone has no city mapping — City is optional", () => { + const doc = toEimsInvoice( + invoice({ company: { ...invoice().company!, zone: "Somewhere Else" } }), + seller, + context({ buyerCityCodes: {} }), + ); + expect(doc.BuyerDetails.City).toBeNull(); + }); + + it("maps a buyer country name to its code via the country code map", () => { + const doc = toEimsInvoice( + invoice({ company: { ...invoice().company!, country: "Djibouti" } }), + seller, + context({ buyerCountryCodes: { Djibouti: "071" } }), + ); + expect(doc.BuyerDetails.Country).toBe("071"); + }); + + it("falls back to the flat domestic country code only for Ethiopia, not any unmapped country", () => { + const doc = toEimsInvoice( + invoice({ company: { ...invoice().company!, country: "Ethiopia" } }), + seller, + context({ buyerCountryCode: "231", buyerCountryCodes: {} }), + ); + expect(doc.BuyerDetails.Country).toBe("231"); + }); + + it("refuses a genuinely foreign buyer country with no mapping — never silently files it as Ethiopia", () => { + expect(() => + toEimsInvoice( + invoice({ company: { ...invoice().company!, country: "Kenya" } }), + seller, + context({ buyerCountryCode: "231", buyerCountryCodes: {} }), + ), + ).toThrow(/buyer Country "Kenya".*EIMS_BUYER_COUNTRY_CODES/); + }); + it("emits NatureOfSupplies lowercase, whatever case it was configured in", () => { const doc = toEimsInvoice(invoice(), seller, context({ natureOfSupplies: "Service" })); expect(doc.ItemList[0].NatureOfSupplies).toBe("service"); 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 index 582e3d326..406952867 100644 --- a/apps/edr-freight-api/src/modules/billing/eims-invoice.mapper.ts +++ b/apps/edr-freight-api/src/modules/billing/eims-invoice.mapper.ts @@ -234,8 +234,13 @@ export interface EimsMapperContext { * from a registered invoice"). */ relatedDocument?: string | null; - /** MoR numeric country code for the buyer; our DB stores the country name. */ + /** + * Domestic fallback only, applied when `company.country` is empty or "Ethiopia" and not already + * in `buyerCountryCodes` — see that field. Never applied to a genuinely foreign buyer. + */ buyerCountryCode?: string | null; + /** Country name → MoR code. Format unconfirmed, so looked up by name only, not digit-validated. */ + buyerCountryCodes: Record; /** * Region name → MoR numeric code, for buyers whose stored region is free text. * @@ -252,9 +257,15 @@ export interface EimsMapperContext { * fail locally on an unmapped name rather than file a guess. */ buyerWeredaCodes: Record; + /** + * Buyer *zone* name → MoR City code. `Company` has no dedicated city column; Zone is the + * closest match in EDR's own data. Unlike Region/Wereda, City is optional — MoR has already + * accepted a live filing with it null — so an unmapped zone resolves to null, it does not fail + * the mapping. + */ + buyerCityCodes: Record; buyerIdType?: string | null; buyerIdNumber?: string | null; - buyerCity?: string | null; /** Required when the invoice currency is not ETB. */ exchangeRate?: number | null; invoiceDiscount?: number | null; @@ -299,17 +310,22 @@ export const formatEimsDate = (issuedAt: Date): string => * exchange rate. */ /** - * A buyer's location value (Region or Wereda) as a MoR code: passed through when already numeric, - * otherwise looked up by name (case- and space-insensitive). Throws when neither applies — sending - * a guessed code onto a tax document is worse than refusing to file. + * A buyer's location value (Region, Wereda or City) as a MoR code: passed through when already + * numeric, otherwise looked up by name (case- and space-insensitive). + * + * Region/Wereda are required: an unmapped value throws — sending a guessed code onto a tax + * document is worse than refusing to file. City is optional (`required: false`, City's own + * caller) — MoR has already accepted a live filing with it null, so an unmapped zone resolves to + * null instead of blocking the invoice. */ function resolveLocationCode( - field: "Region" | "Wereda", + field: "Region" | "Wereda" | "City", value: string | null | undefined, codes: Record, envVar: string, invoiceNumber: string, -): string { + opts: { required?: boolean } = {}, +): string | null { const raw = (value ?? "").trim(); if (LOCATION_CODE.test(raw)) return raw; @@ -319,12 +335,42 @@ function resolveLocationCode( )?.[1]; if (mapped && LOCATION_CODE.test(mapped)) return mapped; + if (opts.required === false) return null; + throw new Error( `EIMS mapping: invoice ${invoiceNumber} has buyer ${field} ${raw ? `"${raw}"` : "(unset)"}, ` + `which is not a MoR ${field} code and has no mapping. Add it to ${envVar}.`, ); } +/** + * A buyer's `Country` as a MoR code: looked up by name in `codes` first; when unmapped, applies + * `domesticFallback` only if the stored country is empty or "Ethiopia" (the DB column's default). + * A genuinely foreign, unmapped country throws rather than silently filing as Ethiopia — same + * "fail locally, don't guess" rule as `resolveLocationCode`, but never digit-validated: MoR's + * Country code format is unconfirmed, unlike Region/Wereda's proven `^[0-9]{1,3}$`. + */ +function resolveCountryCode( + country: string | null | undefined, + codes: Record, + domesticFallback: string | null, + invoiceNumber: string, +): string | null { + const raw = (country ?? "").trim(); + const key = raw.toLowerCase().replace(/\s+/g, " "); + const mapped = Object.entries(codes).find( + ([name]) => name.trim().toLowerCase().replace(/\s+/g, " ") === key, + )?.[1]; + if (mapped) return mapped; + + if ((!raw || key === "ethiopia") && domesticFallback) return domesticFallback; + + throw new Error( + `EIMS mapping: invoice ${invoiceNumber} has buyer Country "${raw || "(unset)"}", which has no ` + + "MoR country code mapping. Add it to EIMS_BUYER_COUNTRY_CODES.", + ); +} + export function toEimsInvoice( invoice: EimsMapperInvoice, seller: EimsSellerDetails, @@ -440,7 +486,16 @@ export function toEimsInvoice( return { BuyerDetails: { - City: context.buyerCity ?? null, + // No dedicated city column on Company — Zone is the closest match; optional (see + // resolveLocationCode's City comment). + City: resolveLocationCode( + "City", + company.zone, + context.buyerCityCodes, + "EIMS_BUYER_CITY_CODES", + invoice.invoiceNumber, + { required: false }, + ), Email: company.email ?? null, HouseNumber: company.houseNo ?? null, IdNumber: context.buyerIdNumber ?? null, @@ -455,7 +510,12 @@ export function toEimsInvoice( "EIMS_BUYER_REGION_CODES", invoice.invoiceNumber, ), - Country: context.buyerCountryCode ?? null, + Country: resolveCountryCode( + company.country, + context.buyerCountryCodes, + context.buyerCountryCode ?? null, + invoice.invoiceNumber, + ), Zone: company.zone ?? null, Kebele: company.kebele ?? null, VatNumber: company.vatNumber ?? null, 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 b77804eda..4c1c82c10 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 @@ -206,8 +206,10 @@ export function buildEimsContext(config: EimsConfig, input: EimsContextInput): E incomeWithholdValue: invoice.incomeWithholdValue!, transactionWithholdValue: invoice.transactionWithholdValue!, buyerCountryCode: invoice.buyerCountryCode, + buyerCountryCodes: invoice.buyerCountryCodes, buyerRegionCodes: invoice.buyerRegionCodes, buyerWeredaCodes: invoice.buyerWeredaCodes, + buyerCityCodes: invoice.buyerCityCodes, // TEMPORARY — see EimsInvoiceConfig.buyerIdType. buyerIdType: invoice.buyerIdType, buyerIdNumber: invoice.buyerIdNumber, 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 93df29da2..650a834b8 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 @@ -33,8 +33,10 @@ export const eimsInvoiceConfig = (over: Partial = {}): EimsIn paymentTerm: "IMMIDIATE", unitDefault: "PCS", buyerCountryCode: null, + buyerCountryCodes: { Ethiopia: "231" }, // test-only, not a confirmed real MoR code buyerRegionCodes: { "Addis Ababa": "13" }, buyerWeredaCodes: { Yeka: "99" }, // test-only, not a real MoR code + buyerCityCodes: { Kirkos: "101" }, // test-only, not a confirmed real MoR code taxCodeByChargeType: {}, taxRateByChargeType: {}, exciseByChargeType: {}, From 28c9dd93e07286e21c187e5ead39023e72c0b6d8 Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Mon, 17 Aug 2026 07:40:40 +0000 Subject: [PATCH 08/43] feat(eims): seller identity enriched from e-Trade, cached (bootstrap only) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ten EIMS_SELLER_* env vars were the only source of EDR's own seller identity, duplicating data the platform already has via the same e-Trade lookup used for every customer company at onboarding. EimsSellerCacheService now enriches it — but static config remains the source of truth: MoR validates SellerDetails against its own taxpayer registry (rule 7017, already cleared against the current static values), so e-Trade fills a field only when the static value is blank, never overrides one already confirmed. The static config is therefore the durable fallback, not the cache; an in-memory snapshot lost on restart is harmless. ETradeService has no request timeout of its own and no AbortController, so the cache enforces one locally (stops waiting, doesn't cancel the request) and de-duplicates concurrent refresh() calls into the same in-flight promise. getSellerDetails() is fully synchronous — zero I/O — so live registration never depends on e-Trade being reachable, at boot or per invoice. VatNumber and Email stay on static config permanently — confirmed by reading e-Trade's actual response shapes, neither field exists anywhere in what it returns. Region/Wereda/City reuse the existing EIMS_BUYER_*_CODES maps rather than adding seller-specific ones — the geography is objective, not buyer-specific. Co-Authored-By: Claude Sonnet 5 --- .../modules/billing/eims-invoice.mapper.ts | 19 +++ .../src/modules/companies/companies.module.ts | 3 + .../eims-invoice-registration.service.spec.ts | 5 + .../eims/eims-invoice-registration.service.ts | 10 +- .../eims/eims-seller-cache.service.spec.ts | 155 ++++++++++++++++++ .../modules/eims/eims-seller-cache.service.ts | 147 +++++++++++++++++ .../src/modules/eims/eims.module.ts | 7 + 7 files changed, 340 insertions(+), 6 deletions(-) create mode 100644 apps/edr-freight-api/src/modules/eims/eims-seller-cache.service.spec.ts create mode 100644 apps/edr-freight-api/src/modules/eims/eims-seller-cache.service.ts 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 index 406952867..b8755c709 100644 --- a/apps/edr-freight-api/src/modules/billing/eims-invoice.mapper.ts +++ b/apps/edr-freight-api/src/modules/billing/eims-invoice.mapper.ts @@ -371,6 +371,25 @@ function resolveCountryCode( ); } +/** + * Same name-or-code resolution as `resolveLocationCode`, for a caller with no invoice to attach an + * error to and that must never throw — currently only `EimsSellerCacheService`, resolving + * e-Trade's region/zone/woreda *names* for EDR's own seller identity. Pass-through numeric code, + * name lookup, `undefined` on no match — the caller falls back to static config either way. + */ +export function resolveOptionalCode( + value: string | null | undefined, + codes: Record, +): string | undefined { + const raw = (value ?? "").trim(); + if (LOCATION_CODE.test(raw)) return raw; + const key = raw.toLowerCase().replace(/\s+/g, " "); + const mapped = Object.entries(codes).find( + ([name]) => name.trim().toLowerCase().replace(/\s+/g, " ") === key, + )?.[1]; + return mapped && LOCATION_CODE.test(mapped) ? mapped : undefined; +} + export function toEimsInvoice( invoice: EimsMapperInvoice, seller: EimsSellerDetails, diff --git a/apps/edr-freight-api/src/modules/companies/companies.module.ts b/apps/edr-freight-api/src/modules/companies/companies.module.ts index b990aa8f0..666634cb8 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.module.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.module.ts @@ -67,6 +67,9 @@ import { VerifaydaModule } from "../verifayda/verifayda.module"; // Consumed by NotificationInboxModule for portal recipient targeting. ExternalProfileRepository, CompanyProfileRepository, + // Consumed by EimsModule's EimsSellerCacheService — same e-Trade business-registry lookup + // already used for every customer company at onboarding, reused for EDR's own TIN. + ETradeService, ], }) export class CompaniesModule { } 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 36c355bed..79d3204dd 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 @@ -11,7 +11,9 @@ import { NotificationsService } from "../notifications/notifications.service"; import { EimsAuthService } from "./eims-auth.service"; import { EimsClientService } from "./eims-client.service"; import { EimsApiException } from "./eims.errors"; +import { buildEimsSeller } from "./eims-invoice-context"; import { EimsInvoiceRegistrationService } from "./eims-invoice-registration.service"; +import { EimsSellerCacheService } from "./eims-seller-cache.service"; import { EimsSystemState } from "./entities/eims-system-state.entity"; import { EimsInvoiceStatus } from "./eims-registration.types"; @@ -172,6 +174,9 @@ const build = ( } as unknown as EimsAuthService, { notify } as unknown as NotificationInboxService, { directSend } as unknown as NotificationsService, + // Same static-config seller the real EimsSellerCacheService falls back to when it has never + // successfully fetched e-Trade — matches prior behavior for every test in this file. + { getSellerDetails: (c: EimsConfig) => buildEimsSeller(c) } as unknown as EimsSellerCacheService, ); /** 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 ab9f953c5..2ca5f5ffd 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 @@ -27,12 +27,9 @@ import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry"; import { EimsAuthService } from "./eims-auth.service"; import { EimsClientService } from "./eims-client.service"; import { EimsApiException } from "./eims.errors"; +import { EimsSellerCacheService } from "./eims-seller-cache.service"; import { EimsSystemState } from "./entities/eims-system-state.entity"; -import { - assertEimsInvoiceConfig, - buildEimsContext, - buildEimsSeller, -} from "./eims-invoice-context"; +import { assertEimsInvoiceConfig, buildEimsContext } from "./eims-invoice-context"; import { EimsInvoiceError, EimsInvoiceStatus, @@ -83,6 +80,7 @@ export class EimsInvoiceRegistrationService { private readonly auth: EimsAuthService, private readonly inbox: NotificationInboxService, private readonly notifications: NotificationsService, + private readonly sellerCache: EimsSellerCacheService, ) {} private get cfg(): EimsConfig { @@ -128,7 +126,7 @@ export class EimsInvoiceRegistrationService { // The request can only be built now: InvoiceCounter and PreviousIrn come from the reservation. const request = toEimsInvoice( invoice, - buildEimsSeller(cfg), + this.sellerCache.getSellerDetails(cfg), buildEimsContext(cfg, { // Allocated from the system state, not our invoiceNumber: MoR validates DocumentNumber // against ^(0|[1-9][0-9]{0,8})$, which "INV-20260807-00006" can never satisfy. diff --git a/apps/edr-freight-api/src/modules/eims/eims-seller-cache.service.spec.ts b/apps/edr-freight-api/src/modules/eims/eims-seller-cache.service.spec.ts new file mode 100644 index 000000000..7b4ff1b3f --- /dev/null +++ b/apps/edr-freight-api/src/modules/eims/eims-seller-cache.service.spec.ts @@ -0,0 +1,155 @@ +import { ConfigService } from "@nestjs/config"; + +import { EimsConfig } from "../../config/eims.config"; +import { ETradeService } from "../companies/services/etrade.service"; +import { eimsConfig, eimsInvoiceConfig } from "./eims-test-fixtures"; +import { EimsSellerCacheService } from "./eims-seller-cache.service"; + +const registrationData = (over: Record = {}) => ({ + companyName: "Ethio-Djibouti Railway PLC (eTrade)", + region: "Addis Ababa", + zone: "Bole", + woreda: "Yeka", + mobilePhone: "0911000000", + regularPhone: "", + ...over, +}); + +const build = (cfg: EimsConfig = eimsConfig()) => { + const resolveCompanyData = jest.fn(); + const extractRegistrationData = jest.fn().mockReturnValue(registrationData()); + const etrade = { resolveCompanyData, extractRegistrationData } as unknown as ETradeService; + const config = { get: () => cfg } as unknown as ConfigService; + const service = new EimsSellerCacheService(etrade, config); + return { service, resolveCompanyData, extractRegistrationData, cfg }; +}; + +const CODES = { + buyerRegionCodes: { "Addis Ababa": "13" }, + buyerWeredaCodes: { Yeka: "99" }, + buyerCityCodes: { Bole: "101" }, +}; + +describe("EimsSellerCacheService.getSellerDetails", () => { + it("static config wins over a conflicting e-Trade value", async () => { + const cfg = eimsConfig({ + invoice: eimsInvoiceConfig({ sellerLegalName: "Ethio-Djibouti Railway S.C.", ...CODES }), + }); + const { service, resolveCompanyData } = build(cfg); + resolveCompanyData.mockResolvedValue({ + companyInfo: {}, + businessInfo: {}, // presence is all that matters — extractRegistrationData is mocked + }); + + await service.refresh(); + const seller = service.getSellerDetails(cfg); + + // The static sellerLegalName ("Ethio-Djibouti Railway S.C.") must survive, not e-Trade's + // differently-punctuated "Ethio-Djibouti Railway PLC (eTrade)". + expect(seller.LegalName).toBe("Ethio-Djibouti Railway S.C."); + }); + + it("e-Trade fills a field only when the static value is blank", async () => { + const cfg = eimsConfig({ + invoice: eimsInvoiceConfig({ + sellerLegalName: "", + sellerRegion: "", + sellerWereda: "", + sellerCity: null, + ...CODES, + }), + }); + const { service, resolveCompanyData } = build(cfg); + resolveCompanyData.mockResolvedValue({ companyInfo: {}, businessInfo: {} }); + + await service.refresh(); + const seller = service.getSellerDetails(cfg); + + expect(seller.LegalName).toBe("Ethio-Djibouti Railway PLC (eTrade)"); + expect(seller.Region).toBe("13"); + expect(seller.Wereda).toBe("99"); + expect(seller.City).toBe("101"); + }); + + it("VatNumber and Email are always the static value, never touched by e-Trade", async () => { + const cfg = eimsConfig({ + invoice: eimsInvoiceConfig({ sellerVatNumber: "0000000000", sellerEmail: "finance@example.et", ...CODES }), + }); + const { service, resolveCompanyData } = build(cfg); + resolveCompanyData.mockResolvedValue({ companyInfo: {}, businessInfo: {} }); + + await service.refresh(); + const seller = service.getSellerDetails(cfg); + + expect(seller.VatNumber).toBe("0000000000"); + expect(seller.Email).toBe("finance@example.et"); + }); + + it("falls back to the static config entirely when e-Trade has never been reachable", () => { + const cfg = eimsConfig(); + const { service } = build(cfg); + + // No refresh() ever called/succeeded — cached stays null. + const seller = service.getSellerDetails(cfg); + + expect(seller.LegalName).toBe(cfg.invoice.sellerLegalName); + expect(seller.Region).toBe(cfg.invoice.sellerRegion); + }); + + it("does no I/O at all — filing never triggers an e-Trade request", () => { + const { service, resolveCompanyData, cfg } = build(); + + service.getSellerDetails(cfg); + service.getSellerDetails(cfg); + + expect(resolveCompanyData).not.toHaveBeenCalled(); + }); +}); + +describe("EimsSellerCacheService.refresh", () => { + it("keeps the previous snapshot when a refresh fails", async () => { + const cfg = eimsConfig({ invoice: eimsInvoiceConfig({ sellerLegalName: "", ...CODES }) }); + const { service, resolveCompanyData } = build(cfg); + resolveCompanyData.mockResolvedValueOnce({ companyInfo: {}, businessInfo: {} }); + await service.refresh(); + expect(service.getSellerDetails(cfg).LegalName).toBe("Ethio-Djibouti Railway PLC (eTrade)"); + + resolveCompanyData.mockRejectedValueOnce(new Error("eTrade down")); + await service.refresh(); + + expect(service.getSellerDetails(cfg).LegalName).toBe("Ethio-Djibouti Railway PLC (eTrade)"); + }); + + it("keeps the previous snapshot on timeout, without waiting for the slow request", async () => { + jest.useFakeTimers(); + try { + const cfg = eimsConfig({ invoice: eimsInvoiceConfig({ sellerLegalName: "", ...CODES }) }); + const { service, resolveCompanyData } = build(cfg); + resolveCompanyData.mockResolvedValueOnce({ companyInfo: {}, businessInfo: {} }); + await service.refresh(); + expect(service.getSellerDetails(cfg).LegalName).toBe("Ethio-Djibouti Railway PLC (eTrade)"); + + resolveCompanyData.mockReturnValueOnce(new Promise(() => {})); // never resolves + const refreshing = service.refresh(); + await jest.advanceTimersByTimeAsync(10_000); + await refreshing; + + expect(service.getSellerDetails(cfg).LegalName).toBe("Ethio-Djibouti Railway PLC (eTrade)"); + } finally { + jest.useRealTimers(); + } + }); + + it("does not start a second e-Trade request while one is already in flight", async () => { + const { service, resolveCompanyData } = build(); + let resolveCall: (value: unknown) => void = () => {}; + resolveCompanyData.mockReturnValue(new Promise((resolve) => (resolveCall = resolve))); + + const first = service.refresh(); + const second = service.refresh(); + resolveCall({ companyInfo: {}, businessInfo: {} }); + await Promise.all([first, second]); + + expect(resolveCompanyData).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/edr-freight-api/src/modules/eims/eims-seller-cache.service.ts b/apps/edr-freight-api/src/modules/eims/eims-seller-cache.service.ts new file mode 100644 index 000000000..a8d242929 --- /dev/null +++ b/apps/edr-freight-api/src/modules/eims/eims-seller-cache.service.ts @@ -0,0 +1,147 @@ +import { Injectable, Logger, OnModuleInit } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; + +import { EimsConfig } from "../../config/eims.config"; +import { ETradeService } from "../companies/services/etrade.service"; +import { EimsSellerDetails, resolveOptionalCode } from "../billing/eims-invoice.mapper"; +import { buildEimsSeller } from "./eims-invoice-context"; + +const has = (value: string | null | undefined): value is string => Boolean(value && value.trim()); + +/** + * EDR's own EIMS seller identity (LegalName/Phone/Region/Wereda/City), enriched from the same + * e-Trade business-registry lookup already used for every customer company at onboarding — instead + * of the whole thing being hand-maintained `EIMS_SELLER_*` config. + * + * **Static config is the source of truth, e-Trade is bootstrap/enrichment only.** MoR validates + * `SellerDetails` against its own taxpayer registry (rule 7017, already cleared and live-tested + * with the current static values) — e-Trade filling a gap is fine, e-Trade silently overriding a + * value already confirmed against MoR is not. `getSellerDetails` therefore only reaches for the + * e-Trade-derived value when the static one is blank; a static value, once set, is never replaced. + * This also means the durable fallback is the static config, not this cache — the in-memory + * snapshot disappearing on a process restart is harmless, not a reliability gap: every field it + * could supply already has a working static value today, so filing is unaffected either way. + * + * `VatNumber` and `Email` are never sourced here — confirmed by reading e-Trade's actual response + * shapes (`ETradeCompanyInfo`, `ETradeBusinessInfo`, `CompanyRegistrationData`): neither field + * exists anywhere in what e-Trade returns. They stay on static config permanently, same as + * `SubCity`/`Locality`/`HouseNumber`, which this pass doesn't touch. + * + * Cache shape follows `PositionTypePermissionsCache`'s precedent (`src/common/ + * position-type-permissions.cache.ts`) for "external/slow data, not fetched per request": a plain + * field refreshed on a raw `setInterval`, `unref()`'d so it never holds the process open, and a + * refresh failure keeps serving the previous snapshot rather than clearing it. Two deliberate + * deviations from that precedent, both because `ETradeService` has no request timeout configured + * at all (confirmed by reading it) and is a third-party dependency, unlike the DB: + * - the first fetch is fire-and-forget in `onModuleInit`, never awaited by boot; + * - `refresh()` is wrapped in a local timeout, and a second call while one is already in flight + * returns the same in-flight promise instead of starting a duplicate request. + * + * `getSellerDetails` is fully synchronous — zero I/O at call time — so a live invoice registration + * never depends on e-Trade being reachable at that moment, whether or not it ever has been. + */ +@Injectable() +export class EimsSellerCacheService implements OnModuleInit { + private readonly logger = new Logger(EimsSellerCacheService.name); + + /** Only the e-Trade-derived fields, used solely to fill a blank static value. */ + private cached: Partial | null = null; + /** Concurrency guard — a second `refresh()` call while one is running joins it. */ + private refreshing: Promise | null = null; + + // ponytail: daily refresh, no invalidation hook — a change at e-Trade takes up to 24h to reach a + // filed invoice. Wire a manual refresh() call (e.g. from an admin action) if that lag ever + // matters; EDR's own business registration changes rarely enough that this is a generous + // ceiling, not a real one. + private static readonly REFRESH_INTERVAL_MS = 24 * 60 * 60 * 1000; + /** Bounded locally since `ETradeService` itself sets none — see the class comment. */ + private static readonly REFRESH_TIMEOUT_MS = 10_000; + + constructor( + private readonly etrade: ETradeService, + private readonly config: ConfigService, + ) {} + + onModuleInit(): void { + void this.refresh(); + const timer = setInterval(() => void this.refresh(), EimsSellerCacheService.REFRESH_INTERVAL_MS); + timer.unref?.(); + } + + /** + * Static config wins whenever it's non-blank — that's the value already confirmed against MoR. + * e-Trade fills a field only when the static one is empty. Synchronous, no I/O: safe to call on + * every registration. + */ + getSellerDetails(cfg: EimsConfig): EimsSellerDetails { + const fallback = buildEimsSeller(cfg); + const e = this.cached; + return { + ...fallback, + LegalName: has(fallback.LegalName) ? fallback.LegalName : (e?.LegalName ?? fallback.LegalName), + Phone: has(fallback.Phone) ? fallback.Phone : (e?.Phone ?? fallback.Phone), + Region: has(fallback.Region) ? fallback.Region : (e?.Region ?? fallback.Region), + Wereda: has(fallback.Wereda) ? fallback.Wereda : (e?.Wereda ?? fallback.Wereda), + City: has(fallback.City) ? fallback.City : (e?.City ?? fallback.City), + }; + } + + /** Reload the cache. Concurrency-safe (see class comment); public so a caller can force one. */ + async refresh(): Promise { + if (this.refreshing) return this.refreshing; + this.refreshing = this.doRefresh().finally(() => { + this.refreshing = null; + }); + return this.refreshing; + } + + private async doRefresh(): Promise { + try { + const cfg = this.config.get("eims")!; + const { companyInfo, businessInfo } = await this.withTimeout( + this.etrade.resolveCompanyData(cfg.tin), + EimsSellerCacheService.REFRESH_TIMEOUT_MS, + ); + if (!businessInfo) return; // no licence on file yet — keep the previous snapshot + const data = this.etrade.extractRegistrationData(businessInfo, companyInfo); + const codes = cfg.invoice; + this.cached = { + LegalName: data.companyName || undefined, + Phone: data.mobilePhone || data.regularPhone || undefined, + // e-Trade returns region/zone/woreda as names ("Addis Ababa", "Bole") — resolved via the + // same buyer code maps, since the geography is objective, not buyer-specific, despite the + // env var's "BUYER_" prefix. Never throws: an unmapped name just leaves that field to + // getSellerDetails' static-config fallback. + Region: resolveOptionalCode(data.region, codes.buyerRegionCodes), + Wereda: resolveOptionalCode(data.woreda, codes.buyerWeredaCodes), + City: resolveOptionalCode(data.zone, codes.buyerCityCodes), + }; + } catch (err) { + this.logger.warn( + `EIMS seller e-Trade refresh failed, keeping previous snapshot: ${(err as Error).message}`, + ); + } + } + + /** + * `ETradeService` sets no request timeout of its own, so one is enforced here. Note this only + * stops *waiting* on the request — nothing cancels the underlying HTTP call (no + * `AbortController` wired into `ETradeService`), so a timed-out request may still complete in + * the background; its result is simply never read. + */ + private withTimeout(promise: Promise, ms: number): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error(`e-Trade lookup timed out after ${ms}ms`)), ms); + promise.then( + (value) => { + clearTimeout(timer); + resolve(value); + }, + (err) => { + clearTimeout(timer); + reject(err); + }, + ); + }); + } +} 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 0ee22ec88..5d55a7ee8 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 { DocumentsModule } from "../billing/documents/documents.module"; +import { CompaniesModule } from "../companies/companies.module"; import { NotificationInboxModule } from "../notification-inbox/notification-inbox.module"; import { NotificationsModule } from "../notifications/notifications.module"; import { EimsAuthService } from "./eims-auth.service"; @@ -14,6 +15,7 @@ import { EimsCredentialsProvider } from "./eims-credentials.provider"; import { EimsInvoiceController } from "./eims-invoice.controller"; import { EimsInvoiceRegistrationService } from "./eims-invoice-registration.service"; import { EimsReceiptService } from "./eims-receipt.service"; +import { EimsSellerCacheService } from "./eims-seller-cache.service"; import { EimsSignerService } from "./eims-signer.service"; import { EimsReceipt } from "./entities/eims-receipt.entity"; import { EimsSystemState } from "./entities/eims-system-state.entity"; @@ -33,6 +35,10 @@ import { EimsSystemState } from "./entities/eims-system-state.entity"; // For EimsReceiptService.document() — the shared sealed invoice/receipt PDF layout. No domain // deps of its own (StampSettingsService/LogoSettingsService are both @Global), so no cycle. DocumentsModule, + // For EimsSellerCacheService's ETradeService — CompaniesModule has a forwardRef cycle with + // ShippingLineCompaniesModule -> BillingModule, but nothing in that chain imports EimsModule, + // so this stays a plain one-directional import, not a new cycle. + CompaniesModule, ], controllers: [EimsInvoiceController], providers: [ @@ -44,6 +50,7 @@ import { EimsSystemState } from "./entities/eims-system-state.entity"; EimsAutoSubmitService, EimsCancellationService, EimsReceiptService, + EimsSellerCacheService, ], exports: [ EimsAuthService, From 339996d27fb1fa116e9fd23d806b2cace672f807 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Mon, 17 Aug 2026 07:49:12 +0000 Subject: [PATCH 09/43] docs --- CLAUDE.md | 388 +++++++++++++++++++++++++++++++++++++++++--------- CLAUDE_NEW.md | 313 ---------------------------------------- docs/MAP.md | 96 +++++++++++++ 3 files changed, 420 insertions(+), 377 deletions(-) delete mode 100644 CLAUDE_NEW.md create mode 100644 docs/MAP.md diff --git a/CLAUDE.md b/CLAUDE.md index b90d3b1dd..a20fbaee7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,102 +1,362 @@ # EDR Platform — Developer Guide +> This file is the contract. If something here contradicts the code, the code is the +> truth and this file is a bug — fix it in the same PR. + +**Looking for where something lives? Read [`docs/MAP.md`](docs/MAP.md) first.** It routes +you to the right module or page without a repo-wide grep. + ## Overview -Monorepo for the Ethio Djibouti Railway (EDR) digital platform. Contains the Freight Management and Passenger Management applications, plus shared types, NestJS utilities, and React component libraries. +Monorepo for the Ethio Djibouti Railway (EDR) digital platform. Contains the Freight +Management and Passenger Management applications, a payment microservice, plus shared +types, NestJS utilities, and React component libraries. + +The freight domain is the largest and most active area. Its core flow is: +**booking → receive to warehouse → store → load onto train → dispatch → arrive → unload +→ customer truck (self-haul) or EDR last mile → handover → exit paper → delivered.** +Fees (storage, demurrage, double handling, truck detention) and allocation rules +(warehouse/yard/zone) hang off the warehouse stage. ## Apps -| App | Package name | Purpose | Port | -| ------------------------------ | --------------------------- | -------------------------------------------------- | ---- | -| `edr-freight-api` | `@edr/freight-api` | NestJS API for freight management | 3001 | -| `edr-freight-web/portal` | `@edr/freight-portal` | React frontend for freight customer/portal users | 5173 | -| `edr-freight-web/backoffice` | `@edr/freight-backoffice` | React frontend for freight backoffice employees | 5183 | -| `edr-passenger-api` | `@edr/passenger-api` | NestJS API for passenger management | 3002 | -| `edr-payment-api` | `@edr/payment-api` | NestJS payment microservice (intents, webhooks) | 3003 | -| `edr-passenger-web/portal` | `@edr/passenger-portal` | React frontend for passenger customer/portal users | 5174 | -| `edr-passenger-web/backoffice` | `@edr/passenger-backoffice` | React frontend for passenger backoffice employees | 5184 | +The two domains are **not built the same way**. Check which stack you are in before +copying a pattern across: -`edr-freight-web` and `edr-passenger-web` are grouping folders, not workspace packages. Each holds a `portal/` and `backoffice/` sub-app, both of which are independent pnpm workspace packages (declared in `pnpm-workspace.yaml`). The existing `pnpm dev:freight` / `pnpm dev:passenger` turbo filters (`@edr/freight-*` / `@edr/passenger-*`) cover all four web apps + their APIs. +| App | Package name | Stack | Default port | +| ------------------------------ | --------------------------- | ---------------------- | ------------ | +| `edr-freight-api` | `@edr/freight-api` | NestJS + **TypeORM** | 3001 | +| `edr-freight-web/portal` | `@edr/freight-portal` | React + **Vite** | 5273 | +| `edr-freight-web/backoffice` | `@edr/freight-backoffice` | React + **Vite** | 5283 | +| `edr-passenger-api` | `@edr/passenger-api` | NestJS + **Prisma** | 4000 | +| `edr-passenger-web/portal` | `@edr/passenger-portal` | **Next.js** | 5174 | +| `edr-passenger-web/backoffice` | `@edr/passenger-backoffice` | **Next.js** | 5184 | +| `edr-payment-api` | `@edr/payment-api` | NestJS + **TypeORM** | 3003 | + +Those are the **fallbacks compiled into the code**, not what you will be running. Every +port is overridden by `PORT` in the app's `.env` / `.env.development`; the freight vite +apps read it in `vite.config.ts` (`Number(env.PORT) || 5273`). This machine is shared by +the whole team and the low ports are contested — see the workspace root `CLAUDE.md` and +`./wt ports` for who currently holds what. + +`edr-freight-web` and `edr-passenger-web` are grouping folders, not workspace packages. +Each holds a `portal/` and `backoffice/` sub-app, both independent pnpm workspace +packages (see `pnpm-workspace.yaml`). + +`apps/edr-landing/` exists on disk but has **no `package.json`** — it is not a workspace +package and is not built, linted, or type-checked. Leave it alone unless asked. + +`apps/edr-gps-tracker/` is a separate service with its own `.env.example`. ## Packages -| Package | Purpose | -| ---------------------- | ---------------------------------------------------------------------------------- | -| `@edr/types` | Shared TypeScript interfaces and enums | -| `@edr/api-common` | Shared NestJS decorators, filters, interceptors, pipes, BaseEntity, BaseRepository | -| `@edr/iam-seed` | IAM baseline seeder for the apps sharing the `iam` schema (freight + passenger) | -| `@edr/ui-common` | Shared React components and theme | -| `@edr/eslint-config` | Shared ESLint configurations (base/nestjs/react) | -| `@edr/tsconfig` | Shared TypeScript configurations | -| `@edr/prettier-config` | Shared Prettier configuration | +| Package | Location | Purpose | +| ----------------------- | ----------------------------- | ------------------------------------------------------------- | +| `@edr/types` | `packages/types` | Shared TypeScript interfaces and enums | +| `@edr/api-common` | `packages/api-common` | NestJS decorators, filters, interceptors, pipes, BaseEntity, BaseRepository | +| `@edr/ui-common` | `packages/ui-common` | Shared React components and theme | +| `@edr/iam-seed` | `packages/iam-seed` | IAM baseline seeder for apps sharing the `iam` schema | +| `@edr/payment-providers`| `packages/payment-providers` | Payment gateway integrations | +| `@edr/eslint-config` | `packages/config/eslint-config` | Shared ESLint configs (base/nestjs/react) | +| `@edr/tsconfig` | `packages/config/tsconfig` | Shared TypeScript configs | +| `@edr/prettier-config` | `packages/config/prettier-config` | Shared Prettier config | + +The three `config/*` packages are nested one level deeper than the rest — `packages/config` +itself is not a package. + +**`@edr/types` is consumed as its built `dist/`** (`main: ./dist/index.js`). Editing a +type in `packages/types/src` changes nothing for consumers until you rebuild: + +```bash +pnpm turbo build --filter=@edr/types +``` + +If a type-check fails on a field you just added to `@edr/types`, this is why. ## Commands -| Command | Description | -| -------------------- | ---------------------------------- | -| `pnpm install` | Install all workspace dependencies | -| `pnpm dev` | Run every app in dev mode | -| `pnpm dev:freight` | Run only freight API + web | -| `pnpm dev:passenger` | Run only passenger API + web | -| `pnpm build` | Build every package and app | -| `pnpm test` | Run all tests | -| `pnpm lint` | Lint everything | -| `pnpm type-check` | Type-check every package | -| `pnpm format` | Format all files with Prettier | +| Command | Description | +| ----------------------------- | ---------------------------------------- | +| `pnpm install` | Install all workspace dependencies | +| `pnpm dev` | Run every app in dev mode | +| `pnpm dev:freight` | Freight API + portal + backoffice | +| `pnpm dev:freight:api` | Freight API only | +| `pnpm dev:freight:portal` | Freight portal only | +| `pnpm dev:freight:backoffice` | Freight backoffice only | +| `pnpm dev:passenger` | Passenger API + web | +| `pnpm dev:payment` | Payment API | +| `pnpm build` | Build every package and app | +| `pnpm test` | Run all tests (turbo) | +| `pnpm type-check` | Type-check every package | +| `pnpm format` | Format all files with Prettier | +| `pnpm lint` | **Does not work** — see below | -## Standards +**`pnpm lint` fails.** `eslint` is not installed anywhere in the workspace, so +`turbo run lint` dies with `eslint: not found` even though every package declares a +`lint` script and `@edr/eslint-config` exists. Until someone adds the dependency, +tsc's `noUnusedLocals` is the only working unused-code check. Do not claim a change is +"lint clean". -- **TypeScript strict mode** is enabled in every package and app. -- **pnpm** is the only supported package manager — never run `npm install` or `yarn`. -- **Conventional commits** are enforced via commitlint on every commit. -- **NestJS modules** follow the 4-layer pattern: `module → controller → service → repository` (entities and DTOs live alongside). +`pnpm format` uses bare `prettier`, which ignores `@edr/prettier-config` — it is wired to +nothing. On the single-quoted passenger apps it will re-quote the whole file. Pass +`--config` explicitly there. + +Prefer targeted turbo filters over whole-repo runs — they are minutes faster: + +```bash +pnpm turbo type-check --filter=@edr/freight-api --filter=@edr/freight-backoffice +``` + +`apps/edr-freight-api` also carries many `seed:*` scripts (demo bookings, wagons, trains, +gate-pass scenarios). Read the script before running one; several write real rows. + +## Environment & database + +- Postgres is **external**. There is no postgres service in `docker-compose.yaml`, and + no port `5433`/`5434` is published anywhere in the repo. +- Freight API connection comes from `DB_HOST`, `DB_PORT`, `DB_USER`, `DB_PASSWORD`, + `DB_NAME` (defaults: `localhost:5433`, `edr_freight`). Development points these at a + remote database. +- The connection sits behind a **connection pooler**. Do **not** pass + `extra.options: '-c search_path=…'` — the pooler rejects it with + `08P01 unsupported startup parameter in options: search_path`. `search_path` is applied + per-connection in a pool `connect` handler instead. See + `apps/edr-freight-api/src/config/database.config.ts` before touching connection options. +- Each app owns its own database. **No cross-database joins**; cross-domain data flows + through API calls or message queues. +- IAM tables live in their own `iam` schema (`iam.users`, `iam.user_credentials`), + freight tables in `freight`. +- `psql` is not installed on the dev machine. To query the database, use the `edr-db` + skill (below) or write a short Node script using `pg` and run it from + `apps/edr-freight-api`, where `pg` resolves. + +## Hard rules + +These are non-negotiable. Everything else is a strong default. + +- **pnpm only.** Never run `npm install` or `yarn`. +- **TypeScript strict mode** is on in every package and app. Do not weaken it, and do not + reach for `any` to make an error go away. +- **Never `synchronize: true`.** Not in production, not anywhere. It is currently `false` + in every config and it has already corrupted this database twice (see *Migrations*). + All schema changes go through migrations. - **All entities** use UUID primary keys (`@PrimaryGeneratedColumn('uuid')`). -- **All entities** have `createdAt`, `updatedAt`, `deletedAt` (soft delete) via `@edr/api-common`'s `BaseEntity`. -- **All columns** use `snake_case` in the database (`@Column({ name: 'snake_case' })`); TypeScript properties use `camelCase`. -- **Never use `synchronize: true`** in production database config. All schema changes go through TypeORM migrations. -- **ESLint + Prettier** run on pre-commit via Husky + lint-staged. -- **Services** never inject TypeORM `Repository` directly — they inject the custom repository class. -- **Controllers** never contain business logic. +- **All entities** extend `BaseEntity` from `@edr/api-common` — `createdAt`, `updatedAt`, + `deletedAt` (soft delete). +- **All columns** are `snake_case` in the database (`@Column({ name: 'snake_case' })`); + TypeScript properties are `camelCase`. +- **Controllers contain no business logic.** They validate, delegate, and shape the response. +- **Conventional commits.** `fix(warehouses): …`, `feat(bookings): …`. +- **Do not commit or push unless asked.** Propose the change; let the human decide when it lands. +- **Do not break working behaviour to add new behaviour.** When a fix is risky, say so and + offer the safe version. -## Auth +## Architecture -Authentication is handled by an external package (`@edr/iamui-common` or equivalent) that will be integrated later. **Do not** implement any auth, login, logout, JWT verification, password hashing, or user management code in this repo. +### NestJS module shape -When auth integration is needed, use placeholder TODO comments: +`module → controller → service → repository`, with `entities/` and `dto/` alongside. +`docs/MAP.md` lists the ~60 freight modules grouped by domain. -- `// @UseGuards(JwtAuthGuard) — TODO: integrate @edr/auth` -- `// TODO: integrate @edr/auth — replace stub @CurrentUser with real one` +### Data access — the real model -The `@CurrentUser`, `@Roles`, and `@Public` decorators in `@edr/api-common` are bare metadata setters with no guard wiring — they exist so controllers can be annotated correctly without depending on auth infrastructure yet. +There are two sanctioned ways to read and write, and you must pick the right one: -## Port Assignments +1. **Entity CRUD → the custom repository class.** Extends `BaseRepository` from + `@edr/api-common`. Services inject the repository class, never `Repository` directly. +2. **Read projections, queue endpoints, cross-table reports → raw SQL** via + `this.dataSource.query(...)` or `manager.query(...)` inside a transaction. -- `edr-freight-api`: 3001 -- `edr-freight-web/portal`: 5173 -- `edr-freight-web/backoffice`: 5183 -- `edr-passenger-api`: 3002 -- `edr-payment-api`: 3003 -- `edr-passenger-web/portal`: 5174 -- `edr-passenger-web/backoffice`: 5184 +Raw SQL is normal here, not a smell — the warehouse and scheduling modules are built on it. +It carries one obligation: -## Database Layout +> **HARD RULE — validate every raw SQL statement against a real database before you ship it.** +> A typo'd column name is a runtime 500 that no type-checker will catch. Run it through +> `EXPLAIN` against the dev database. Column drift is real (see *Migrations*). -- `postgres-freight` (port 5433): database `edr_freight` — freight API only. -- `postgres-passenger` (port 5434): database `edr_passenger` — passenger API only. -- `edr_payment` schema — lives in the same Postgres database as the domain system (whatever the passenger `DATABASE_URL` points at) but is owned exclusively by `apps/edr-payment-api`. Dedicated DB user, no cross-schema FKs, domain apps have no grants on it (see `docs/payment-service/`). -- Each app owns its own DB. No cross-database joins; cross-domain data flows through API calls or message queues. +Writes inside a transaction use `manager.getRepository(Entity)`, not the injected repository, +so they join the caller's transaction. + +**Never do slow I/O inside a database transaction.** Queue the work and fan it out after +commit. An SMS awaited inside a transaction once held capacity locks open for the whole +gateway timeout. Any outbound HTTP call must set an explicit `timeout` — axios defaults to +no timeout and will wait forever. + +### Migrations + +Migrations are the most dangerous surface in this repo. Two production-grade incidents have +already come from it. **Freight and payment use TypeORM migrations; passenger uses Prisma** +(`apps/edr-passenger-api/prisma/migrations`) — the rules below are about the TypeORM side. + +- `migrationsRun: false` — **migrations do NOT run on API boot.** They run as a separate + one-shot step, via the Dockerfile's `migration` build target (`docker build --target + migration`), with `migrationsTransactionMode: 'each'`. + - CI: `.github/workflows/deploy.yml` builds the `migration` image and runs it + (`docker run --rm --env-file ...`) *before* building/deploying the app image. + - e2e: `docker-compose.e2e.yaml`'s `freight-migration-e2e` service runs once and + `freight-api-e2e` depends on it (`condition: service_completed_successfully`). + - Local dev (`docker-compose.yaml`) has no equivalent migration service yet — run + migrations yourself before `docker compose up freight-api`, e.g. + `docker build --target migration -f apps/edr-freight-api/Dockerfile -t freight-migration .` + then `docker run --rm --env-file apps/edr-freight-api/.env freight-migration`. Don't + use `pnpm run migrate` for this — it runs via `ts-node`, which never writes compiled + output to `dist/`, and the freight migrations glob only matches `dist/migrations/*.js`. + It silently applies zero freight migrations while exiting 0. +- Consequences you must design for: + - A watch-mode hot reload does **not** re-run migrations. If you add a column that new + code reads, apply it to the dev database yourself (idempotently) or fully restart. + - `apps/edr-freight-api/src/config/database.config.ts`'s `iamEntities` array is a + hand-maintained list of `@tria-plc/iamapi-common` entity classes. The live app never + notices when it's stale (`autoLoadEntities: true` papers over gaps via IAM's own + `forFeature()` registrations), but the standalone migration `DataSource` + (`data-source.ts`, no `autoLoadEntities`) does not have that fallback — a missing + entity throws `Entity metadata for X#y was not found` at `initialize()`, before a + single migration runs. **Every `@tria-plc/iamapi-common` version bump is a candidate + for this to break again** — diff the package's entity classes against `iamEntities` + when bumping it. +- **Give every migration a unique timestamp.** `apps/edr-freight-api/src/migrations` holds + 39 files, and 8 timestamps are shared by two or more of them. TypeORM orders by timestamp + and breaks ties non-deterministically. Check before adding one: + + ```bash + ls apps/edr-freight-api/src/migrations | grep -oE '^[0-9]+' | sort | uniq -d + ``` + + The prefix must be unused *and* higher than the newest recorded row. Note the + `freight.migrations` table has far more rows (~309) than this folder has files — most + come from `@tria-plc/iamapi-common`'s own migrations, which run from the same data source. +- **Write idempotent DDL**: `ADD COLUMN IF NOT EXISTS`, `CREATE INDEX IF NOT EXISTS`, and + backfills guarded by `WHERE col IS NULL`. +- **Never assume a recorded migration actually applied.** `AddGrnNumberToWarehouseInventory` + was recorded in `migrations` while its column was absent — it had been dropped out of band. + TypeORM will never re-run a recorded migration, so the fix is a *new repair migration*. +- **A repair migration's `down()` should be a no-op.** Reverting a repair must not + re-introduce the outage it fixed. + +### Auth + +Auth **is implemented in this repo.** Do not add TODO stubs, and do not write your own. + +- `@CurrentUser()` (`@edr/api-common`) is a real `createParamDecorator`, not a metadata stub. +- Route protection uses `@UseGuards(JwtGuard)` and `@UseGuards(PermissionGuard([...]))`. +- Freight-domain checks use `hasFreightPermission(user, FREIGHT_PERMS..)`. +- Permissions are declared in `apps/edr-freight-api/src/seed/freight-permissions.registry.ts`. + Add a permission there before referencing it. +- Login is freight-api's own `POST /api/auth/login` (SharedAuthModule from + `@tria-plc/api-common`). Every login call needs an **`x-client-app` header** — + `backoffice` for employees, `portal` for customers. Without it the API 403s with + "Missing or unrecognized x-client-app header". Browsers send it; curl must add it. +- IAM has its own migrations, run ahead of freight migrations from the same data source, and + its own CLI scripts (`iam:migration:run`, `iam:seed:run`). + +Ownership checks are separate from permission checks. A staff user passes +`hasFreightPermission`; a customer must additionally pass an ownership assertion such as +`assertCustomerCanAccessBooking`. Do not drop the ownership check because the permission check passed. + +## Frontend conventions + +- The **freight** web apps use **Mantine v9** (`^9.3.0`). Its APIs differ from v6/v7 — + check the installed version before copying a snippet. +- `@edr/ui-common` holds shared components and theme; it is imported in ~94 files across the + freight web apps. Prefer it over re-implementing a component. +- **Blob downloads need the async error decoder.** A request with `responseType: 'blob'` + delivers the JSON error body as a `Blob`, so the synchronous `extractErrorMessage` finds no + `.message` and degrades to `"Request failed with status code 400"`. Use + `await extractDownloadErrorMessage(error)` in every PDF/blob catch block. Mutation catches + keep the synchronous version — their bodies are already parsed JSON. +- Server-side guards must be reflected in the UI. If the API will reject the action, the + button should be disabled, hidden, or explain the blocker — not fire and surface a 400. +- Prefer disabling a control with a visible reason over silently hiding it. + +## Notifications + +In-app notifications resolve recipients from the company's **linked portal users**. If a +company has none, `notify()` logs `0 recipients — skipped` and stores nothing, with no error. +SMS and email still send, because they address the company's phone and email directly. Check +this before debugging a "missing notification". + +## PDF generation + +Chromium is not installed in every environment. PDF paths must fall back to the hand-rolled +generators (`styled-pdf.util.ts`, `buildFallbackPdf`, `buildTabularFallbackPdf`) rather than +assume a headless browser exists. ## Adding a new module to a NestJS app -1. Create `modules//` with `entities/`, `dto/`, and the four `.{module,controller,service,repository}.ts` files. +1. Create `modules//` with `entities/`, `dto/`, and the four + `.{module,controller,service,repository}.ts` files. 2. The entity extends `BaseEntity` from `@edr/api-common`. 3. The repository extends `BaseRepository` from `@edr/api-common`. 4. The service injects the repository class (not `Repository` directly). -5. The controller uses `@ApiTags()` + `@ApiOperation()` for Swagger. +5. The controller uses `@ApiTags()` + `@ApiOperation()` for Swagger, and guards the route. 6. Register the module in the app's `app.module.ts`. ## Adding a new shared component to `@edr/ui-common` 1. Create `src/components//.tsx` and `src/components//index.ts`. 2. Export from `src/index.ts`. -3. Component is a functional component with a `ComponentNameProps` interface (named-exported alongside the default). +3. Component is a functional component with a `ComponentNameProps` interface + (named-exported alongside the default). + +## Definition of done + +A change is done when **all** of these hold. State explicitly which you ran. + +1. **It type-checks.** `pnpm turbo type-check --filter=` passes. + If you edited `packages/types`, you ran `pnpm turbo build --filter=@edr/types` first. +2. **Raw SQL is verified.** Every new or edited SQL statement ran under `EXPLAIN` against the + dev database without error. +3. **Migrations are safe.** Unique timestamp, idempotent DDL, and — if the migration adds + something the new code reads — applied to the dev database, since watch mode will not run it. +4. **No new test failures.** `pnpm test` for `@edr/freight-api` has been red on `dev`, so a + fully green suite is not the bar — but confirm that for yourself rather than assuming it, + then run the specs covering what you touched and confirm you introduced no new failure. +5. **Formatting is clean** for the files you touched. Git hooks do **not** run automatically + (see below), and `pnpm lint` does not work at all, so `noUnusedLocals` from the + type-check is your only unused-code signal. +6. **The behaviour was actually observed**, not merely compiled — you drove the flow, hit the + endpoint, or ran the query. If you could not, say so plainly. +7. **Report honestly.** If a check was skipped, tests failed, or a fix is unverified, say it in + the summary. Never describe unverified work as done. + +### Hooks do not run + +`commitlint.config.js` and a `lint-staged` config both exist, and husky's shims are installed +at `.husky/_/`. But there are **no user hook scripts** (`.husky/pre-commit`, +`.husky/commit-msg`), so husky's shim exits 0 and **neither lint-staged nor commitlint ever +fire.** Nothing validates your commit message or formats your staged files. Run the checks by +hand; do not assume the hook caught it. + +## Known traps + +| Trap | What happens | What to do | +| --- | --- | --- | +| Schema drift | A recorded migration's column is missing; queries and inserts 500 | Write a new repair migration; never edit the recorded one | +| Duplicate migration timestamps | Non-deterministic ordering; a migration can be skipped | Pick a fresh, higher timestamp | +| `@edr/types` not rebuilt | Consumers can't see your new field | `pnpm turbo build --filter=@edr/types` | +| Slow I/O in a transaction | Locks held for the gateway timeout | Queue it; fan out after commit; always set an HTTP timeout | +| Blob error bodies | Real 400 message replaced by "Request failed with status code 400" | `await extractDownloadErrorMessage(error)` | +| Company with no portal user | In-app notification silently vanishes | Check portal users before debugging | +| Watch-mode reload | New code, old schema → 500 | Apply the migration to the dev DB or restart fully | +| Login 403 from curl | "Missing or unrecognized x-client-app header" | Send `x-client-app: backoffice` or `portal` | +| Copying a passenger pattern into freight | Passenger is Prisma + Next.js, freight is TypeORM + Vite | Check which stack you are in first | + +## Project skills + +Reusable workflows live in `.claude/skills/`. Use them instead of re-deriving the steps: + +| Skill | Use for | +| --- | --- | +| `edr-db` | Query / `EXPLAIN`-validate / inspect the remote dev DB (`node .claude/skills/edr-db/query.cjs …`). psql is not installed — this is the sanctioned path. Also carries the 400/500 diagnosis loop. | +| `verify` | The definition-of-done runner: targeted type-check, `@edr/types` rebuild, SQL validation, migration checklist, honest test bar. Run before calling anything finished. | +| `standup` | "What did I do today / this week" reports for tickets, grounded in `git log` — including the check that commit subjects match their contents. | + +## Working style + +- **Verify before asserting.** Read the code or query the database. Do not infer behaviour + from a filename. +- **Investigate, then propose.** For anything risky or wide-reaching, present the plan and the + trade-off before changing files. +- **Small, reviewable commits**, one logical change each, conventional message. +- **Branch from `dev`; PRs target `dev`.** +- When a finding turns out to be wrong, say so and retract it. A rejected finding is a result. diff --git a/CLAUDE_NEW.md b/CLAUDE_NEW.md deleted file mode 100644 index a99f64d0a..000000000 --- a/CLAUDE_NEW.md +++ /dev/null @@ -1,313 +0,0 @@ -# EDR Platform — Developer Guide - -> This file is the contract. If something here contradicts the code, the code is the -> truth and this file is a bug — fix it in the same PR. - -## Overview - -Monorepo for the Ethio Djibouti Railway (EDR) digital platform. Contains the Freight -Management and Passenger Management applications, a payment microservice, plus shared -types, NestJS utilities, and React component libraries. - -The freight domain is the largest and most active area. Its core flow is: -**booking → receive to warehouse → store → load onto train → dispatch → arrive → unload -→ customer truck (self-haul) or EDR last mile → handover → exit paper → delivered.** -Fees (storage, demurrage, double handling, truck detention) and allocation rules -(warehouse/yard/zone) hang off the warehouse stage. - -## Apps - -| App | Package name | Purpose | Default port | -| ------------------------------ | --------------------------- | -------------------------------------------------- | ------------ | -| `edr-freight-api` | `@edr/freight-api` | NestJS API for freight management | 3001 | -| `edr-freight-web/portal` | `@edr/freight-portal` | React frontend for freight customer/portal users | 5173 | -| `edr-freight-web/backoffice` | `@edr/freight-backoffice` | React frontend for freight backoffice employees | 5183 | -| `edr-passenger-api` | `@edr/passenger-api` | NestJS API for passenger management | 3002 | -| `edr-payment-api` | `@edr/payment-api` | NestJS payment microservice (intents, webhooks) | 3003 | -| `edr-passenger-web/portal` | `@edr/passenger-portal` | React frontend for passenger customer/portal users | 5174 | -| `edr-passenger-web/backoffice` | `@edr/passenger-backoffice` | React frontend for passenger backoffice employees | 5184 | - -`edr-freight-web` and `edr-passenger-web` are grouping folders, not workspace packages. -Each holds a `portal/` and `backoffice/` sub-app, both independent pnpm workspace -packages (see `pnpm-workspace.yaml`). - -`apps/edr-landing/` exists on disk but has **no `package.json`** — it is not a workspace -package and is not built, linted, or type-checked. Leave it alone unless asked. - -## Packages - -| Package | Purpose | -| ---------------------- | ---------------------------------------------------------------------------------- | -| `@edr/types` | Shared TypeScript interfaces and enums | -| `@edr/api-common` | Shared NestJS decorators, filters, interceptors, pipes, BaseEntity, BaseRepository | -| `@edr/ui-common` | Shared React components and theme | -| `@edr/eslint-config` | Shared ESLint configurations (base/nestjs/react) | -| `@edr/tsconfig` | Shared TypeScript configurations | -| `@edr/prettier-config` | Shared Prettier configuration | - -**`@edr/types` is consumed as its built `dist/`** (`main: ./dist/index.js`). Editing a -type in `packages/types/src` changes nothing for consumers until you rebuild: - -```bash -pnpm turbo build --filter=@edr/types -``` - -If a type-check fails on a field you just added to `@edr/types`, this is why. - -## Commands - -| Command | Description | -| --------------------------- | ---------------------------------------- | -| `pnpm install` | Install all workspace dependencies | -| `pnpm dev` | Run every app in dev mode | -| `pnpm dev:freight` | Freight API + portal + backoffice | -| `pnpm dev:freight:api` | Freight API only | -| `pnpm dev:freight:portal` | Freight portal only | -| `pnpm dev:freight:backoffice` | Freight backoffice only | -| `pnpm dev:passenger` | Passenger API + web | -| `pnpm dev:payment` | Payment API | -| `pnpm build` | Build every package and app | -| `pnpm test` | Run all tests (turbo) | -| `pnpm lint` | Lint everything | -| `pnpm type-check` | Type-check every package | -| `pnpm format` | Format all files with Prettier | - -Prefer targeted turbo filters over whole-repo runs — they are minutes faster: - -```bash -pnpm turbo type-check --filter=@edr/freight-api --filter=@edr/freight-backoffice -``` - -`apps/edr-freight-api` also carries many `seed:*` scripts (demo bookings, wagons, trains, -gate-pass scenarios). Read the script before running one; several write real rows. - -## Environment & database - -- Postgres is **external**. There is no postgres service in `docker-compose.yaml`, and - no port `5433`/`5434` is published anywhere in the repo. -- Freight API connection comes from `DB_HOST`, `DB_PORT`, `DB_USER`, `DB_PASSWORD`, - `DB_NAME` (defaults: `localhost:5433`, `edr_freight`). Development points these at a - remote database. -- The connection sits behind a **connection pooler**. Do **not** pass - `extra.options: '-c search_path=…'` — the pooler rejects it with - `08P01 unsupported startup parameter in options: search_path`. `search_path` is applied - per-connection in a pool `connect` handler instead. See - `apps/edr-freight-api/src/config/database.config.ts` before touching connection options. -- Each app owns its own database. **No cross-database joins**; cross-domain data flows - through API calls or message queues. -- `psql` is not installed on the dev machine. To query the database, write a short Node - script using the `pg` client and run it from `apps/edr-freight-api` (where `pg` resolves). - -## Hard rules - -These are non-negotiable. Everything else is a strong default. - -- **pnpm only.** Never run `npm install` or `yarn`. -- **TypeScript strict mode** is on in every package and app. Do not weaken it, and do not - reach for `any` to make an error go away. -- **Never `synchronize: true`.** Not in production, not anywhere. It is currently `false` - in every config and it has already corrupted this database twice (see *Migrations*). - All schema changes go through TypeORM migrations. -- **All entities** use UUID primary keys (`@PrimaryGeneratedColumn('uuid')`). -- **All entities** extend `BaseEntity` from `@edr/api-common` — `createdAt`, `updatedAt`, - `deletedAt` (soft delete). -- **All columns** are `snake_case` in the database (`@Column({ name: 'snake_case' })`); - TypeScript properties are `camelCase`. -- **Controllers contain no business logic.** They validate, delegate, and shape the response. -- **Conventional commits.** `fix(warehouses): …`, `feat(bookings): …`. -- **Do not commit or push unless asked.** Propose the change; let the human decide when it lands. -- **Do not break working behaviour to add new behaviour.** When a fix is risky, say so and - offer the safe version. - -## Architecture - -### NestJS module shape - -`module → controller → service → repository`, with `entities/` and `dto/` alongside. - -### Data access — the real model - -There are two sanctioned ways to read and write, and you must pick the right one: - -1. **Entity CRUD → the custom repository class.** Extends `BaseRepository` from - `@edr/api-common`. Services inject the repository class, never `Repository` directly. -2. **Read projections, queue endpoints, cross-table reports → raw SQL** via - `this.dataSource.query(...)` or `manager.query(...)` inside a transaction. - -Raw SQL is normal here, not a smell — the warehouse and scheduling modules are built on it. -It carries one obligation: - -> **HARD RULE — validate every raw SQL statement against a real database before you ship it.** -> A typo'd column name is a runtime 500 that no type-checker will catch. Run it through -> `EXPLAIN` against the dev database. Column drift is real (see *Migrations*). - -Writes inside a transaction use `manager.getRepository(Entity)`, not the injected repository, -so they join the caller's transaction. - -**Never do slow I/O inside a database transaction.** Queue the work and fan it out after -commit. An SMS awaited inside a transaction once held capacity locks open for the whole -gateway timeout. Any outbound HTTP call must set an explicit `timeout` — axios defaults to -no timeout and will wait forever. - -### Migrations - -Migrations are the most dangerous surface in this repo. Two production-grade incidents have -already come from it. - -- `migrationsRun: false` — **migrations do NOT run on API boot.** They run as a separate - one-shot step, via the Dockerfile's `migration` build target (`docker build --target - migration`), with `migrationsTransactionMode: 'each'`. - - CI: `.github/workflows/deploy.yml` builds the `migration` image and runs it - (`docker run --rm --env-file ...`) *before* building/deploying the app image. - - e2e: `docker-compose.e2e.yaml`'s `freight-migration-e2e` service runs once and - `freight-api-e2e` depends on it (`condition: service_completed_successfully`). - - Local dev (`docker-compose.yaml`) has no equivalent migration service yet — run - migrations yourself before `docker compose up freight-api`, e.g. - `docker build --target migration -f apps/edr-freight-api/Dockerfile -t freight-migration .` - then `docker run --rm --env-file apps/edr-freight-api/.env freight-migration`. Don't - use `pnpm run migrate` for this — it runs via `ts-node`, which never writes compiled - output to `dist/`, and the freight migrations glob only matches `dist/migrations/*.js`. - It silently applies zero freight migrations while exiting 0. -- Consequences you must design for: - - A watch-mode hot reload does **not** re-run migrations. If you add a column that new - code reads, apply it to the dev database yourself (idempotently) or fully restart. - - `apps/edr-freight-api/src/config/database.config.ts`'s `iamEntities` array is a - hand-maintained list of `@tria-plc/iamapi-common` entity classes. The live app never - notices when it's stale (`autoLoadEntities: true` papers over gaps via IAM's own - `forFeature()` registrations), but the standalone migration `DataSource` - (`data-source.ts`, no `autoLoadEntities`) does not have that fallback — a missing - entity throws `Entity metadata for X#y was not found` at `initialize()`, before a - single migration runs. **Every `@tria-plc/iamapi-common` version bump is a candidate - for this to break again** — diff the package's entity classes against `iamEntities` - when bumping it. -- **Give every migration a unique timestamp.** 34 timestamps are currently shared by two or - more migrations. TypeORM orders by timestamp and breaks ties non-deterministically. Before - adding one, check the filename prefix is unused *and* higher than the newest recorded row. -- **Write idempotent DDL**: `ADD COLUMN IF NOT EXISTS`, `CREATE INDEX IF NOT EXISTS`, and - backfills guarded by `WHERE col IS NULL`. -- **Never assume a recorded migration actually applied.** `AddGrnNumberToWarehouseInventory` - was recorded in `migrations` while its column was absent — it had been dropped out of band. - TypeORM will never re-run a recorded migration, so the fix is a *new repair migration*. -- **A repair migration's `down()` should be a no-op.** Reverting a repair must not - re-introduce the outage it fixed. - -### Auth - -Auth **is implemented in this repo.** Do not add TODO stubs, and do not write your own. - -- `@CurrentUser()` (`@edr/api-common`) is a real `createParamDecorator`, not a metadata stub. -- Route protection uses `@UseGuards(JwtGuard)` and `@UseGuards(PermissionGuard([...]))`. -- Freight-domain checks use `hasFreightPermission(user, FREIGHT_PERMS..)`. -- Permissions are declared in `apps/edr-freight-api/src/seed/freight-permissions.registry.ts`. - Add a permission there before referencing it. -- IAM has its own migrations, run ahead of freight migrations from the same data source, and - its own CLI scripts (`iam:migration:run`, `iam:seed:run`). - -Ownership checks are separate from permission checks. A staff user passes -`hasFreightPermission`; a customer must additionally pass an ownership assertion such as -`assertCustomerCanAccessBooking`. Do not drop the ownership check because the permission check passed. - -## Frontend conventions - -- The web apps use **Mantine v9**. Its APIs differ from v6/v7 — check the installed version - before copying a snippet. -- `@edr/ui-common` holds shared components and theme; it is imported in ~94 files across the - freight web apps. Prefer it over re-implementing a component. -- **Blob downloads need the async error decoder.** A request with `responseType: 'blob'` - delivers the JSON error body as a `Blob`, so the synchronous `extractErrorMessage` finds no - `.message` and degrades to `"Request failed with status code 400"`. Use - `await extractDownloadErrorMessage(error)` in every PDF/blob catch block. Mutation catches - keep the synchronous version — their bodies are already parsed JSON. -- Server-side guards must be reflected in the UI. If the API will reject the action, the - button should be disabled, hidden, or explain the blocker — not fire and surface a 400. -- Prefer disabling a control with a visible reason over silently hiding it. - -## Notifications - -In-app notifications resolve recipients from the company's **linked portal users**. If a -company has none, `notify()` logs `0 recipients — skipped` and stores nothing, with no error. -SMS and email still send, because they address the company's phone and email directly. Check -this before debugging a "missing notification". - -## PDF generation - -Chromium is not installed in every environment. PDF paths must fall back to the hand-rolled -generators (`styled-pdf.util.ts`, `buildFallbackPdf`, `buildTabularFallbackPdf`) rather than -assume a headless browser exists. - -## Adding a new module to a NestJS app - -1. Create `modules//` with `entities/`, `dto/`, and the four - `.{module,controller,service,repository}.ts` files. -2. The entity extends `BaseEntity` from `@edr/api-common`. -3. The repository extends `BaseRepository` from `@edr/api-common`. -4. The service injects the repository class (not `Repository` directly). -5. The controller uses `@ApiTags()` + `@ApiOperation()` for Swagger, and guards the route. -6. Register the module in the app's `app.module.ts`. - -## Adding a new shared component to `@edr/ui-common` - -1. Create `src/components//.tsx` and `src/components//index.ts`. -2. Export from `src/index.ts`. -3. Component is a functional component with a `ComponentNameProps` interface - (named-exported alongside the default). - -## Definition of done - -A change is done when **all** of these hold. State explicitly which you ran. - -1. **It type-checks.** `pnpm turbo type-check --filter=` passes. - If you edited `packages/types`, you ran `pnpm turbo build --filter=@edr/types` first. -2. **Raw SQL is verified.** Every new or edited SQL statement ran under `EXPLAIN` against the - dev database without error. -3. **Migrations are safe.** Unique timestamp, idempotent DDL, and — if the migration adds - something the new code reads — applied to the dev database, since watch mode will not run it. -4. **No new test failures.** `pnpm test` for `@edr/freight-api` is **currently red on `dev`**, - so a fully green suite is not the bar. Run the specs covering what you touched and confirm - you introduced no new failure. -5. **Lint and format are clean** for the files you touched. Git hooks do **not** run these - automatically (see below), so run them yourself. -6. **The behaviour was actually observed**, not merely compiled — you drove the flow, hit the - endpoint, or ran the query. If you could not, say so plainly. -7. **Report honestly.** If a check was skipped, tests failed, or a fix is unverified, say it in - the summary. Never describe unverified work as done. - -### Hooks do not run - -`commitlint.config.js` and a `lint-staged` config both exist, and husky's shims are installed -at `.husky/_/`. But there are **no user hook scripts** (`.husky/pre-commit`, -`.husky/commit-msg`), so husky's shim exits 0 and **neither lint-staged nor commitlint ever -fire.** Nothing validates your commit message or formats your staged files. Run the checks by -hand; do not assume the hook caught it. - -## Known traps - -| Trap | What happens | What to do | -| --- | --- | --- | -| Schema drift | A recorded migration's column is missing; queries and inserts 500 | Write a new repair migration; never edit the recorded one | -| Duplicate migration timestamps | Non-deterministic ordering; a migration can be skipped | Pick a fresh, higher timestamp | -| `@edr/types` not rebuilt | Consumers can't see your new field | `pnpm turbo build --filter=@edr/types` | -| Slow I/O in a transaction | Locks held for the gateway timeout | Queue it; fan out after commit; always set an HTTP timeout | -| Blob error bodies | Real 400 message replaced by "Request failed with status code 400" | `await extractDownloadErrorMessage(error)` | -| Company with no portal user | In-app notification silently vanishes | Check portal users before debugging | -| Watch-mode reload | New code, old schema → 500 | Apply the migration to the dev DB or restart fully | - -## Project skills - -Reusable workflows live in `.claude/skills/`. Use them instead of re-deriving the steps: - -| Skill | Use for | -| --- | --- | -| `edr-db` | Query / `EXPLAIN`-validate / inspect the remote dev DB (`node .claude/skills/edr-db/query.cjs …`). psql is not installed — this is the sanctioned path. Also carries the 400/500 diagnosis loop. | -| `verify` | The definition-of-done runner: targeted type-check, `@edr/types` rebuild, SQL validation, migration checklist, honest test bar. Run before calling anything finished. | -| `standup` | "What did I do today / this week" reports for tickets, grounded in `git log` — including the check that commit subjects match their contents. | - -## Working style - -- **Verify before asserting.** Read the code or query the database. Do not infer behaviour - from a filename. -- **Investigate, then propose.** For anything risky or wide-reaching, present the plan and the - trade-off before changing files. -- **Small, reviewable commits**, one logical change each, conventional message. -- **Branch from `dev`; PRs target `dev`.** -- When a finding turns out to be wrong, say so and retract it. A rejected finding is a result. diff --git a/docs/MAP.md b/docs/MAP.md new file mode 100644 index 000000000..d6aa76322 --- /dev/null +++ b/docs/MAP.md @@ -0,0 +1,96 @@ +# Repo map — start here + +Routing table for "where does X live". Read this before a repo-wide grep. Paths are from +the repo root. The rules and traps are in [`../CLAUDE.md`](../CLAUDE.md); this file only +answers *where*. + +## Pick your stack first + +| If you are working on… | Code lives in | Stack | +| --------------------------------- | --------------------------------- | -------------------- | +| Freight API / business logic | `apps/edr-freight-api/src` | NestJS + TypeORM | +| Freight customer UI | `apps/edr-freight-web/portal` | React + Vite + Mantine v9 | +| Freight staff UI | `apps/edr-freight-web/backoffice` | React + Vite + Mantine v9 | +| Passenger API | `apps/edr-passenger-api` | NestJS + **Prisma** | +| Passenger UI | `apps/edr-passenger-web/*` | **Next.js** | +| Payments (intents, webhooks) | `apps/edr-payment-api` | NestJS + TypeORM | +| Gateway integrations | `packages/payment-providers` | — | +| A shared type or enum | `packages/types/src` | rebuild after editing | +| A shared React component | `packages/ui-common/src` | — | +| A Nest decorator/filter/base class| `packages/api-common/src` | — | + +## Freight API entry points + +| File | What it is | +| --- | --- | +| `src/main.ts` | Boot, port (`PORT`, falls back to 3001), global pipes | +| `src/app.module.ts` | Every module is registered here — the index of the API | +| `src/config/database.config.ts` | Connection, pooler `search_path` handling, `iamEntities` list | +| `src/data-source.ts` | Standalone DataSource used by migrations only (no `autoLoadEntities`) | +| `src/migrations/` | TypeORM migrations (39 files; check for timestamp clashes) | +| `src/seed/freight-permissions.registry.ts` | Every freight permission; declare before use | +| `src/scripts/` | One-off and `seed:*` scripts — several write real rows | + +## Freight modules by domain + +All under `apps/edr-freight-api/src/modules/`. + +| Domain | Modules | +| --- | --- | +| **Booking & commercial** | `bookings` `contracts` `contract-templates` `consignments` `cargoes` `companies` `user-trade-access` `transit-agents` `shipping-lines` | +| **Warehouse & yard** | `warehouses` `facilities` `container-management` | +| **Rail operations** | `trains` `train-schedules` `train-scheduling` `train-sets` `wagons` `wagon-types` `locomotives` `routes` `scheduling` `scheduling-reschedule` `interchange-documents` | +| **Road / first & last mile** | `first-mile` `last-mile` `last-mile-requests` `drivers` `vehicles` `truck-types` `fleet-history` `fuel` `maintenance` `gps-tracking` `tracking` | +| **Money** | `billing` `payment` `exchange-settings` | +| **Identity & access** | `auth` `otp` `verifayda` `audit` | +| **Documents & files** | `files` `file-upload-settings` `signatures` `stamp-settings` `logo-settings` `minio` | +| **Comms** | `notifications` `notification-inbox` `support-chat` `support-content` | +| **Ops & admin** | `backoffice` `overview` `reports` `dropdown-settings` `rule-engine` `compliance` `procurement` `incidents` `import-operations` `eims` `ai` `health` | + +Each module follows `module → controller → service → repository`, with `entities/` and +`dto/` alongside. + +## Freight web + +Pages live in `src/pages/`, roughly mirroring the API domains. + +- **portal** (customer): `bookings` `contracts` `consignments` `billing` `payments` + `tracking` `accounts` `customers` `shipping-line` `support` `settings`, plus + `MyPortalPage/`, `MySignaturePage.tsx`, `EDRFreightLandingPage.tsx`. +- **backoffice** (staff): `bookings` `contracts` `contract_templates` `consignments` + `customers` `billing` `invoices` `documents` `fleet` `dashboard` `admin` `configuration` + `auth` `ai`, plus many single-file pages (`AuditLogsPage.tsx`, `ActivityLogPage.tsx`, + `BulkUploadPage.tsx`, `ContentManagementPage.tsx`, …). + +Shared components and theme come from `@edr/ui-common` — check there before writing one. + +## Tests + +| Suite | Location | Run with | +| --- | --- | --- | +| Unit / spec | beside the code, `*.spec.ts` | `pnpm --filter @edr/freight-api test` | +| Freight e2e (Cypress, containerized) | `e2e/freight` | `pnpm e2e:freight:ci` | +| Passenger e2e | `e2e/` + `e2e/run.sh` | `pnpm test:e2e:passenger` | +| UI e2e (Playwright) | `e2e-ui/` | `pnpm test:e2e:ui` | +| Integration | `integration/` | `pnpm it:up`, `pnpm it:test` | + +The freight e2e stack has no host Xvfb — use `ci` (containerized), not `run`/`open`. +Its compose project name is fixed (`edr-freight-e2e`), so only one can run on this +machine at a time. + +## Documents + +| Doc | Covers | Trust | +| --- | --- | --- | +| `../CLAUDE.md` | The contract: rules, traps, definition of done | Current — fix in the same PR if wrong | +| `docs/TESTING.md` | Test strategy | — | +| `docs/e2e-test-matrix.md`, `docs/ui-e2e-test-matrix.md` | Coverage matrices | — | +| `docs/ISSUES.md`, `docs/SOLUTIONS.md` | Running log of problems and fixes | Historical | +| `docs/uploads.md` | File upload handling | — | +| `docs/qa/edr-freight-qa-test-plan.md` | QA test plan | — | +| `../DEPLOYMENT.md` | Deploy process | — | +| `../ITMLS_DB_Design.md`, `../orgstructure.md` | Design notes | Historical | +| `../E2E_TEST_REPORT.md`, `../checkpoint.md` | Point-in-time snapshots | **Stale by design — dated artifacts, not references** | + +Root-level `*.sql` and `*.dump` files are ad-hoc data snapshots, not part of the schema. +Migrations are the only source of truth for schema. From 00bd1250ee056406e626e2bcddd2f5d98b13c4e1 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Fri, 31 Jul 2026 06:36:03 +0000 Subject: [PATCH 10/43] feat: WIP element Chat intergration --- .github/workflows/deploy.yml | 8 +- apps/edr-freight-api/.env.example | 19 ++ apps/edr-freight-api/src/app.module.ts | 6 + .../src/common/booking-guards.ts | 2 + .../edr-freight-api/src/config/chat.config.ts | 59 ++++ .../src/modules/chat/chat-bridge.service.ts | 73 +++++ .../modules/chat/chat-provisioning.service.ts | 192 +++++++++++++ .../src/modules/chat/chat-sso.service.ts | 59 ++++ .../src/modules/chat/chat.controller.ts | 35 +++ .../src/modules/chat/chat.module.ts | 16 ++ .../src/modules/chat/matrix.client.ts | 263 ++++++++++++++++++ .../notification-inbox.module.ts | 3 + .../notification-inbox.service.ts | 12 +- .../src/seed/edr-freight.seed.ts | 1 + .../src/seed/freight-permissions.registry.ts | 11 + apps/edr-freight-web/backoffice/src/App.tsx | 9 + .../src/components/layout/route-meta.ts | 7 + .../components/layout/sidebar-sections.tsx | 7 + .../backoffice/src/features/chat/chatApi.ts | 13 + .../src/features/chat/useChatSso.ts | 19 ++ .../backoffice/src/lib/permissions.ts | 4 + .../src/pages/chat/ChatLaunchPage.tsx | 62 +++++ docker-compose.yaml | 28 ++ infrastructure/matrix/element/Dockerfile | 9 + infrastructure/matrix/element/config.json | 17 ++ infrastructure/matrix/element/sso.html | 36 +++ infrastructure/matrix/synapse/Dockerfile | 15 + .../matrix/synapse/docker-entrypoint.sh | 20 ++ .../matrix/synapse/homeserver.yaml.tmpl | 95 +++++++ infrastructure/matrix/synapse/log.config | 25 ++ scripts/deploy/sync-env-from-server.sh | 5 + 31 files changed, 1128 insertions(+), 2 deletions(-) create mode 100644 apps/edr-freight-api/src/config/chat.config.ts create mode 100644 apps/edr-freight-api/src/modules/chat/chat-bridge.service.ts create mode 100644 apps/edr-freight-api/src/modules/chat/chat-provisioning.service.ts create mode 100644 apps/edr-freight-api/src/modules/chat/chat-sso.service.ts create mode 100644 apps/edr-freight-api/src/modules/chat/chat.controller.ts create mode 100644 apps/edr-freight-api/src/modules/chat/chat.module.ts create mode 100644 apps/edr-freight-api/src/modules/chat/matrix.client.ts create mode 100644 apps/edr-freight-web/backoffice/src/features/chat/chatApi.ts create mode 100644 apps/edr-freight-web/backoffice/src/features/chat/useChatSso.ts create mode 100644 apps/edr-freight-web/backoffice/src/pages/chat/ChatLaunchPage.tsx create mode 100644 infrastructure/matrix/element/Dockerfile create mode 100644 infrastructure/matrix/element/config.json create mode 100644 infrastructure/matrix/element/sso.html create mode 100644 infrastructure/matrix/synapse/Dockerfile create mode 100644 infrastructure/matrix/synapse/docker-entrypoint.sh create mode 100644 infrastructure/matrix/synapse/homeserver.yaml.tmpl create mode 100644 infrastructure/matrix/synapse/log.config diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index b1c797dc6..b6a7fdb69 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -43,6 +43,8 @@ jobs: "passenger-portal" "passenger-backoffice" "payment-api" + "synapse" + "element-web" ) if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then @@ -84,6 +86,10 @@ jobs: echo "$CHANGED" | grep -q "^apps/edr-passenger-web/portal/" && SERVICES+=("passenger-portal") echo "$CHANGED" | grep -q "^apps/edr-passenger-web/backoffice/" && SERVICES+=("passenger-backoffice") echo "$CHANGED" | grep -q "^apps/edr-payment-api/" && SERVICES+=("payment-api") + # synapse / element-web have no per-service filter line: their only + # source is infrastructure/matrix/, already caught by GLOBAL_PATTERN + # above (which redeploys every service), so a dedicated line here + # would never fire. SERVICES=($(printf '%s\n' "${SERVICES[@]}" | sort -u)) @@ -119,7 +125,7 @@ jobs: - name: Resolve project and build env file run: | case "${{ matrix.service }}" in - freight-api|freight-portal|freight-backoffice|gps-tracker) + freight-api|freight-portal|freight-backoffice|gps-tracker|synapse|element-web) echo "PROJECT=edr-freight" >> "$GITHUB_ENV" echo "BUILD_ENV_FILE=freight-web.build.env" >> "$GITHUB_ENV" ;; diff --git a/apps/edr-freight-api/.env.example b/apps/edr-freight-api/.env.example index d2359dd97..930c8f1ca 100644 --- a/apps/edr-freight-api/.env.example +++ b/apps/edr-freight-api/.env.example @@ -219,3 +219,22 @@ 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 +# ── Internal chat (Matrix/Element) ────────────────────────────────────────── +# Disabled by default; /chat/sso and the nightly room/membership reconcile are +# no-ops until enabled. See infrastructure/matrix/. +MATRIX_ENABLED=false +# Synapse URL reachable from this container (docker-compose service DNS in +# prod, e.g. http://synapse:8008 — NOT the public https://matrix.edr.et). +MATRIX_BASE_URL=http://localhost:8008 +# Synapse's own public_baseurl — what Element itself is configured to call. +# Only used to seed the sso.html handoff page's localStorage. +MATRIX_PUBLIC_BASE_URL=https://matrix.edr.et +MATRIX_CHAT_WEB_URL=https://chat.edr.et +MATRIX_SERVER_NAME=matrix.edr.et +# Must exactly match infrastructure/matrix/synapse/.env's MATRIX_JWT_SECRET — +# this is the whole trust boundary for the SSO handoff. +MATRIX_JWT_SECRET= +# access_token of a Synapse server-admin account. Bootstrap it once via +# infrastructure/matrix/synapse's MATRIX_REGISTRATION_SHARED_SECRET (see that +# file's comments) — this app never touches the shared secret itself. +MATRIX_ADMIN_TOKEN= diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index d38c6ea92..405612ddf 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -23,6 +23,7 @@ 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 chatConfig from "./config/chat.config"; import { BookingsModule } from "./modules/bookings/bookings.module"; import { ContractsModule } from "./modules/contracts/contracts.module"; @@ -116,7 +117,10 @@ import { InterchangeDocumentsModule } from "./modules/interchange-documents/inte import { ImportOperationsModule } from "./modules/import-operations/import-operations.module"; import { AiModule } from "./modules/ai/ai.module"; import { AuditModule } from "./modules/audit/audit.module"; +// dev replaced the local LoggerMiddleware with the shared RequestLogMiddleware +// and deleted ./logger.middleware, so the branch's import is dropped here. import { RequestLogMiddleware } from "@edr/api-common"; +import { ChatModule } from "./modules/chat/chat.module"; import { LoginAudienceMiddleware } from "./modules/auth/login-audience.middleware"; import { PositionTypePermissionsCache } from "./common/position-type-permissions.cache"; @@ -135,6 +139,7 @@ if (!process.env.APPLICATION_NAME) { rabbitmqConfig, faydaConfig, eimsConfig, + chatConfig, ], }), ScheduleModule.forRoot(), @@ -252,6 +257,7 @@ if (!process.env.APPLICATION_NAME) { FleetHistoryModule, AiModule, AuditModule, + ChatModule, ], providers: [ EdrOrgSeeder, diff --git a/apps/edr-freight-api/src/common/booking-guards.ts b/apps/edr-freight-api/src/common/booking-guards.ts index 36ed7ae74..f9eab4d39 100644 --- a/apps/edr-freight-api/src/common/booking-guards.ts +++ b/apps/edr-freight-api/src/common/booking-guards.ts @@ -49,6 +49,8 @@ export const MixedAudience = (permission: string | string[]) => export const BookingView = () => BookingStaff(FREIGHT_PERMS.bookings.view); +export const ChatSync = () => BookingStaff(FREIGHT_PERMS.chat.sync); + /** * The document-review countdown in the backoffice header. Its own permission so * it can be granted to exactly the position types that decide operation diff --git a/apps/edr-freight-api/src/config/chat.config.ts b/apps/edr-freight-api/src/config/chat.config.ts new file mode 100644 index 000000000..ce20b610b --- /dev/null +++ b/apps/edr-freight-api/src/config/chat.config.ts @@ -0,0 +1,59 @@ +import { registerAs } from '@nestjs/config'; + +export interface ChatConfig { + enabled: boolean; + /** Synapse base URL reachable from this container (client + admin APIs). */ + baseUrl: string; + /** Synapse's public_baseurl — what Element itself is configured to call. Only + * used to seed the sso.html handoff; server-to-server calls use {@link baseUrl}. */ + publicBaseUrl: string; + /** Public Element Web origin — the SSO handoff link points here. */ + webUrl: string; + /** Matrix server_name — the `:domain` half of every MXID. */ + serverName: string; + /** HS256 secret. Must exactly match Synapse's jwt_config.secret. */ + jwtSecret: string; + /** Bearer token for a Synapse server admin account (room/user provisioning). */ + adminToken: string; +} + +const REQUIRED_VARS = [ + 'MATRIX_BASE_URL', + 'MATRIX_PUBLIC_BASE_URL', + 'MATRIX_CHAT_WEB_URL', + 'MATRIX_SERVER_NAME', + 'MATRIX_JWT_SECRET', + 'MATRIX_ADMIN_TOKEN', +] as const; + +export default registerAs('chat', (): ChatConfig => { + const enabled = (process.env.MATRIX_ENABLED ?? 'false').toLowerCase() === 'true'; + if (!enabled) { + return { + enabled: false, + baseUrl: '', + publicBaseUrl: '', + webUrl: '', + serverName: '', + jwtSecret: '', + adminToken: '', + }; + } + + const missing = REQUIRED_VARS.filter((name) => !process.env[name]); + if (missing.length > 0) { + throw new Error( + `Internal chat is enabled (MATRIX_ENABLED=true) but the following env vars are missing: ${missing.join(', ')}`, + ); + } + + return { + enabled: true, + baseUrl: process.env.MATRIX_BASE_URL!.replace(/\/$/, ''), + publicBaseUrl: process.env.MATRIX_PUBLIC_BASE_URL!.replace(/\/$/, ''), + webUrl: process.env.MATRIX_CHAT_WEB_URL!.replace(/\/$/, ''), + serverName: process.env.MATRIX_SERVER_NAME!, + jwtSecret: process.env.MATRIX_JWT_SECRET!, + adminToken: process.env.MATRIX_ADMIN_TOKEN!, + }; +}); diff --git a/apps/edr-freight-api/src/modules/chat/chat-bridge.service.ts b/apps/edr-freight-api/src/modules/chat/chat-bridge.service.ts new file mode 100644 index 000000000..75bbe9a29 --- /dev/null +++ b/apps/edr-freight-api/src/modules/chat/chat-bridge.service.ts @@ -0,0 +1,73 @@ +import { Inject, Injectable, Logger } from '@nestjs/common'; +import type { ConfigType } from '@nestjs/config'; +import { NotificationType, type NotifyInput } from '@edr/types'; + +import chatConfig from '../../config/chat.config'; +import { MatrixClient } from './matrix.client'; + +const FALLBACK_ROOM = { alias: 'freight-alerts', name: 'Freight Alerts' }; + +/** + * Best-effort per-type routing to an existing dept room. Anything not listed + * (including GENERIC) falls through to #freight-alerts — safer than a wrong + * guess at which department a type belongs to. Extend as real usage shows + * which types actually want a dept room instead of the shared feed. + * + * `name` matters only if this bridge is the very first thing to touch that + * alias (normally the nightly/on-demand reconcile creates dept rooms first, + * with the position's real name) — ensureRoom never renames an existing + * room, so this must match what ChatProvisioningService would have used. + */ +const ROOM_FOR_TYPE: Partial> = { + [NotificationType.REQUEST_SUBMITTED]: { alias: 'dept-operation', name: 'Operation' }, + [NotificationType.CLEARANCE_REVIEW]: { alias: 'dept-operation', name: 'Operation' }, +}; + +/** + * Mirrors BACKOFFICE-audience notifications into chat so staff see them + * without having the inbox open. Hooked once into + * NotificationInboxService.notify() — every one of that service's ~20 + * callers gets this for free. + * + * Gated on BACKOFFICE only: notify() also serves PORTAL (customer) + * notifications, which must never land in an internal staff room. + */ +@Injectable() +export class ChatBridgeService { + private readonly logger = new Logger(ChatBridgeService.name); + + constructor( + @Inject(chatConfig.KEY) + private readonly config: ConfigType, + private readonly matrix: MatrixClient, + ) {} + + async bridge(input: NotifyInput): Promise { + if (!this.config.enabled) return; + + try { + const room = ROOM_FOR_TYPE[input.type] ?? FALLBACK_ROOM; + const roomId = await this.matrix.ensureRoom(room.alias, room.name); + const body = input.link ? `${input.title}\n${input.body}\n${input.link}` : `${input.title}\n${input.body}`; + const html = `${escapeHtml(input.title)}
${escapeHtml(input.body)}${ + input.link ? `
${escapeHtml(input.link)}` : '' + }`; + await this.matrix.sendMessage(roomId, body, html); + } catch (err) { + // Same contract as NotificationInboxService.notify(): a chat-bridge + // failure must never break or roll back the notification that + // triggered it. + this.logger.error( + `Chat bridge failed for ${input.type}: ${(err as Error).message}`, + ); + } + } +} + +function escapeHtml(s: string): string { + return s + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} diff --git a/apps/edr-freight-api/src/modules/chat/chat-provisioning.service.ts b/apps/edr-freight-api/src/modules/chat/chat-provisioning.service.ts new file mode 100644 index 000000000..7b748e576 --- /dev/null +++ b/apps/edr-freight-api/src/modules/chat/chat-provisioning.service.ts @@ -0,0 +1,192 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { Cron, CronExpression } from '@nestjs/schedule'; +import { InjectDataSource } from '@nestjs/typeorm'; +import { DataSource } from 'typeorm'; + +import { MatrixClient } from './matrix.client'; + +/** edr-org.seeder.ts's EDR_ORG_KEY / EDR_UNIT_KEY — the org is currently flat + * (one org, one unit), so this is the entire scope of what gets provisioned. */ +const ORG_KEY = 'edr_freight'; +const UNIT_KEY = 'edr_freight_app'; + +const SPACE_ALIAS = 'edr-freight'; +const GENERAL_ALIAS = 'general'; + +interface PositionHolder { + positionKey: string; + positionName: string; + userId: string; + userName: string; +} + +export interface ReconcileResult { + rooms: number; + joined: number; + kicked: number; + deactivated: number; +} + +/** + * Keeps Matrix rooms and their membership in sync with IAM's unit/position + * tree. There is no local hook on "employee position changed" — IAM writes + * happen inside the vendored @tria-plc/iamapi-common package — so this is a + * reconcile loop, not an event handler: nightly, plus on-demand via + * POST /chat/sync. + * + * Room identity is a deterministic alias (#dept-), not a stored + * mapping table — resolved via the directory API, created on first miss. + * Room membership is diffed against Matrix's own joined_members, not a local + * snapshot — so a user removed from IAM disappears from chat on the very + * next reconcile, with no extra state for this service to own. + */ +@Injectable() +export class ChatProvisioningService { + private readonly logger = new Logger(ChatProvisioningService.name); + + constructor( + @InjectDataSource() private readonly dataSource: DataSource, + private readonly matrix: MatrixClient, + ) {} + + @Cron(CronExpression.EVERY_DAY_AT_3AM, { name: 'chat-provisioning-reconcile' }) + async scheduledReconcile(): Promise { + try { + const result = await this.reconcile(); + this.logger.log( + `Chat reconcile: ${result.rooms} room(s), ${result.joined} joined, ` + + `${result.kicked} kicked, ${result.deactivated} deactivated`, + ); + } catch (err) { + // Never throws into the scheduler — chat provisioning must not be able + // to take down anything else on the cron registry. + this.logger.error( + `Chat reconcile failed: ${(err as Error).message}`, + (err as Error).stack, + ); + } + } + + private async currentHolders(): Promise { + return this.dataSource.query( + `SELECT p.key AS "positionKey", + COALESCE(p.name->>'en', p.key) AS "positionName", + e.user_id AS "userId", + COALESCE(iu.name->>'en', iu.username, iu.email) AS "userName" + FROM iam.employee_positions ep + JOIN iam.employees e ON e.id = ep.employee_id + JOIN iam.positions p ON p.id = ep.position_id + JOIN iam.units u ON u.id = p.unit_id + JOIN iam.organizations o ON o.id = u.organization_id + JOIN iam.users iu ON iu.id = e.user_id + WHERE ep.is_current = true + AND e.is_current = true + AND o.key = $1 + AND u.key = $2`, + [ORG_KEY, UNIT_KEY], + ); + } + + /** Force-joins additions, kicks+deactivates users no longer entitled anywhere. */ + private async syncMembership( + roomId: string, + desiredUserIds: Set, + botMxid: string, + ): Promise<{ joined: number; kicked: string[] }> { + const current = await this.matrix.joinedMembers(roomId); + const currentSet = new Set(current.filter((id) => id !== botMxid)); + + let joined = 0; + for (const userId of desiredUserIds) { + if (!currentSet.has(userId)) { + await this.matrix.forceJoin(roomId, userId); + joined += 1; + } + } + + const kicked: string[] = []; + for (const userId of currentSet) { + if (!desiredUserIds.has(userId)) { + await this.matrix.kick(roomId, userId, 'No longer assigned to this room'); + kicked.push(userId); + } + } + + return { joined, kicked }; + } + + async reconcile(): Promise { + const holders = await this.currentHolders(); + const botMxid = await this.matrix.whoami(); + + const spaceId = await this.matrix.ensureRoom(SPACE_ALIAS, 'EDR Freight', { + isSpace: true, + }); + const generalRoomId = await this.matrix.ensureRoom(GENERAL_ALIAS, 'General', { + parentSpaceId: spaceId, + }); + + const allUserIds = new Set(holders.map((h) => this.matrix.mxid(h.userId))); + + // Accounts are otherwise only created lazily on first JWT login (see + // ChatSsoService) — force-joining someone who has never clicked "Chat" + // yet 404s ("User not found") without this. + const seenUserIds = new Set(); + for (const h of holders) { + const mxid = this.matrix.mxid(h.userId); + if (seenUserIds.has(mxid)) continue; + seenUserIds.add(mxid); + await this.matrix.ensureUser(mxid, h.userName); + } + + let rooms = 2; // space + general + let joined = 0; + let kicked = 0; + // A user kicked from anything while holding zero current positions + // anywhere in the unit (allUserIds spans every position) is a full + // leaver, not just moved between positions — deactivate their account. + const kickedUserIds = new Set(); + + const generalDiff = await this.syncMembership(generalRoomId, allUserIds, botMxid); + joined += generalDiff.joined; + kicked += generalDiff.kicked.length; + generalDiff.kicked.forEach((uid) => kickedUserIds.add(uid)); + + const byPosition = new Map }>(); + for (const h of holders) { + const entry = byPosition.get(h.positionKey) ?? { + name: h.positionName, + userIds: new Set(), + }; + entry.userIds.add(this.matrix.mxid(h.userId)); + byPosition.set(h.positionKey, entry); + } + + for (const [positionKey, { name, userIds }] of byPosition) { + const roomId = await this.matrix.ensureRoom(`dept-${positionKey}`, name, { + parentSpaceId: spaceId, + }); + rooms += 1; + + const diff = await this.syncMembership(roomId, userIds, botMxid); + joined += diff.joined; + kicked += diff.kicked.length; + diff.kicked.forEach((uid) => kickedUserIds.add(uid)); + } + + let deactivated = 0; + for (const userId of kickedUserIds) { + if (allUserIds.has(userId)) continue; // moved position, still current elsewhere + try { + await this.matrix.deactivateUser(userId); + deactivated += 1; + } catch (err) { + this.logger.warn( + `Failed to deactivate departed user ${userId}: ${(err as Error).message}`, + ); + } + } + + return { rooms, joined, kicked, deactivated }; + } +} diff --git a/apps/edr-freight-api/src/modules/chat/chat-sso.service.ts b/apps/edr-freight-api/src/modules/chat/chat-sso.service.ts new file mode 100644 index 000000000..8f9357e10 --- /dev/null +++ b/apps/edr-freight-api/src/modules/chat/chat-sso.service.ts @@ -0,0 +1,59 @@ +import { Inject, Injectable } from '@nestjs/common'; +import type { ConfigType } from '@nestjs/config'; +import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type'; +import { SignJWT } from 'jose'; + +import chatConfig from '../../config/chat.config'; +import { MatrixClient } from './matrix.client'; + +/** Matrix login_tokens are single-use and expire in 5 minutes (Synapse default). */ +const JWT_TTL_SECONDS = 60; + +function displayName(user: TCurrentUser): string { + return ( + user.name?.en || + Object.values(user.name ?? {}).find((v) => typeof v === 'string' && v) || + user.username || + user.email + ); +} + +/** + * The SSO handoff: turn an already-authenticated freight session into a + * one-click Element sign-in link, with no second password anywhere. + * + * 1. Sign a short-lived JWT asserting this user's id (Synapse's + * org.matrix.login.jwt auto-registers the account on first use). + * 2. Trade that JWT for a real Matrix access token. + * 3. Trade the access token for a one-shot login_token. + * 4. Hand the caller a link to Element's sso.html shim, which seeds + * localStorage and forwards the token into Element's own login flow. + */ +@Injectable() +export class ChatSsoService { + constructor( + @Inject(chatConfig.KEY) + private readonly config: ConfigType, + private readonly matrix: MatrixClient, + ) {} + + async getSsoUrl(user: TCurrentUser): Promise<{ url: string }> { + const secret = new TextEncoder().encode(this.config.jwtSecret); + const jwt = await new SignJWT({ name: displayName(user) }) + .setProtectedHeader({ alg: 'HS256' }) + .setSubject(user.id) + .setIssuer('edr-freight-api') + .setAudience('matrix') + .setIssuedAt() + .setExpirationTime(`${JWT_TTL_SECONDS}s`) + .sign(secret); + + const { access_token } = await this.matrix.loginWithJwt(jwt); + const { login_token } = await this.matrix.getLoginToken(access_token); + + const url = new URL(`${this.config.webUrl}/sso.html`); + url.searchParams.set('t', login_token); + url.searchParams.set('hs', this.config.publicBaseUrl); + return { url: url.toString() }; + } +} diff --git a/apps/edr-freight-api/src/modules/chat/chat.controller.ts b/apps/edr-freight-api/src/modules/chat/chat.controller.ts new file mode 100644 index 000000000..0ecca04c0 --- /dev/null +++ b/apps/edr-freight-api/src/modules/chat/chat.controller.ts @@ -0,0 +1,35 @@ +import { Controller, Get, Post, UseGuards } from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { CurrentUser } from '@tria-plc/api-common/modules/auth/decorators/current-user.decorator'; +import { JwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard'; +import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type'; + +import { ChatSync } from '../../common/booking-guards'; +import { ChatProvisioningService } from './chat-provisioning.service'; +import { ChatSsoService } from './chat-sso.service'; + +@ApiTags('chat') +@Controller('chat') +@ApiBearerAuth() +export class ChatController { + constructor( + private readonly sso: ChatSsoService, + private readonly provisioning: ChatProvisioningService, + ) {} + + @Get('sso') + @UseGuards(JwtGuard) + @ApiOperation({ summary: 'One-click sign-in link into EDR internal chat' }) + getSso(@CurrentUser() user: TCurrentUser) { + return this.sso.getSsoUrl(user); + } + + @Post('sync') + @ChatSync() + @ApiOperation({ + summary: 'Re-run the chat room/membership reconcile immediately (normally nightly)', + }) + sync() { + return this.provisioning.reconcile(); + } +} diff --git a/apps/edr-freight-api/src/modules/chat/chat.module.ts b/apps/edr-freight-api/src/modules/chat/chat.module.ts new file mode 100644 index 000000000..8df827339 --- /dev/null +++ b/apps/edr-freight-api/src/modules/chat/chat.module.ts @@ -0,0 +1,16 @@ +import { Module } from '@nestjs/common'; + +import { ChatBridgeService } from './chat-bridge.service'; +import { ChatController } from './chat.controller'; +import { ChatProvisioningService } from './chat-provisioning.service'; +import { ChatSsoService } from './chat-sso.service'; +import { MatrixClient } from './matrix.client'; + +@Module({ + controllers: [ChatController], + providers: [MatrixClient, ChatSsoService, ChatProvisioningService, ChatBridgeService], + // ChatBridgeService: consumed by NotificationInboxModule to mirror + // BACKOFFICE notifications into chat — see notification-inbox.module.ts. + exports: [ChatBridgeService], +}) +export class ChatModule {} diff --git a/apps/edr-freight-api/src/modules/chat/matrix.client.ts b/apps/edr-freight-api/src/modules/chat/matrix.client.ts new file mode 100644 index 000000000..01dafb6de --- /dev/null +++ b/apps/edr-freight-api/src/modules/chat/matrix.client.ts @@ -0,0 +1,263 @@ +import { Inject, Injectable } from '@nestjs/common'; +import type { ConfigType } from '@nestjs/config'; + +import chatConfig from '../../config/chat.config'; + +/** + * Thin wrapper over the handful of Matrix Client-Server + Synapse Admin API + * calls this app needs. Not a general Matrix SDK — matrix-js-sdk is a + * browser/Element concern; the server side only ever provisions rooms/users + * and posts bot messages, so a fetch wrapper is the whole job. + * + * All admin-scoped calls act as the account behind MATRIX_ADMIN_TOKEN. That + * same account also posts the notification-bridge messages (see + * ChatBridgeService) — one bot/admin account covers both jobs, no separate + * bot user needed. + */ +@Injectable() +export class MatrixClient { + constructor( + @Inject(chatConfig.KEY) + private readonly config: ConfigType, + ) {} + + /** `@:` — the one place this format is assembled. */ + mxid(localpart: string): string { + return `@${localpart}:${this.config.serverName}`; + } + + get serverName(): string { + return this.config.serverName; + } + + private async request( + method: string, + path: string, + body?: unknown, + token: string = this.config.adminToken, + ): Promise { + const res = await fetch(`${this.config.baseUrl}${path}`, { + method, + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + if (!res.ok) { + const text = await res.text().catch(() => ''); + throw new Error( + `Matrix ${method} ${path} -> ${res.status}: ${text.slice(0, 500)}`, + ); + } + if (res.status === 204) return undefined as T; + return (await res.json()) as T; + } + + /** No auth — only /login accepts a bare JWT with nothing else on the request. */ + private async publicRequest( + method: string, + path: string, + body: unknown, + ): Promise { + const res = await fetch(`${this.config.baseUrl}${path}`, { + method, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + if (!res.ok) { + const text = await res.text().catch(() => ''); + throw new Error( + `Matrix ${method} ${path} -> ${res.status}: ${text.slice(0, 500)}`, + ); + } + return (await res.json()) as T; + } + + /** 404 → null. Every other non-2xx still throws via {@link request}. */ + private async requestOrNull( + method: string, + path: string, + token?: string, + ): Promise { + const res = await fetch(`${this.config.baseUrl}${path}`, { + method, + headers: { Authorization: `Bearer ${token ?? this.config.adminToken}` }, + }); + if (res.status === 404) return null; + if (!res.ok) { + const text = await res.text().catch(() => ''); + throw new Error( + `Matrix ${method} ${path} -> ${res.status}: ${text.slice(0, 500)}`, + ); + } + return (await res.json()) as T; + } + + /** Sign an already-authenticated freight session into a Matrix session. */ + loginWithJwt( + jwt: string, + ): Promise<{ access_token: string; user_id: string; device_id: string }> { + return this.publicRequest('POST', '/_matrix/client/v3/login', { + type: 'org.matrix.login.jwt', + token: jwt, + initial_device_display_name: 'EDR Backoffice', + }); + } + + /** The account behind MATRIX_ADMIN_TOKEN — used to exclude the bot itself from membership reconciliation. */ + async whoami(): Promise { + const res = await this.request<{ user_id: string }>( + 'GET', + '/_matrix/client/v3/account/whoami', + ); + return res.user_id; + } + + /** Currently-joined user ids for a room (not full member-event state). */ + async joinedMembers(roomId: string): Promise { + const res = await this.request<{ joined: Record }>( + 'GET', + `/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/joined_members`, + ); + return Object.keys(res.joined); + } + + /** Exchange a fresh access token for a one-shot login_token (5 min TTL). */ + getLoginToken(accessToken: string): Promise<{ login_token: string }> { + return this.request( + 'POST', + '/_matrix/client/v1/login/get_token', + {}, + accessToken, + ); + } + + /** null when the alias doesn't resolve to a room yet. */ + resolveAlias(alias: string): Promise<{ room_id: string } | null> { + return this.requestOrNull( + 'GET', + `/_matrix/client/v3/directory/room/${encodeURIComponent(alias)}`, + ); + } + + createRoom(input: { + alias: string; + name: string; + topic?: string; + isSpace?: boolean; + parentSpaceId?: string; + }): Promise<{ room_id: string }> { + return this.request('POST', '/_matrix/client/v3/createRoom', { + room_alias_name: input.alias, + name: input.name, + topic: input.topic, + preset: 'private_chat', + creation_content: input.isSpace ? { type: 'm.space' } : undefined, + initial_state: input.parentSpaceId + ? [ + { + type: 'm.space.parent', + state_key: input.parentSpaceId, + content: { via: [this.config.serverName], canonical: true }, + }, + ] + : undefined, + }); + } + + addToSpace(spaceId: string, childRoomId: string): Promise { + return this.request( + 'PUT', + `/_matrix/client/v3/rooms/${encodeURIComponent(spaceId)}/state/m.space.child/${encodeURIComponent(childRoomId)}`, + { via: [this.config.serverName] }, + ); + } + + /** + * Get-or-create by alias — the room identity scheme this whole module + * relies on instead of a local id-mapping table. Idempotent: safe to call + * on every reconcile run and every bridged notification alike. + */ + async ensureRoom( + alias: string, + name: string, + opts: { isSpace?: boolean; parentSpaceId?: string } = {}, + ): Promise { + const existing = await this.resolveAlias(`#${alias}:${this.config.serverName}`); + if (existing) return existing.room_id; + + const { room_id } = await this.createRoom({ + alias, + name, + isSpace: opts.isSpace, + parentSpaceId: opts.parentSpaceId, + }); + if (opts.parentSpaceId) { + await this.addToSpace(opts.parentSpaceId, room_id); + } + return room_id; + } + + /** + * Create the account if absent (no password — this deployment is JWT-SSO + * only), or no-op if it already exists. Needed before force-joining a + * position holder who has never clicked "Chat": accounts are otherwise + * only created lazily on first JWT login, and the admin join API 404s + * ("User not found") on an account that doesn't exist yet. + */ + async ensureUser(userId: string, displayName?: string): Promise { + const existing = await this.requestOrNull<{ name: string }>( + 'GET', + `/_synapse/admin/v2/users/${encodeURIComponent(userId)}`, + ); + if (existing) return; + await this.request( + 'PUT', + `/_synapse/admin/v2/users/${encodeURIComponent(userId)}`, + displayName ? { displayname: displayName } : {}, + ); + } + + /** Server-admin force-join — no invite to accept, works even mid-outage for the invitee. */ + forceJoin(roomIdOrAlias: string, userId: string): Promise { + return this.request( + 'POST', + `/_synapse/admin/v1/join/${encodeURIComponent(roomIdOrAlias)}`, + { user_id: userId }, + ); + } + + kick(roomId: string, userId: string, reason: string): Promise { + return this.request( + 'POST', + `/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/kick`, + { user_id: userId, reason }, + ); + } + + /** Deactivating (rather than just kicking) a leaver's account revokes all their sessions. */ + deactivateUser(userId: string): Promise { + return this.request( + 'POST', + `/_synapse/admin/v1/deactivate/${encodeURIComponent(userId)}`, + { erase: false }, + ); + } + + sendMessage(roomId: string, body: string, formattedBody?: string): Promise { + const txnId = `edr-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + return this.request( + 'PUT', + `/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/send/m.room.message/${txnId}`, + formattedBody + ? { + msgtype: 'm.text', + body, + format: 'org.matrix.custom.html', + formatted_body: formattedBody, + } + : { msgtype: 'm.text', body }, + ); + } +} diff --git a/apps/edr-freight-api/src/modules/notification-inbox/notification-inbox.module.ts b/apps/edr-freight-api/src/modules/notification-inbox/notification-inbox.module.ts index e9f3af958..aa363ad3d 100644 --- a/apps/edr-freight-api/src/modules/notification-inbox/notification-inbox.module.ts +++ b/apps/edr-freight-api/src/modules/notification-inbox/notification-inbox.module.ts @@ -4,6 +4,7 @@ import { Session } from "@tria-plc/iamapi-common/entities/iam/user/session.entit import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity"; import { BackofficeModule } from "../backoffice/backoffice.module"; +import { ChatModule } from "../chat/chat.module"; import { CompaniesModule } from "../companies/companies.module"; import { NotificationsModule } from "../notifications/notifications.module"; import { Notification } from "./entities/notification.entity"; @@ -24,6 +25,8 @@ import { WsAuthService } from "./ws-auth.service"; BackofficeModule, // EmailClientService + SmsClientService (HIGH-priority fan-out) NotificationsModule, + // ChatBridgeService (mirrors BACKOFFICE notifications into chat) + ChatModule, ], controllers: [NotificationInboxController], providers: [ diff --git a/apps/edr-freight-api/src/modules/notification-inbox/notification-inbox.service.ts b/apps/edr-freight-api/src/modules/notification-inbox/notification-inbox.service.ts index 7cfbf81df..119019997 100644 --- a/apps/edr-freight-api/src/modules/notification-inbox/notification-inbox.service.ts +++ b/apps/edr-freight-api/src/modules/notification-inbox/notification-inbox.service.ts @@ -1,4 +1,5 @@ import { + NotificationAudience, NotificationChannels, NotificationChannelsSent, NotificationDto, @@ -11,6 +12,7 @@ import { InjectRepository } from "@nestjs/typeorm"; import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity"; import { Repository } from "typeorm"; +import { ChatBridgeService } from "../chat/chat-bridge.service"; import { EmailClientService } from "../notifications/email-client.service"; import { SmsClientService } from "../notifications/sms-client.service"; import { ListNotificationsQueryDto } from "./dto/list-notifications-query.dto"; @@ -37,6 +39,7 @@ export class NotificationInboxService { private readonly gateway: NotificationsGateway, private readonly emailClient: EmailClientService, private readonly smsClient: SmsClientService, + private readonly chatBridge: ChatBridgeService, @InjectRepository(User) private readonly users: Repository, ) {} @@ -44,11 +47,18 @@ export class NotificationInboxService { /** * Fan a logical notification out to every resolved recipient: persist one row * each, push it live over WebSocket, and (for HIGH priority) also queue - * email/SMS via the existing clients. + * email/SMS via the existing clients. BACKOFFICE-audience notifications are + * also mirrored into internal chat (ChatBridgeService) — a shared-room + * broadcast, not per-recipient, so it runs once regardless of how many (if + * any) in-app rows get created below. Never PORTAL — that's customer-facing + * and must never reach a staff room. */ async notify(input: NotifyInput): Promise { try { const userIds = await this.recipients.resolve(input.recipients); + if (input.audience === NotificationAudience.BACKOFFICE) { + await this.chatBridge.bridge(input); + } if (userIds.length === 0) { this.logger.debug( `notify(${input.type}) resolved 0 recipients — skipped`, diff --git a/apps/edr-freight-api/src/seed/edr-freight.seed.ts b/apps/edr-freight-api/src/seed/edr-freight.seed.ts index 1798a8d5f..e4ab41954 100644 --- a/apps/edr-freight-api/src/seed/edr-freight.seed.ts +++ b/apps/edr-freight-api/src/seed/edr-freight.seed.ts @@ -242,6 +242,7 @@ export const EDR_FREIGHT_ROLES: FreightSeedRole[] = [ "edr_freight_app:hierarchy_positions:view", "edr_freight_app:hierarchy_employee_assignment:view", "edr_freight_app:position_types:view", + "edr_freight_app:chat:view", ], }, { 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 9e905df6d..4043430e2 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts @@ -523,6 +523,12 @@ export const SHIPPING_LINE_PERMISSIONS: FreightPermissionSeed[] = [ ), ]; +// Internal chat (Matrix/Element) — sidebar visibility + manual reconcile trigger. +export const CHAT_PERMISSIONS: FreightPermissionSeed[] = [ + perm('c9a00001-0001-4000-8000-000000000001', 'edr_freight_app:chat:view', 'Open internal chat'), + perm('c9a00001-0001-4000-8000-000000000002', 'edr_freight_app:chat:sync', 'Re-run chat room/membership sync'), +]; + // D. Finance — payments + invoices export const FINANCE_PERMISSIONS: FreightPermissionSeed[] = [ perm( @@ -1641,6 +1647,7 @@ export const ADVANCED_BACKOFFICE_PERMISSIONS: FreightPermissionSeed[] = [ ...REPORT_PERMISSIONS, ...CUSTOMER_PERMISSIONS, ...SHIPPING_LINE_PERMISSIONS, + ...CHAT_PERMISSIONS, ...FINANCE_PERMISSIONS, ...MILE_PERMISSIONS, ...FLEET_RAIL_PERMISSIONS, @@ -1872,6 +1879,10 @@ export const FREIGHT_PERMS = { /** Reject any pending invoice request. */ invoiceReject: "edr_freight_app:shipping_line_credits:invoice_reject", }, + chat: { + view: 'edr_freight_app:chat:view', + sync: 'edr_freight_app:chat:sync', + }, payments: { view: "edr_freight_app:payments:view", }, diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index 9ac730bda..2ea1c1890 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -115,6 +115,7 @@ import FaydaCallbackPage from "./pages/FaydaCallbackPage"; import { UserManagementRoutes } from "./user-management/route"; import SetPassword from "./shared/components/SetPassword"; import SupportInboxPage from "./pages/support/SupportInboxPage"; +import ChatLaunchPage from "./pages/chat/ChatLaunchPage"; import { APP_TITLE, buildSidebarSections, @@ -298,6 +299,14 @@ const App = () => { } /> + + + + } + /> = [ subtitle: "Manage your account and signature", }, }, + { + prefix: "/dashboard/chat", + meta: { + title: "Chat", + subtitle: "Internal messaging for EDR staff", + }, + }, { // Invoices, Payments, and USD Payments are tabs on one page now // (FinanceHubPage); the header title itself is set per-tab there. 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 293e82450..301bf6939 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 @@ -32,6 +32,7 @@ import { Users, Wallet, LifeBuoy, + MessageSquare, TrainFront, XCircle, } from "lucide-react"; @@ -124,6 +125,12 @@ export const buildSidebarSections = ( icon: , permission: FREIGHT_PERMS.support.agentView, }, + { + label: "Chat", + href: "/dashboard/chat", + icon: , + permission: FREIGHT_PERMS.chat.view, + }, ...demoItems, ], }, diff --git a/apps/edr-freight-web/backoffice/src/features/chat/chatApi.ts b/apps/edr-freight-web/backoffice/src/features/chat/chatApi.ts new file mode 100644 index 000000000..65522aafd --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/features/chat/chatApi.ts @@ -0,0 +1,13 @@ +import { api } from "@/auth/http"; + +/** + * Internal chat (Matrix/Element) REST calls. Just the one endpoint — Chat + * itself is a separate app (chat.edr.et); this backoffice only ever asks for + * a fresh sign-in link into it. + */ +export const chatApi = { + getSsoUrl: async (): Promise => { + const { data } = await api.get<{ url: string }>("/chat/sso"); + return data.url; + }, +}; diff --git a/apps/edr-freight-web/backoffice/src/features/chat/useChatSso.ts b/apps/edr-freight-web/backoffice/src/features/chat/useChatSso.ts new file mode 100644 index 000000000..d25b5f4a1 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/features/chat/useChatSso.ts @@ -0,0 +1,19 @@ +import { useQuery } from "@tanstack/react-query"; + +import { chatApi } from "./chatApi"; + +export const CHAT_SSO_KEY = ["chat", "sso"] as const; + +/** + * The login_token this resolves to is single-use and expires in 5 minutes + * (Synapse default) — the global `staleTime: 0` (queryClient.ts) already + * means every fresh mount of the launch page refetches rather than reusing + * a possibly-spent link. + */ +export function useChatSso() { + return useQuery({ + queryKey: CHAT_SSO_KEY, + queryFn: chatApi.getSsoUrl, + retry: false, + }); +} diff --git a/apps/edr-freight-web/backoffice/src/lib/permissions.ts b/apps/edr-freight-web/backoffice/src/lib/permissions.ts index fe4d4fff9..b2797fa59 100644 --- a/apps/edr-freight-web/backoffice/src/lib/permissions.ts +++ b/apps/edr-freight-web/backoffice/src/lib/permissions.ts @@ -33,6 +33,10 @@ export const FREIGHT_PERMS = { staffUsers: { view: "edr_freight_app:staff:users:view", }, + chat: { + view: "edr_freight_app:chat:view", + sync: "edr_freight_app:chat:sync", + }, bookings: { view: "edr_freight_app:bookings:view", create: "edr_freight_app:bookings:create", diff --git a/apps/edr-freight-web/backoffice/src/pages/chat/ChatLaunchPage.tsx b/apps/edr-freight-web/backoffice/src/pages/chat/ChatLaunchPage.tsx new file mode 100644 index 000000000..6b51b6512 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/chat/ChatLaunchPage.tsx @@ -0,0 +1,62 @@ +import { Alert, Button, Card, Center, Loader, Stack, Text } from "@mantine/core"; +import { MessageSquare, TriangleAlert } from "lucide-react"; + +import { PageContainer, PageHeader } from "@/components/page"; +import { useChatSso } from "@/features/chat/useChatSso"; + +/** + * Chat itself lives at chat.edr.et (Element), not in this app — this page's + * only job is a fresh one-click sign-in link into it. A real `` (not + * `window.open()` in a click handler) so the browser never treats it as a + * blocked popup, and no iframe: Element's own CSP refuses to be framed. + */ +export default function ChatLaunchPage() { + const { data: url, isLoading, isError, refetch } = useChatSso(); + + return ( + + + +
+ + {isLoading && } + + {isError && ( + + } + color="red" + title="Couldn't get a sign-in link" + variant="light" + > + Something went wrong reaching chat. Try again. + + + + )} + + {url && ( + + + + Opens EDR Chat in a new tab, already signed in as you. + + + + )} + +
+
+
+ ); +} diff --git a/docker-compose.yaml b/docker-compose.yaml index 5d5faa2de..050afc5df 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -116,3 +116,31 @@ services: env_file: - apps/edr-payment-api/.env restart: always + + # Internal employee chat (freight backoffice). No federation, no public + # registration — see infrastructure/matrix/synapse/homeserver.yaml.tmpl. + synapse: + build: + context: infrastructure/matrix/synapse + ports: + - "${SYNAPSE_PORT:-8008}:8008" + env_file: + - infrastructure/matrix/synapse/.env + volumes: + - matrix-data:/data + # Local dev: Postgres runs in the separate docker-compose.db.dev.yml + # project (different docker network) and is only reachable from here via + # the host's published port — see MATRIX_DB_HOST in synapse/.env.example. + extra_hosts: + - "host.docker.internal:host-gateway" + restart: always + + element-web: + build: + context: infrastructure/matrix/element + ports: + - "${ELEMENT_WEB_PORT:-8080}:80" + restart: always + +volumes: + matrix-data: diff --git a/infrastructure/matrix/element/Dockerfile b/infrastructure/matrix/element/Dockerfile new file mode 100644 index 000000000..5ab02cd9b --- /dev/null +++ b/infrastructure/matrix/element/Dockerfile @@ -0,0 +1,9 @@ +# syntax=docker/dockerfile:1 +# +# EDR internal chat web client. Unmodified upstream Element Web + our public, +# non-secret config (homeserver URL, branding) and the SSO handoff page. +# Pin the tag; never float on `latest`. +FROM ghcr.io/element-hq/element-web:v1.11.108 + +COPY config.json /app/config.json +COPY sso.html /app/sso.html diff --git a/infrastructure/matrix/element/config.json b/infrastructure/matrix/element/config.json new file mode 100644 index 000000000..6f988d3e9 --- /dev/null +++ b/infrastructure/matrix/element/config.json @@ -0,0 +1,17 @@ +{ + "default_server_config": { + "m.homeserver": { + "base_url": "https://matrix.edr.et", + "server_name": "matrix.edr.et" + } + }, + "brand": "EDR Chat", + "permalink_prefix": "https://chat.edr.et", + "disable_guests": true, + "disable_3pid_login": true, + "disable_custom_urls": true, + "default_theme": "light", + "settingDefaults": { + "UIFeature.registration": false + } +} diff --git a/infrastructure/matrix/element/sso.html b/infrastructure/matrix/element/sso.html new file mode 100644 index 000000000..59a458fa4 --- /dev/null +++ b/infrastructure/matrix/element/sso.html @@ -0,0 +1,36 @@ + + + + + + Signing in to EDR Chat… + + + + + diff --git a/infrastructure/matrix/synapse/Dockerfile b/infrastructure/matrix/synapse/Dockerfile new file mode 100644 index 000000000..ec424e41f --- /dev/null +++ b/infrastructure/matrix/synapse/Dockerfile @@ -0,0 +1,15 @@ +# syntax=docker/dockerfile:1 +# +# EDR internal chat homeserver. Unmodified upstream Synapse + our config +# template — no source build. Pin the tag; never float on `latest`. +FROM ghcr.io/element-hq/synapse:v1.140.0 + +RUN apt-get update && apt-get install -y --no-install-recommends gettext-base \ + && rm -rf /var/lib/apt/lists/* + +COPY homeserver.yaml.tmpl /synapse/homeserver.yaml.tmpl +COPY log.config /synapse/log.config +COPY docker-entrypoint.sh /synapse/docker-entrypoint.sh +RUN chmod +x /synapse/docker-entrypoint.sh + +ENTRYPOINT ["/synapse/docker-entrypoint.sh"] diff --git a/infrastructure/matrix/synapse/docker-entrypoint.sh b/infrastructure/matrix/synapse/docker-entrypoint.sh new file mode 100644 index 000000000..674bf7947 --- /dev/null +++ b/infrastructure/matrix/synapse/docker-entrypoint.sh @@ -0,0 +1,20 @@ +#!/bin/sh +# Renders homeserver.yaml from the template using the runtime env (so +# MATRIX_JWT_SECRET / DB password / registration_shared_secret come from the +# service's .env file, never get baked into the image), then hands off to the +# upstream Synapse image's own entrypoint. +set -eu + +mkdir -p /data +envsubst \ + '${MATRIX_SERVER_NAME} ${MATRIX_PUBLIC_BASEURL} ${MATRIX_DB_USER} ${MATRIX_DB_PASSWORD} ${MATRIX_DB_NAME} ${MATRIX_DB_HOST} ${MATRIX_DB_PORT} ${MATRIX_JWT_SECRET} ${MATRIX_REGISTRATION_SHARED_SECRET}' \ + < /synapse/homeserver.yaml.tmpl > /data/homeserver.yaml + +# start.py's `run` mode (the implicit default we hit below) gosu's straight +# into uid 991 with no chown — it only chowns /data in its `generate` / +# `migrate_config` modes, which we skip by providing our own pre-rendered +# config. Without this, 991 can't write its signing key on first boot. +chown -R 991:991 /data + +export SYNAPSE_CONFIG_PATH=/data/homeserver.yaml +exec /start.py "$@" diff --git a/infrastructure/matrix/synapse/homeserver.yaml.tmpl b/infrastructure/matrix/synapse/homeserver.yaml.tmpl new file mode 100644 index 000000000..2f7d59502 --- /dev/null +++ b/infrastructure/matrix/synapse/homeserver.yaml.tmpl @@ -0,0 +1,95 @@ +# EDR internal chat — Synapse homeserver config. +# +# Rendered to /data/homeserver.yaml at container start by docker-entrypoint.sh +# (envsubst over this template) so secrets come from the runtime env file, +# never baked into the image — same convention as freight-api's .env. +# +# server_name is PERMANENT: it is baked into every user id and event and +# cannot change without wiping the server. Do not repoint this at a +# different value after go-live. +server_name: "${MATRIX_SERVER_NAME}" +public_baseurl: "${MATRIX_PUBLIC_BASEURL}" +pid_file: /data/homeserver.pid + +listeners: + - port: 8008 + tls: false + type: http + x_forwarded: true + resources: + - names: [client, federation] + compress: false + +database: + name: psycopg2 + args: + user: "${MATRIX_DB_USER}" + password: "${MATRIX_DB_PASSWORD}" + dbname: "${MATRIX_DB_NAME}" + host: "${MATRIX_DB_HOST}" + port: ${MATRIX_DB_PORT} + cp_min: 5 + cp_max: 10 + +media_store_path: /data/media_store +max_upload_size: 50M + +log_config: "/synapse/log.config" + +# Internal comms tool: no federation, no open registration, no E2EE-by-default. +# ponytail: E2EE off — turn on per-room (HR/legal) if compliance asks. +federation_domain_whitelist: [] +enable_registration: false +encryption_enabled_by_default_for_room_type: "off" + +# Employees authenticate via freight-api's SSO handoff, never a Matrix +# password prompt. This is the entire auth story for this deployment. +password_config: + enabled: false + +jwt_config: + enabled: true + secret: "${MATRIX_JWT_SECRET}" + algorithm: "HS256" + issuer: "edr-freight-api" + audiences: ["matrix"] + # Matches the `name` claim chat-sso.service.ts puts in the JWT — only read + # on first login (auto-registration), never updates it on later logins. + display_name_claim: "name" + +# Consumes the login_token minted by freight-api's SSO endpoint via +# POST /_matrix/client/v1/login/get_token (issued against an existing, +# already-JWT-authenticated session — not a bare password grant). +login_via_existing_session: + enabled: true + require_ui_auth: false + token_timeout: 5m + +# Bootstrap-only: used once by ops to register the first admin account +# (register_new_matrix_user against /_synapse/admin/v1/register), whose +# access token becomes MATRIX_ADMIN_TOKEN for freight-api's provisioning +# service. Rotate/remove after bootstrap if desired — nothing else depends +# on shared-secret registration once the admin account exists. +registration_shared_secret: "${MATRIX_REGISTRATION_SHARED_SECRET}" + +trusted_key_servers: [] +suppress_key_server_warning: true + +report_stats: false + +# Synapse's default rc_login is sized to defend against internet-facing +# password brute-forcing. That threat doesn't exist on this deployment — +# password login is off (see password_config above), and the only path in +# requires a freight-api-signed JWT — so the default is mostly just +# punishing legitimate rapid logins from the same office/NAT IP or normal +# page-refresh retries. Loosened, not disabled, to keep some ceiling. +rc_login: + address: + per_second: 100 + burst_count: 200 + account: + per_second: 100 + burst_count: 200 + failed_attempts: + per_second: 100 + burst_count: 200 diff --git a/infrastructure/matrix/synapse/log.config b/infrastructure/matrix/synapse/log.config new file mode 100644 index 000000000..0894e974f --- /dev/null +++ b/infrastructure/matrix/synapse/log.config @@ -0,0 +1,25 @@ +# Log straight to stdout — the container runtime (docker compose logs / the +# self-hosted runner's log collection) owns rotation and retention, matching +# how every other app container in this repo logs. +version: 1 + +formatters: + precise: + format: "%(asctime)s - %(name)s - %(lineno)d - %(levelname)s - %(message)s" + +handlers: + console: + class: logging.StreamHandler + formatter: precise + stream: ext://sys.stdout + +loggers: + synapse.storage.SQL: + # SQL queries are DEBUG-only noise; leave at INFO unless diagnosing. + level: INFO + +root: + level: INFO + handlers: [console] + +disable_existing_loggers: false diff --git a/scripts/deploy/sync-env-from-server.sh b/scripts/deploy/sync-env-from-server.sh index 69f7eef56..1e9a1ff52 100644 --- a/scripts/deploy/sync-env-from-server.sh +++ b/scripts/deploy/sync-env-from-server.sh @@ -31,6 +31,11 @@ declare -A SERVICE_ENV_TARGET=( ["passenger-portal"]="apps/edr-passenger-web/portal/.env" ["passenger-backoffice"]="apps/edr-passenger-web/backoffice/.env" ["payment-api"]="apps/edr-payment-api/.env" + ["synapse"]="infrastructure/matrix/synapse/.env" + # element-web has no runtime secrets (its config.json is baked into the + # image), but the sync step still runs unconditionally per service and + # needs a PORT= line to compute ELEMENT_WEB_PORT for docker compose. + ["element-web"]="infrastructure/matrix/element/.env" ) for service in "$@"; do From 4a4b1981cb39042d7c5797d65f5bbaac294a04a7 Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Mon, 17 Aug 2026 09:14:38 +0000 Subject: [PATCH 11/43] feat(eims): allow private key/cert as inline base64 env vars MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit EIMS_PRIVATE_KEY_BASE64 / EIMS_CERTIFICATE_BASE64, alternative to the existing _PATH vars. Wins over the path when set; falls back to the file otherwise. Neither var required at boot on its own — the either/or check moved out of the flat REQUIRED_VARS list. Lets a dockerized deployment receive the key/cert the same way it already receives every other EIMS_* secret (plain env var into the container) instead of needing a host bind mount into the container filesystem. --- .../edr-freight-api/src/config/eims.config.ts | 31 ++++++++++++----- .../modules/eims/eims-credentials.provider.ts | 33 ++++++++++++++----- .../modules/eims/eims-signer.service.spec.ts | 26 +++++++++++++-- .../src/modules/eims/eims-test-fixtures.ts | 2 ++ 4 files changed, 72 insertions(+), 20 deletions(-) diff --git a/apps/edr-freight-api/src/config/eims.config.ts b/apps/edr-freight-api/src/config/eims.config.ts index c5565cbc1..7091c52ef 100644 --- a/apps/edr-freight-api/src/config/eims.config.ts +++ b/apps/edr-freight-api/src/config/eims.config.ts @@ -30,6 +30,14 @@ export interface EimsConfig { privateKeyPath: string; /** Filesystem path to the INSA-issued certificate bundle; sent as base64 of its exact bytes. */ certificatePath: string; + /** + * Inline alternative to `privateKeyPath` — the key file's own bytes, base64-encoded, so a + * container that can't be given a host bind mount can still receive it as a plain env var. + * Takes precedence over the path when set. Either one must be present when EIMS is enabled. + */ + privateKeyBase64: string; + /** Inline alternative to `certificatePath`, same precedence rule. */ + certificateBase64: string; httpTimeoutMs: number; /** Re-authenticate this many ms before the access token actually expires. */ tokenSkewMs: number; @@ -135,14 +143,14 @@ export interface EimsInvoiceConfig { buyerIdNumber: string | null; } -const REQUIRED_VARS = [ - "EIMS_CLIENT_ID", - "EIMS_CLIENT_SECRET", - "EIMS_API_KEY", - "EIMS_TIN", - "EIMS_PRIVATE_KEY_PATH", - "EIMS_CERTIFICATE_PATH", -] as const; +const REQUIRED_VARS = ["EIMS_CLIENT_ID", "EIMS_CLIENT_SECRET", "EIMS_API_KEY", "EIMS_TIN"] as const; + +// Key/cert each have two ways in (file path or inline base64) — checked separately from +// REQUIRED_VARS since it's "at least one of", not "this exact var". +const REQUIRED_EITHER_OR: Array<[string, string]> = [ + ["EIMS_PRIVATE_KEY_PATH", "EIMS_PRIVATE_KEY_BASE64"], + ["EIMS_CERTIFICATE_PATH", "EIMS_CERTIFICATE_BASE64"], +]; const positiveInt = (raw: string | undefined, fallback: number, name: string): number => { if (raw === undefined || raw === "") return fallback; @@ -189,6 +197,8 @@ export default registerAs("eims", (): EimsConfig => { systemType: process.env.EIMS_SYSTEM_TYPE ?? "", privateKeyPath: process.env.EIMS_PRIVATE_KEY_PATH ?? "", certificatePath: process.env.EIMS_CERTIFICATE_PATH ?? "", + privateKeyBase64: process.env.EIMS_PRIVATE_KEY_BASE64 ?? "", + certificateBase64: process.env.EIMS_CERTIFICATE_BASE64 ?? "", httpTimeoutMs, tokenSkewMs, autoSubmit: (process.env.EIMS_AUTO_SUBMIT ?? "false").toLowerCase() === "true", @@ -245,7 +255,10 @@ export default registerAs("eims", (): EimsConfig => { if (!enabled) return base; - const missing = REQUIRED_VARS.filter((name) => !process.env[name]); + const missing: string[] = REQUIRED_VARS.filter((name) => !process.env[name]); + for (const [pathVar, base64Var] of REQUIRED_EITHER_OR) { + if (!process.env[pathVar] && !process.env[base64Var]) missing.push(`${pathVar} or ${base64Var}`); + } if (missing.length > 0) { throw new Error( `EIMS integration is enabled (EIMS_ENABLED=true) but the following env vars are missing: ${missing.join(", ")}`, 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 index b68a4a32f..18725df37 100644 --- a/apps/edr-freight-api/src/modules/eims/eims-credentials.provider.ts +++ b/apps/edr-freight-api/src/modules/eims/eims-credentials.provider.ts @@ -24,25 +24,31 @@ export class EimsCredentialsProvider { return this.config.get("eims")!; } - /** RSA private key, parsed once. Throws a config error if the path is missing or unusable. */ + /** + * RSA private key, parsed once. `privateKeyBase64` wins when set (no file I/O at all — for a + * container that can't be given a host bind mount); otherwise falls back to `privateKeyPath`. + * Throws a config error if neither is usable. + */ 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"); + const { privateKeyBase64, privateKeyPath: path } = this.cfg; + const source = privateKeyBase64 ? "EIMS_PRIVATE_KEY_BASE64" : `EIMS_PRIVATE_KEY_PATH (${path})`; + if (!privateKeyBase64 && !path) throw new EimsConfigException("EIMS_PRIVATE_KEY_PATH is not set"); let key: KeyObject; try { - key = createPrivateKey(readFileSync(path)); + const bytes = privateKeyBase64 ? Buffer.from(privateKeyBase64, "base64") : readFileSync(path); + key = createPrivateKey(bytes); } catch (err) { - // The path is operational information, not a secret; the key material never appears. + // The source 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}`, + `EIMS private key from ${source} 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`, + `EIMS private key from ${source} is ${key.asymmetricKeyType ?? "of unknown type"}; EIMS requires RSA`, ); } @@ -51,11 +57,20 @@ export class EimsCredentialsProvider { return key; } - /** Base64 of the certificate file's exact bytes. No parsing, no re-encoding. */ + /** + * Base64 of the certificate file's exact bytes. No parsing, no re-encoding. `certificateBase64` + * config wins when set (already base64, used as-is); otherwise read from `certificatePath`. + */ getCertificateBase64(): string { if (this.certificateBase64) return this.certificateBase64; - const path = this.cfg.certificatePath; + const { certificateBase64: inline, certificatePath: path } = this.cfg; + if (inline) { + this.certificateBase64 = inline; + this.logger.log(`EIMS certificate bundle loaded from EIMS_CERTIFICATE_BASE64`); + return this.certificateBase64; + } + if (!path) throw new EimsConfigException("EIMS_CERTIFICATE_PATH is not set"); let bytes: Buffer; 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 index 5e408ddf7..d2f190172 100644 --- 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 @@ -97,8 +97,12 @@ describe("EimsSignerService", () => { }); describe("EimsCredentialsProvider", () => { - const providerFor = (paths: { privateKeyPath?: string; certificatePath?: string }) => - new EimsCredentialsProvider({ get: () => paths } as unknown as ConfigService); + const providerFor = (cfg: { + privateKeyPath?: string; + certificatePath?: string; + privateKeyBase64?: string; + certificateBase64?: string; + }) => new EimsCredentialsProvider({ get: () => cfg } as unknown as ConfigService); it("fails clearly when the key path is unset", () => { expect(() => providerFor({}).getPrivateKey()).toThrow(/EIMS_PRIVATE_KEY_PATH is not set/); @@ -115,4 +119,22 @@ describe("EimsCredentialsProvider", () => { writeFileSync(emptyPath, ""); expect(() => providerFor({ certificatePath: emptyPath }).getCertificateBase64()).toThrow(/is empty/); }); + + it("loads the key from inline base64, no file involved", () => { + const keyBase64 = readFileSync(keyPath).toString("base64"); + const key = providerFor({ privateKeyBase64: keyBase64 }).getPrivateKey(); + expect(key.asymmetricKeyType).toBe("rsa"); + }); + + it("prefers inline base64 over the path when both are set", () => { + const keyBase64 = readFileSync(keyPath).toString("base64"); + // A path that would fail if it were ever actually read. + const key = providerFor({ privateKeyBase64: keyBase64, privateKeyPath: join(dir, "nope.key") }).getPrivateKey(); + expect(key.asymmetricKeyType).toBe("rsa"); + }); + + it("loads the certificate from inline base64 as-is, no re-encoding", () => { + const certBase64 = Buffer.from(CERTIFICATE_FIXTURE, "utf8").toString("base64"); + expect(providerFor({ certificateBase64: certBase64 }).getCertificateBase64()).toBe(certBase64); + }); }); 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 650a834b8..662436d50 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 @@ -59,6 +59,8 @@ export const eimsConfig = (over: Partial = {}): EimsConfig => ({ systemType: EIMS_SYSTEM_TYPE, privateKeyPath: "/dev/null", certificatePath: "/dev/null", + privateKeyBase64: "", + certificateBase64: "", httpTimeoutMs: 30_000, tokenSkewMs: 45_000, autoSubmit: false, From 13f7bea590b914acd91b484c8b0b4dc4f52bd5df Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Mon, 17 Aug 2026 09:14:38 +0000 Subject: [PATCH 12/43] feat(eims): accept private key/cert as raw PEM env vars MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit EIMS_PRIVATE_KEY / EIMS_CERTIFICATE — the PEM text pasted directly, no encode/decode step at all. Precedence: raw PEM > base64 > path. Motivated by the base64 path hitting a DECODER::unsupported error in a live deployment with no way to tell whether the cause was transport truncation, double-encoding, or an actually-bad file. Two fixes for that class of problem together: - the raw-PEM var removes the encode/decode step entirely, so there's nothing left to corrupt in transit - a literal \\n (two chars) is unescaped to a real newline, for env stores that can't hold a literal line break - getPrivateKey() now checks the decoded bytes look like a PEM header before handing them to OpenSSL, so a still-bad value fails with byte count + safe preview instead of an opaque decoder error --- .../src/config/eims.config.spec.ts | 72 +++++++++++++++++++ .../edr-freight-api/src/config/eims.config.ts | 37 +++++++--- .../modules/eims/eims-credentials.provider.ts | 67 ++++++++++++++--- .../modules/eims/eims-signer.service.spec.ts | 32 +++++++++ .../src/modules/eims/eims-test-fixtures.ts | 2 + 5 files changed, 191 insertions(+), 19 deletions(-) create mode 100644 apps/edr-freight-api/src/config/eims.config.spec.ts diff --git a/apps/edr-freight-api/src/config/eims.config.spec.ts b/apps/edr-freight-api/src/config/eims.config.spec.ts new file mode 100644 index 000000000..a6aa3895d --- /dev/null +++ b/apps/edr-freight-api/src/config/eims.config.spec.ts @@ -0,0 +1,72 @@ +import eimsConfigFactory from "./eims.config"; + +const REQUIRED = { + EIMS_ENABLED: "true", + EIMS_CLIENT_ID: "cid", + EIMS_CLIENT_SECRET: "secret", + EIMS_API_KEY: "apikey", + EIMS_TIN: "0000000000", +}; + +const withEnv = (vars: Record, fn: () => void) => { + const prior: Record = {}; + for (const [key, value] of Object.entries(vars)) { + prior[key] = process.env[key]; + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + try { + fn(); + } finally { + for (const [key, value] of Object.entries(prior)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + } +}; + +describe("eims.config — private key / certificate resolution", () => { + it("unescapes a literal \\n when the PEM was pasted without real newlines", () => { + withEnv( + { ...REQUIRED, EIMS_PRIVATE_KEY: "line1\\nline2", EIMS_CERTIFICATE_PATH: "/dev/null" }, + () => { + expect(eimsConfigFactory().privateKeyPem).toBe("line1\nline2"); + }, + ); + }); + + it("leaves a PEM with real newlines untouched", () => { + withEnv( + { ...REQUIRED, EIMS_PRIVATE_KEY: "line1\nline2", EIMS_CERTIFICATE_PATH: "/dev/null" }, + () => { + expect(eimsConfigFactory().privateKeyPem).toBe("line1\nline2"); + }, + ); + }); + + it("throws naming all three key/cert options when none are set", () => { + withEnv( + { + ...REQUIRED, + EIMS_PRIVATE_KEY_PATH: undefined, + EIMS_PRIVATE_KEY_BASE64: undefined, + EIMS_PRIVATE_KEY: undefined, + EIMS_CERTIFICATE_PATH: "/dev/null", + }, + () => { + expect(() => eimsConfigFactory()).toThrow( + /EIMS_PRIVATE_KEY_PATH or EIMS_PRIVATE_KEY_BASE64 or EIMS_PRIVATE_KEY/, + ); + }, + ); + }); + + it("is satisfied by any single one of the three key options", () => { + withEnv( + { ...REQUIRED, EIMS_PRIVATE_KEY: "x", EIMS_CERTIFICATE_PATH: "/dev/null" }, + () => { + expect(() => eimsConfigFactory()).not.toThrow(); + }, + ); + }); +}); diff --git a/apps/edr-freight-api/src/config/eims.config.ts b/apps/edr-freight-api/src/config/eims.config.ts index 7091c52ef..9d99ae455 100644 --- a/apps/edr-freight-api/src/config/eims.config.ts +++ b/apps/edr-freight-api/src/config/eims.config.ts @@ -33,11 +33,20 @@ export interface EimsConfig { /** * Inline alternative to `privateKeyPath` — the key file's own bytes, base64-encoded, so a * container that can't be given a host bind mount can still receive it as a plain env var. - * Takes precedence over the path when set. Either one must be present when EIMS is enabled. + * Either one must be present when EIMS is enabled. Precedence: `privateKeyPem` > `privateKeyBase64` + * > `privateKeyPath`. */ privateKeyBase64: string; - /** Inline alternative to `certificatePath`, same precedence rule. */ + /** Inline alternative to `certificatePath`, same precedence rule as the key. */ certificateBase64: string; + /** + * The PEM key pasted directly into the env var, no encoding step at all — the most direct of the + * three inline forms, and the hardest for a broken transport step to mangle since there's no + * decode stage to get wrong. Wins over `privateKeyBase64`/`privateKeyPath` when set. + */ + privateKeyPem: string; + /** Inline alternative to `certificateBase64`, same precedence rule. */ + certificatePem: string; httpTimeoutMs: number; /** Re-authenticate this many ms before the access token actually expires. */ tokenSkewMs: number; @@ -145,11 +154,11 @@ export interface EimsInvoiceConfig { const REQUIRED_VARS = ["EIMS_CLIENT_ID", "EIMS_CLIENT_SECRET", "EIMS_API_KEY", "EIMS_TIN"] as const; -// Key/cert each have two ways in (file path or inline base64) — checked separately from -// REQUIRED_VARS since it's "at least one of", not "this exact var". -const REQUIRED_EITHER_OR: Array<[string, string]> = [ - ["EIMS_PRIVATE_KEY_PATH", "EIMS_PRIVATE_KEY_BASE64"], - ["EIMS_CERTIFICATE_PATH", "EIMS_CERTIFICATE_BASE64"], +// Key/cert each have three ways in (file path, inline base64, or raw PEM) — checked separately +// from REQUIRED_VARS since it's "at least one of", not "this exact var". +const REQUIRED_ANY_OF: string[][] = [ + ["EIMS_PRIVATE_KEY_PATH", "EIMS_PRIVATE_KEY_BASE64", "EIMS_PRIVATE_KEY"], + ["EIMS_CERTIFICATE_PATH", "EIMS_CERTIFICATE_BASE64", "EIMS_CERTIFICATE"], ]; const positiveInt = (raw: string | undefined, fallback: number, name: string): number => { @@ -171,6 +180,14 @@ const parseCodeMap = (raw: string | undefined): Record => { return map; }; +// Some env stores (single-line .env files, certain secret managers) can't hold a literal newline +// and expect the caller to write "\n" as two characters instead. If the raw value already has a +// real newline, leave it alone; otherwise unescape "\n" so a PEM pasted that way still parses. +const normalizePem = (raw: string | undefined): string => { + if (!raw) return ""; + return raw.includes("\n") ? raw : raw.replace(/\\n/g, "\n"); +}; + /** 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; @@ -198,6 +215,8 @@ export default registerAs("eims", (): EimsConfig => { privateKeyPath: process.env.EIMS_PRIVATE_KEY_PATH ?? "", certificatePath: process.env.EIMS_CERTIFICATE_PATH ?? "", privateKeyBase64: process.env.EIMS_PRIVATE_KEY_BASE64 ?? "", + privateKeyPem: normalizePem(process.env.EIMS_PRIVATE_KEY), + certificatePem: normalizePem(process.env.EIMS_CERTIFICATE), certificateBase64: process.env.EIMS_CERTIFICATE_BASE64 ?? "", httpTimeoutMs, tokenSkewMs, @@ -256,8 +275,8 @@ export default registerAs("eims", (): EimsConfig => { if (!enabled) return base; const missing: string[] = REQUIRED_VARS.filter((name) => !process.env[name]); - for (const [pathVar, base64Var] of REQUIRED_EITHER_OR) { - if (!process.env[pathVar] && !process.env[base64Var]) missing.push(`${pathVar} or ${base64Var}`); + for (const vars of REQUIRED_ANY_OF) { + if (vars.every((name) => !process.env[name])) missing.push(vars.join(" or ")); } if (missing.length > 0) { throw new Error( 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 index 18725df37..a5f837c1e 100644 --- a/apps/edr-freight-api/src/modules/eims/eims-credentials.provider.ts +++ b/apps/edr-freight-api/src/modules/eims/eims-credentials.provider.ts @@ -5,6 +5,17 @@ import { ConfigService } from "@nestjs/config"; import { EimsConfig } from "../../config/eims.config"; import { EimsConfigException } from "./eims.errors"; +const PEM_HEADER = /-----BEGIN [A-Z ]*(PRIVATE KEY|CERTIFICATE)-----/; + +/** + * A safe-to-log fingerprint of decoded key/cert bytes: length + a printable-only preview of the + * first line. Never the actual key material — PEM headers aren't secret, the base64 body is. + */ +const describeBytes = (bytes: Buffer): string => { + const preview = bytes.toString("utf8", 0, 40).replace(/[^\x20-\x7e]/g, "?"); + return `${bytes.length} bytes, starts with "${preview}"`; +}; + /** * Loads the INSA-issued EIMS credentials from disk, once, and keeps them in memory. * @@ -25,20 +36,50 @@ export class EimsCredentialsProvider { } /** - * RSA private key, parsed once. `privateKeyBase64` wins when set (no file I/O at all — for a - * container that can't be given a host bind mount); otherwise falls back to `privateKeyPath`. - * Throws a config error if neither is usable. + * RSA private key, parsed once. Three ways in, checked in this order: `privateKeyPem` (the PEM + * text itself, no encoding step to get wrong), `privateKeyBase64` (for stores that can't hold a + * literal newline), `privateKeyPath` (the original file-on-disk form). Throws a config error if + * none is usable. */ getPrivateKey(): KeyObject { if (this.privateKey) return this.privateKey; - const { privateKeyBase64, privateKeyPath: path } = this.cfg; - const source = privateKeyBase64 ? "EIMS_PRIVATE_KEY_BASE64" : `EIMS_PRIVATE_KEY_PATH (${path})`; - if (!privateKeyBase64 && !path) throw new EimsConfigException("EIMS_PRIVATE_KEY_PATH is not set"); + const { privateKeyPem, privateKeyBase64, privateKeyPath: path } = this.cfg; + const source = privateKeyPem + ? "EIMS_PRIVATE_KEY" + : privateKeyBase64 + ? "EIMS_PRIVATE_KEY_BASE64" + : `EIMS_PRIVATE_KEY_PATH (${path})`; + if (!privateKeyPem && !privateKeyBase64 && !path) { + throw new EimsConfigException("EIMS_PRIVATE_KEY_PATH is not set"); + } + + let bytes: Buffer; + try { + bytes = privateKeyPem + ? Buffer.from(privateKeyPem, "utf8") + : privateKeyBase64 + ? Buffer.from(privateKeyBase64, "base64") + : readFileSync(path); + } catch (err) { + throw new EimsConfigException( + `EIMS private key from ${source} could not be read or parsed: ${(err as Error).message}`, + ); + } + + // Fail with a diagnosable message before handing possibly-garbled bytes to OpenSSL, whose own + // error ("unsupported") gives no hint whether the problem is truncation, double-encoding, or a + // genuinely wrong file — all indistinguishable from outside without seeing the decoded bytes. + if (!PEM_HEADER.test(bytes.toString("utf8", 0, 100))) { + throw new EimsConfigException( + `EIMS private key from ${source} does not look like a PEM key after decoding ` + + `(${describeBytes(bytes)}) — check it's base64 of the raw key file with no line-wrapping ` + + `or truncation, and not base64 applied twice.`, + ); + } let key: KeyObject; try { - const bytes = privateKeyBase64 ? Buffer.from(privateKeyBase64, "base64") : readFileSync(path); key = createPrivateKey(bytes); } catch (err) { // The source is operational information, not a secret; the key material never appears. @@ -58,13 +99,19 @@ export class EimsCredentialsProvider { } /** - * Base64 of the certificate file's exact bytes. No parsing, no re-encoding. `certificateBase64` - * config wins when set (already base64, used as-is); otherwise read from `certificatePath`. + * Base64 of the certificate file's exact bytes. No parsing, no re-encoding of what MoR issued. + * `certificatePem`/`certificateBase64` config win when set (used as-is, or re-encoded from the + * pasted text respectively); otherwise read from `certificatePath`. */ getCertificateBase64(): string { if (this.certificateBase64) return this.certificateBase64; - const { certificateBase64: inline, certificatePath: path } = this.cfg; + const { certificatePem: pem, certificateBase64: inline, certificatePath: path } = this.cfg; + if (pem) { + this.certificateBase64 = Buffer.from(pem, "utf8").toString("base64"); + this.logger.log(`EIMS certificate bundle loaded from EIMS_CERTIFICATE`); + return this.certificateBase64; + } if (inline) { this.certificateBase64 = inline; this.logger.log(`EIMS certificate bundle loaded from EIMS_CERTIFICATE_BASE64`); 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 index d2f190172..f8210cab6 100644 --- 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 @@ -102,6 +102,8 @@ describe("EimsCredentialsProvider", () => { certificatePath?: string; privateKeyBase64?: string; certificateBase64?: string; + privateKeyPem?: string; + certificatePem?: string; }) => new EimsCredentialsProvider({ get: () => cfg } as unknown as ConfigService); it("fails clearly when the key path is unset", () => { @@ -137,4 +139,34 @@ describe("EimsCredentialsProvider", () => { const certBase64 = Buffer.from(CERTIFICATE_FIXTURE, "utf8").toString("base64"); expect(providerFor({ certificateBase64: certBase64 }).getCertificateBase64()).toBe(certBase64); }); + + it("fails with a decoded-bytes preview when the base64 doesn't decode to a PEM key", () => { + // Simulates the real failure this guards against: a truncated/mangled env var still decodes + // as *some* bytes, but not a key — OpenSSL's own error here gives no hint why. + const notAKey = Buffer.from("not actually a pem file", "utf8").toString("base64"); + expect(() => providerFor({ privateKeyBase64: notAKey }).getPrivateKey()).toThrow( + /does not look like a PEM key.*23 bytes, starts with "not actually a pem file"/s, + ); + }); + + it("loads the key from the raw PEM env var directly, no encoding step", () => { + const pem = readFileSync(keyPath).toString("utf8"); + const key = providerFor({ privateKeyPem: pem }).getPrivateKey(); + expect(key.asymmetricKeyType).toBe("rsa"); + }); + + it("prefers the raw PEM var over base64 and path when all three are set", () => { + const pem = readFileSync(keyPath).toString("utf8"); + const key = providerFor({ + privateKeyPem: pem, + privateKeyBase64: Buffer.from("garbage").toString("base64"), + privateKeyPath: join(dir, "nope.key"), + }).getPrivateKey(); + expect(key.asymmetricKeyType).toBe("rsa"); + }); + + it("loads the certificate from the raw PEM env var, re-encoded to base64", () => { + const base64 = providerFor({ certificatePem: CERTIFICATE_FIXTURE }).getCertificateBase64(); + expect(base64).toBe(Buffer.from(CERTIFICATE_FIXTURE, "utf8").toString("base64")); + }); }); 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 662436d50..a55951db4 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 @@ -61,6 +61,8 @@ export const eimsConfig = (over: Partial = {}): EimsConfig => ({ certificatePath: "/dev/null", privateKeyBase64: "", certificateBase64: "", + privateKeyPem: "", + certificatePem: "", httpTimeoutMs: 30_000, tokenSkewMs: 45_000, autoSubmit: false, From c04859d333f94ecd1c709b4acb07ade326599103 Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Mon, 17 Aug 2026 11:02:11 +0000 Subject: [PATCH 13/43] fix(warehouses): add container size to standalone container-return form MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend already accepted containerSize ('20'|'40') on CreateEmptyContainerReturnDto and enforces one-40ft-or-two-20ft-per-wagon via assertWagonLoad — the Standalone Return modal just never collected it. Add a Container Type select and wire it into the submit payload. --- .../src/pages/warehouses/ContainerReturnsPage.tsx | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/apps/edr-freight-web/backoffice/src/pages/warehouses/ContainerReturnsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/warehouses/ContainerReturnsPage.tsx index 0510ca997..685e7d2a3 100644 --- a/apps/edr-freight-web/backoffice/src/pages/warehouses/ContainerReturnsPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/warehouses/ContainerReturnsPage.tsx @@ -961,6 +961,7 @@ interface StandaloneReturnModalProps { function StandaloneReturnModal({ opened, onClose, onSubmit, loading }: StandaloneReturnModalProps) { const [containerNumber, setContainerNumber] = useState(""); + const [containerSize, setContainerSize] = useState(null); const [returnedBy, setReturnedBy] = useState<"EDR" | "CUSTOMER" | null>(null); const [returnDate, setReturnDate] = useState(new Date().toISOString().split("T")[0]); const [warehouse, setWarehouse] = useState(null); @@ -1021,6 +1022,7 @@ function StandaloneReturnModal({ opened, onClose, onSubmit, loading }: Standalon containers: [ { containerNumber, + containerSize: containerSize ?? undefined, returnDate, warehouse: selectedWarehouse?.name || warehouse, yard: selectedYard?.name, @@ -1034,6 +1036,7 @@ function StandaloneReturnModal({ opened, onClose, onSubmit, loading }: Standalon }); setContainerNumber(""); + setContainerSize(null); setReturnedBy(null); setReturnDate(new Date().toISOString().split("T")[0]); setWarehouse(null); @@ -1071,6 +1074,17 @@ function StandaloneReturnModal({ opened, onClose, onSubmit, loading }: Standalon required /> + Date: Mon, 17 Aug 2026 11:21:39 +0000 Subject: [PATCH 14/43] fix(eims): a config error before signing must not block the whole system MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit settleFailure() treated any non-EimsApiException error as ambiguous ("might have reached MoR") and permanently blocked all further filing until manually resolved. EimsConfigException (bad/missing key, unparseable cert) is thrown by EimsSignerService before EimsClientService.send()'s try/catch is even entered — by construction it never reached the wire, so there is nothing ambiguous about it. This is exactly what happened live: a private-key parse failure during the key/cert migration work reserved a counter, failed before any HTTP call, and got treated as an unresolved in-flight submission — blocking every other invoice from filing until someone manually POSTs /eims/resolve. Fix: EimsConfigException is now deterministic in settleFailure, same treatment as a clean MoR rejection — both counters roll back, no system-wide block, invoice marked FAILED (not UNKNOWN). Added a CONFIG failure kind so the invoice's eimsLastError and the staff alert both say plainly that the request never reached MoR, instead of implying a MoR rejection. --- .../eims-invoice-registration.service.spec.ts | 25 ++++++++++++++++++- .../eims/eims-invoice-registration.service.ts | 23 +++++++++++++---- .../src/modules/eims/eims.errors.ts | 3 ++- 3 files changed, 44 insertions(+), 7 deletions(-) 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 79d3204dd..5fc74f988 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 @@ -10,7 +10,7 @@ import { NotificationInboxService } from "../notification-inbox/notification-inb import { NotificationsService } from "../notifications/notifications.service"; import { EimsAuthService } from "./eims-auth.service"; import { EimsClientService } from "./eims-client.service"; -import { EimsApiException } from "./eims.errors"; +import { EimsApiException, EimsConfigException } from "./eims.errors"; import { buildEimsSeller } from "./eims-invoice-context"; import { EimsInvoiceRegistrationService } from "./eims-invoice-registration.service"; import { EimsSellerCacheService } from "./eims-seller-cache.service"; @@ -479,6 +479,29 @@ describe("EimsInvoiceRegistrationService.registerInvoiceWithEims", () => { }); }); + it("a config error (bad key, never reached MoR) rolls back both counters, no system block", async () => { + const db = new FakeDb([invoiceRow()]); + const postSigned = jest + .fn() + .mockRejectedValue(new EimsConfigException("EIMS private key ... could not be read or parsed")); + + await expect(build(db, postSigned).registerInvoiceWithEims(INVOICE_ID)).rejects.toBeInstanceOf( + EimsConfigException, + ); + + expect(db.invoices.get(INVOICE_ID)).toMatchObject({ + eimsStatus: EimsInvoiceStatus.Failed, + eimsIrn: null, + eimsLastError: expect.objectContaining({ kind: "CONFIG" }), + }); + expect(db.state).toMatchObject({ + inFlightInvoiceId: null, + blockedReason: null, + previousIrn: null, + nextInvoiceCounter: 7, + }); + }); + 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: "" } }); 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 2ca5f5ffd..d96c64947 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 @@ -26,7 +26,7 @@ import { toEimsInvoiceStatusView } from "./eims-invoice-view.util"; import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry"; import { EimsAuthService } from "./eims-auth.service"; import { EimsClientService } from "./eims-client.service"; -import { EimsApiException } from "./eims.errors"; +import { EimsApiException, EimsConfigException } from "./eims.errors"; import { EimsSellerCacheService } from "./eims-seller-cache.service"; import { EimsSystemState } from "./entities/eims-system-state.entity"; import { assertEimsInvoiceConfig, buildEimsContext } from "./eims-invoice-context"; @@ -469,6 +469,14 @@ export class EimsInvoiceRegistrationService { * when two rejected self-test attempts deadlocked the sequence until a manual DB reset. * * An ambiguous result keeps both: MoR may have counted and stored the document. + * + * `EimsConfigException` is also deterministic, for a different reason: it's thrown by + * `EimsSignerService`/`EimsCredentialsProvider` *before* `EimsClientService.send()`'s own + * try/catch is even entered (see `send()` — signing happens above its try block), so by + * construction no HTTP call was ever made. There is nothing to be ambiguous about — a bad key or + * missing config can't have reached MoR. Every error that *did* touch the wire is normalized to + * `EimsApiException` before it gets here (`toEimsApiException`), so this check is exhaustive: + * config errors are the only other kind `submit()` can throw. */ private async settleFailure( invoiceId: string, @@ -476,10 +484,11 @@ export class EimsInvoiceRegistrationService { err: unknown, ): Promise { const api = err instanceof EimsApiException ? err : null; - const deterministic = api ? DETERMINISTIC_KINDS.has(api.kind) : false; + const isConfigError = err instanceof EimsConfigException; + const deterministic = isConfigError || (api ? DETERMINISTIC_KINDS.has(api.kind) : false); const status = deterministic ? EimsInvoiceStatus.Failed : EimsInvoiceStatus.Unknown; const lastError: EimsInvoiceError = { - kind: api?.kind ?? "UNKNOWN", + kind: isConfigError ? "CONFIG" : (api?.kind ?? "UNKNOWN"), message: (err as Error)?.message ?? "unknown error", httpStatus: api?.httpStatus, details: api?.details, @@ -586,10 +595,14 @@ export class EimsInvoiceRegistrationService { type: NotificationType.GENERIC, priority: deterministic ? NotificationPriority.NORMAL : NotificationPriority.HIGH, title: deterministic - ? "EIMS rejected an invoice" + ? error.kind === "CONFIG" + ? "EIMS filing failed before reaching MoR" + : "EIMS rejected an invoice" : "EIMS filing unresolved — all further filing is blocked", body: deterministic - ? `MoR rejected the filing (${error.kind}): ${error.message}. The invoice is marked FAILED; correct it and file again.` + ? error.kind === "CONFIG" + ? `EIMS is misconfigured: ${error.message}. Nothing was sent to MoR; fix the config and file again.` + : `MoR rejected the filing (${error.kind}): ${error.message}. The invoice is marked FAILED; correct it and file again.` : `A submission was sent but never acknowledged (${error.kind}). Its IRN is unknown, so no further invoice can be filed until it is resolved with MoR.`, link: `/dashboard/invoices/${invoiceId}`, data: { invoiceId, eimsStatus: status, kind: error.kind, action: "EIMS_FILING_FAILED" }, diff --git a/apps/edr-freight-api/src/modules/eims/eims.errors.ts b/apps/edr-freight-api/src/modules/eims/eims.errors.ts index 3be21fdd3..75c513824 100644 --- a/apps/edr-freight-api/src/modules/eims/eims.errors.ts +++ b/apps/edr-freight-api/src/modules/eims/eims.errors.ts @@ -10,7 +10,8 @@ export type EimsFailureKind = | "FORBIDDEN" | "RULE_VALIDATION" | "SERVER" - | "UNKNOWN"; + | "UNKNOWN" + | "CONFIG"; /** Raised when EIMS is disabled or its credential files are unusable. */ export class EimsConfigException extends ServiceUnavailableException { From 0999bc0b8ba177f357c7485508a5389252d3f0cd Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Mon, 17 Aug 2026 11:53:12 +0000 Subject: [PATCH 15/43] fix(eims): a mapper failure after reservation also orphaned the block MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit toEimsInvoice/buildEimsContext sat outside the try/catch that calls settleFailure — reservation happens (TX1), then request-building ran unguarded, then submit() was the only thing actually wrapped. Any exception during mapping (a validation error like an unmapped buyer country, or a bug) skipped settleFailure entirely and left the reservation permanently held: exactly the live incident just seen — register 500'd, and every subsequent attempt on any invoice 409'd 'already in flight' until manually resolved. Fix: the try block now starts right after reserve(), covering request-building and submit() both. settleFailure's determinism check is generalized to match — any error that is not an EimsApiException is pre-wire and safe to release, not just EimsConfigException (still labeled CONFIG; everything else pre-wire is now labeled the new LOCAL kind). This is exhaustive by construction: every error that actually touches the wire is already normalized to EimsApiException inside EimsClientService.send()'s own catch, so nothing outside that can be ambiguous. --- .../eims-invoice-registration.service.spec.ts | 30 +++++++++ .../eims/eims-invoice-registration.service.ts | 64 ++++++++++--------- .../src/modules/eims/eims.errors.ts | 3 +- 3 files changed, 66 insertions(+), 31 deletions(-) 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 5fc74f988..f560ff917 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 @@ -502,6 +502,36 @@ describe("EimsInvoiceRegistrationService.registerInvoiceWithEims", () => { }); }); + it("a mapper failure after reservation (e.g. unmapped buyer country) also releases the reservation", async () => { + // Regression: toEimsInvoice/buildEimsContext used to sit outside the try/catch that calls + // settleFailure — a throw here left the reservation permanently orphaned (a real live incident: + // 500 on register, then every subsequent attempt 409'd "already in flight" until manually + // resolved). This never reaches postSigned at all — the mapper throws before submit() is called. + const db = new FakeDb([ + invoiceRow({ company: { ...invoiceRow().company, country: "France" } as never }), + ]); + const postSigned = jest.fn(); + + // The mapper throws a plain Error (it's a pure function, not a NestJS layer) — that's the + // point: settleFailure must treat *any* non-EimsApiException as pre-wire, not just its own + // known exception types. + await expect(build(db, postSigned).registerInvoiceWithEims(INVOICE_ID)).rejects.toThrow( + /no MoR country code mapping/, + ); + + expect(postSigned).not.toHaveBeenCalled(); + expect(db.invoices.get(INVOICE_ID)).toMatchObject({ + eimsStatus: EimsInvoiceStatus.Failed, + eimsLastError: expect.objectContaining({ kind: "LOCAL" }), + }); + expect(db.state).toMatchObject({ + inFlightInvoiceId: null, + blockedReason: null, + previousIrn: null, + nextInvoiceCounter: 7, + }); + }); + 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: "" } }); 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 d96c64947..a823e5ddf 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 @@ -123,27 +123,29 @@ export class EimsInvoiceRegistrationService { const reservation = await this.reserve(invoiceId, session.systemNumber); if (!reservation) return this.getEimsStatus(invoiceId); - // The request can only be built now: InvoiceCounter and PreviousIrn come from the reservation. - const request = toEimsInvoice( - invoice, - this.sellerCache.getSellerDetails(cfg), - buildEimsContext(cfg, { - // Allocated from the system state, not our invoiceNumber: MoR validates DocumentNumber - // against ^(0|[1-9][0-9]{0,8})$, which "INV-20260807-00006" can never satisfy. - documentNumber: reservation.documentNumber, - invoiceCounter: reservation.invoiceCounter, - previousIrn: reservation.previousIrn, - session, - documentType, - reason: invoice.eimsReason, - relatedDocument, - }), - ); - let irn: string; let ackDate: string | undefined; let signedQR: string | undefined; try { + // The request can only be built now: InvoiceCounter and PreviousIrn come from the + // reservation. Building it — and everything after — stays inside this try: a reservation is + // held from here on, and *any* failure past this point, mapper or wire, must release it + // through settleFailure rather than leave it orphaned as a permanent system-wide block. + const request = toEimsInvoice( + invoice, + this.sellerCache.getSellerDetails(cfg), + buildEimsContext(cfg, { + // Allocated from the system state, not our invoiceNumber: MoR validates DocumentNumber + // against ^(0|[1-9][0-9]{0,8})$, which "INV-20260807-00006" can never satisfy. + documentNumber: reservation.documentNumber, + invoiceCounter: reservation.invoiceCounter, + previousIrn: reservation.previousIrn, + session, + documentType, + reason: invoice.eimsReason, + relatedDocument, + }), + ); // Deliberately outside every transaction — no DB lock is held across the wire. const result = await this.submit(request); irn = result.irn; @@ -470,13 +472,14 @@ export class EimsInvoiceRegistrationService { * * An ambiguous result keeps both: MoR may have counted and stored the document. * - * `EimsConfigException` is also deterministic, for a different reason: it's thrown by - * `EimsSignerService`/`EimsCredentialsProvider` *before* `EimsClientService.send()`'s own - * try/catch is even entered (see `send()` — signing happens above its try block), so by - * construction no HTTP call was ever made. There is nothing to be ambiguous about — a bad key or - * missing config can't have reached MoR. Every error that *did* touch the wire is normalized to - * `EimsApiException` before it gets here (`toEimsApiException`), so this check is exhaustive: - * config errors are the only other kind `submit()` can throw. + * Any error that is *not* an `EimsApiException` is also deterministic, on a different basis: + * every error that actually touches the wire is normalized to `EimsApiException` before it gets + * here (`EimsClientService.send()`'s catch calls `toEimsApiException` on whatever the HTTP call + * threw). The try block this feeds covers request-building (`toEimsInvoice`/`buildEimsContext` — + * pure, no I/O) and `submit()`; nothing in that span can produce another exception shape by + * touching MoR. So a non-`EimsApiException` here — a mapper validation error (unmapped buyer + * country, say), `EimsConfigException` from a bad signing key, or a bug — failed strictly before + * any HTTP call went out, and releasing the reservation is always safe, never a guess. */ private async settleFailure( invoiceId: string, @@ -484,11 +487,12 @@ export class EimsInvoiceRegistrationService { err: unknown, ): Promise { const api = err instanceof EimsApiException ? err : null; - const isConfigError = err instanceof EimsConfigException; - const deterministic = isConfigError || (api ? DETERMINISTIC_KINDS.has(api.kind) : false); + // Never touched the wire (see the doc comment above) — always safe to release, whatever it is. + const deterministic = api ? DETERMINISTIC_KINDS.has(api.kind) : true; const status = deterministic ? EimsInvoiceStatus.Failed : EimsInvoiceStatus.Unknown; + const localKind = err instanceof EimsConfigException ? "CONFIG" : "LOCAL"; const lastError: EimsInvoiceError = { - kind: isConfigError ? "CONFIG" : (api?.kind ?? "UNKNOWN"), + kind: api?.kind ?? localKind, message: (err as Error)?.message ?? "unknown error", httpStatus: api?.httpStatus, details: api?.details, @@ -595,13 +599,13 @@ export class EimsInvoiceRegistrationService { type: NotificationType.GENERIC, priority: deterministic ? NotificationPriority.NORMAL : NotificationPriority.HIGH, title: deterministic - ? error.kind === "CONFIG" + ? error.kind === "CONFIG" || error.kind === "LOCAL" ? "EIMS filing failed before reaching MoR" : "EIMS rejected an invoice" : "EIMS filing unresolved — all further filing is blocked", body: deterministic - ? error.kind === "CONFIG" - ? `EIMS is misconfigured: ${error.message}. Nothing was sent to MoR; fix the config and file again.` + ? error.kind === "CONFIG" || error.kind === "LOCAL" + ? `${error.kind === "CONFIG" ? "EIMS is misconfigured" : "Filing failed locally"}: ${error.message}. Nothing was sent to MoR; fix it and file again.` : `MoR rejected the filing (${error.kind}): ${error.message}. The invoice is marked FAILED; correct it and file again.` : `A submission was sent but never acknowledged (${error.kind}). Its IRN is unknown, so no further invoice can be filed until it is resolved with MoR.`, link: `/dashboard/invoices/${invoiceId}`, diff --git a/apps/edr-freight-api/src/modules/eims/eims.errors.ts b/apps/edr-freight-api/src/modules/eims/eims.errors.ts index 75c513824..853b32c29 100644 --- a/apps/edr-freight-api/src/modules/eims/eims.errors.ts +++ b/apps/edr-freight-api/src/modules/eims/eims.errors.ts @@ -11,7 +11,8 @@ export type EimsFailureKind = | "RULE_VALIDATION" | "SERVER" | "UNKNOWN" - | "CONFIG"; + | "CONFIG" + | "LOCAL"; /** Raised when EIMS is disabled or its credential files are unusable. */ export class EimsConfigException extends ServiceUnavailableException { From fa16087a4a373ad1c5522b7e119fbcb47b376c8a Mon Sep 17 00:00:00 2001 From: Abubeker Yasin Date: Mon, 17 Aug 2026 15:33:43 +0300 Subject: [PATCH 16/43] fix: ( excess-baggage ) pay in the selected method's currency and record settlement --- .../excess-baggage-currency.spec.ts | 451 ++++++++++++++++++ .../excess-baggage.controller.ts | 48 +- .../excess-baggage/excess-baggage.dto.ts | 29 +- .../excess-baggage/excess-baggage.module.ts | 9 +- .../excess-baggage/excess-baggage.service.ts | 235 ++++++++- .../payments/internal-payments.controller.ts | 7 + .../modules/payments/payments.service.spec.ts | 196 ++++++++ .../src/modules/payments/payments.service.ts | 142 ++++++ .../test/money-integrity.e2e-spec.ts | 12 +- .../app/excess-baggage/pay/[token]/page.tsx | 421 +++++++++++++++- 10 files changed, 1522 insertions(+), 28 deletions(-) create mode 100644 apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage-currency.spec.ts diff --git a/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage-currency.spec.ts b/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage-currency.spec.ts new file mode 100644 index 000000000..abc79c798 --- /dev/null +++ b/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage-currency.spec.ts @@ -0,0 +1,451 @@ +import { BadRequestException } from '@nestjs/common'; +import { PaymentMethodType } from '@prisma/client'; +import { ExcessBaggageService } from './excess-baggage.service'; +import { CurrencyService } from '../currency/currency.service'; + +/** + * An excess baggage charge is always booked in ETB, but each payment method settles in its own + * currency and the payment microservice forwards whatever it is given straight to the gateway. + * These cover the ETB→settlement conversion that has to happen here — and that the quote shown to + * the payer is computed from the same code path as the amount actually charged. + */ +describe('ExcessBaggageService — charge currency', () => { + const CHARGE_ID = 'charge-1'; + const TOKEN = 'tok-1'; + + // 350.00 ETB owed for 7kg at 50.00 ETB/kg. + const charge = { + id: CHARGE_ID, + totalMinor: 35_000, + currency: 'ETB', + status: 'PENDING', + expiresAt: new Date(Date.now() + 10 * 60 * 1000), + booking: { bookingRef: 'BAG-001', scheduleId: 'sched-1' }, + }; + + let prisma: Record; + let paymentClient: { + initiate: jest.Mock; + getIntentByReference: jest.Mock; + confirmOtp: jest.Mock; + }; + let service: ExcessBaggageService; + + const build = (rate?: { rate: number }) => { + prisma = { + excessBaggageCharge: { + findUnique: jest.fn().mockResolvedValue(charge), + update: jest.fn().mockResolvedValue({ ...charge, status: 'PAID' }), + }, + paymentMethod: { findUnique: jest.fn() }, + currencyExchangeRate: { + findFirst: jest.fn().mockResolvedValue(rate ?? null), + }, + }; + paymentClient = { + initiate: jest.fn().mockResolvedValue({ + status: 'REQUIRES_ACTION', + clientAction: { type: 'REDIRECT', url: 'https://gateway.test/pay' }, + merchantOrderId: 'MO-1', + }), + getIntentByReference: jest.fn(), + confirmOtp: jest.fn(), + }; + service = new ExcessBaggageService( + prisma as any, + { log: jest.fn() } as any, // auditService + new CurrencyService(prisma as any), + paymentClient as any, + {} as any, // notifications + {} as any, // smsClient + {} as any, // emailClient + ); + }; + + const withMethod = (type: string, currency: string) => + prisma.paymentMethod.findUnique.mockResolvedValue({ type, currency }); + + it('charges an Ethiopian wallet in ETB, unconverted', async () => { + build(); + withMethod(PaymentMethodType.TELEBIRR, 'ETB'); + + const quote = await service.quoteAmount(TOKEN, PaymentMethodType.TELEBIRR); + + expect(quote).toMatchObject({ currency: 'ETB', amount: 350 }); + expect(prisma.currencyExchangeRate.findFirst).not.toHaveBeenCalled(); + }); + + it('converts to DJF for Waafi and rounds to whole francs', async () => { + build({ rate: 3.2 }); // 1 ETB = 3.2 DJF + withMethod(PaymentMethodType.WAAFI, 'DJF'); + + const quote = await service.quoteAmount(TOKEN, PaymentMethodType.WAAFI); + + // 350.00 ETB × 3.2 = 1120 DJF — DJF has no minor unit. + expect(quote).toMatchObject({ currency: 'DJF', amount: 1120 }); + expect(Number.isInteger(quote.amount)).toBe(true); + }); + + it('sends the provider the converted amount and its own currency, not the stored ETB total', async () => { + build({ rate: 3.2 }); + withMethod(PaymentMethodType.WAAFI, 'DJF'); + + await service.initiatePayment(TOKEN, { + method: PaymentMethodType.WAAFI, + platform: 'web', + } as any); + + expect(paymentClient.initiate).toHaveBeenCalledWith( + expect.objectContaining({ + referenceType: 'EXCESS_BAGGAGE', + referenceId: CHARGE_ID, + amountMinor: 1120, + currency: 'DJF', + provider: PaymentMethodType.WAAFI, + }), + ); + }); + + it('quotes and charges the same figure for the same method', async () => { + build({ rate: 0.0175 }); // 1 ETB = 0.0175 USD + withMethod(PaymentMethodType.CARD, 'USD'); + + const quote = await service.quoteAmount(TOKEN, PaymentMethodType.CARD); + await service.initiatePayment(TOKEN, { + method: PaymentMethodType.CARD, + platform: 'web', + } as any); + + const sent = paymentClient.initiate.mock.calls[0][0]; + expect(quote.amount).toBe(sent.amountMinor); + expect(quote.currency).toBe(sent.currency); + expect(sent.amountMinor).toBe(6.13); // 350 × 0.0175 = 6.125 → 6.13 USD + }); + + it('forces ETB for CBE_BILL, which settles ETB only', async () => { + build({ rate: 3.2 }); + withMethod(PaymentMethodType.CBE_BILL, 'DJF'); // misconfigured row must not win + + const quote = await service.quoteAmount(TOKEN, PaymentMethodType.CBE_BILL); + + expect(quote).toMatchObject({ currency: 'ETB', amount: 350 }); + }); + + it('refuses WALLET, which has no excess-baggage path', async () => { + build(); + + await expect( + service.quoteAmount(TOKEN, PaymentMethodType.WALLET), + ).rejects.toBeInstanceOf(BadRequestException); + await expect( + service.initiatePayment(TOKEN, { + method: PaymentMethodType.WALLET, + } as any), + ).rejects.toBeInstanceOf(BadRequestException); + expect(paymentClient.initiate).not.toHaveBeenCalled(); + }); + + it('fails closed when no exchange rate is configured — never charges at parity', async () => { + build(); // no rate rows at all + withMethod(PaymentMethodType.WAAFI, 'DJF'); + + await expect( + service.initiatePayment(TOKEN, { + method: PaymentMethodType.WAAFI, + } as any), + ).rejects.toBeInstanceOf(BadRequestException); + expect(paymentClient.initiate).not.toHaveBeenCalled(); + }); +}); + +/** + * CAC Bank is an OTP debit: the bank SMSes a one-time password to a mobile number it must be given + * at initiate, and the payment only settles once that password is submitted back. + */ +describe('ExcessBaggageService — CAC Bank OTP debit', () => { + const CHARGE_ID = 'charge-1'; + const TOKEN = 'tok-1'; + + const charge = { + id: CHARGE_ID, + totalMinor: 25_000, + currency: 'ETB', + status: 'PENDING', + expiresAt: new Date(Date.now() + 10 * 60 * 1000), + booking: { bookingRef: 'BAG-001', scheduleId: 'sched-1' }, + }; + + let prisma: Record; + let paymentClient: { + initiate: jest.Mock; + getIntentByReference: jest.Mock; + confirmOtp: jest.Mock; + }; + let service: ExcessBaggageService; + + beforeEach(() => { + prisma = { + excessBaggageCharge: { + findUnique: jest.fn().mockResolvedValue(charge), + update: jest.fn().mockResolvedValue({ ...charge, status: 'PAID' }), + }, + paymentMethod: { + findUnique: jest + .fn() + .mockResolvedValue({ type: 'CAC_BANK', currency: 'DJF' }), + }, + currencyExchangeRate: { + findFirst: jest.fn().mockResolvedValue({ rate: 3.25 }), + }, + }; + paymentClient = { + initiate: jest.fn().mockResolvedValue({ + intentId: 'intent-1', + status: 'REQUIRES_ACTION', + clientAction: { + type: 'COLLECT_OTP', + message: 'Enter the OTP sent to 77****56', + }, + merchantOrderId: 'MO-1', + }), + getIntentByReference: jest + .fn() + .mockResolvedValue({ intentId: 'intent-1', status: 'REQUIRES_ACTION' }), + confirmOtp: jest.fn().mockResolvedValue({ + intentId: 'intent-1', + status: 'SUCCEEDED', + providerTxnId: 'CAC-TXN-9', + }), + }; + service = new ExcessBaggageService( + prisma as any, + { log: jest.fn() } as any, + new CurrencyService(prisma as any), + paymentClient as any, + {} as any, + {} as any, + {} as any, + ); + }); + + it('rejects initiate without a payer mobile — the bank has nowhere to send the OTP', async () => { + await expect( + service.initiatePayment(TOKEN, { + method: PaymentMethodType.CAC_BANK, + platform: 'web', + } as any), + ).rejects.toBeInstanceOf(BadRequestException); + expect(paymentClient.initiate).not.toHaveBeenCalled(); + }); + + it('forwards the payer mobile and returns the OTP client action', async () => { + const result = await service.initiatePayment(TOKEN, { + method: PaymentMethodType.CAC_BANK, + platform: 'web', + payerAccount: ' 77123456 ', + } as any); + + expect(paymentClient.initiate).toHaveBeenCalledWith( + expect.objectContaining({ + payerAccount: '77123456', // trimmed + currency: 'DJF', + amountMinor: 813, // 250.00 ETB × 3.25, whole francs + }), + ); + expect(result.clientAction).toMatchObject({ type: 'COLLECT_OTP' }); + }); + + it('submits the OTP against the charge’s active intent and marks it paid', async () => { + const result = await service.confirmOtp(TOKEN, '4530'); + + expect(paymentClient.getIntentByReference).toHaveBeenCalledWith( + 'EXCESS_BAGGAGE', + CHARGE_ID, + ); + expect(paymentClient.confirmOtp).toHaveBeenCalledWith('intent-1', '4530'); + expect(prisma.excessBaggageCharge.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: CHARGE_ID }, + data: expect.objectContaining({ status: 'PAID' }), + }), + ); + expect(result).toMatchObject({ status: 'SUCCEEDED', alreadyPaid: false }); + }); + + it('leaves the charge unpaid when the OTP does not settle', async () => { + paymentClient.confirmOtp.mockResolvedValue({ + intentId: 'intent-1', + status: 'REQUIRES_ACTION', + }); + + const result = await service.confirmOtp(TOKEN, '0000'); + + expect(prisma.excessBaggageCharge.update).not.toHaveBeenCalled(); + expect(result).toMatchObject({ status: 'REQUIRES_ACTION' }); + }); + + it('confirms an OTP even after the link TTL lapsed — the debit is already in flight', async () => { + prisma.excessBaggageCharge.findUnique.mockResolvedValue({ + ...charge, + expiresAt: new Date(Date.now() - 60_000), + }); + + await expect(service.confirmOtp(TOKEN, '4530')).resolves.toMatchObject({ + status: 'SUCCEEDED', + }); + }); + + it('is idempotent once the charge is already paid', async () => { + prisma.excessBaggageCharge.findUnique.mockResolvedValue({ + ...charge, + status: 'PAID', + }); + + const result = await service.confirmOtp(TOKEN, '4530'); + + expect(result).toMatchObject({ alreadyPaid: true }); + expect(paymentClient.confirmOtp).not.toHaveBeenCalled(); + }); +}); + +/** + * CBE bill payment is inbound-only: no provider session is opened, a bill reference is minted and + * the payer settles it at a branch/app hours later. The expiry handed to the payment service is + * therefore the charge's own deadline, never the 30-minute link TTL — a short one would have the + * reconciliation sweep kill the intent within the hour (CBE plan §6.4). + */ +describe('ExcessBaggageService — CBE bill', () => { + const CHARGE_ID = 'charge-1'; + const TOKEN = 'tok-1'; + const THIRTY_MIN = 30 * 60 * 1000; + + let prisma: Record; + let paymentClient: { initiate: jest.Mock }; + let service: ExcessBaggageService; + let charge: any; + + beforeEach(() => { + charge = { + id: CHARGE_ID, + bookingId: 'booking-1', + totalMinor: 25_000, + currency: 'ETB', + status: 'PENDING', + // A freshly created charge: the short browser-session TTL. + expiresAt: new Date(Date.now() + THIRTY_MIN), + booking: { bookingRef: 'BAG-001' }, + }; + prisma = { + excessBaggageCharge: { + findUnique: jest.fn().mockResolvedValue(charge), + update: jest.fn().mockResolvedValue(charge), + }, + booking: { + findUnique: jest.fn().mockResolvedValue({ + seats: [{ leg: 1, passengerName: 'Abebe Kebede' }], + passenger: { user: { fullName: 'Account Holder' } }, + }), + }, + paymentMethod: { + findUnique: jest + .fn() + .mockResolvedValue({ type: 'CBE_BILL', currency: 'ETB' }), + }, + currencyExchangeRate: { findFirst: jest.fn().mockResolvedValue(null) }, + }; + paymentClient = { + initiate: jest.fn().mockResolvedValue({ + intentId: 'intent-1', + status: 'REQUIRES_ACTION', + clientAction: { + type: 'SHOW_BILL_REFERENCE', + billReference: '900123456', + }, + merchantOrderId: 'MO-1', + }), + }; + service = new ExcessBaggageService( + prisma as any, + { log: jest.fn() } as any, + new CurrencyService(prisma as any), + paymentClient as any, + {} as any, + {} as any, + {} as any, + ); + }); + + const initiate = () => + service.initiatePayment(TOKEN, { + method: PaymentMethodType.CBE_BILL, + platform: 'web', + } as any); + + it('extends the charge deadline past the 30-minute link TTL', async () => { + await initiate(); + + expect(prisma.excessBaggageCharge.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: CHARGE_ID }, + data: expect.objectContaining({ expiresAt: expect.any(Date) }), + }), + ); + const written = + prisma.excessBaggageCharge.update.mock.calls[0][0].data.expiresAt; + // Comfortably beyond the session TTL — a payer has to reach a branch. + expect(written.getTime()).toBeGreaterThan(Date.now() + 2 * THIRTY_MIN); + }); + + it('hands the payment service that deadline as the intent expiry, in ETB', async () => { + await initiate(); + + const sent = paymentClient.initiate.mock.calls[0][0]; + expect(sent.currency).toBe('ETB'); + expect(sent.amountMinor).toBe(250); + expect(new Date(sent.expiresAt).getTime()).toBeGreaterThan( + Date.now() + 2 * THIRTY_MIN, + ); + }); + + it('sends the lead passenger as Full_Name, which CBE requires', async () => { + await initiate(); + + expect(paymentClient.initiate.mock.calls[0][0].payerName).toBe( + 'Abebe Kebede', + ); + }); + + it('never shortens a deadline the payer already has', async () => { + const farFuture = new Date(Date.now() + 90 * 60 * 60 * 1000); + charge.expiresAt = farFuture; + + await initiate(); + + expect(prisma.excessBaggageCharge.update).not.toHaveBeenCalled(); + expect(paymentClient.initiate.mock.calls[0][0].expiresAt).toBe( + farFuture.toISOString(), + ); + }); + + it('returns the bill reference to the caller', async () => { + const result = await initiate(); + expect(result.clientAction).toMatchObject({ + type: 'SHOW_BILL_REFERENCE', + billReference: '900123456', + }); + }); + + it('reports a paid charge through getStatus without the payability gate', async () => { + prisma.excessBaggageCharge.findUnique.mockResolvedValue({ + ...charge, + status: 'PAID', + paidAt: new Date(), + }); + + // getByToken would throw "already paid" here; the poll must simply report it. + await expect(service.getStatus(TOKEN)).resolves.toMatchObject({ + status: 'PAID', + paid: true, + }); + }); +}); diff --git a/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.controller.ts b/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.controller.ts index 40a44f36b..b246566e4 100644 --- a/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.controller.ts +++ b/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.controller.ts @@ -1,11 +1,12 @@ import { Body, Controller, Delete, Get, Param, Patch, Post, Query, Request, UseGuards, SetMetadata } from '@nestjs/common'; -import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; +import { ApiTags, ApiOperation, ApiBearerAuth, ApiQuery } from '@nestjs/swagger'; import { IsInt, IsOptional, IsPositive, IsString } from 'class-validator'; import { ExcessBaggageService } from './excess-baggage.service'; import { LogExcessBaggageDto, WaiveChargeDto, InitiateExcessPaymentDto, + ConfirmExcessOtpDto, } from './excess-baggage.dto'; import { JwtGuard as IamJwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard'; import { PassengerAdmin } from '../../common/passenger-guards'; @@ -118,6 +119,37 @@ export class ExcessBaggagePublicController { return this.service.getByToken(token); } + @Get('pay/:token/amount') + @ApiOperation({ + summary: 'Quote the charge in a payment method’s settlement currency', + description: + 'Returns what the given method would debit, converted from the charge’s stored ETB total ' + + 'to that method’s settlement currency (WAAFI/DMONEY settle in DJF, CARD in USD, Ethiopian ' + + 'wallets in ETB) at the latest exchange rate. The pay page quotes this before the payer ' + + 'commits; initiating a payment recomputes it identically.', + }) + @ApiQuery({ + name: 'method', + required: true, + example: 'WAAFI', + description: 'Payment method type the payer has selected', + }) + quoteAmount(@Param('token') token: string, @Query('method') method: string) { + return this.service.quoteAmount(token, method); + } + + @Get('pay/:token/status') + @ApiOperation({ + summary: 'Poll the charge’s settlement status (public)', + description: + 'Reports the charge’s current status without the payability gate on GET /pay/:token, so a ' + + 'page can watch for settlement. Used while a CBE bill is outstanding and after a redirect ' + + 'payment returns — both settle server-side, out of band from the browser.', + }) + getStatus(@Param('token') token: string) { + return this.service.getStatus(token); + } + @Post('pay/:token/initiate') @ApiOperation({ summary: 'Passenger initiates payment for excess baggage charge' }) initiatePayment( @@ -126,4 +158,18 @@ export class ExcessBaggagePublicController { ) { return this.service.initiatePayment(token, dto); } + + @Post('pay/:token/confirm') + @ApiOperation({ + summary: 'Confirm an OTP-debit excess baggage payment (CAC Bank)', + description: + 'Submits the one-time password the payer received by SMS. A wrong or expired OTP returns ' + + '400 and the payment stays open for retry.', + }) + confirmOtp( + @Param('token') token: string, + @Body() dto: ConfirmExcessOtpDto, + ) { + return this.service.confirmOtp(token, dto.otp); + } } diff --git a/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.dto.ts b/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.dto.ts index df0abd8be..4b06e906e 100644 --- a/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.dto.ts +++ b/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.dto.ts @@ -20,7 +20,34 @@ export class WaiveChargeDto { } export class InitiateExcessPaymentDto { - @ApiProperty({ enum: ['TELEBIRR', 'CBE_BIRR', 'EBIRR', 'WAAFI', 'DMONEY', 'CARD'] }) + @ApiProperty({ + enum: [ + 'TELEBIRR', + 'CBE_BIRR', + 'EBIRR', + 'WAAFI', + 'DMONEY', + 'CARD', + 'CAC_BANK', + 'CBE_BILL', + ], + }) @IsString() method: string; @ApiPropertyOptional({ enum: ['web', 'mobile'] }) @IsOptional() platform?: string; + @ApiPropertyOptional({ + description: + 'Payer account / mobile number. Required for the push-debit methods: CAC_BANK (the bank ' + + 'SMSes a one-time password to this number) and EBIRR (the wallet pushes a USSD PIN prompt ' + + 'to it). Normalised server-side by the payment service.', + example: '77123456', + }) + @IsOptional() @IsString() payerAccount?: string; +} + +export class ConfirmExcessOtpDto { + @ApiProperty({ + description: 'One-time password the payer received by SMS (CAC Bank).', + example: '4530', + }) + @IsString() otp: string; } diff --git a/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.module.ts b/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.module.ts index e734d4fb4..e9c648ca8 100644 --- a/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.module.ts +++ b/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.module.ts @@ -6,11 +6,18 @@ import { ExcessBaggagePublicController, } from './excess-baggage.controller'; import { PaymentsModule } from '../payments/payments.module'; +import { CurrencyModule } from '../currency/currency.module'; import { NotificationsModule } from '../notifications/notifications.module'; import { AuditModule } from '../../common/audit.module'; @Module({ - imports: [HttpModule, PaymentsModule, NotificationsModule, AuditModule], + imports: [ + HttpModule, + PaymentsModule, + CurrencyModule, + NotificationsModule, + AuditModule, + ], controllers: [ExcessBaggageAgentController, ExcessBaggagePublicController], providers: [ExcessBaggageService], exports: [ExcessBaggageService], diff --git a/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.service.ts b/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.service.ts index 32fc06c13..d70c823e5 100644 --- a/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.service.ts +++ b/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.service.ts @@ -6,6 +6,7 @@ import { } from '@nestjs/common'; import { PrismaService } from '../../common/prisma.service'; import { AuditService } from '../../common/audit.service'; +import { CurrencyService } from '../currency/currency.service'; import { PaymentClientService } from '../payments/payment-client.service'; import { NotificationsService } from '../notifications/notifications.service'; import { SmsClientService } from '../notifications/sms-client.service'; @@ -25,6 +26,41 @@ import { PaymentMethodType, PaymentIntentStatus } from '@prisma/client'; const CHARGE_TTL_MS = 30 * 60 * 1000; // 30 minutes +/** + * WALLET is an internal balance debit handled entirely inside this app (PaymentsService + * .initiateWalletPayment) — it is not a provider and the payment microservice rejects it as one. + * Excess baggage has no wallet path, so it is refused up front with a message a payer can act on + * rather than a 502 from the gateway layer. + */ +const UNSUPPORTED_METHODS = new Set([PaymentMethodType.WALLET]); + +/** + * Push-debit methods charge an account we must be told up front — CAC Bank SMSes a one-time + * password to it, eBirr pushes a USSD PIN prompt to it. Neither opens a hosted page that could + * collect the number later, so initiate is rejected without it (mirrors PaymentsService). + */ +const METHODS_REQUIRING_PAYER_ACCOUNT = new Set([ + PaymentMethodType.CAC_BANK, + PaymentMethodType.EBIRR, +]); + +/** + * How long an excess baggage charge stays payable once a CBE bill has been issued for it. + * + * The 30-minute link TTL is a browser-session window: it assumes the payer is sitting in front of + * the page. A CBE bill is the opposite — the payer walks to a branch, or opens CBE Birr later, and + * the bill reference may already be written on a slip of paper. Handing the payment service a + * 30-minute `expiresAt` would also make the reconciliation sweep expire the intent and emit + * payment.failed within the hour (CBE_IMPLEMENTATION_PLAN.md §6.4 calls this the single most + * important detail of the integration). + * + * So issuing a bill EXTENDS the charge's own deadline to this window. `charge.expiresAt` stays the + * single source of truth for both the pay link and the bill. + */ +const CBE_BILL_WINDOW_HOURS = Number( + process.env.EXCESS_BAGGAGE_CBE_BILL_HOURS ?? 24, +); + @Injectable() export class ExcessBaggageService { private readonly logger = new Logger(ExcessBaggageService.name); @@ -32,6 +68,7 @@ export class ExcessBaggageService { constructor( private prisma: PrismaService, private auditService: AuditService, + private currencyService: CurrencyService, private paymentClient: PaymentClientService, private notifications: NotificationsService, private smsClient: SmsClientService, @@ -165,21 +202,103 @@ export class ExcessBaggageService { return charge; } + /** + * What the payer is actually charged when paying this charge with `method`. + * + * The charge itself is always booked in ETB (`ExcessBaggageCharge.currency` defaults to ETB and + * nothing overrides it), but the selected method settles in its own currency — WAAFI/DMONEY in + * DJF, CARD in USD, the Ethiopian wallets in ETB — recorded on the PaymentMethod row. The payment + * microservice is currency-agnostic and hands whatever it is given straight to the gateway + * verbatim, so the ETB→settlement conversion has to happen here or the provider is asked to debit + * an ETB number labelled as its own currency. + * + * Both the quote shown to the payer and the amount sent to the provider come through this one + * method, so the price on the button and the price debited cannot drift apart. + */ + private async resolveChargeAmount( + charge: { totalMinor: number; currency: string }, + method: string, + ): Promise<{ amount: number; currency: string }> { + if (UNSUPPORTED_METHODS.has(method)) { + throw new BadRequestException( + `${method} is not available for excess baggage payments`, + ); + } + + const paymentMethod = await this.prisma.paymentMethod.findUnique({ + where: { type: method as PaymentMethodType }, + }); + // CBE settles ETB only (docs/cbe/CBE_IMPLEMENTATION_PLAN.md D8) — never converted. Every other + // method charges in its configured settlement currency, falling back to the charge's own. + const chargeCurrency = + method === PaymentMethodType.CBE_BILL + ? 'ETB' + : (paymentMethod?.currency ?? charge.currency).toUpperCase(); + + // Applies the target currency's own precision — DJF rounds to whole francs, ETB/USD to cents. + const amount = await this.currencyService.convertMinorToChargeMajor( + charge.totalMinor, + charge.currency, + chargeCurrency, + ); + return { amount, currency: chargeCurrency }; + } + + /** + * Price quote for the pay page: what `method` would debit, in that method's settlement currency. + * The payer sees this before committing, and `initiatePayment` recomputes it the same way. + */ + async quoteAmount(token: string, method: string) { + const charge = await this.getByToken(token); + const { amount, currency } = await this.resolveChargeAmount(charge, method); + return { chargeId: charge.id, method, currency, amount }; + } + async initiatePayment(token: string, dto: InitiateExcessPaymentDto) { const charge = await this.getByToken(token); + if ( + METHODS_REQUIRING_PAYER_ACCOUNT.has(dto.method) && + !dto.payerAccount?.trim() + ) { + throw new BadRequestException( + `payerAccount (mobile number) is required for ${dto.method}`, + ); + } + const portalUrl = process.env.PORTAL_URL ?? 'http://localhost:5174'; const returnUrl = `${portalUrl}/excess-baggage/pay/${token}/result`; + const { amount, currency } = await this.resolveChargeAmount( + charge, + dto.method, + ); + + // CBE_BILL is inbound-only: no provider session is opened, the bill simply sits in CBE's + // system until someone pays it. It therefore needs a real deadline and a payer name (Full_Name + // is mandatory in CBE's envelope) rather than the redirect flow's session semantics. + let payerName: string | undefined; + let expiresAt: string | undefined; + if (dto.method === PaymentMethodType.CBE_BILL) { + const deadline = await this.extendForCbeBill(charge); + expiresAt = deadline.toISOString(); + payerName = (await this.resolvePayerName(charge.bookingId)) ?? undefined; + } + const snapshot = await this.paymentClient.initiate({ service: PaymentServiceEnum.PASSENGER, referenceType: 'EXCESS_BAGGAGE' as PaymentReferenceType, referenceId: charge.id, orderRef: `EXB-${charge.id.substring(0, 8).toUpperCase()}`, - amountMinor: charge.totalMinor / 100, - currency: charge.currency, + // `amountMinor` is the contract's name but its value is MAJOR units — the provider layer + // charges it verbatim at the currency's own precision (see PaymentIntentSnapshot). + amountMinor: amount, + currency, provider: dto.method as unknown as ProviderMethod, platform: dto.platform as any, + payerAccount: dto.payerAccount?.trim() || undefined, + payerName, + expiresAt, returnUrl, failureUrl: returnUrl, }); @@ -196,6 +315,118 @@ export class ExcessBaggageService { }; } + /** + * Pushes the charge's deadline out to the CBE bill window and returns it. Only ever extends — + * a charge that already has longer left (a re-issued bill, an agent's resend) keeps it, so + * re-initiating a bill can never shorten a window the payer was already given. + */ + private async extendForCbeBill(charge: { + id: string; + expiresAt: Date; + }): Promise { + const target = new Date(Date.now() + CBE_BILL_WINDOW_HOURS * 60 * 60 * 1000); + if (charge.expiresAt >= target) return charge.expiresAt; + + await this.prisma.excessBaggageCharge.update({ + where: { id: charge.id }, + data: { expiresAt: target }, + }); + this.logger.log( + `charge ${charge.id}: expiry extended to ${target.toISOString()} for CBE bill`, + ); + return target; + } + + /** + * Full_Name for CBE's confirmation screen — mandatory in its envelope. The passenger the + * baggage belongs to: lead traveller on the booking, falling back to the account holder. + */ + private async resolvePayerName(bookingId: string): Promise { + const booking = await this.prisma.booking.findUnique({ + where: { id: bookingId }, + include: { seats: true, passenger: { include: { user: true } } }, + }); + if (!booking) return null; + return ( + booking.seats?.find((s: any) => s.leg === 1)?.passengerName ?? + booking.seats?.[0]?.passengerName ?? + booking.passenger?.user?.fullName ?? + null + ); + } + + /** + * Bare status for the pay/result pages to poll. Unlike getByToken this does NOT reject a paid, + * expired or waived charge — the whole point is to report those states. A CBE bill can settle + * long after the payer closed the tab, and the redirect methods only converge when the + * settlement event lands, so the page needs something it can watch. + */ + async getStatus(token: string) { + const charge = await this.prisma.excessBaggageCharge.findUnique({ + where: { paymentToken: token }, + select: { + id: true, + status: true, + paidAt: true, + totalMinor: true, + currency: true, + expiresAt: true, + }, + }); + if (!charge) throw new NotFoundException('Payment link not found'); + return { + chargeId: charge.id, + status: charge.status, + paid: charge.status === 'PAID' || charge.status === 'CASH_COLLECTED', + paidAt: charge.paidAt, + totalMinor: charge.totalMinor, + currency: charge.currency, + expiresAt: charge.expiresAt, + }; + } + + /** + * Submit the one-time password for a COLLECT_OTP provider (CAC Bank). The bank SMSed it to the + * payerAccount given at initiate; this forwards it to the payment service and marks the charge + * paid when the debit settles. A wrong or expired OTP bubbles up as a 400 and the intent stays + * open, so the payer can simply re-enter it. + * + * Deliberately reads the charge directly rather than through getByToken: the bank is already + * holding a debit against this payer, and refusing to submit their OTP because the 30-minute + * link TTL lapsed while they were reading the SMS would strand a payment that is mid-flight. + */ + async confirmOtp(token: string, otp: string) { + const charge = await this.prisma.excessBaggageCharge.findUnique({ + where: { paymentToken: token }, + }); + if (!charge) throw new NotFoundException('Payment link not found'); + if (charge.status === 'PAID' || charge.status === 'CASH_COLLECTED') { + return { chargeId: charge.id, status: 'SUCCEEDED', alreadyPaid: true }; + } + + const snapshot = await this.paymentClient.getIntentByReference( + 'EXCESS_BAGGAGE' as PaymentReferenceType, + charge.id, + ); + if (!snapshot) { + throw new NotFoundException('No active payment to confirm for this charge'); + } + + const confirmed = await this.paymentClient.confirmOtp( + snapshot.intentId, + otp, + ); + if (confirmed.status === ProviderPaymentStatus.SUCCEEDED) { + await this.markPaid(charge.id, confirmed.providerTxnId); + } + + return { + chargeId: charge.id, + status: confirmed.status, + alreadyPaid: false, + }; + } + async markPaid(chargeId: string, providerTxnId?: string) { const charge = await this.prisma.excessBaggageCharge.findUnique({ where: { id: chargeId } }); if (!charge) throw new NotFoundException('Charge not found'); diff --git a/apps/edr-passenger-api/src/modules/payments/internal-payments.controller.ts b/apps/edr-passenger-api/src/modules/payments/internal-payments.controller.ts index df5949bde..be46135a1 100644 --- a/apps/edr-passenger-api/src/modules/payments/internal-payments.controller.ts +++ b/apps/edr-passenger-api/src/modules/payments/internal-payments.controller.ts @@ -8,6 +8,7 @@ import { UseGuards, } from "@nestjs/common"; import { ApiOperation, ApiTags } from "@nestjs/swagger"; +import { PaymentReferenceType } from "@edr/types"; import { ServiceAuthGuard } from "../../common/guards/service-auth.guard"; import { PaymentEventDto, @@ -51,6 +52,12 @@ export class InternalPaymentsController { async billQuery( @Body() request: BillQueryRequestDto, ): Promise { + // Routed on referenceType: the passenger app issues CBE bills for bookings AND for excess + // baggage charges, and they live in different tables. Treating every referenceId as a + // bookingId would report a perfectly payable baggage bill as NOT_FOUND to the teller. + if (request.referenceType === PaymentReferenceType.EXCESS_BAGGAGE) { + return this.paymentsService.billQueryExcessBaggage(request.referenceId); + } return this.paymentsService.billQuery(request.referenceId); } } 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 9b94053b6..36b062261 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 @@ -46,6 +46,10 @@ describe("PaymentsService", () => { paymentMethod: { findUnique: jest.fn(), }, + excessBaggageCharge: { + findUnique: jest.fn(), + update: jest.fn(), + }, currencyExchangeRate: { findFirst: jest.fn(), }, @@ -562,4 +566,196 @@ describe("PaymentsService", () => { ); }); }); + + /** + * Excess baggage settles through the same outbox → RabbitMQ path as bookings. Before this + * existed the consumer dropped every EXCESS_BAGGAGE event as "foreign-reference", so a charge + * the payer had genuinely paid stayed PENDING until its TTL flipped it to EXPIRED. + */ + describe("handlePaymentEvent — excess baggage", () => { + const CHARGE_ID = "charge-1"; + + const succeededEvent = (overrides: Record = {}) => + ({ + eventId: "evt-1", + eventType: "payment.succeeded", + service: PaymentServiceEnum.PASSENGER, + referenceType: PaymentReferenceType.EXCESS_BAGGAGE, + referenceId: CHARGE_ID, + amountMinor: 500, + currency: "ETB", + providerTxnId: "TXN-9", + ...overrides, + }) as any; + + it("marks a pending charge PAID", async () => { + mockPrisma.excessBaggageCharge.findUnique.mockResolvedValue({ + id: CHARGE_ID, + status: "PENDING", + }); + + const result = await service.handlePaymentEvent(succeededEvent()); + + expect(mockPrisma.excessBaggageCharge.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: CHARGE_ID }, + data: expect.objectContaining({ status: "PAID" }), + }), + ); + expect(result).toEqual({ processed: true }); + }); + + it("marks an EXPIRED charge PAID — the TTL governs starting a payment, not receiving one", async () => { + mockPrisma.excessBaggageCharge.findUnique.mockResolvedValue({ + id: CHARGE_ID, + status: "EXPIRED", + }); + + await service.handlePaymentEvent(succeededEvent()); + + expect(mockPrisma.excessBaggageCharge.update).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ status: "PAID" }), + }), + ); + }); + + it("does not re-pay an already PAID charge", async () => { + mockPrisma.excessBaggageCharge.findUnique.mockResolvedValue({ + id: CHARGE_ID, + status: "PAID", + }); + + const result = await service.handlePaymentEvent(succeededEvent()); + + expect(mockPrisma.excessBaggageCharge.update).not.toHaveBeenCalled(); + expect(result).toEqual({ processed: true, alreadyFinalized: true }); + }); + + it("accepts a foreign-currency settlement without a short-pay comparison", async () => { + mockPrisma.excessBaggageCharge.findUnique.mockResolvedValue({ + id: CHARGE_ID, + status: "PENDING", + }); + + // 500.00 ETB charge settled as 1625 DJF — numerically unlike the stored total. + await service.handlePaymentEvent( + succeededEvent({ amountMinor: 1625, currency: "DJF" }), + ); + + expect(mockPrisma.excessBaggageCharge.update).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ status: "PAID" }), + }), + ); + }); + + it("acks a failure event without touching the charge", async () => { + const result = await service.handlePaymentEvent( + succeededEvent({ eventType: "payment.failed" }), + ); + + expect(mockPrisma.excessBaggageCharge.update).not.toHaveBeenCalled(); + expect(result).toEqual({ processed: true }); + }); + + it("acks an event for a charge that no longer exists", async () => { + mockPrisma.excessBaggageCharge.findUnique.mockResolvedValue(null); + + const result = await service.handlePaymentEvent(succeededEvent()); + + expect(result).toEqual({ + processed: false, + reason: "charge-not-found", + }); + }); + }); + + /** + * The live hop CBE makes while a teller is on the line, for a baggage bill. This is the + * double-payment guard: anything other than stillPayable=true makes CBE refuse the debit. + */ + describe("billQueryExcessBaggage", () => { + const payable = { + id: "charge-1", + excessWeightKg: 7, + totalMinor: 25_000, + status: "PENDING", + expiresAt: new Date(Date.now() + 60 * 60 * 1000), + booking: { + bookingRef: "BAG-001", + seats: [{ leg: 1, passengerName: "Abebe Kebede" }], + passenger: { user: { fullName: "Account Holder" } }, + }, + }; + + it("reports a pending charge as payable, in ETB, with the passenger name", async () => { + mockPrisma.excessBaggageCharge.findUnique.mockResolvedValue(payable); + + const result = await service.billQueryExcessBaggage("charge-1"); + + expect(result).toMatchObject({ + stillPayable: true, + currency: "ETB", + currentAmountMinor: 250, + payerName: "Abebe Kebede", + }); + expect(result.paymentReason).toContain("BAG-001"); + }); + + it("refuses a charge already paid at the counter in cash", async () => { + mockPrisma.excessBaggageCharge.findUnique.mockResolvedValue({ + ...payable, + status: "CASH_COLLECTED", + }); + + await expect( + service.billQueryExcessBaggage("charge-1"), + ).resolves.toMatchObject({ + stillPayable: false, + reason: "ALREADY_PAID", + }); + }); + + it("refuses a waived charge", async () => { + mockPrisma.excessBaggageCharge.findUnique.mockResolvedValue({ + ...payable, + status: "WAIVED", + }); + + await expect( + service.billQueryExcessBaggage("charge-1"), + ).resolves.toMatchObject({ stillPayable: false, reason: "CANCELLED" }); + }); + + it("refuses a charge whose deadline has passed", async () => { + mockPrisma.excessBaggageCharge.findUnique.mockResolvedValue({ + ...payable, + expiresAt: new Date(Date.now() - 1000), + }); + + await expect( + service.billQueryExcessBaggage("charge-1"), + ).resolves.toMatchObject({ stillPayable: false, reason: "EXPIRED" }); + }); + + it("refuses within the settle margin, so a debit cannot land after expiry", async () => { + mockPrisma.excessBaggageCharge.findUnique.mockResolvedValue({ + ...payable, + expiresAt: new Date(Date.now() + 5_000), // inside the 60s margin + }); + + await expect( + service.billQueryExcessBaggage("charge-1"), + ).resolves.toMatchObject({ stillPayable: false, reason: "EXPIRED" }); + }); + + it("reports NOT_FOUND for a bill whose charge is gone", async () => { + mockPrisma.excessBaggageCharge.findUnique.mockResolvedValue(null); + + await expect( + service.billQueryExcessBaggage("charge-1"), + ).resolves.toEqual({ stillPayable: false, reason: "NOT_FOUND" }); + }); + }); }); 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 5cd483830..1415c1dcc 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.service.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.service.ts @@ -533,6 +533,73 @@ export class PaymentsService { return { ...base, stillPayable: true, reason: null }; } + /** + * Bill-query for an excess baggage charge — the same live "still payable?" hop as bookings, + * against `ExcessBaggageCharge` instead. This is the double-payment guard for baggage bills: + * once the charge is paid, waived or lapsed, CBE is told to refuse the debit. + * + * The charge's own `expiresAt` is the deadline (extended to the CBE bill window when the bill + * was issued), so there is no separate schedule-derived deadline to compute as there is for a + * booking. + */ + async billQueryExcessBaggage( + chargeId: string, + ): Promise { + const charge = await this.prisma.excessBaggageCharge.findUnique({ + where: { id: chargeId }, + include: { + booking: { + include: { seats: true, passenger: { include: { user: true } } }, + }, + }, + }); + // A bill reference we issued whose charge has since been deleted — a data problem, not a + // customer-facing cancellation. + if (!charge) return { stillPayable: false, reason: "NOT_FOUND" }; + + const base = { + payerName: + charge.booking?.seats?.find((s) => s.leg === 1)?.passengerName ?? + charge.booking?.seats?.[0]?.passengerName ?? + charge.booking?.passenger?.user?.fullName ?? + null, + // The charge is always booked in ETB and CBE settles ETB only, so no conversion applies. + currentAmountMinor: this.currencyService.displayMinorToChargeMajor( + charge.totalMinor, + "ETB", + ), + currency: "ETB", + // Rendered beside the amount on CBE's confirmation screen. The weight and booking ref are + // both on the agent's slip, so the payer can match the two before confirming. + paymentReason: `Excess baggage ${charge.excessWeightKg}kg — booking ${ + charge.booking?.bookingRef ?? "" + }`.trim(), + }; + + // Paid first: a charge settled by any method (including cash at the counter) must be reported + // as already paid, never as merely "not payable". + if (charge.status === "PAID" || charge.status === "CASH_COLLECTED") { + return { ...base, stillPayable: false, reason: "ALREADY_PAID" }; + } + // A supervisor wrote the charge off; from the payer's side the debt is gone. + if (charge.status === "WAIVED") { + return { ...base, stillPayable: false, reason: "CANCELLED" }; + } + // Confirmed CBE debits land in seconds, but must not be accepted so close to the deadline + // that the sweep expires the intent before the capture is registered. + if ( + charge.status === "EXPIRED" || + charge.expiresAt.getTime() - PAYMENT_SETTLE_MARGIN_SECONDS * 1000 < + Date.now() + ) { + return { ...base, stillPayable: false, reason: "EXPIRED" }; + } + if (charge.status !== "PENDING") { + return { ...base, stillPayable: false, reason: "NOT_PAYABLE" }; + } + return { ...base, stillPayable: true, reason: null }; + } + /** * The booking's payment deadline, resolved exactly like the auto-cancel job: the booking's * origin-segment time and that stop's own check-in window, falling back to the route default. @@ -1396,6 +1463,77 @@ export class PaymentsService { return { processed: true }; } + /** + * Settlement for an excess baggage charge paid through the passenger portal link. + * + * Deliberately has NO short-payment amount guard, unlike the booking path: the charge is stored + * in ETB while `event.amountMinor` arrives in the provider's settlement currency (DJF for + * Waafi/D-Money/CAC, USD for card), so comparing the two directly would reject every legitimate + * cross-currency payment. The amount actually charged was computed server-side at initiate. + * + * An EXPIRED charge is still marked PAID. The link TTL only governs whether a NEW payment may be + * started; once a provider has captured the money the charge is paid, and leaving it EXPIRED + * would hide a real settlement from the agent who has to reconcile it. + */ + private async handleExcessBaggageChargeEvent( + event: PaymentEventDto, + ): Promise { + if (event.eventType === "payment.failed") { + this.logger.warn( + `excess baggage charge ${event.referenceId} payment failed`, + ); + return { processed: true }; + } + + const charge = await this.prisma.excessBaggageCharge.findUnique({ + where: { id: event.referenceId }, + }); + if (!charge) { + // Ack — a missing charge will not appear on redelivery; needs investigation. + this.logger.error( + `mark-paid: no excess baggage charge for reference ${event.referenceId}`, + ); + return { processed: false, reason: "charge-not-found" }; + } + if (charge.status === "PAID" || charge.status === "CASH_COLLECTED") { + return { processed: true, alreadyFinalized: true }; + } + // Money arrived against a charge nobody expected to be paid — record it as PAID (that is the + // truth) but say so loudly: a waived charge that settles anyway needs a refund decision. + if (charge.status !== "PENDING") { + this.logger.warn( + `mark-paid: excess baggage charge ${charge.id} settled while ${charge.status} ` + + `(${event.amountMinor} ${event.currency}) — marking PAID; needs review`, + ); + } + + await this.prisma.excessBaggageCharge.update({ + where: { id: charge.id }, + data: { + status: "PAID", + // The provider's own capture time, not when this event happened to be processed — a + // replayed or dead-lettered event must not backdate the money to the wrong minute. + paidAt: event.paidAt ? new Date(event.paidAt) : new Date(), + }, + }); + await this.auditService.log({ + action: "UPDATE", + entityType: "ExcessBaggageCharge", + entityId: charge.id, + oldData: { status: charge.status }, + newData: { + status: "PAID", + providerTxnId: event.providerTxnId, + settledAmount: event.amountMinor, + settledCurrency: event.currency, + }, + }); + this.logger.log( + `excess baggage charge ${charge.id} marked PAID (${event.amountMinor} ${event.currency}, txn ${event.providerTxnId ?? "n/a"})`, + ); + return { processed: true }; + } + async handlePaymentEvent( event: PaymentEventDto, ): Promise { @@ -1410,6 +1548,10 @@ export class PaymentsService { return this.handleSupplementaryChargeEvent(event); } + if (event.referenceType === PaymentReferenceType.EXCESS_BAGGAGE) { + return this.handleExcessBaggageChargeEvent(event); + } + if (event.referenceType !== PaymentReferenceType.BOOKING) { this.logger.warn( `mark-paid: ignoring unknown referenceType ${event.referenceType}`, diff --git a/apps/edr-passenger-api/test/money-integrity.e2e-spec.ts b/apps/edr-passenger-api/test/money-integrity.e2e-spec.ts index dace45843..91b0a4f2f 100644 --- a/apps/edr-passenger-api/test/money-integrity.e2e-spec.ts +++ b/apps/edr-passenger-api/test/money-integrity.e2e-spec.ts @@ -130,11 +130,12 @@ describe("Money integrity (Tier-2 direct instantiation)", () => { const service = new ExcessBaggageService( prisma as any, - asyncStub(), - asyncStub(), - asyncStub(), - asyncStub(), - asyncStub(), + asyncStub(), // auditService + asyncStub(), // currencyService + asyncStub(), // paymentClient + asyncStub(), // notifications + asyncStub(), // smsClient + asyncStub(), // emailClient ); const charge: any = await service.logCharge({ @@ -174,6 +175,7 @@ describe("Money integrity (Tier-2 direct instantiation)", () => { const service = new ExcessBaggageService( prisma as any, asyncStub(), // auditService + asyncStub(), // currencyService asyncStub(), // paymentClient asyncStub(), // notifications asyncStub(), // smsClient diff --git a/apps/edr-passenger-web/portal/src/app/excess-baggage/pay/[token]/page.tsx b/apps/edr-passenger-web/portal/src/app/excess-baggage/pay/[token]/page.tsx index 209651756..73976fcda 100644 --- a/apps/edr-passenger-web/portal/src/app/excess-baggage/pay/[token]/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/excess-baggage/pay/[token]/page.tsx @@ -1,14 +1,17 @@ "use client"; -import { useMemo, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { useParams, useRouter } from "next/navigation"; import { useQuery, useMutation } from "@tanstack/react-query"; import { apiClient } from "@/lib/api-client"; import { PaymentMethod } from "@/types"; import { AlertCircle, + Check, CheckCircle, + Copy, CreditCard, + KeyRound, Landmark, Loader2, Smartphone, @@ -22,6 +25,28 @@ const getIconForMethod = (methodId: string) => { return Smartphone; }; +// WALLET is an internal balance debit with no excess-baggage path — the API refuses it, so it is +// never offered here. +const UNSUPPORTED_METHODS = ["WALLET"]; + +// Push-debit methods charge an account we must know before initiating: CAC Bank SMSes a one-time +// password to it, eBirr pushes a USSD PIN prompt to it. Neither opens a hosted page that could +// collect the number afterwards, so it is asked for up front. +const requiresPayerMobile = (method: string | null) => + method === "CAC_BANK" || method === "EBIRR"; + +// DJF has no minor unit; ETB and USD are quoted to cents. Matches the API's charge-side rounding, +// so the quote renders exactly the figure the provider will debit. +const formatAmount = (amount: number, currency: string) => + amount.toFixed(currency.toUpperCase() === "DJF" ? 0 : 2); + +interface AmountQuote { + chargeId: string; + method: string; + currency: string; + amount: number; +} + export default function ExcessBaggagePayPage() { const { token } = useParams<{ token: string }>(); const router = useRouter(); @@ -29,6 +54,26 @@ export default function ExcessBaggagePayPage() { const [isProcessing, setIsProcessing] = useState(false); const [paymentError, setPaymentError] = useState(null); + // Push-debit (CAC Bank / eBirr): collect the payer's mobile before initiating, then — for CAC — + // the OTP the bank SMSes to it. + const [phoneModalOpen, setPhoneModalOpen] = useState(false); + const [payerMobile, setPayerMobile] = useState(""); + const [phoneError, setPhoneError] = useState(null); + const [otpModalOpen, setOtpModalOpen] = useState(false); + const [otpCode, setOtpCode] = useState(""); + const [otpMessage, setOtpMessage] = useState(null); + const [otpError, setOtpError] = useState(null); + const [pushMessage, setPushMessage] = useState(null); + + // CBE bill: no redirect and no OTP — the payer walks away with a bill number and pays it at a + // branch/app later, so the page shows the number and watches for settlement. + const [billAction, setBillAction] = useState<{ + billReference: string; + instructions?: string; + expiresAt?: string; + } | null>(null); + const [billCopied, setBillCopied] = useState(false); + const { data: charge, isLoading: loadingCharge, error: chargeError } = useQuery({ queryKey: ["excessBaggageCharge", token], queryFn: () => apiClient.get(`/excess-baggage/pay/${token}`), @@ -45,24 +90,104 @@ export default function ExcessBaggagePayPage() { enabled: !!charge, }); - const amountDisplay = useMemo(() => { - const amountMinor = Number(charge?.totalMinor ?? charge?.amountMinor ?? 0); - return (amountMinor / 100).toFixed(2); - }, [charge]); + const availableMethods = useMemo( + () => + paymentMethods.filter( + (m) => m.enabled && !UNSUPPORTED_METHODS.includes(m.type), + ), + [paymentMethods], + ); - const currency = charge?.currency ?? charge?.booking?.currency ?? "ETB"; + // The charge is always booked in ETB; this is what it costs before a method is chosen. + const chargeCurrency = charge?.currency ?? charge?.booking?.currency ?? "ETB"; + const chargeAmount = useMemo( + () => Number(charge?.totalMinor ?? charge?.amountMinor ?? 0) / 100, + [charge], + ); + + // Each method settles in its own currency (WAAFI/DMONEY in DJF, CARD in USD, Ethiopian wallets + // in ETB), so the price has to be re-quoted server-side whenever the selection changes — the + // stored ETB total is not what a Djiboutian wallet would debit. + const { + data: quote, + isFetching: fetchingQuote, + error: quoteError, + } = useQuery({ + queryKey: ["excessBaggageAmount", token, selectedMethod], + queryFn: () => + apiClient.get( + `/excess-baggage/pay/${token}/amount?method=${selectedMethod}`, + ), + enabled: !!token && !!selectedMethod, + retry: false, + staleTime: 30_000, + }); + + // A quote is only usable once it belongs to the method currently selected — otherwise it is a + // leftover from the previous selection and would price the payment in the wrong currency. + const quoteReady = !fetchingQuote && quote?.method === selectedMethod; + + const displayCurrency = selectedMethod + ? (quote?.currency ?? "") + : chargeCurrency; + const displayAmount = selectedMethod ? quote?.amount : chargeAmount; + const amountLabel = + quoteReady && displayAmount != null + ? `${displayCurrency} ${formatAmount(displayAmount, displayCurrency)}` + : !selectedMethod && displayAmount != null + ? `${chargeCurrency} ${formatAmount(displayAmount, chargeCurrency)}` + : null; + + // Never let Pay fire against a price the payer has not been shown. + const awaitingQuote = !!selectedMethod && !quoteReady; const payMutation = useMutation({ - mutationFn: (method: string) => + mutationFn: (vars: { method: string; payerAccount?: string }) => apiClient.post(`/excess-baggage/pay/${token}/initiate`, { - method, + method: vars.method, platform: "web", + ...(vars.payerAccount ? { payerAccount: vars.payerAccount } : {}), }), onSuccess: (data: any) => { - if (data?.clientAction?.type === "REDIRECT") { - window.location.href = data.clientAction.url; + const action = data?.clientAction; + + if (action?.type === "REDIRECT") { + window.location.href = action.url; return; } + + // CAC Bank: no redirect — the bank SMS'd an OTP. Collect it here and confirm. + if (action?.type === "COLLECT_OTP") { + setOtpMessage(action.message ?? "Enter the OTP sent to your phone"); + setOtpCode(""); + setOtpError(null); + setOtpModalOpen(true); + setIsProcessing(false); + return; + } + + // CBE: the bill now exists in CBE's system. Nothing to navigate to — show the number. + if (action?.type === "SHOW_BILL_REFERENCE") { + setBillAction({ + billReference: action.billReference, + instructions: action.instructions, + expiresAt: action.expiresAt, + }); + setBillCopied(false); + setIsProcessing(false); + return; + } + + // eBirr: the PIN prompt was pushed to the payer's handset; there is nothing to navigate to. + if (action?.type === "AWAIT_PUSH") { + setPushMessage( + action.message ?? + `Approve the payment on your phone${action.payerAccountMasked ? ` (${action.payerAccountMasked})` : ""}.`, + ); + setIsProcessing(false); + return; + } + router.push(`/excess-baggage/pay/${token}/result`); }, onError: (err: any) => { @@ -71,11 +196,92 @@ export default function ExcessBaggagePayPage() { }, }); - const handlePay = () => { + // CAC Bank OTP confirmation. A 200 means the debit settled; a 400 is a wrong/expired OTP — + // keep the modal open so the payer can re-enter it (the intent stays open). + const otpMutation = useMutation({ + mutationFn: (otp: string) => + apiClient.post(`/excess-baggage/pay/${token}/confirm`, { otp }), + onSuccess: () => { + setOtpModalOpen(false); + router.push(`/excess-baggage/pay/${token}/result`); + }, + onError: (err: any) => { + setOtpError( + err?.response?.data?.message ?? + err?.message ?? + "Invalid or expired OTP. Please try again.", + ); + }, + }); + + const startPayment = (mobile?: string) => { if (!selectedMethod) return; setIsProcessing(true); setPaymentError(null); - payMutation.mutate(selectedMethod); + payMutation.mutate({ + method: selectedMethod, + payerAccount: requiresPayerMobile(selectedMethod) + ? mobile?.trim() + : undefined, + }); + }; + + const handlePay = () => { + if (!selectedMethod || awaitingQuote) return; + setPaymentError(null); + + if (requiresPayerMobile(selectedMethod)) { + // Prefill with the number the charge was raised against, but leave it editable — the + // handset paying is often not the one the booking was made under. + if (!payerMobile.trim() && charge?.contactPhone) { + setPayerMobile(charge.contactPhone); + } + setPhoneError(null); + setPhoneModalOpen(true); + return; + } + + startPayment(); + }; + + const submitPhone = () => { + if (!payerMobile.trim()) { + setPhoneError("Please enter your mobile number"); + return; + } + setPhoneModalOpen(false); + startPayment(payerMobile); + }; + + // While a bill or a pushed PIN prompt is outstanding, watch the charge. Settlement happens + // server-side — a CBE teller, or the provider's webhook — so the browser has no other signal. + // Success is only ever claimed from this, never from a client-side guess. + const watching = !!billAction || !!pushMessage; + const { data: liveStatus } = useQuery<{ status: string; paid: boolean }>({ + queryKey: ["excessBaggageStatus", token], + queryFn: () => + apiClient.get<{ status: string; paid: boolean }>( + `/excess-baggage/pay/${token}/status`, + ), + enabled: !!token && watching, + refetchInterval: 5_000, + }); + + useEffect(() => { + if (watching && liveStatus?.paid) { + router.push(`/excess-baggage/pay/${token}/result`); + } + }, [watching, liveStatus?.paid, router, token]); + + const copyBillReference = async () => { + if (!billAction) return; + try { + await navigator.clipboard.writeText(billAction.billReference); + setBillCopied(true); + setTimeout(() => setBillCopied(false), 2000); + } catch { + /* clipboard unavailable — the number is still shown on screen */ + } }; if (loadingCharge) { @@ -112,10 +318,25 @@ export default function ExcessBaggagePayPage() {
Amount due - - {currency} {amountDisplay} - + {amountLabel ? ( + {amountLabel} + ) : quoteError ? ( + + ) : ( + + )}
+ {selectedMethod && quoteReady && displayCurrency !== chargeCurrency && ( +

+ Converted from {chargeCurrency} {formatAmount(chargeAmount, chargeCurrency)} at today's rate +

+ )} + {quoteError && ( +

+ {(quoteError as any)?.response?.data?.message ?? + "This payment method is unavailable right now. Please choose another."} +

+ )}
Weight {charge.excessWeightKg ?? "—"} kg @@ -131,7 +352,7 @@ export default function ExcessBaggagePayPage() {
) : (
- {paymentMethods.filter((m) => m.enabled).map((method) => { + {availableMethods.map((method) => { const Icon = getIconForMethod(method.type); const isSelected = selectedMethod === method.type; return ( @@ -166,17 +387,181 @@ export default function ExcessBaggagePayPage() { + + {/* CBE bill — show the number; confirmation only ever comes from the status poll */} + {billAction && ( +
+
+
+ +

Pay at CBE

+
+

+ {billAction.instructions ?? + "Pay this bill at any CBE branch, the CBE Birr app, mobile banking or USSD."} +

+
+ + {billAction.billReference} + + +
+
+

+ Amount: ETB {formatAmount(chargeAmount, "ETB")} +

+ {billAction.expiresAt && ( +

+ Pay before:{" "} + + {new Date(billAction.expiresAt).toLocaleString()} + +

+ )} +
+
+ + Waiting for payment confirmation — this page updates automatically once CBE + confirms your payment. +
+ +
+
+ )} + + {/* eBirr: the PIN prompt is on the payer's handset — nothing to navigate to. */} + {pushMessage && ( +
+ +
+

Check your phone

+

{pushMessage}

+
+
+ )} + + {/* Push-debit methods (CAC Bank, eBirr) — collect payer mobile before initiating */} + {phoneModalOpen && ( +
+
+
+ +

Your mobile number

+
+

+ {selectedMethod === "EBIRR" + ? "eBirr will prompt this number for your PIN to authorize the payment. Make sure it's the phone you have with you." + : "CAC Bank will send a one-time password to this number to authorize the payment."} +

+ { setPayerMobile(e.target.value); setPhoneError(null); }} + onKeyDown={(e) => { if (e.key === "Enter") submitPhone(); }} + placeholder={selectedMethod === "EBIRR" ? "09XX XXX XXX" : "77 XX XX XX"} + className="w-full px-3 py-3 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:border-primary focus:ring-1 focus:ring-primary outline-none" + /> + {phoneError && ( +

⚠️ {phoneError}

+ )} +
+ + +
+
+
+ )} + + {/* CAC Bank OTP entry */} + {otpModalOpen && ( +
+
+
+ +

Enter OTP

+
+

{otpMessage}

+ { setOtpCode(e.target.value.replace(/\D/g, "")); setOtpError(null); }} + onKeyDown={(e) => { if (e.key === "Enter" && otpCode.trim() && !otpMutation.isPending) otpMutation.mutate(otpCode.trim()); }} + placeholder="Enter code" + maxLength={10} + className="w-full text-center tracking-[0.4em] text-lg font-semibold px-3 py-3 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:border-primary focus:ring-1 focus:ring-primary outline-none" + /> + {otpError && ( +

⚠️ {otpError}

+ )} +
+ + +
+
+
+ )}
); From 7143ba1040a6a86a66ca76310963d94ac6aaafe0 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Mon, 17 Aug 2026 12:38:06 +0000 Subject: [PATCH 17/43] feat(customers): notify marketing on returned changes, name actors in history MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three gaps on the backoffice customer detail page: - Rejecting a change request or sending it back for correction notified nobody. Adds CompanyNotifierService.changeRequestReturned, which pings the customer desk with the reviewer, the outcome and the note. Marketing joins that desk via customers:view + customers:get_notification in the role preset — grants still come from the IAM UI, the preset only sets the default for new environments. - submitted_by / reviewed_by / actor_id were stored but never resolved, so the History tab could say what changed but never who asked or who sent it back. Resolves them through a shared iam-user-name util (deduped from the private copy in contract-document-history.service) and renders "Requested by" / "Sent back to marketing by" lines. The changes_requested badge is relabelled to match the workflow. - "View" opened an in-page modal one document at a time. Adds openFileInNewTab, which opens the tab inside the click gesture and fills it once the authenticated fetch resolves, and an "Open all" button that loops over the documents table so every file lands in its own tab. --- .../src/common/utils/iam-user-name.util.ts | 49 +++++++++++ .../modules/companies/companies.service.ts | 82 ++++++++++++++++++- .../companies/company-notifier.service.ts | 29 +++++++ .../dto/change-request-response.dto.ts | 6 ++ .../dto/company-revision-response.dto.ts | 3 + .../entities/company-change-request.entity.ts | 8 ++ .../entities/company-revision.entity.ts | 6 ++ .../contract-document-history.service.ts | 38 +-------- .../src/seed/freight-permissions.registry.ts | 6 ++ .../customers/ChangeRequestReview.tsx | 21 ++--- .../components/customers/CompanyTimeline.tsx | 66 +++++++++++++-- .../pages/customers/CustomerDetailPage.tsx | 57 +++++++------ .../backoffice/src/services/files.service.ts | 47 +++++++++++ .../backoffice/src/types/customer.ts | 6 ++ 14 files changed, 335 insertions(+), 89 deletions(-) create mode 100644 apps/edr-freight-api/src/common/utils/iam-user-name.util.ts diff --git a/apps/edr-freight-api/src/common/utils/iam-user-name.util.ts b/apps/edr-freight-api/src/common/utils/iam-user-name.util.ts new file mode 100644 index 000000000..e4a3465d0 --- /dev/null +++ b/apps/edr-freight-api/src/common/utils/iam-user-name.util.ts @@ -0,0 +1,49 @@ +import { DataSource } from "typeorm"; + +/** + * `iam.users.name` is a localized object ({ en, am, … }), not a string — a + * plain `String(name)` there yields "[object Object]" in an audit trail. + */ +export interface IamUserRow { + name?: Record | string | null; + username?: string | null; + email?: string | null; +} + +/** Best display name for a user row: English label → any locale → login → email. */ +export function pickUserName(user: IamUserRow): string | null { + const { name } = user; + if (typeof name === "string" && name.trim()) return name.trim(); + if (name && typeof name === "object") { + const localized = + name.en ?? + Object.values(name).find((v) => typeof v === "string" && v.trim()); + if (localized?.trim()) return localized.trim(); + } + return user.username?.trim() || user.email?.trim() || null; +} + +/** + * Display names for a set of IAM user ids — one query for the whole set. + * `iam.users` is owned by the auth system and has no entity here, so it is read + * directly. A miss is not an error: the caller still holds the id and can fall + * back to it. + */ +export async function resolveIamUserNames( + dataSource: DataSource, + userIds: (string | null | undefined)[], +): Promise> { + const resolved = new Map(); + const ids = [...new Set(userIds.filter((id): id is string => Boolean(id)))]; + if (ids.length === 0) return resolved; + + const rows = (await dataSource.query( + `SELECT id, name, username, email FROM iam.users WHERE id = ANY($1::uuid[])`, + [ids], + )) as Array; + for (const row of rows) { + const name = pickUserName(row); + if (name) resolved.set(row.id, name); + } + return resolved; +} diff --git a/apps/edr-freight-api/src/modules/companies/companies.service.ts b/apps/edr-freight-api/src/modules/companies/companies.service.ts index 98b30f006..bf8184833 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.service.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.service.ts @@ -7,6 +7,7 @@ import { ForbiddenException, } from "@nestjs/common"; import { DataSource, EntityManager } from "typeorm"; +import { resolveIamUserNames } from "../../common/utils/iam-user-name.util"; import { CompaniesRepository } from "./companies.repository"; import { CompanyProfileRepository } from "./company-profile.repository"; import { CompanyChangeRequestRepository } from "./company-change-request.repository"; @@ -1141,16 +1142,55 @@ export class CompaniesService { return new ProfileResponseDto(profile, live, request); } - /** List a company's change requests, newest first (backoffice review). */ + /** + * List a company's change requests, newest first (backoffice review). Actor + * ids are resolved to display names here — the history screen has to say who + * asked for a change and who sent it back, not print two uuids. + */ async listChangeRequests(companyId: string): Promise { await this.findCompanyById(companyId); - return this.changeRequestRepo.findByCompanyId(companyId); + const requests = await this.changeRequestRepo.findByCompanyId(companyId); + const names = await this.resolveActorNames( + requests.flatMap((r) => [r.submittedBy, r.reviewedBy]), + ); + for (const request of requests) { + request.submittedByName = request.submittedBy + ? (names.get(request.submittedBy) ?? null) + : null; + request.reviewedByName = request.reviewedBy + ? (names.get(request.reviewedBy) ?? null) + : null; + } + return requests; } /** Onboarding-phase edit history (see {@link recordCompanyRevision}), newest first. */ async listCompanyRevisions(companyId: string): Promise { await this.findCompanyById(companyId); - return this.revisionRepo.findByCompanyId(companyId); + const revisions = await this.revisionRepo.findByCompanyId(companyId); + const names = await this.resolveActorNames(revisions.map((r) => r.actorId)); + for (const revision of revisions) { + revision.actorName = revision.actorId + ? (names.get(revision.actorId) ?? null) + : null; + } + return revisions; + } + + /** + * Display names for actor ids, one query for the whole list. A lookup failure + * degrades the history to ids rather than failing the request — the entry is + * still worth showing without the name. + */ + private async resolveActorNames( + actorIds: (string | null | undefined)[], + ): Promise> { + try { + return await resolveIamUserNames(this.dataSource, actorIds); + } catch (err) { + this.logger.warn(`Could not resolve actor names: ${String(err)}`); + return new Map(); + } } /** @@ -1519,6 +1559,7 @@ export class CompaniesService { } await this.discardLicenseChanges(request); await this.discardDocumentChanges(request); + await this.notifyChangeRequestReturned(request, "rejected", note, reviewerId); return ( (await this.changeRequestRepo.update(id, { status: ChangeRequestStatus.Rejected, @@ -1556,6 +1597,12 @@ export class CompaniesService { `Change request ${id} is already ${request.status}`, ); } + await this.notifyChangeRequestReturned( + request, + "changes_requested", + note, + reviewerId, + ); return ( (await this.changeRequestRepo.update(id, { status: ChangeRequestStatus.ChangesRequested, @@ -1566,6 +1613,35 @@ export class CompaniesService { ); } + /** + * Tell the customer desk a change request came back unapproved. Best-effort: + * a missing company or an unresolvable reviewer name must not fail the + * reviewer's decision, which is already the point of the try/catch. + */ + private async notifyChangeRequestReturned( + request: CompanyChangeRequest, + outcome: "rejected" | "changes_requested", + note: string, + reviewerId?: string, + ): Promise { + try { + const company = await this.companiesRepo.findById(request.companyId); + if (!company) return; + const names = await this.resolveActorNames([reviewerId]); + this.companyNotifier.changeRequestReturned( + company, + request.id, + outcome, + note, + reviewerId ? (names.get(reviewerId) ?? null) : null, + ); + } catch (err) { + this.logger.warn( + `Could not notify the customer desk about ${request.id}: ${String(err)}`, + ); + } + } + async deleteCompany(id: string): Promise { await this.findCompanyById(id); await this.companiesRepo.softDelete(id); 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 6bcd21f08..d3a556f50 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 @@ -244,6 +244,35 @@ export class CompanyNotifierService { ); } + /** + * A reviewer did NOT approve a customer's profile changes — they rejected it + * or sent it back for correction. The customer desk (Marketing included, via + * the `customers:get_notification` key) owns the follow-up with the customer, + * so the decision has to reach their inbox; without this it was silent, and + * only visible to whoever happened to reopen the customer's History tab. + */ + changeRequestReturned( + company: Company, + changeRequestId: string, + outcome: "rejected" | "changes_requested", + note: string, + reviewerName?: string | null, + ): void { + const rejected = outcome === "rejected"; + const by = reviewerName?.trim() ? ` by ${reviewerName.trim()}` : ""; + this.logger.log(`CHANGE_REQUEST_${outcome.toUpperCase()} — ${company.id}`); + this.notifyStaff( + company, + rejected + ? "Customer profile changes rejected" + : "Customer profile changes sent back for correction", + `${company.name}'s profile changes were ` + + `${rejected ? "rejected" : "sent back for correction"}${by}. ` + + `Reason: ${note}`, + { changeRequestId, outcome, note, reviewerName: reviewerName ?? null }, + ); + } + // ── Customer-facing: a specific document needs correcting ────────────────── /** diff --git a/apps/edr-freight-api/src/modules/companies/dto/change-request-response.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/change-request-response.dto.ts index 4a931dae3..2a4f65708 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/change-request-response.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/change-request-response.dto.ts @@ -23,8 +23,12 @@ export class ChangeRequestResponseDto { documentChanges: DocumentChangeIntent[]; note: string | null; submittedBy: string | null; + /** Who filed the request, for the history screen (null when unresolvable). */ + submittedByName: string | null; submittedAt: Date | null; reviewedBy: string | null; + /** Who approved / rejected / sent it back. */ + reviewedByName: string | null; reviewedAt: Date | null; createdAt: Date; updatedAt: Date; @@ -39,8 +43,10 @@ export class ChangeRequestResponseDto { this.documentChanges = req.documents?.documentChanges ?? []; this.note = req.note ?? null; this.submittedBy = req.submittedBy ?? null; + this.submittedByName = req.submittedByName ?? null; this.submittedAt = req.submittedAt ?? null; this.reviewedBy = req.reviewedBy ?? null; + this.reviewedByName = req.reviewedByName ?? null; this.reviewedAt = req.reviewedAt ?? null; this.createdAt = req.createdAt; this.updatedAt = req.updatedAt; diff --git a/apps/edr-freight-api/src/modules/companies/dto/company-revision-response.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/company-revision-response.dto.ts index c93c7387c..198a44e06 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/company-revision-response.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/company-revision-response.dto.ts @@ -8,6 +8,8 @@ export class CompanyRevisionResponseDto { id: string; companyId: string; actorId: string | null; + /** Who made the edit, for the history screen (null when unresolvable). */ + actorName: string | null; summary: string; changes: CompanyRevisionChange[]; createdAt: Date; @@ -16,6 +18,7 @@ export class CompanyRevisionResponseDto { this.id = revision.id; this.companyId = revision.companyId; this.actorId = revision.actorId ?? null; + this.actorName = revision.actorName ?? null; this.summary = revision.summary; this.changes = revision.changes ?? []; this.createdAt = revision.createdAt; diff --git a/apps/edr-freight-api/src/modules/companies/entities/company-change-request.entity.ts b/apps/edr-freight-api/src/modules/companies/entities/company-change-request.entity.ts index 2ba39ecad..02b49f849 100644 --- a/apps/edr-freight-api/src/modules/companies/entities/company-change-request.entity.ts +++ b/apps/edr-freight-api/src/modules/companies/entities/company-change-request.entity.ts @@ -110,4 +110,12 @@ export class CompanyChangeRequest extends BaseEntity { @Column({ name: "reviewed_at", type: "timestamptz", nullable: true }) reviewedAt?: Date | null; + + /** + * Display names for {@link submittedBy} / {@link reviewedBy}, resolved from + * `iam.users` on read. Not columns — the history screen has to name the + * person who asked for the change, and an opaque uuid does not. + */ + submittedByName?: string | null; + reviewedByName?: string | null; } diff --git a/apps/edr-freight-api/src/modules/companies/entities/company-revision.entity.ts b/apps/edr-freight-api/src/modules/companies/entities/company-revision.entity.ts index 222a8364f..533f08d7a 100644 --- a/apps/edr-freight-api/src/modules/companies/entities/company-revision.entity.ts +++ b/apps/edr-freight-api/src/modules/companies/entities/company-revision.entity.ts @@ -43,4 +43,10 @@ export class CompanyRevision extends BaseEntity { @Column({ name: "changes", type: "jsonb", default: () => `'[]'::jsonb` }) changes!: CompanyRevisionChange[]; + + /** + * Display name for {@link actorId}, resolved from `iam.users` on read. Not a + * column — history has to name who made the edit, and a uuid does not. + */ + actorName?: string | null; } diff --git a/apps/edr-freight-api/src/modules/contracts/contract-document-history.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-document-history.service.ts index e38f737c6..1f1469182 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-document-history.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-document-history.service.ts @@ -2,6 +2,7 @@ import { Injectable, Logger } from '@nestjs/common'; import { InjectDataSource, InjectRepository } from '@nestjs/typeorm'; import { DataSource, Repository } from 'typeorm'; +import { resolveIamUserNames } from '../../common/utils/iam-user-name.util'; import { ContractDocumentChange, diffSnapshots, @@ -20,28 +21,6 @@ export interface RecordRevisionInput { stepId?: string | null; } -/** - * `iam.users.name` is a localized object ({ en, am, … }), not a string — a - * plain `String(name)` there yields "[object Object]" in the audit trail. - */ -interface IamUserRow { - name?: Record | string | null; - username?: string | null; - email?: string | null; -} - -/** Best display name for a user row: English label → any locale → login → email. */ -function pickUserName(user: IamUserRow): string | null { - const { name } = user; - if (typeof name === 'string' && name.trim()) return name.trim(); - if (name && typeof name === 'object') { - const localized = - name.en ?? Object.values(name).find((v) => typeof v === 'string' && v.trim()); - if (localized?.trim()) return localized.trim(); - } - return user.username?.trim() || user.email?.trim() || null; -} - /** Pre-computed changes (contract fields), rather than a document diff. */ export interface RecordChangesInput { contractId: string; @@ -120,23 +99,12 @@ export class ContractDocumentHistoryService { private async resolveActorNames( actorIds: string[], ): Promise> { - const resolved = new Map(); - const ids = [...new Set(actorIds.filter(Boolean))]; - if (ids.length === 0) return resolved; - try { - const rows = (await this.dataSource.query( - `SELECT id, name, username, email FROM iam.users WHERE id = ANY($1::uuid[])`, - [ids], - )) as Array; - for (const row of rows) { - const name = pickUserName(row); - if (name) resolved.set(row.id, name); - } + return await resolveIamUserNames(this.dataSource, actorIds); } catch (err) { this.logger.warn(`Could not resolve actor names: ${String(err)}`); + return new Map(); } - return resolved; } /** Revision history for a contract, newest first. */ 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 adbd22584..3fde18648 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts @@ -2484,6 +2484,12 @@ export const ROLE_PERMISSION_PRESETS = { FREIGHT_PERMS.contracts.suspend, FREIGHT_PERMS.contracts.editDocument, ...BOOKING_DESK_NOTIFICATION_KEYS, + // Marketing follows up with the customer when a reviewer sends profile + // changes back, so they sit on the customer desk: read-only on the customer + // record (no verify/deactivate — the decision stays with the chief) plus the + // desk key the change-request pings are addressed to. + FREIGHT_PERMS.customers.view, + FREIGHT_PERMS.customers.getNotification, ], orgManager: [...BOOKING_RULE_ENGINE_PERMISSION_KEYS], } as const; diff --git a/apps/edr-freight-web/backoffice/src/components/customers/ChangeRequestReview.tsx b/apps/edr-freight-web/backoffice/src/components/customers/ChangeRequestReview.tsx index 65c4f25e3..7f4cf30ce 100644 --- a/apps/edr-freight-web/backoffice/src/components/customers/ChangeRequestReview.tsx +++ b/apps/edr-freight-web/backoffice/src/components/customers/ChangeRequestReview.tsx @@ -20,11 +20,10 @@ import { FileX2, } from "lucide-react"; import { useState } from "react"; -import { useFileViewer } from "@edr/ui-common"; import { useAuth } from "@/auth/useAuth"; import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions"; -import { fetchViewableFile } from "@/services/files.service"; +import { openFileInNewTab } from "@/services/files.service"; import { api } from "@/services/api"; import type { Company } from "@/types/customer"; import { formatDate, humanize } from "./format"; @@ -227,7 +226,6 @@ export function ChangeRequestReview({ company }: { company: Company }) { api.customers.requestChangeRequestChanges.mutationOptions(), ); - const { view, viewer } = useFileViewer(); const [actionTarget, setActionTarget] = useState<{ id: string; kind: "reject" | "request-changes"; @@ -350,10 +348,10 @@ export function ChangeRequestReview({ company }: { company: Company }) { type="button" size="sm" onClick={() => - void fetchViewableFile( + openFileInNewTab( c.fileId, c.fileName ?? humanize(c.code), - ).then(view) + ) } style={{ textDecoration: @@ -382,12 +380,7 @@ export function ChangeRequestReview({ company }: { company: Company }) { component="button" type="button" size="sm" - onClick={() => - void fetchViewableFile( - fileId, - `Document ${i + 1}`, - ).then(view) - } + onClick={() => openFileInNewTab(fileId, `Document ${i + 1}`)} > Document {i + 1} @@ -421,10 +414,10 @@ export function ChangeRequestReview({ company }: { company: Company }) { type="button" size="sm" onClick={() => - void fetchViewableFile( + openFileInNewTab( c.fileId, c.fileName ?? "License document", - ).then(view) + ) } style={{ textDecoration: @@ -532,8 +525,6 @@ export function ChangeRequestReview({ company }: { company: Company }) {
- - {viewer} ); } diff --git a/apps/edr-freight-web/backoffice/src/components/customers/CompanyTimeline.tsx b/apps/edr-freight-web/backoffice/src/components/customers/CompanyTimeline.tsx index cd421d9bd..8469e4031 100644 --- a/apps/edr-freight-web/backoffice/src/components/customers/CompanyTimeline.tsx +++ b/apps/edr-freight-web/backoffice/src/components/customers/CompanyTimeline.tsx @@ -1,9 +1,8 @@ import { Alert, Anchor, Badge, Card, Group, SimpleGrid, Stack, Text } from "@mantine/core"; import { useQuery } from "@tanstack/react-query"; import { FilePlus2, FileX2, History } from "lucide-react"; -import { useFileViewer } from "@edr/ui-common"; -import { fetchViewableFile } from "@/services/files.service"; +import { openFileInNewTab } from "@/services/files.service"; import { api } from "@/services/api"; import type { Company, @@ -36,6 +35,12 @@ interface TimelineEntry { at: string; note?: string | null; summary?: string; + /** Who filed the change (the customer, or staff editing during onboarding). */ + requestedBy?: string | null; + /** When they filed it — the "asked" half of the ask/decide pair below. */ + requestedAt?: string | null; + /** Who decided (approved / rejected / sent it back to marketing). */ + decidedBy?: string | null; fieldDiffs: FieldDiff[]; docDiffs: DocDiff[]; } @@ -43,10 +48,18 @@ interface TimelineEntry { const KIND_BADGE: Record = { approved: { label: "Approved", color: "edr-green" }, rejected: { label: "Rejected", color: "red" }, - changes_requested: { label: "Changes requested", color: "yellow" }, + // Sending a request back is what "reverted to marketing" means here: the + // request stays open and marketing owns the follow-up with the customer. + changes_requested: { label: "Sent back to marketing", color: "yellow" }, revision: { label: "Recorded", color: "blue" }, }; +/** "Requested by X" / "Reviewed by X", with the id-less case reading sanely. */ +function actorLine(verb: string, who?: string | null, when?: string | null) { + if (!who && !when) return null; + return `${verb}${who ? ` by ${who}` : ""}${when ? ` · ${formatDate(when)}` : ""}`; +} + /** * Pair adjacent remove-then-add intents into one before/after doc diff — a * "replace" is always staged as `[{op:'remove'}, {op:'add'}]` pushed together @@ -132,6 +145,9 @@ function fromChangeRequest( kind: r.status as TimelineEntry["kind"], at: r.reviewedAt ?? r.updatedAt, note: r.note, + requestedBy: r.submittedByName, + requestedAt: r.submittedAt ?? r.createdAt, + decidedBy: r.reviewedByName, fieldDiffs, docDiffs, }; @@ -156,6 +172,7 @@ function fromRevision(rev: CompanyRevision): TimelineEntry { kind: "revision", at: rev.createdAt, summary: rev.summary, + requestedBy: rev.actorName, fieldDiffs, docDiffs, }; @@ -170,7 +187,6 @@ function fromRevision(rev: CompanyRevision): TimelineEntry { * single answer instead of two places to check. */ export function CompanyTimeline({ company }: { company: Company }) { - const { view, viewer } = useFileViewer(); const changeRequestsQuery = useQuery( api.customers.changeRequests.queryOptions({ input: { id: company.id } }), ); @@ -186,7 +202,7 @@ export function CompanyTimeline({ company }: { company: Company }) { ].sort((a, b) => new Date(b.at).getTime() - new Date(a.at).getTime()); const openFile = (file: { id: string; name: string }) => - void fetchViewableFile(file.id, file.name).then(view); + openFileInNewTab(file.id, file.name); if (entries.length === 0) { return ( @@ -205,6 +221,20 @@ export function CompanyTimeline({ company }: { company: Company }) { {entries.map((entry) => { const badge = KIND_BADGE[entry.kind]; + const requestedLine = actorLine( + entry.kind === "revision" ? "Edited" : "Requested", + entry.requestedBy, + entry.requestedAt, + ); + const decidedLine = actorLine( + entry.kind === "changes_requested" + ? "Sent back to marketing" + : entry.kind === "rejected" + ? "Rejected" + : "Approved", + entry.decidedBy, + entry.kind === "revision" ? null : entry.at, + ); return ( @@ -224,10 +254,33 @@ export function CompanyTimeline({ company }: { company: Company }) { + {/* Who asked, and who decided. Without this the feed said what + changed and when, but never named a person — the first thing + anyone auditing a returned request needs. */} + {(requestedLine || decidedLine) && ( + + {requestedLine && ( + + {requestedLine} + + )} + {decidedLine && ( + + {decidedLine} + + )} + + )} + {entry.note && ( - Note: {entry.note} + + {entry.kind === "changes_requested" + ? "What was asked for:" + : "Note:"} + {" "} + {entry.note} )} @@ -293,7 +346,6 @@ export function CompanyTimeline({ company }: { company: Company }) { ); })} - {viewer} ); } 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 b395cc710..40185f990 100644 --- a/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx @@ -23,6 +23,7 @@ import { Banknote, Contact, Download, + ExternalLink, Eye, FileSignature, FileText, @@ -69,7 +70,7 @@ import { useAuth } from "@/auth/useAuth"; import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions"; import { downloadBookingFile, - fetchViewableFile, + openFileInNewTab, } from "@/services/files.service"; import { api } from "@/services/api"; import type { @@ -81,12 +82,7 @@ import type { } from "@/types/customer"; import { hasSubmittedOnboarding, isOnboardingDraft } from "@/types/customer"; import type { Invoice } from "@/types/invoice"; -import { - DataTable, - useFileViewer, - usePagination, - type ColumnDef, -} from "@edr/ui-common"; +import { DataTable, usePagination, type ColumnDef } from "@edr/ui-common"; import type { Freight } from "@edr/types"; /** Plain-text summary of the company's eTrade-sourced record, downloaded client-side (eTrade returns data, not a document). */ @@ -146,7 +142,6 @@ const POA_DELEGATION_PENDING_CODE = "poa_delegation_letter_pending"; export default function CustomerDetailPage() { const { id } = useParams<{ id: string }>(); const navigate = useNavigate(); - const { view, viewer } = useFileViewer(); const { user } = useAuth(); const { data: company, isLoading } = useQuery( @@ -271,9 +266,7 @@ export default function CustomerDetailPage() { variant="subtle" color="gray" aria-label={`View ${f.name}`} - onClick={() => - void fetchViewableFile(f.id, f.name).then(view) - } + onClick={() => openFileInNewTab(f.id, f.name)} > @@ -282,9 +275,7 @@ export default function CustomerDetailPage() { type="button" size="xs" lineClamp={1} - onClick={() => - void fetchViewableFile(f.id, f.name).then(view) - } + onClick={() => openFileInNewTab(f.id, f.name)} style={{ maxWidth: 170, textAlign: "left", @@ -339,7 +330,7 @@ export default function CustomerDetailPage() { ), }, ], - [view, canReview], + [canReview], ); const bookingColumns: ColumnDef[] = useMemo( @@ -522,9 +513,7 @@ export default function CustomerDetailPage() { aria-label="View" data-stop-row-click onClick={() => - void fetchViewableFile(row.original.id, row.original.name).then( - view, - ) + openFileInNewTab(row.original.id, row.original.name) } > @@ -564,7 +553,7 @@ export default function CustomerDetailPage() { ), }, ], - [view, canRequestDocChange], + [canRequestDocChange], ); const paymentColumns: ColumnDef[] = useMemo( @@ -1163,9 +1152,7 @@ export default function CustomerDetailPage() { lineClamp={1} style={{ flex: 1, textAlign: "left" }} onClick={() => - void fetchViewableFile(doc.id, doc.name).then( - view, - ) + openFileInNewTab(doc.id, doc.name) } > {doc.name} @@ -1176,9 +1163,7 @@ export default function CustomerDetailPage() { color="gray" aria-label={`Preview ${doc.name}`} onClick={() => - void fetchViewableFile(doc.id, doc.name).then( - view, - ) + openFileInNewTab(doc.id, doc.name) } > @@ -1280,6 +1265,23 @@ export default function CustomerDetailPage() { {/* DOCUMENTS */} + {/* Reviewing a customer means reading every document, so offer the + whole set at once — each opens in its own tab. The loop is + synchronous inside the click handler on purpose: that is what + keeps the browser treating all of them as user-initiated. */} + + + + - void fetchViewableFile(f.id, f.name).then(view) - } + onClick={() => openFileInNewTab(f.id, f.name)} size="xs" style={{ textDecoration: @@ -1424,7 +1424,6 @@ export default function CustomerDetailPage() { onClose={() => setChangeRequestDoc(null)} /> - {viewer} ); } diff --git a/apps/edr-freight-web/backoffice/src/services/files.service.ts b/apps/edr-freight-web/backoffice/src/services/files.service.ts index 038b5a86d..c86a3beb4 100644 --- a/apps/edr-freight-web/backoffice/src/services/files.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/files.service.ts @@ -25,6 +25,53 @@ export async function downloadBookingFile( URL.revokeObjectURL(url); } +/** + * Open a stored file in its own browser tab. + * + * Two things make this less trivial than an ``: + * - `GET /files/:id` is authenticated, so the bytes have to come through the + * axios client and be handed over as a blob URL (same reason as + * {@link fetchViewableFile}). + * - The tab therefore has to be opened *synchronously*, inside the click + * gesture, and filled once the download resolves — a `window.open()` after an + * `await` is blocked as a popup. That also means a loop over several + * documents opens one tab each, all within the same gesture. + * + * `noopener` is deliberately not passed: it makes `window.open` return null, and + * the handle is what lets us navigate the tab. `opener` is nulled instead. + */ +export function openFileInNewTab(id: string, filename: string): void { + const tab = window.open("", "_blank"); + if (tab) { + tab.opener = null; + tab.document.title = filename; + if (tab.document.body) { + tab.document.body.textContent = `Opening ${filename}…`; + } + } + void filesService.download(id).then( + (blob) => { + const url = URL.createObjectURL(blob); + if (tab) tab.location.replace(url); + // Popup blocked — fall back to a save, so the click still does something. + else { + const a = document.createElement("a"); + a.href = url; + a.download = filename; + a.click(); + } + // Revoking immediately would cancel the tab's own load of the URL. + setTimeout(() => URL.revokeObjectURL(url), 60_000); + }, + (error: unknown) => { + if (tab?.document.body) { + tab.document.body.textContent = `Could not open ${filename}.`; + } + console.error(`Failed to open file ${id}`, error); + }, + ); +} + /** * GET /files/:id is authenticated (global JwtGuard) — raw browser loads * (/