From 46971fd5c57f07fec7b3b49f46fb934f6c425c9f Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Fri, 3 Jul 2026 07:30:12 +0000 Subject: [PATCH 01/38] Export Djbouti unloading queue --- .../ExportDjiboutiUnloadingQueuePage.tsx | 52 ++++++++++++++++++- 1 file changed, 50 insertions(+), 2 deletions(-) diff --git a/apps/edr-freight-web/backoffice/src/pages/warehouses/ExportDjiboutiUnloadingQueuePage.tsx b/apps/edr-freight-web/backoffice/src/pages/warehouses/ExportDjiboutiUnloadingQueuePage.tsx index 31ee5ea36..609995e3e 100644 --- a/apps/edr-freight-web/backoffice/src/pages/warehouses/ExportDjiboutiUnloadingQueuePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/warehouses/ExportDjiboutiUnloadingQueuePage.tsx @@ -14,7 +14,17 @@ import { Text, } from '@mantine/core'; import { useNavigate } from 'react-router-dom'; -import { ChevronDown, ChevronRight, Eye, FileText, History, PackageOpen, Truck } from 'lucide-react'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { + ChevronDown, + ChevronRight, + Eye, + FileText, + History, + PackageOpen, + ShieldCheck, + Truck, +} from 'lucide-react'; import { PageHeader } from '@/components/page'; import Breadcrumbs from '@/components/ui/Breadcrumbs'; @@ -36,6 +46,7 @@ import { useInterchangeDocuments, } from '@/hooks/useInterchangeDocuments'; import { useToast } from '@/hooks/use-toast'; +import { trainSchedulingService } from '@/services/trainScheduling.service'; import type { AutoUnloadExportDjiboutiResult, ExportTrain, @@ -179,6 +190,14 @@ export default function ExportDjiboutiUnloadingQueuePage() { const { data: interchangeDocuments = [] } = useInterchangeDocuments({ direction: 'EXPORT' }); const autoUnload = useAutoUnloadExportAtDjibouti(); const generateInterchange = useGenerateInterchangeDocument(); + const qc = useQueryClient(); + const secureGatePass = useMutation({ + mutationFn: (scheduleId: string) => trainSchedulingService.grantImportDjiboutiGatepass(scheduleId), + onSuccess: () => + qc.invalidateQueries({ + queryKey: ['warehouse-inventory', 'export-djibouti-arrival-queue'], + }), + }); const [openScheduleId, setOpenScheduleId] = useState(null); const [busyScheduleId, setBusyScheduleId] = useState(null); const [historyInventoryId, setHistoryInventoryId] = useState(null); @@ -189,6 +208,25 @@ export default function ExportDjiboutiUnloadingQueuePage() { .map((doc) => [doc.scheduleId as string, doc]), ); + const secureGate = async (train: ExportTrain) => { + setBusyScheduleId(train.scheduleId); + try { + await secureGatePass.mutateAsync(train.scheduleId); + toast({ + title: 'Gate pass secured', + description: `Djibouti Port entry allowed for ${train.trainNumber ?? 'the train'}. You can now auto unload.`, + }); + } catch (error) { + toast({ + variant: 'destructive', + title: 'Could not secure gate pass', + description: getErrorMessage(error), + }); + } finally { + setBusyScheduleId(null); + } + }; + const unloadTrain = async (train: ExportTrain) => { setBusyScheduleId(train.scheduleId); try { @@ -346,6 +384,16 @@ export default function ExportDjiboutiUnloadingQueuePage() { > Open + + + )} + {verifyWithFayda && faydaError && ( + + {faydaError} + + )} {shortFields.map(renderField)} diff --git a/apps/edr-freight-web/backoffice/src/components/fleet/fleetFormat.tsx b/apps/edr-freight-web/backoffice/src/components/fleet/fleetFormat.tsx index 709f288ce..b2a34eadc 100644 --- a/apps/edr-freight-web/backoffice/src/components/fleet/fleetFormat.tsx +++ b/apps/edr-freight-web/backoffice/src/components/fleet/fleetFormat.tsx @@ -4,7 +4,7 @@ import { Badge, Text } from "@mantine/core"; import type { ColumnFormat } from "@/pages/ruleEngine/config/resources"; import { formatCell as formatRuleEngineCell } from "@/components/ruleEngine/ruleEngineFormat"; -export type FleetColumnFormat = ColumnFormat | "statusBadge"; +export type FleetColumnFormat = ColumnFormat | "statusBadge" | "verifiedBadge"; const optionLabelMap = new Map>(); @@ -20,6 +20,16 @@ export const formatFleetCell = ( format?: FleetColumnFormat, accessorKey?: string, ): ReactNode => { + if (format === "verifiedBadge") { + return value === true ? ( + + Verified + + ) : ( + + ); + } + if (format === "statusBadge") { const status = value == null || value === "" ? "—" : String(value); const getStatusColor = (st: string): string => { diff --git a/apps/edr-freight-web/backoffice/src/pages/FaydaCallbackPage.tsx b/apps/edr-freight-web/backoffice/src/pages/FaydaCallbackPage.tsx new file mode 100644 index 000000000..5febb664a --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/FaydaCallbackPage.tsx @@ -0,0 +1,56 @@ +import { useEffect, useState } from "react"; +import { Center, Loader, Stack, Text } from "@mantine/core"; + +import type { FaydaCallbackMessage } from "@/services/verifayda.service"; + +/** + * Landing page for the eSignet redirect_uri (FAYDA_WEB_REDIRECT_URI → + * http://localhost:5183/callback). Runs inside the verification popup: + * relays ?code&state (or ?error) to the window that opened it via + * postMessage, then closes itself. The opener performs the /complete call + * so the single-use session is only consumed once, in one place. + */ +const FaydaCallbackPage = () => { + const [standalone, setStandalone] = useState(false); + + useEffect(() => { + const params = new URLSearchParams(window.location.search); + const message: FaydaCallbackMessage = { + type: "fayda-callback", + code: params.get("code") ?? undefined, + state: params.get("state") ?? undefined, + error: params.get("error") ?? undefined, + errorDescription: params.get("error_description") ?? undefined, + }; + + if (window.opener && window.opener !== window) { + (window.opener as Window).postMessage(message, window.location.origin); + window.close(); + } else { + // Opened as a full-page redirect instead of a popup — nothing to relay to. + setStandalone(true); + } + }, []); + + return ( +
+ + {standalone ? ( + <> + Verification window lost its parent page + + Close this tab and restart the verification from the form. + + + ) : ( + <> + + Completing Fayda verification… + + )} + +
+ ); +}; + +export default FaydaCallbackPage; 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 13be9ef71..db85fb0f9 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/FleetResourcePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/FleetResourcePage.tsx @@ -510,6 +510,7 @@ const FleetResourcePage = () => { isSubmitting={create.isPending || update.isPending} selectOptionsLoading={selectOptionsLoading} onSubmit={handleFormSubmit} + verifyWithFayda={Boolean(config.faydaVerification)} /> = { diff --git a/apps/edr-freight-web/backoffice/src/services/drivers.service.ts b/apps/edr-freight-web/backoffice/src/services/drivers.service.ts index d288975c2..79b0f88ef 100644 --- a/apps/edr-freight-web/backoffice/src/services/drivers.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/drivers.service.ts @@ -26,6 +26,8 @@ export interface Driver { address?: string | null; emergencyContact?: string | null; notes?: string | null; + faydaVerified?: boolean; + faydaSub?: string | null; totalTrips: number; rating: number; createdAt: string; diff --git a/apps/edr-freight-web/backoffice/src/services/verifayda.service.ts b/apps/edr-freight-web/backoffice/src/services/verifayda.service.ts new file mode 100644 index 000000000..d02394278 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/services/verifayda.service.ts @@ -0,0 +1,46 @@ +import { api as apiClient } from '../auth/http'; + +export interface FaydaStartResponse { + authorizationUrl: string; +} + +export interface FaydaCompleteResult { + purpose: 'LOGIN' | 'VERIFY'; + verified: boolean; + fullName?: string; + email?: string; + phoneNumber?: string; + /** ISO yyyy-MM-dd */ + birthdate?: string; + gender?: string; + iamUserId?: string; + userDataSaved?: boolean; +} + +/** Message posted from the /callback popup back to the opener window. */ +export interface FaydaCallbackMessage { + type: 'fayda-callback'; + code?: string; + state?: string; + error?: string; + errorDescription?: string; +} + +export const verifaydaService = { + /** Returns the eSignet authorize URL to open in a popup. */ + start: () => + apiClient + .post('/fayda/verification/start', { + purpose: 'VERIFY', + platform: 'WEB', + }) + .then((r) => r.data), + + /** Exchange the callback code+state for the verified identity attributes. */ + complete: (code: string, state: string) => + apiClient + .get( + `/fayda/verification/complete?code=${encodeURIComponent(code)}&state=${encodeURIComponent(state)}`, + ) + .then((r) => r.data), +}; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2f4a28bec..08c3fc2f7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -83,13 +83,13 @@ importers: version: 11.4.4(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2) '@nestjs/typeorm': specifier: ^11.0.1 - version: 11.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.30(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3))) + version: 11.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.30(babel-plugin-macros@3.1.0)(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3))) '@tria-plc/api-common': specifier: file:../../local-packages/tria-plc-api-common-1.4.3.tgz - version: file:local-packages/tria-plc-api-common-1.4.3.tgz(d6b22b11dde6cd6764a6a0c7e4c9ae51) + version: file:local-packages/tria-plc-api-common-1.4.3.tgz(bad2eb10df48448775040459098de142) '@tria-plc/iamapi-common': specifier: file:../../local-packages/tria-plc-iamapi-common-0.7.7.tgz - version: file:local-packages/tria-plc-iamapi-common-0.7.7.tgz(4837bbb980f4864b0c26765895373b58) + version: file:local-packages/tria-plc-iamapi-common-0.7.7.tgz(578386f46cf99fd4720e3e99f196f69e) amqp-connection-manager: specifier: ^5.0.0 version: 5.0.0(amqplib@2.0.1) @@ -117,6 +117,9 @@ importers: handlebars: specifier: ^4.7.9 version: 4.7.9 + jose: + specifier: ^5.10.0 + version: 5.10.0 libphonenumber-js: specifier: ^1.13.6 version: 1.13.6 @@ -137,7 +140,7 @@ importers: version: 7.8.2 typeorm: specifier: ^0.3.30 - version: 0.3.30(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3)) + version: 0.3.30(babel-plugin-macros@3.1.0)(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3)) devDependencies: '@edr/eslint-config': specifier: workspace:* @@ -147,10 +150,10 @@ importers: version: link:../../packages/config/tsconfig '@nestjs/cli': specifier: ^11.0.0 - version: 11.0.21(@types/node@20.19.42) + version: 11.0.21(@types/node@20.19.42)(prettier@3.8.3) '@nestjs/schematics': specifier: ^11.0.0 - version: 11.1.0(chokidar@4.0.3)(typescript@5.9.3) + version: 11.1.0(chokidar@4.0.3)(prettier@3.8.3)(typescript@5.9.3) '@nestjs/testing': specifier: ^11.0.0 version: 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(@nestjs/microservices@11.1.24)(@nestjs/platform-express@11.1.24) @@ -180,13 +183,13 @@ importers: version: 1.12.8 jest: specifier: ^29.7.0 - version: 29.7.0(@types/node@20.19.42)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3)) + version: 29.7.0(@types/node@20.19.42)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3)) supertest: specifier: ^7.0.0 version: 7.2.2 ts-jest: specifier: ^29.2.5 - version: 29.4.11(@babel/core@7.29.7)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(jest-util@29.7.0)(jest@29.7.0(@types/node@20.19.42)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3)))(typescript@5.9.3) + version: 29.4.11(@babel/core@7.29.7)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(jest-util@29.7.0)(jest@29.7.0(@types/node@20.19.42)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3)))(typescript@5.9.3) ts-loader: specifier: ^9.5.1 version: 9.6.0(typescript@5.9.3)(webpack@5.106.0) @@ -667,7 +670,7 @@ importers: version: 8.5.15 tailwindcss: specifier: ^3.4.13 - version: 3.4.19(tsx@4.22.4)(yaml@2.9.0) + version: 3.4.19(yaml@2.9.0) typescript: specifier: ^5.5.4 version: 5.9.3 @@ -755,7 +758,7 @@ importers: version: 8.5.15 tailwindcss: specifier: ^3.4.13 - version: 3.4.19(tsx@4.22.4)(yaml@2.9.0) + version: 3.4.19(yaml@2.9.0) typescript: specifier: ^5.5.4 version: 5.9.3 @@ -1497,294 +1500,138 @@ packages: cpu: [ppc64] os: [aix] - '@esbuild/aix-ppc64@0.28.1': - resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [aix] - '@esbuild/android-arm64@0.21.5': resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==} engines: {node: '>=12'} cpu: [arm64] os: [android] - '@esbuild/android-arm64@0.28.1': - resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [android] - '@esbuild/android-arm@0.21.5': resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==} engines: {node: '>=12'} cpu: [arm] os: [android] - '@esbuild/android-arm@0.28.1': - resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==} - engines: {node: '>=18'} - cpu: [arm] - os: [android] - '@esbuild/android-x64@0.21.5': resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==} engines: {node: '>=12'} cpu: [x64] os: [android] - '@esbuild/android-x64@0.28.1': - resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==} - engines: {node: '>=18'} - cpu: [x64] - os: [android] - '@esbuild/darwin-arm64@0.21.5': resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==} engines: {node: '>=12'} cpu: [arm64] os: [darwin] - '@esbuild/darwin-arm64@0.28.1': - resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==} - engines: {node: '>=18'} - cpu: [arm64] - os: [darwin] - '@esbuild/darwin-x64@0.21.5': resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==} engines: {node: '>=12'} cpu: [x64] os: [darwin] - '@esbuild/darwin-x64@0.28.1': - resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [darwin] - '@esbuild/freebsd-arm64@0.21.5': resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==} engines: {node: '>=12'} cpu: [arm64] os: [freebsd] - '@esbuild/freebsd-arm64@0.28.1': - resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==} - engines: {node: '>=18'} - cpu: [arm64] - os: [freebsd] - '@esbuild/freebsd-x64@0.21.5': resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==} engines: {node: '>=12'} cpu: [x64] os: [freebsd] - '@esbuild/freebsd-x64@0.28.1': - resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [freebsd] - '@esbuild/linux-arm64@0.21.5': resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==} engines: {node: '>=12'} cpu: [arm64] os: [linux] - '@esbuild/linux-arm64@0.28.1': - resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==} - engines: {node: '>=18'} - cpu: [arm64] - os: [linux] - '@esbuild/linux-arm@0.21.5': resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==} engines: {node: '>=12'} cpu: [arm] os: [linux] - '@esbuild/linux-arm@0.28.1': - resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==} - engines: {node: '>=18'} - cpu: [arm] - os: [linux] - '@esbuild/linux-ia32@0.21.5': resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==} engines: {node: '>=12'} cpu: [ia32] os: [linux] - '@esbuild/linux-ia32@0.28.1': - resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==} - engines: {node: '>=18'} - cpu: [ia32] - os: [linux] - '@esbuild/linux-loong64@0.21.5': resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==} engines: {node: '>=12'} cpu: [loong64] os: [linux] - '@esbuild/linux-loong64@0.28.1': - resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==} - engines: {node: '>=18'} - cpu: [loong64] - os: [linux] - '@esbuild/linux-mips64el@0.21.5': resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==} engines: {node: '>=12'} cpu: [mips64el] os: [linux] - '@esbuild/linux-mips64el@0.28.1': - resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==} - engines: {node: '>=18'} - cpu: [mips64el] - os: [linux] - '@esbuild/linux-ppc64@0.21.5': resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==} engines: {node: '>=12'} cpu: [ppc64] os: [linux] - '@esbuild/linux-ppc64@0.28.1': - resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [linux] - '@esbuild/linux-riscv64@0.21.5': resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==} engines: {node: '>=12'} cpu: [riscv64] os: [linux] - '@esbuild/linux-riscv64@0.28.1': - resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==} - engines: {node: '>=18'} - cpu: [riscv64] - os: [linux] - '@esbuild/linux-s390x@0.21.5': resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==} engines: {node: '>=12'} cpu: [s390x] os: [linux] - '@esbuild/linux-s390x@0.28.1': - resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==} - engines: {node: '>=18'} - cpu: [s390x] - os: [linux] - '@esbuild/linux-x64@0.21.5': resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==} engines: {node: '>=12'} cpu: [x64] os: [linux] - '@esbuild/linux-x64@0.28.1': - resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==} - engines: {node: '>=18'} - cpu: [x64] - os: [linux] - - '@esbuild/netbsd-arm64@0.28.1': - resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==} - engines: {node: '>=18'} - cpu: [arm64] - os: [netbsd] - '@esbuild/netbsd-x64@0.21.5': resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==} engines: {node: '>=12'} cpu: [x64] os: [netbsd] - '@esbuild/netbsd-x64@0.28.1': - resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==} - engines: {node: '>=18'} - cpu: [x64] - os: [netbsd] - - '@esbuild/openbsd-arm64@0.28.1': - resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==} - engines: {node: '>=18'} - cpu: [arm64] - os: [openbsd] - '@esbuild/openbsd-x64@0.21.5': resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==} engines: {node: '>=12'} cpu: [x64] os: [openbsd] - '@esbuild/openbsd-x64@0.28.1': - resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==} - engines: {node: '>=18'} - cpu: [x64] - os: [openbsd] - - '@esbuild/openharmony-arm64@0.28.1': - resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [openharmony] - '@esbuild/sunos-x64@0.21.5': resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==} engines: {node: '>=12'} cpu: [x64] os: [sunos] - '@esbuild/sunos-x64@0.28.1': - resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [sunos] - '@esbuild/win32-arm64@0.21.5': resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==} engines: {node: '>=12'} cpu: [arm64] os: [win32] - '@esbuild/win32-arm64@0.28.1': - resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==} - engines: {node: '>=18'} - cpu: [arm64] - os: [win32] - '@esbuild/win32-ia32@0.21.5': resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==} engines: {node: '>=12'} cpu: [ia32] os: [win32] - '@esbuild/win32-ia32@0.28.1': - resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==} - engines: {node: '>=18'} - cpu: [ia32] - os: [win32] - '@esbuild/win32-x64@0.21.5': resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==} engines: {node: '>=12'} cpu: [x64] os: [win32] - '@esbuild/win32-x64@0.28.1': - resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==} - engines: {node: '>=18'} - cpu: [x64] - os: [win32] - '@eslint-community/eslint-utils@4.9.1': resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -6484,11 +6331,6 @@ packages: engines: {node: '>=12'} hasBin: true - esbuild@0.28.1: - resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} - engines: {node: '>=18'} - hasBin: true - escalade@3.2.0: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} @@ -10799,11 +10641,6 @@ packages: tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} - tsx@4.22.4: - resolution: {integrity: sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==} - engines: {node: '>=18.0.0'} - hasBin: true - turbo@2.9.16: resolution: {integrity: sha512-NqgRQy6j6dPYcdSdv0q1g9QsZg7SWg87RERM8otw/1AtKU2yTFVClOM7cbwKzOonZr/Ek1blTBucw64L9H0Bwg==} hasBin: true @@ -11600,11 +11437,11 @@ snapshots: '@babel/helpers': 7.29.7 '@babel/parser': 7.29.7 '@babel/template': 7.29.7 - '@babel/traverse': 7.29.7 + '@babel/traverse': 7.29.7(supports-color@5.5.0) '@babel/types': 7.29.7 '@jridgewell/remapping': 2.3.5 convert-source-map: 2.0.0 - debug: 4.4.3 + debug: 4.4.3(supports-color@5.5.0) gensync: 1.0.0-beta.2 json5: 2.2.3 semver: 6.3.1 @@ -11653,13 +11490,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/helper-module-imports@7.29.7': - dependencies: - '@babel/traverse': 7.29.7 - '@babel/types': 7.29.7 - transitivePeerDependencies: - - supports-color - '@babel/helper-module-imports@7.29.7(supports-color@5.5.0)': dependencies: '@babel/traverse': 7.29.7(supports-color@5.5.0) @@ -11670,9 +11500,9 @@ snapshots: '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-module-imports': 7.29.7 + '@babel/helper-module-imports': 7.29.7(supports-color@5.5.0) '@babel/helper-validator-identifier': 7.29.7 - '@babel/traverse': 7.29.7 + '@babel/traverse': 7.29.7(supports-color@5.5.0) transitivePeerDependencies: - supports-color @@ -11846,18 +11676,6 @@ snapshots: '@babel/parser': 7.29.7 '@babel/types': 7.29.7 - '@babel/traverse@7.29.7': - dependencies: - '@babel/code-frame': 7.29.7 - '@babel/generator': 7.29.7 - '@babel/helper-globals': 7.29.7 - '@babel/parser': 7.29.7 - '@babel/template': 7.29.7 - '@babel/types': 7.29.7 - debug: 4.4.3 - transitivePeerDependencies: - - supports-color - '@babel/traverse@7.29.7(supports-color@5.5.0)': dependencies: '@babel/code-frame': 7.29.7 @@ -12142,150 +11960,72 @@ snapshots: '@esbuild/aix-ppc64@0.21.5': optional: true - '@esbuild/aix-ppc64@0.28.1': - optional: true - '@esbuild/android-arm64@0.21.5': optional: true - '@esbuild/android-arm64@0.28.1': - optional: true - '@esbuild/android-arm@0.21.5': optional: true - '@esbuild/android-arm@0.28.1': - optional: true - '@esbuild/android-x64@0.21.5': optional: true - '@esbuild/android-x64@0.28.1': - optional: true - '@esbuild/darwin-arm64@0.21.5': optional: true - '@esbuild/darwin-arm64@0.28.1': - optional: true - '@esbuild/darwin-x64@0.21.5': optional: true - '@esbuild/darwin-x64@0.28.1': - optional: true - '@esbuild/freebsd-arm64@0.21.5': optional: true - '@esbuild/freebsd-arm64@0.28.1': - optional: true - '@esbuild/freebsd-x64@0.21.5': optional: true - '@esbuild/freebsd-x64@0.28.1': - optional: true - '@esbuild/linux-arm64@0.21.5': optional: true - '@esbuild/linux-arm64@0.28.1': - optional: true - '@esbuild/linux-arm@0.21.5': optional: true - '@esbuild/linux-arm@0.28.1': - optional: true - '@esbuild/linux-ia32@0.21.5': optional: true - '@esbuild/linux-ia32@0.28.1': - optional: true - '@esbuild/linux-loong64@0.21.5': optional: true - '@esbuild/linux-loong64@0.28.1': - optional: true - '@esbuild/linux-mips64el@0.21.5': optional: true - '@esbuild/linux-mips64el@0.28.1': - optional: true - '@esbuild/linux-ppc64@0.21.5': optional: true - '@esbuild/linux-ppc64@0.28.1': - optional: true - '@esbuild/linux-riscv64@0.21.5': optional: true - '@esbuild/linux-riscv64@0.28.1': - optional: true - '@esbuild/linux-s390x@0.21.5': optional: true - '@esbuild/linux-s390x@0.28.1': - optional: true - '@esbuild/linux-x64@0.21.5': optional: true - '@esbuild/linux-x64@0.28.1': - optional: true - - '@esbuild/netbsd-arm64@0.28.1': - optional: true - '@esbuild/netbsd-x64@0.21.5': optional: true - '@esbuild/netbsd-x64@0.28.1': - optional: true - - '@esbuild/openbsd-arm64@0.28.1': - optional: true - '@esbuild/openbsd-x64@0.21.5': optional: true - '@esbuild/openbsd-x64@0.28.1': - optional: true - - '@esbuild/openharmony-arm64@0.28.1': - optional: true - '@esbuild/sunos-x64@0.21.5': optional: true - '@esbuild/sunos-x64@0.28.1': - optional: true - '@esbuild/win32-arm64@0.21.5': optional: true - '@esbuild/win32-arm64@0.28.1': - optional: true - '@esbuild/win32-ia32@0.21.5': optional: true - '@esbuild/win32-ia32@0.28.1': - optional: true - '@esbuild/win32-x64@0.21.5': optional: true - '@esbuild/win32-x64@0.28.1': - optional: true - '@eslint-community/eslint-utils@4.9.1(eslint@8.57.1)': dependencies: eslint: 8.57.1 @@ -12677,41 +12417,6 @@ snapshots: - supports-color - ts-node - '@jest/core@29.7.0(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3))': - dependencies: - '@jest/console': 29.7.0 - '@jest/reporters': 29.7.0 - '@jest/test-result': 29.7.0 - '@jest/transform': 29.7.0 - '@jest/types': 29.6.3 - '@types/node': 20.19.42 - ansi-escapes: 4.3.2 - chalk: 4.1.2 - ci-info: 3.9.0 - exit: 0.1.2 - graceful-fs: 4.2.11 - jest-changed-files: 29.7.0 - jest-config: 29.7.0(@types/node@20.19.42)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3)) - jest-haste-map: 29.7.0 - jest-message-util: 29.7.0 - jest-regex-util: 29.6.3 - jest-resolve: 29.7.0 - jest-resolve-dependencies: 29.7.0 - jest-runner: 29.7.0 - jest-runtime: 29.7.0 - jest-snapshot: 29.7.0 - jest-util: 29.7.0 - jest-validate: 29.7.0 - jest-watcher: 29.7.0 - micromatch: 4.0.8 - pretty-format: 29.7.0 - slash: 3.0.0 - strip-ansi: 6.0.1 - transitivePeerDependencies: - - babel-plugin-macros - - supports-color - - ts-node - '@jest/environment@29.7.0': dependencies: '@jest/fake-timers': 29.7.0 @@ -13203,42 +12908,6 @@ snapshots: axios: 1.17.0 rxjs: 7.8.2 - '@nestjs/cli@11.0.21(@types/node@20.19.42)': - dependencies: - '@angular-devkit/core': 19.2.24(chokidar@4.0.3) - '@angular-devkit/schematics': 19.2.24(chokidar@4.0.3) - '@angular-devkit/schematics-cli': 19.2.24(@types/node@20.19.42)(chokidar@4.0.3) - '@inquirer/prompts': 7.10.1(@types/node@20.19.42) - '@nestjs/schematics': 11.1.0(chokidar@4.0.3)(typescript@5.9.3) - ansis: 4.2.0 - chokidar: 4.0.3 - cli-table3: 0.6.5 - commander: 4.1.1 - fork-ts-checker-webpack-plugin: 9.1.0(typescript@5.9.3)(webpack@5.106.0) - glob: 13.0.6 - node-emoji: 1.11.0 - ora: 5.4.1 - tsconfig-paths: 4.2.0 - tsconfig-paths-webpack-plugin: 4.2.0 - typescript: 5.9.3 - webpack: 5.106.0 - webpack-node-externals: 3.0.0 - transitivePeerDependencies: - - '@minify-html/node' - - '@swc/css' - - '@swc/html' - - '@types/node' - - clean-css - - cssnano - - csso - - esbuild - - html-minifier-terser - - lightningcss - - postcss - - prettier - - uglify-js - - webpack-cli - '@nestjs/cli@11.0.21(@types/node@20.19.42)(prettier@3.8.3)': dependencies: '@angular-devkit/core': 19.2.24(chokidar@4.0.3) @@ -13389,17 +13058,6 @@ snapshots: transitivePeerDependencies: - chokidar - '@nestjs/schematics@11.1.0(chokidar@4.0.3)(typescript@5.9.3)': - dependencies: - '@angular-devkit/core': 19.2.24(chokidar@4.0.3) - '@angular-devkit/schematics': 19.2.24(chokidar@4.0.3) - comment-json: 5.0.0 - jsonc-parser: 3.3.1 - pluralize: 8.0.0 - typescript: 5.9.3 - transitivePeerDependencies: - - chokidar - '@nestjs/swagger@11.4.4(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)': dependencies: '@microsoft/tsdoc': 0.16.0 @@ -13453,14 +13111,6 @@ snapshots: rxjs: 7.8.2 typeorm: 0.3.30(babel-plugin-macros@3.1.0)(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3)) - '@nestjs/typeorm@11.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.30(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3)))': - dependencies: - '@nestjs/common': 11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/microservices@11.1.24)(@nestjs/platform-express@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2) - reflect-metadata: 0.2.2 - rxjs: 7.8.2 - typeorm: 0.3.30(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3)) - '@next/env@14.2.35': {} '@next/eslint-plugin-next@14.2.35': @@ -13647,7 +13297,7 @@ snapshots: '@puppeteer/browsers@2.13.2': dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@5.5.0) extract-zip: 2.0.1 progress: 2.0.3 proxy-agent: 6.5.0 @@ -15711,7 +15361,7 @@ snapshots: '@tokenizer/inflate@0.4.1': dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@5.5.0) token-types: 6.1.2 transitivePeerDependencies: - supports-color @@ -15720,6 +15370,50 @@ snapshots: '@tootallnate/quickjs-emscripten@0.23.0': {} + '@tria-plc/api-common@file:local-packages/tria-plc-api-common-1.4.3.tgz(bad2eb10df48448775040459098de142)': + dependencies: + '@nestjs/axios': 4.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(axios@1.17.0)(rxjs@7.8.2) + '@nestjs/common': 11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/microservices@11.1.24)(@nestjs/platform-express@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/jwt': 10.2.0(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)) + '@nestjs/microservices': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(amqp-connection-manager@5.0.0(amqplib@2.0.1))(amqplib@2.0.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/passport': 10.0.3(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(passport@0.7.0) + '@nestjs/swagger': 11.4.4(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2) + '@nestjs/throttler': 6.5.0(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2) + '@nestjs/typeorm': 11.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.30(babel-plugin-macros@3.1.0)(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3))) + '@tria-plc/iamapi-common': file:local-packages/tria-plc-iamapi-common-0.7.7.tgz(578386f46cf99fd4720e3e99f196f69e) + argon2: 0.43.1 + axios: 1.17.0 + change-case: 5.4.4 + class-transformer: 0.5.1 + class-validator: 0.14.4 + dotenv: 16.6.1 + ethiopian-calendar-date-converter: 2.1.6 + ethiopian-date: 0.0.6 + exceljs: 4.4.0 + file-type: 21.3.4 + handlebars: 4.7.9 + handlebars-helpers: 0.10.0 + jmespath: 0.16.0 + jose: 5.10.0 + jsonwebtoken: 9.0.3 + libphonenumber-js: 1.13.6 + libreoffice-convert: 1.8.1 + nestjs-minio-client: 2.2.0(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24) + passport-jwt: 4.0.1 + qrcode: 1.5.4 + reflect-metadata: 0.2.2 + rxjs: 7.8.2 + style-object-to-css-string: 1.1.3 + typeorm: 0.3.30(babel-plugin-macros@3.1.0)(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3)) + typeorm-extension: 3.9.0(@faker-js/faker@10.4.0)(typeorm@0.3.30(babel-plugin-macros@3.1.0)(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3))) + uuid: 11.1.1 + xlsx: 0.18.5 + transitivePeerDependencies: + - '@faker-js/faker' + - debug + - supports-color + '@tria-plc/api-common@file:local-packages/tria-plc-api-common-1.4.3.tgz(c061d697b8a1e15b1d1aba893da7b0e6)': dependencies: '@nestjs/axios': 4.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(axios@1.17.0)(rxjs@7.8.2) @@ -15764,50 +15458,6 @@ snapshots: - debug - supports-color - '@tria-plc/api-common@file:local-packages/tria-plc-api-common-1.4.3.tgz(d6b22b11dde6cd6764a6a0c7e4c9ae51)': - dependencies: - '@nestjs/axios': 4.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(axios@1.17.0)(rxjs@7.8.2) - '@nestjs/common': 11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/microservices@11.1.24)(@nestjs/platform-express@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/jwt': 10.2.0(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)) - '@nestjs/microservices': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(amqp-connection-manager@5.0.0(amqplib@2.0.1))(amqplib@2.0.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/passport': 10.0.3(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(passport@0.7.0) - '@nestjs/swagger': 11.4.4(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2) - '@nestjs/throttler': 6.5.0(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2) - '@nestjs/typeorm': 11.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.30(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3))) - '@tria-plc/iamapi-common': file:local-packages/tria-plc-iamapi-common-0.7.7.tgz(4837bbb980f4864b0c26765895373b58) - argon2: 0.43.1 - axios: 1.17.0 - change-case: 5.4.4 - class-transformer: 0.5.1 - class-validator: 0.14.4 - dotenv: 16.6.1 - ethiopian-calendar-date-converter: 2.1.6 - ethiopian-date: 0.0.6 - exceljs: 4.4.0 - file-type: 21.3.4 - handlebars: 4.7.9 - handlebars-helpers: 0.10.0 - jmespath: 0.16.0 - jose: 5.10.0 - jsonwebtoken: 9.0.3 - libphonenumber-js: 1.13.6 - libreoffice-convert: 1.8.1 - nestjs-minio-client: 2.2.0(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24) - passport-jwt: 4.0.1 - qrcode: 1.5.4 - reflect-metadata: 0.2.2 - rxjs: 7.8.2 - style-object-to-css-string: 1.1.3 - typeorm: 0.3.30(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3)) - typeorm-extension: 3.9.0(@faker-js/faker@10.4.0)(typeorm@0.3.30(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3))) - uuid: 11.1.1 - xlsx: 0.18.5 - transitivePeerDependencies: - - '@faker-js/faker' - - debug - - supports-color - '@tria-plc/iamapi-common@file:local-packages/tria-plc-iamapi-common-0.7.6.tgz(c97ba831ddde82920910406ab5262991)': dependencies: '@nestjs/axios': 4.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(axios@1.17.0)(rxjs@7.8.2) @@ -15843,7 +15493,7 @@ snapshots: - '@faker-js/faker' - supports-color - '@tria-plc/iamapi-common@file:local-packages/tria-plc-iamapi-common-0.7.7.tgz(4837bbb980f4864b0c26765895373b58)': + '@tria-plc/iamapi-common@file:local-packages/tria-plc-iamapi-common-0.7.7.tgz(578386f46cf99fd4720e3e99f196f69e)': dependencies: '@nestjs/axios': 4.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(axios@1.17.0)(rxjs@7.8.2) '@nestjs/common': 11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) @@ -15853,8 +15503,8 @@ snapshots: '@nestjs/passport': 10.0.3(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(passport@0.7.0) '@nestjs/swagger': 11.4.4(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2) '@nestjs/throttler': 6.5.0(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2) - '@nestjs/typeorm': 11.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.30(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3))) - '@tria-plc/api-common': file:local-packages/tria-plc-api-common-1.4.3.tgz(d6b22b11dde6cd6764a6a0c7e4c9ae51) + '@nestjs/typeorm': 11.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.30(babel-plugin-macros@3.1.0)(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3))) + '@tria-plc/api-common': file:local-packages/tria-plc-api-common-1.4.3.tgz(bad2eb10df48448775040459098de142) api-common: 1.2.2 argon2: 0.43.1 axios: 1.17.0 @@ -15871,8 +15521,8 @@ snapshots: qrcode: 1.5.4 reflect-metadata: 0.2.2 rxjs: 7.8.2 - typeorm: 0.3.30(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3)) - typeorm-extension: 3.9.0(@faker-js/faker@10.4.0)(typeorm@0.3.30(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3))) + typeorm: 0.3.30(babel-plugin-macros@3.1.0)(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3)) + typeorm-extension: 3.9.0(@faker-js/faker@10.4.0)(typeorm@0.3.30(babel-plugin-macros@3.1.0)(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3))) uuid: 11.1.1 transitivePeerDependencies: - '@faker-js/faker' @@ -16658,7 +16308,7 @@ snapshots: agent-base@6.0.2: dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@5.5.0) transitivePeerDependencies: - supports-color @@ -17311,7 +16961,7 @@ snapshots: dependencies: bytes: 3.1.2 content-type: 1.0.5 - debug: 4.4.3 + debug: 4.4.3(supports-color@5.5.0) http-errors: 2.0.1 iconv-lite: 0.7.2 on-finished: 2.4.1 @@ -17843,21 +17493,6 @@ snapshots: - supports-color - ts-node - create-jest@29.7.0(@types/node@20.19.42)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3)): - dependencies: - '@jest/types': 29.6.3 - chalk: 4.1.2 - exit: 0.1.2 - graceful-fs: 4.2.11 - jest-config: 29.7.0(@types/node@20.19.42)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3)) - jest-util: 29.7.0 - prompts: 2.4.2 - transitivePeerDependencies: - - '@types/node' - - babel-plugin-macros - - supports-color - - ts-node - create-require@1.1.1: {} cron@4.4.0: @@ -18009,10 +17644,6 @@ snapshots: dependencies: ms: 2.1.3 - debug@4.4.3: - dependencies: - ms: 2.1.3 - debug@4.4.3(supports-color@5.5.0): dependencies: ms: 2.1.3 @@ -18027,8 +17658,6 @@ snapshots: decode-uri-component@0.2.2: {} - dedent@1.7.2: {} - dedent@1.7.2(babel-plugin-macros@3.1.0): optionalDependencies: babel-plugin-macros: 3.1.0 @@ -18426,36 +18055,6 @@ snapshots: '@esbuild/win32-ia32': 0.21.5 '@esbuild/win32-x64': 0.21.5 - esbuild@0.28.1: - optionalDependencies: - '@esbuild/aix-ppc64': 0.28.1 - '@esbuild/android-arm': 0.28.1 - '@esbuild/android-arm64': 0.28.1 - '@esbuild/android-x64': 0.28.1 - '@esbuild/darwin-arm64': 0.28.1 - '@esbuild/darwin-x64': 0.28.1 - '@esbuild/freebsd-arm64': 0.28.1 - '@esbuild/freebsd-x64': 0.28.1 - '@esbuild/linux-arm': 0.28.1 - '@esbuild/linux-arm64': 0.28.1 - '@esbuild/linux-ia32': 0.28.1 - '@esbuild/linux-loong64': 0.28.1 - '@esbuild/linux-mips64el': 0.28.1 - '@esbuild/linux-ppc64': 0.28.1 - '@esbuild/linux-riscv64': 0.28.1 - '@esbuild/linux-s390x': 0.28.1 - '@esbuild/linux-x64': 0.28.1 - '@esbuild/netbsd-arm64': 0.28.1 - '@esbuild/netbsd-x64': 0.28.1 - '@esbuild/openbsd-arm64': 0.28.1 - '@esbuild/openbsd-x64': 0.28.1 - '@esbuild/openharmony-arm64': 0.28.1 - '@esbuild/sunos-x64': 0.28.1 - '@esbuild/win32-arm64': 0.28.1 - '@esbuild/win32-ia32': 0.28.1 - '@esbuild/win32-x64': 0.28.1 - optional: true - escalade@3.2.0: {} escape-html@1.0.3: {} @@ -18482,7 +18081,7 @@ snapshots: '@typescript-eslint/parser': 8.60.1(eslint@8.57.1)(typescript@5.9.3) eslint: 8.57.1 eslint-import-resolver-node: 0.3.10 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@8.57.1) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.60.1(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1) eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.60.1(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1) eslint-plugin-jsx-a11y: 6.10.2(eslint@8.57.1) eslint-plugin-react: 7.37.5(eslint@8.57.1) @@ -18506,7 +18105,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@8.57.1): + eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.60.1(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1): dependencies: '@nolyfill/is-core-module': 1.0.39 debug: 4.4.3(supports-color@5.5.0) @@ -18521,14 +18120,14 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-module-utils@2.13.0(@typescript-eslint/parser@8.60.1(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1): + eslint-module-utils@2.13.0(@typescript-eslint/parser@8.60.1(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.60.1(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1): dependencies: debug: 3.2.7 optionalDependencies: '@typescript-eslint/parser': 8.60.1(eslint@8.57.1)(typescript@5.9.3) eslint: 8.57.1 eslint-import-resolver-node: 0.3.10 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@8.57.1) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.60.1(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1) transitivePeerDependencies: - supports-color @@ -18543,7 +18142,7 @@ snapshots: doctrine: 2.1.0 eslint: 8.57.1 eslint-import-resolver-node: 0.3.10 - eslint-module-utils: 2.13.0(@typescript-eslint/parser@8.60.1(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1) + eslint-module-utils: 2.13.0(@typescript-eslint/parser@8.60.1(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.60.1(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1) hasown: 2.0.4 is-core-module: 2.16.2 is-glob: 4.0.3 @@ -18849,7 +18448,7 @@ snapshots: content-type: 1.0.5 cookie: 0.7.2 cookie-signature: 1.2.2 - debug: 4.4.3 + debug: 4.4.3(supports-color@5.5.0) depd: 2.0.0 encodeurl: 2.0.0 escape-html: 1.0.3 @@ -18900,7 +18499,7 @@ snapshots: extract-zip@2.0.1: dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@5.5.0) get-stream: 5.2.0 yauzl: 2.10.0 optionalDependencies: @@ -19047,7 +18646,7 @@ snapshots: finalhandler@2.1.1: dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@5.5.0) encodeurl: 2.0.0 escape-html: 1.0.3 on-finished: 2.4.1 @@ -19281,7 +18880,7 @@ snapshots: dependencies: basic-ftp: 5.3.1 data-uri-to-buffer: 6.0.2 - debug: 4.4.3 + debug: 4.4.3(supports-color@5.5.0) transitivePeerDependencies: - supports-color @@ -19547,21 +19146,21 @@ snapshots: http-proxy-agent@7.0.2: dependencies: agent-base: 7.1.4 - debug: 4.4.3 + debug: 4.4.3(supports-color@5.5.0) transitivePeerDependencies: - supports-color https-proxy-agent@5.0.1: dependencies: agent-base: 6.0.2 - debug: 4.4.3 + debug: 4.4.3(supports-color@5.5.0) transitivePeerDependencies: - supports-color https-proxy-agent@7.0.6: dependencies: agent-base: 7.1.4 - debug: 4.4.3 + debug: 4.4.3(supports-color@5.5.0) transitivePeerDependencies: - supports-color @@ -19965,7 +19564,7 @@ snapshots: istanbul-lib-source-maps@4.0.1: dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@5.5.0) istanbul-lib-coverage: 3.2.2 source-map: 0.6.1 transitivePeerDependencies: @@ -20009,32 +19608,6 @@ snapshots: jest-util: 29.7.0 p-limit: 3.1.0 - jest-circus@29.7.0: - dependencies: - '@jest/environment': 29.7.0 - '@jest/expect': 29.7.0 - '@jest/test-result': 29.7.0 - '@jest/types': 29.6.3 - '@types/node': 20.19.42 - chalk: 4.1.2 - co: 4.6.0 - dedent: 1.7.2 - is-generator-fn: 2.1.0 - jest-each: 29.7.0 - jest-matcher-utils: 29.7.0 - jest-message-util: 29.7.0 - jest-runtime: 29.7.0 - jest-snapshot: 29.7.0 - jest-util: 29.7.0 - p-limit: 3.1.0 - pretty-format: 29.7.0 - pure-rand: 6.1.0 - slash: 3.0.0 - stack-utils: 2.0.6 - transitivePeerDependencies: - - babel-plugin-macros - - supports-color - jest-circus@29.7.0(babel-plugin-macros@3.1.0): dependencies: '@jest/environment': 29.7.0 @@ -20080,25 +19653,6 @@ snapshots: - supports-color - ts-node - jest-cli@29.7.0(@types/node@20.19.42)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3)): - dependencies: - '@jest/core': 29.7.0(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3)) - '@jest/test-result': 29.7.0 - '@jest/types': 29.6.3 - chalk: 4.1.2 - create-jest: 29.7.0(@types/node@20.19.42)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3)) - exit: 0.1.2 - import-local: 3.2.0 - jest-config: 29.7.0(@types/node@20.19.42)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3)) - jest-util: 29.7.0 - jest-validate: 29.7.0 - yargs: 17.7.2 - transitivePeerDependencies: - - '@types/node' - - babel-plugin-macros - - supports-color - - ts-node - jest-config@29.7.0(@types/node@20.19.42)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3)): dependencies: '@babel/core': 7.29.7 @@ -20130,37 +19684,6 @@ snapshots: - babel-plugin-macros - supports-color - jest-config@29.7.0(@types/node@20.19.42)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3)): - dependencies: - '@babel/core': 7.29.7 - '@jest/test-sequencer': 29.7.0 - '@jest/types': 29.6.3 - babel-jest: 29.7.0(@babel/core@7.29.7) - chalk: 4.1.2 - ci-info: 3.9.0 - deepmerge: 4.3.1 - glob: 7.2.3 - graceful-fs: 4.2.11 - jest-circus: 29.7.0 - jest-environment-node: 29.7.0 - jest-get-type: 29.6.3 - jest-regex-util: 29.6.3 - jest-resolve: 29.7.0 - jest-runner: 29.7.0 - jest-util: 29.7.0 - jest-validate: 29.7.0 - micromatch: 4.0.8 - parse-json: 5.2.0 - pretty-format: 29.7.0 - slash: 3.0.0 - strip-json-comments: 3.1.1 - optionalDependencies: - '@types/node': 20.19.42 - ts-node: 10.9.2(@types/node@20.19.42)(typescript@5.9.3) - transitivePeerDependencies: - - babel-plugin-macros - - supports-color - jest-diff@29.7.0: dependencies: chalk: 4.1.2 @@ -20394,18 +19917,6 @@ snapshots: - supports-color - ts-node - jest@29.7.0(@types/node@20.19.42)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3)): - dependencies: - '@jest/core': 29.7.0(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3)) - '@jest/types': 29.6.3 - import-local: 3.2.0 - jest-cli: 29.7.0(@types/node@20.19.42)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3)) - transitivePeerDependencies: - - '@types/node' - - babel-plugin-macros - - supports-color - - ts-node - jiti@1.21.7: {} jiti@2.6.1: {} @@ -21462,7 +20973,7 @@ snapshots: dependencies: '@tootallnate/quickjs-emscripten': 0.23.0 agent-base: 7.1.4 - debug: 4.4.3 + debug: 4.4.3(supports-color@5.5.0) get-uri: 6.0.5 http-proxy-agent: 7.0.2 https-proxy-agent: 7.0.6 @@ -21677,13 +21188,12 @@ snapshots: camelcase-css: 2.0.1 postcss: 8.5.15 - postcss-load-config@6.0.1(jiti@1.21.7)(postcss@8.5.15)(tsx@4.22.4)(yaml@2.9.0): + postcss-load-config@6.0.1(jiti@1.21.7)(postcss@8.5.15)(yaml@2.9.0): dependencies: lilconfig: 3.1.3 optionalDependencies: jiti: 1.21.7 postcss: 8.5.15 - tsx: 4.22.4 yaml: 2.9.0 postcss-nested@6.2.0(postcss@8.5.15): @@ -21777,7 +21287,7 @@ snapshots: proxy-agent@6.5.0: dependencies: agent-base: 7.1.4 - debug: 4.4.3 + debug: 4.4.3(supports-color@5.5.0) http-proxy-agent: 7.0.2 https-proxy-agent: 7.0.6 lru-cache: 7.18.3 @@ -21804,7 +21314,7 @@ snapshots: dependencies: '@puppeteer/browsers': 2.13.2 chromium-bidi: 14.0.0(devtools-protocol@0.0.1608973) - debug: 4.4.3 + debug: 4.4.3(supports-color@5.5.0) devtools-protocol: 0.0.1608973 typed-query-selector: 2.12.2 webdriver-bidi-protocol: 0.4.1 @@ -22583,7 +22093,7 @@ snapshots: router@2.2.0: dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@5.5.0) depd: 2.0.0 is-promise: 4.0.0 parseurl: 1.3.3 @@ -22701,7 +22211,7 @@ snapshots: send@1.2.1: dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@5.5.0) encodeurl: 2.0.0 escape-html: 1.0.3 etag: 1.8.1 @@ -22933,7 +22443,7 @@ snapshots: socks-proxy-agent@8.0.5: dependencies: agent-base: 7.1.4 - debug: 4.4.3 + debug: 4.4.3(supports-color@5.5.0) socks: 2.8.9 transitivePeerDependencies: - supports-color @@ -23221,7 +22731,7 @@ snapshots: dependencies: component-emitter: 1.3.1 cookiejar: 2.1.4 - debug: 4.4.3 + debug: 4.4.3(supports-color@5.5.0) fast-safe-stringify: 2.1.1 form-data: 4.0.5 formidable: 3.5.4 @@ -23289,7 +22799,7 @@ snapshots: dependencies: tailwindcss: 4.3.0 - tailwindcss@3.4.19(tsx@4.22.4)(yaml@2.9.0): + tailwindcss@3.4.19(yaml@2.9.0): dependencies: '@alloc/quick-lru': 5.2.0 arg: 5.0.2 @@ -23308,7 +22818,7 @@ snapshots: postcss: 8.5.15 postcss-import: 15.1.0(postcss@8.5.15) postcss-js: 4.1.0(postcss@8.5.15) - postcss-load-config: 6.0.1(jiti@1.21.7)(postcss@8.5.15)(tsx@4.22.4)(yaml@2.9.0) + postcss-load-config: 6.0.1(jiti@1.21.7)(postcss@8.5.15)(yaml@2.9.0) postcss-nested: 6.2.0(postcss@8.5.15) postcss-selector-parser: 6.1.2 resolve: 1.22.12 @@ -23539,26 +23049,6 @@ snapshots: babel-jest: 29.7.0(@babel/core@7.29.7) jest-util: 29.7.0 - ts-jest@29.4.11(@babel/core@7.29.7)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(jest-util@29.7.0)(jest@29.7.0(@types/node@20.19.42)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3)))(typescript@5.9.3): - dependencies: - bs-logger: 0.2.6 - fast-json-stable-stringify: 2.1.0 - handlebars: 4.7.9 - jest: 29.7.0(@types/node@20.19.42)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3)) - json5: 2.2.3 - lodash.memoize: 4.1.2 - make-error: 1.3.6 - semver: 7.8.2 - type-fest: 4.41.0 - typescript: 5.9.3 - yargs-parser: 21.1.1 - optionalDependencies: - '@babel/core': 7.29.7 - '@jest/transform': 29.7.0 - '@jest/types': 29.6.3 - babel-jest: 29.7.0(@babel/core@7.29.7) - jest-util: 29.7.0 - ts-loader@9.6.0(typescript@5.9.3)(webpack@5.106.0): dependencies: chalk: 4.1.2 @@ -23635,13 +23125,6 @@ snapshots: tslib@2.8.1: {} - tsx@4.22.4: - dependencies: - esbuild: 0.28.1 - optionalDependencies: - fsevents: 2.3.3 - optional: true - turbo@2.9.16: optionalDependencies: '@turbo/darwin-64': 2.9.16 @@ -23734,19 +23217,6 @@ snapshots: typeorm: 0.3.30(babel-plugin-macros@3.1.0)(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3)) yargs: 18.0.0 - typeorm-extension@3.9.0(@faker-js/faker@10.4.0)(typeorm@0.3.30(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3))): - dependencies: - '@faker-js/faker': 10.4.0 - consola: 3.4.2 - envix: 1.5.0 - locter: 2.2.1 - pascal-case: 3.1.2 - rapiq: 0.9.0 - reflect-metadata: 0.2.2 - smob: 1.6.2 - typeorm: 0.3.30(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3)) - yargs: 18.0.0 - typeorm@0.3.30(babel-plugin-macros@3.1.0)(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3)): dependencies: '@sqltools/formatter': 1.2.5 @@ -23795,30 +23265,6 @@ snapshots: - babel-plugin-macros - supports-color - typeorm@0.3.30(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3)): - dependencies: - '@sqltools/formatter': 1.2.5 - ansis: 4.3.1 - app-root-path: 3.1.0 - buffer: 6.0.3 - dayjs: 1.11.21 - debug: 4.4.3 - dedent: 1.7.2 - dotenv: 16.6.1 - glob: 10.5.0 - reflect-metadata: 0.2.2 - sha.js: 2.4.12 - sql-highlight: 6.1.0 - tslib: 2.8.1 - uuid: 11.1.1 - yargs: 17.7.2 - optionalDependencies: - pg: 8.21.0 - ts-node: 10.9.2(@types/node@20.19.42)(typescript@5.9.3) - transitivePeerDependencies: - - babel-plugin-macros - - supports-color - typescript@5.9.3: {} uglify-js@3.19.3: From 48db0b240b7ab78ad997d2b8400c35782af0bd2d Mon Sep 17 00:00:00 2001 From: natib21 Date: Fri, 3 Jul 2026 09:32:41 +0000 Subject: [PATCH 03/38] fix fayda --- apps/edr-freight-api/src/main.ts | 3 +- .../verifayda/fayda-callback.controller.ts | 31 +++++++++++++++++++ .../src/modules/verifayda/verifayda.module.ts | 3 +- 3 files changed, 35 insertions(+), 2 deletions(-) create mode 100644 apps/edr-freight-api/src/modules/verifayda/fayda-callback.controller.ts diff --git a/apps/edr-freight-api/src/main.ts b/apps/edr-freight-api/src/main.ts index 0107956e4..0fa1056dd 100644 --- a/apps/edr-freight-api/src/main.ts +++ b/apps/edr-freight-api/src/main.ts @@ -38,7 +38,8 @@ async function bootstrap() { maxAge: 86400, // cache preflight for 24h to cut chatter in dev }); - app.setGlobalPrefix("api"); + // /callback stays un-prefixed: it's the Fayda OAuth redirect_uri ack endpoint. + app.setGlobalPrefix("api", { exclude: ["callback"] }); // enableImplicitConversion is OFF: class-transformer's implicit boolean // coercion turns any non-empty multipart/form-data string (including the // literal "false") into `true`, silently corrupting flags like isHazardous diff --git a/apps/edr-freight-api/src/modules/verifayda/fayda-callback.controller.ts b/apps/edr-freight-api/src/modules/verifayda/fayda-callback.controller.ts new file mode 100644 index 000000000..71b0dc4b0 --- /dev/null +++ b/apps/edr-freight-api/src/modules/verifayda/fayda-callback.controller.ts @@ -0,0 +1,31 @@ +import { Controller, Get, Query } from '@nestjs/common'; +import { ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator'; +import { VerifaydaCallbackDto } from './verifayda.dto'; + +/** + * Plain acknowledgement endpoint for the Fayda redirect_uri when it points at + * the API instead of the web app (e.g. MOBILE clients or connectivity checks). + * Registered at /callback (excluded from the global /api prefix in main.ts). + * It does NOT consume the verification session — the client must still call + * GET /api/fayda/verification/complete with the echoed code+state. + */ +@ApiTags('Fayda Verification') +@Controller('callback') +export class FaydaCallbackController { + @Get() + @IsPublic() + @ApiOperation({ summary: 'Acknowledge a Fayda redirect (returns OK, echoes code/state)' }) + @ApiOkResponse({ + schema: { example: { status: 'ok', code: '...', state: '...' } }, + }) + ok(@Query() query: VerifaydaCallbackDto) { + return { + status: 'ok', + ...(query.code ? { code: query.code } : {}), + ...(query.state ? { state: query.state } : {}), + ...(query.error ? { error: query.error } : {}), + ...(query.error_description ? { error_description: query.error_description } : {}), + }; + } +} diff --git a/apps/edr-freight-api/src/modules/verifayda/verifayda.module.ts b/apps/edr-freight-api/src/modules/verifayda/verifayda.module.ts index b87b90e16..82fb9435c 100644 --- a/apps/edr-freight-api/src/modules/verifayda/verifayda.module.ts +++ b/apps/edr-freight-api/src/modules/verifayda/verifayda.module.ts @@ -1,12 +1,13 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { VerifaydaController } from './verifayda.controller'; +import { FaydaCallbackController } from './fayda-callback.controller'; import { VerifaydaService } from './verifayda.service'; import { FaydaVerificationSession } from './entities/fayda-verification-session.entity'; @Module({ imports: [TypeOrmModule.forFeature([FaydaVerificationSession])], - controllers: [VerifaydaController], + controllers: [VerifaydaController, FaydaCallbackController], providers: [VerifaydaService], exports: [VerifaydaService], }) From 4e6e614b48b276b187c5c34001c1978836519ecd Mon Sep 17 00:00:00 2001 From: Nathnael Date: Fri, 3 Jul 2026 10:59:06 +0000 Subject: [PATCH 04/38] feat: add email to notification and otp --- apps/edr-freight-api/.env.example | 6 +- .../modules/notifications/dtos/email.dto.ts | 30 +++++ .../notifications/email-client.service.ts | 51 ++++++++ .../notifications/notifications.module.ts | 20 ++- .../src/modules/otp/otp.controller.ts | 25 +++- .../src/modules/otp/otp.entity.ts | 11 +- .../src/modules/otp/otp.module.ts | 1 + .../src/modules/otp/otp.repository.ts | 49 ++++++- .../src/modules/otp/otp.service.ts | 123 ++++++++++++++---- 9 files changed, 278 insertions(+), 38 deletions(-) create mode 100644 apps/edr-freight-api/src/modules/notifications/dtos/email.dto.ts create mode 100644 apps/edr-freight-api/src/modules/notifications/email-client.service.ts diff --git a/apps/edr-freight-api/.env.example b/apps/edr-freight-api/.env.example index e02391ccd..1ed8ff9fc 100644 --- a/apps/edr-freight-api/.env.example +++ b/apps/edr-freight-api/.env.example @@ -55,8 +55,10 @@ REDIS_HOST=localhost REDIS_PORT=6379 # --- Notification broker (RabbitMQ) --------------------------------------------- -# SMS OTP / notifications are queued to RabbitMQ (consumed by the shared SMS service). -# Set RABBITMQ_ENABLED=false to skip the broker entirely (dev without a local broker). +# SMS/email OTP + notifications are queued to RabbitMQ (consumed by the shared +# SMS/email services). Set RABBITMQ_ENABLED=false to skip the broker entirely +# (dev without a local broker). RABBITMQ_ENABLED=false RABBITMQ_URL=amqp://localhost:5672 SMS_QUEUE=sms_queue +EMAIL_QUEUE=email_queue diff --git a/apps/edr-freight-api/src/modules/notifications/dtos/email.dto.ts b/apps/edr-freight-api/src/modules/notifications/dtos/email.dto.ts new file mode 100644 index 000000000..79a6547bc --- /dev/null +++ b/apps/edr-freight-api/src/modules/notifications/dtos/email.dto.ts @@ -0,0 +1,30 @@ +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { IsEmail, IsNotEmpty, IsOptional, IsString } from "class-validator"; + +export class SendEmailDto { + @ApiProperty({ + description: "Recipient email address", + example: "customer@example.com", + }) + @IsEmail() + @IsNotEmpty() + to!: string; + + @ApiProperty({ + description: "Email subject", + example: "Your EDR Freight verification code", + }) + @IsString() + @IsNotEmpty() + subject!: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + text?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + html?: string; +} diff --git a/apps/edr-freight-api/src/modules/notifications/email-client.service.ts b/apps/edr-freight-api/src/modules/notifications/email-client.service.ts new file mode 100644 index 000000000..161b2486a --- /dev/null +++ b/apps/edr-freight-api/src/modules/notifications/email-client.service.ts @@ -0,0 +1,51 @@ +import { + Inject, + Injectable, + Logger, + OnApplicationBootstrap, +} from "@nestjs/common"; +import { ClientProxy } from "@nestjs/microservices"; +import { SendEmailDto } from "./dtos/email.dto"; + +@Injectable() +export class EmailClientService implements OnApplicationBootstrap { + private readonly logger = new Logger(EmailClientService.name); + + constructor( + @Inject("EMAIL_SERVICE") + private readonly emailClient: ClientProxy, + ) {} + + private readonly enabled = process.env.RABBITMQ_ENABLED !== "false"; + + async onApplicationBootstrap() { + if (!this.enabled) return; + this.emailClient + .connect() + .then(() => this.logger.log("connected to Email service")) + .catch((err) => { + console.error("Error happened at Email service", err); + }); + } + + async sendEmail(dto: SendEmailDto): Promise<{ queued: boolean }> { + if (!this.enabled) { + this.logger.warn(`RABBITMQ disabled — skipped EMAIL to=${dto.to}`); + return { queued: false }; + } + this.emailClient.emit("send-email", { + to: dto.to, + subject: dto.subject, + text: dto.text, + html: dto.html, + appKey: "IFHCRS-LICENSE-MANAGEMENT", + }); + // Fire-and-forget enqueue: confirms hand-off to RabbitMQ, NOT delivery. + this.logger.log( + `EMAIL queued to RabbitMQ [${process.env.EMAIL_QUEUE ?? "email_queue"}] pattern='send-email'`, + ); + // Recipient + content are PII — debug only. + this.logger.debug(`EMAIL payload to=${dto.to} subject="${dto.subject}"`); + return { queued: true }; + } +} diff --git a/apps/edr-freight-api/src/modules/notifications/notifications.module.ts b/apps/edr-freight-api/src/modules/notifications/notifications.module.ts index 663f931ef..4e56c8b70 100644 --- a/apps/edr-freight-api/src/modules/notifications/notifications.module.ts +++ b/apps/edr-freight-api/src/modules/notifications/notifications.module.ts @@ -4,6 +4,7 @@ import { ClientsModule, Transport } from "@nestjs/microservices"; import { NotificationsService } from "./notifications.service"; import { SmsClientService } from "./sms-client.service"; +import { EmailClientService } from "./email-client.service"; import { EmailNotificationStrategy } from "./strategies/notification.email.strategy"; import { SmsNotificationStrategy } from "./strategies/notification.sms.strategy"; @@ -20,10 +21,25 @@ import { SmsNotificationStrategy } from "./strategies/notification.sms.strategy" queueOptions: { durable: true }, }, }, + { + name: "EMAIL_SERVICE", + transport: Transport.RMQ, + options: { + urls: [process.env.RABBITMQ_URL as string], + queue: process.env.EMAIL_QUEUE ?? "email_queue", + queueOptions: { durable: true }, + }, + }, ]), ], controllers: [], - providers: [EmailNotificationStrategy, SmsNotificationStrategy, NotificationsService, SmsClientService], - exports: [NotificationsService, SmsClientService], + providers: [ + EmailNotificationStrategy, + SmsNotificationStrategy, + NotificationsService, + SmsClientService, + EmailClientService, + ], + exports: [NotificationsService, SmsClientService, EmailClientService], }) export class NotificationsModule {} diff --git a/apps/edr-freight-api/src/modules/otp/otp.controller.ts b/apps/edr-freight-api/src/modules/otp/otp.controller.ts index 5850cbb1a..155657a74 100644 --- a/apps/edr-freight-api/src/modules/otp/otp.controller.ts +++ b/apps/edr-freight-api/src/modules/otp/otp.controller.ts @@ -1,15 +1,24 @@ // otp.controller.ts import { + BadRequestException, Body, Controller, Post, } from "@nestjs/common"; -import { OtpService } from "./otp.service"; +import { OtpService, OtpTarget } from "./otp.service"; import { Public } from "@edr/api-common"; +// Exactly one of phone/email must be present per request — the channel the +// code is sent through / checked against. +function toTarget(phone?: string, email?: string): OtpTarget { + if (email) return { email }; + if (phone) return { phone }; + throw new BadRequestException("phone or email is required"); +} + @Controller("otp") @Public() export class OtpController { @@ -24,9 +33,12 @@ export class OtpController { @Post("send") async sendOtp( @Body("phone") - phone: string + phone?: string, + + @Body("email") + email?: string ) { - return this.otpService.sendOtp(phone); + return this.otpService.sendOtp(toTarget(phone, email)); } // --------------------------------------------------------------------------- @@ -36,13 +48,16 @@ export class OtpController { @Post("verify") async verifyOtp( @Body("phone") - phone: string, + phone: string | undefined, + + @Body("email") + email: string | undefined, @Body("otp") otp: string ) { return this.otpService.verifyOtp( - phone, + toTarget(phone, email), otp ); } diff --git a/apps/edr-freight-api/src/modules/otp/otp.entity.ts b/apps/edr-freight-api/src/modules/otp/otp.entity.ts index f5900f6b8..022bbf767 100644 --- a/apps/edr-freight-api/src/modules/otp/otp.entity.ts +++ b/apps/edr-freight-api/src/modules/otp/otp.entity.ts @@ -10,10 +10,19 @@ import { BaseEntity } from "@edr/api-common"; name: "otp_verifications", }) export class OtpVerification extends BaseEntity{ + // Exactly one of phone/email is set per row — the channel the code was sent + // through. @Column({ unique: true, + nullable: true, }) - phone!: string; + phone?: string; + + @Column({ + unique: true, + nullable: true, + }) + email?: string; @Column() otp!: string; diff --git a/apps/edr-freight-api/src/modules/otp/otp.module.ts b/apps/edr-freight-api/src/modules/otp/otp.module.ts index ec1d9f9ed..511fe4bbb 100644 --- a/apps/edr-freight-api/src/modules/otp/otp.module.ts +++ b/apps/edr-freight-api/src/modules/otp/otp.module.ts @@ -31,6 +31,7 @@ import { NotificationsModule } from "../notifications/notifications.module"; exports: [ OtpRepository, + OtpService, ], }) export class OtpModule {} \ No newline at end of file diff --git a/apps/edr-freight-api/src/modules/otp/otp.repository.ts b/apps/edr-freight-api/src/modules/otp/otp.repository.ts index 8aa69dcd6..7abd434d8 100644 --- a/apps/edr-freight-api/src/modules/otp/otp.repository.ts +++ b/apps/edr-freight-api/src/modules/otp/otp.repository.ts @@ -31,17 +31,44 @@ export class OtpRepository { }); } + // --------------------------------------------------------------------------- + // Find By Email + // --------------------------------------------------------------------------- + + async findByEmail( + email: string + ) { + return this.repository.findOne({ + where: { + email, + }, + }); + } + + // --------------------------------------------------------------------------- + // Find By Target (either channel) + // --------------------------------------------------------------------------- + + async findByTarget( + target: { phone?: string; email?: string } + ) { + return target.email + ? this.findByEmail(target.email) + : this.findByPhone(target.phone!); + } + // --------------------------------------------------------------------------- // Create OTP // --------------------------------------------------------------------------- async createOtp( - phone: string, + target: { phone?: string; email?: string }, otp: string ) { const entity = this.repository.create({ - phone, + phone: target.phone, + email: target.email, otp, verified: false, }); @@ -70,10 +97,10 @@ export class OtpRepository { } // --------------------------------------------------------------------------- - // Verify Phone + // Mark Verified // --------------------------------------------------------------------------- - async verifyPhone( + async markVerified( otpVerification: OtpVerification ) { otpVerification.verified = @@ -83,4 +110,18 @@ export class OtpRepository { otpVerification ); } + + // --------------------------------------------------------------------------- + // Delete OTP (single-use consume) + // --------------------------------------------------------------------------- + + // Hard delete so the unique `phone` row is freed and a fresh code can be + // requested for the same number on the next action. + async deleteOtp( + otpVerification: OtpVerification + ) { + return this.repository.remove( + otpVerification + ); + } } \ No newline at end of file diff --git a/apps/edr-freight-api/src/modules/otp/otp.service.ts b/apps/edr-freight-api/src/modules/otp/otp.service.ts index ffa9c4e68..436f34411 100644 --- a/apps/edr-freight-api/src/modules/otp/otp.service.ts +++ b/apps/edr-freight-api/src/modules/otp/otp.service.ts @@ -8,12 +8,18 @@ import { import { OtpRepository } from "./otp.repository"; import { SmsClientService } from "../notifications/sms-client.service"; +import { EmailClientService } from "../notifications/email-client.service"; + +// Exactly one of phone/email is set — enforced by the controller before it +// reaches here. +export type OtpTarget = { phone?: string; email?: string }; @Injectable() export class OtpService { constructor( private readonly otpRepository: OtpRepository, - private readonly smsClient: SmsClientService + private readonly smsClient: SmsClientService, + private readonly emailClient: EmailClientService ) {} // --------------------------------------------------------------------------- @@ -30,38 +36,47 @@ export class OtpService { // Send OTP // --------------------------------------------------------------------------- - async sendOtp(phone: string) { + async sendOtp(target: OtpTarget) { try { // The verification code is generated server-side — never supplied by the // caller — so the OTP stays a secret known only to the server and the - // recipient of the SMS. + // recipient of the SMS/email. const otp = this.generateOtp(); - // find existing phone - const existingPhone = - await this.otpRepository.findByPhone( - phone + // find existing row for this channel + const existing = + await this.otpRepository.findByTarget( + target ); // update existing otp - if (existingPhone) { + if (existing) { await this.otpRepository.updateOtp( - existingPhone, + existing, otp ); } else { // create new otp await this.otpRepository.createOtp( - phone, + target, otp ); } - // send sms (queued to RabbitMQ via the shared SMS service) - await this.smsClient.sendSms({ - to: phone, - message: `Your verification code is ${otp}`, - }); + if (target.email) { + // send email (queued to RabbitMQ via the shared Email service) + await this.emailClient.sendEmail({ + to: target.email, + subject: "Your EDR Freight verification code", + text: `Your verification code is ${otp}`, + }); + } else { + // send sms (queued to RabbitMQ via the shared SMS service) + await this.smsClient.sendSms({ + to: target.phone as string, + message: `Your verification code is ${otp}`, + }); + } return { success: true, @@ -83,19 +98,21 @@ export class OtpService { // --------------------------------------------------------------------------- async verifyOtp( - phone: string, + target: OtpTarget, otp: string ) { - // find phone + // find the channel's row const otpData = - await this.otpRepository.findByPhone( - phone + await this.otpRepository.findByTarget( + target ); - // phone not found + // not found if (!otpData) { throw new BadRequestException( - "Phone number not found" + target.email + ? "Email address not found" + : "Phone number not found" ); } @@ -106,8 +123,8 @@ export class OtpService { ); } - // verify phone - await this.otpRepository.verifyPhone( + // mark verified + await this.otpRepository.markVerified( otpData ); @@ -115,7 +132,65 @@ export class OtpService { success: true, message: - "Phone verified successfully", + target.email + ? "Email verified successfully" + : "Phone verified successfully", }; } + + // --------------------------------------------------------------------------- + // Verify OTP for a sensitive action (sudo mode) + // --------------------------------------------------------------------------- + + // Fresh, single-use challenge gating a sensitive action (e.g. applying a + // contract signature). Unlike verifyOtp above — which marks a phone verified + // and leaves the code in place — this enforces a short TTL and consumes the + // code on success so it can never be replayed. + private readonly ACTION_OTP_TTL_MS = + 5 * 60 * 1000; + + async verifyOtpForAction( + phone: string, + otp: string + ) { + const otpData = + await this.otpRepository.findByPhone( + phone + ); + + if (!otpData) { + throw new BadRequestException( + "No verification code was requested for this phone" + ); + } + + const ageMs = + Date.now() - + new Date( + otpData.updatedAt + ).getTime(); + + if (ageMs > this.ACTION_OTP_TTL_MS) { + await this.otpRepository.deleteOtp( + otpData + ); + + throw new BadRequestException( + "Verification code has expired. Request a new one." + ); + } + + if (otpData.otp !== otp) { + throw new BadRequestException( + "Invalid verification code" + ); + } + + // single-use: consume on success + await this.otpRepository.deleteOtp( + otpData + ); + + return { success: true }; + } } \ No newline at end of file From 2d71f24937af4880cdf174661c8ddb1d2aea5b69 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Fri, 3 Jul 2026 11:08:57 +0000 Subject: [PATCH 05/38] chore: rm the verify step in onboarding --- .../onboarding/OnboardingWizardDialog.tsx | 7 - .../src/pages/accounts/CompanyProfileForm.tsx | 205 +----------------- .../accounts/companyProfileForm/schema.ts | 2 - 3 files changed, 1 insertion(+), 213 deletions(-) diff --git a/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx b/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx index 4d4c8664a..d6c965053 100644 --- a/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx +++ b/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx @@ -46,7 +46,6 @@ type FormStep = | "company" | "personnel" | "contact" - | "verify" | "poa" | "documents" | "additional"; @@ -54,7 +53,6 @@ const FORM_STEPS: FormStep[] = [ "company", "personnel", "contact", - "verify", "poa", "documents", "additional", @@ -95,11 +93,6 @@ const STEP_META: Record< title: "Contact Person", description: "Who should we reach out to about this account?", }, - verify: { - icon: , - title: "Verify Contact Person", - description: "Confirm the contact phone with a one-time SMS code.", - }, poa: { icon: , title: "Power of Attorney", diff --git a/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx b/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx index bfde5c43b..3c8ad0271 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx @@ -4,7 +4,6 @@ import { Divider, Group, Loader, - PinInput, SimpleGrid, Stack, Text, @@ -16,9 +15,6 @@ import { AlertCircle, ArrowLeft, ArrowRight, - CheckCircle2, - RotateCw, - Smartphone, UserCheck, } from "lucide-react"; import { useEffect, useRef, useState } from "react"; @@ -35,7 +31,6 @@ import RoleLicenseStep, { type RoleLicenseProfile, } from "@/components/onboarding/RoleLicenseStep"; import ETradeInfo from "@/components/onboarding/ETradeInfo"; -import { extractApiError } from "@/utils/result"; import { type CompanyStep, type FormData, @@ -44,8 +39,6 @@ import { } from "./companyProfileForm/schema"; import { buildPayload, - maskPhone, - samePhone, stepPayload, toFormValues, } from "./companyProfileForm/helpers"; @@ -350,85 +343,6 @@ export default function CompanyProfileForm({ } }; - // --- Contact-phone SMS OTP verification ----------------------------------- - // The phone we verify is the contact-person phone, normalised to E.164 so it - // matches what the backend persists as `contactVerifiedPhone`. - const contactPhoneE164 = toEthiopianE164(watch("contactPersonPhone") ?? ""); - // Source of truth for "already verified" comes from the onboarding/profile - // info (rehydrate) — so a refresh resumes the verify step's "done" state. - const [verifiedPhone, setVerifiedPhone] = useState( - rehydrate?.contactVerifiedPhone ?? null, - ); - useEffect(() => { - if (rehydrate?.contactVerifiedPhone) { - setVerifiedPhone(rehydrate.contactVerifiedPhone); - } - }, [rehydrate?.contactVerifiedPhone]); - const phoneVerified = samePhone(verifiedPhone, contactPhoneE164); - - const [otpSent, setOtpSent] = useState(false); - const [otpCode, setOtpCode] = useState(""); - const [sendingOtp, setSendingOtp] = useState(false); - const [verifyingOtp, setVerifyingOtp] = useState(false); - const [otpError, setOtpError] = useState(null); - const [resendIn, setResendIn] = useState(0); - - // Resend cooldown countdown (no Date.now needed — pure setTimeout ticks). - useEffect(() => { - if (resendIn <= 0) return; - const t = setTimeout(() => setResendIn((s) => s - 1), 1000); - return () => clearTimeout(t); - }, [resendIn]); - - // A changed contact phone invalidates any in-flight code entry (the previous - // code was for a different number). Verified state is handled separately via - // the phone comparison, so this only resets the send/enter UI. - useEffect(() => { - setOtpSent(false); - setOtpCode(""); - setOtpError(null); - }, [contactPhoneE164]); - - const sendContactOtp = async () => { - setOtpError(null); - if (!contactPhoneE164) { - setOtpError("Enter a valid contact phone number first."); - return; - } - setSendingOtp(true); - try { - await api.auth.sendOTP.call({ phone: contactPhoneE164 }); - setOtpSent(true); - setOtpCode(""); - setResendIn(60); - } catch (err) { - setOtpError(extractApiError(err).message); - } finally { - setSendingOtp(false); - } - }; - - const verifyContactOtp = async () => { - setOtpError(null); - if (otpCode.length !== 6) { - setOtpError("Enter the 6-digit code we sent you."); - return; - } - setVerifyingOtp(true); - try { - await api.auth.verifyOTP.call({ phone: contactPhoneE164, otp: otpCode }); - setVerifiedPhone(contactPhoneE164); - setOtpSent(false); - // Persist the verified phone so the step resumes as "done" after a refresh - // (best-effort — the OTP itself already succeeded server-side). - onSaveStep?.({ contactVerifiedPhone: contactPhoneE164 }).catch(() => { }); - } catch (err) { - setOtpError(extractApiError(err).message); - } finally { - setVerifyingOtp(false); - } - }; - const hasDocuments = Boolean(uploadSetting?.fields?.length); // The registration/license details come straight from the eTrade lookup and @@ -451,7 +365,6 @@ export default function CompanyProfileForm({ "company", "personnel", "contact", - "verify", "poa", "documents", "additional", @@ -495,20 +408,6 @@ export default function CompanyProfileForm({ handleSubmit((data) => onSubmit(buildPayload(data, user)))(); return; } - // Contact-phone verification gates advancing past the verify step. The - // verified phone is already persisted (on verify success), so there's - // nothing extra to save here. - if (step === "verify") { - if (!phoneVerified) { - setSaveError( - "Please verify the contact person's phone number to continue.", - ); - return; - } - setSaveError(null); - setStep(stepOrder[currentIdx + 1]); - return; - } // The documents step auto-uploads whatever the user selected as they // continue (partial uploads are allowed — required-doc completeness is // re-checked on resume). A failed upload holds them on the step. @@ -783,107 +682,6 @@ export default function CompanyProfileForm({ )} - {step === "verify" && ( - - - We'll text a one-time code to the contact person's phone to - confirm it's reachable. This is required before you continue. - - - {!contactPhoneE164 ? ( - } - > - Add a valid contact phone number on the previous step first. - - ) : phoneVerified ? ( - } - title="Phone verified" - > - {maskPhone(contactPhoneE164)} has been verified. - - ) : ( - - - - - {maskPhone(contactPhoneE164)} - - - - {!otpSent ? ( - - ) : ( - - - - - - - - )} - - {otpError && ( - } - > - {otpError} - - )} - - )} - - )} - {step === "poa" && ( <> @@ -1009,8 +807,7 @@ export default function CompanyProfileForm({ disabled={ isPending || saving || - (step === "documents" && !hasDocuments && loadingDocuments) || - (step === "verify" && !phoneVerified) + (step === "documents" && !hasDocuments && loadingDocuments) } loading={isPending || saving} rightSection={ diff --git a/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/schema.ts b/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/schema.ts index 31ee21ced..9a123e255 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/schema.ts +++ b/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/schema.ts @@ -6,7 +6,6 @@ export type CompanyStep = | "company" | "personnel" | "contact" - | "verify" | "poa" | "documents" | "additional"; @@ -103,7 +102,6 @@ export const stepFields: Record = { "contactPersonEmail", "contactPersonPhone", ], - verify: [], poa: [], documents: [], additional: [], From 37ec1c40ab31f4308fef7c6dba1cf62425dcec3d Mon Sep 17 00:00:00 2001 From: Nathnael Date: Fri, 3 Jul 2026 11:48:39 +0000 Subject: [PATCH 06/38] chore: add loger --- .../src/modules/otp/otp.service.ts | 103 +++++------------- 1 file changed, 29 insertions(+), 74 deletions(-) diff --git a/apps/edr-freight-api/src/modules/otp/otp.service.ts b/apps/edr-freight-api/src/modules/otp/otp.service.ts index 436f34411..67fbdec9b 100644 --- a/apps/edr-freight-api/src/modules/otp/otp.service.ts +++ b/apps/edr-freight-api/src/modules/otp/otp.service.ts @@ -1,9 +1,6 @@ // otp.service.ts -import { - BadRequestException, - Injectable, -} from "@nestjs/common"; +import { BadRequestException, Injectable, Logger } from "@nestjs/common"; import { OtpRepository } from "./otp.repository"; @@ -16,20 +13,19 @@ export type OtpTarget = { phone?: string; email?: string }; @Injectable() export class OtpService { + logger = new Logger(OtpService.name); constructor( private readonly otpRepository: OtpRepository, private readonly smsClient: SmsClientService, - private readonly emailClient: EmailClientService - ) {} + private readonly emailClient: EmailClientService, + ) { } // --------------------------------------------------------------------------- // Generate OTP // --------------------------------------------------------------------------- generateOtp(): string { - return Math.floor( - 100000 + Math.random() * 900000 - ).toString(); + return Math.floor(100000 + Math.random() * 900000).toString(); } // --------------------------------------------------------------------------- @@ -44,23 +40,14 @@ export class OtpService { const otp = this.generateOtp(); // find existing row for this channel - const existing = - await this.otpRepository.findByTarget( - target - ); + const existing = await this.otpRepository.findByTarget(target); // update existing otp if (existing) { - await this.otpRepository.updateOtp( - existing, - otp - ); + await this.otpRepository.updateOtp(existing, otp); } else { // create new otp - await this.otpRepository.createOtp( - target, - otp - ); + await this.otpRepository.createOtp(target, otp); } if (target.email) { @@ -78,18 +65,16 @@ export class OtpService { }); } + this.logger.log(`OTP send for ${target.email ?? target.phone}: ${otp}`); return { success: true, - message: - "OTP sent successfully", + message: "OTP sent successfully", }; } catch (error) { console.log(error); - throw new BadRequestException( - "Failed to send OTP" - ); + throw new BadRequestException("Failed to send OTP"); } } @@ -97,44 +82,31 @@ export class OtpService { // Verify OTP // --------------------------------------------------------------------------- - async verifyOtp( - target: OtpTarget, - otp: string - ) { + async verifyOtp(target: OtpTarget, otp: string) { // find the channel's row - const otpData = - await this.otpRepository.findByTarget( - target - ); + const otpData = await this.otpRepository.findByTarget(target); // not found if (!otpData) { throw new BadRequestException( - target.email - ? "Email address not found" - : "Phone number not found" + target.email ? "Email address not found" : "Phone number not found", ); } // invalid otp if (otpData.otp !== otp) { - throw new BadRequestException( - "Invalid OTP" - ); + throw new BadRequestException("Invalid OTP"); } // mark verified - await this.otpRepository.markVerified( - otpData - ); + await this.otpRepository.markVerified(otpData); return { success: true, - message: - target.email - ? "Email verified successfully" - : "Phone verified successfully", + message: target.email + ? "Email verified successfully" + : "Phone verified successfully", }; } @@ -146,51 +118,34 @@ export class OtpService { // contract signature). Unlike verifyOtp above — which marks a phone verified // and leaves the code in place — this enforces a short TTL and consumes the // code on success so it can never be replayed. - private readonly ACTION_OTP_TTL_MS = - 5 * 60 * 1000; + private readonly ACTION_OTP_TTL_MS = 5 * 60 * 1000; - async verifyOtpForAction( - phone: string, - otp: string - ) { - const otpData = - await this.otpRepository.findByPhone( - phone - ); + async verifyOtpForAction(phone: string, otp: string) { + const otpData = await this.otpRepository.findByPhone(phone); if (!otpData) { throw new BadRequestException( - "No verification code was requested for this phone" + "No verification code was requested for this phone", ); } - const ageMs = - Date.now() - - new Date( - otpData.updatedAt - ).getTime(); + const ageMs = Date.now() - new Date(otpData.updatedAt).getTime(); if (ageMs > this.ACTION_OTP_TTL_MS) { - await this.otpRepository.deleteOtp( - otpData - ); + await this.otpRepository.deleteOtp(otpData); throw new BadRequestException( - "Verification code has expired. Request a new one." + "Verification code has expired. Request a new one.", ); } if (otpData.otp !== otp) { - throw new BadRequestException( - "Invalid verification code" - ); + throw new BadRequestException("Invalid verification code"); } // single-use: consume on success - await this.otpRepository.deleteOtp( - otpData - ); + await this.otpRepository.deleteOtp(otpData); return { success: true }; } -} \ No newline at end of file +} From 414c9610dc0afddc3dfa687880377559c7d0dda6 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Fri, 3 Jul 2026 11:57:40 +0000 Subject: [PATCH 07/38] chore: migrate to otp table --- ...900000000000-AddEmailToOtpVerifications.ts | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 apps/edr-freight-api/src/migrations/1900000000000-AddEmailToOtpVerifications.ts diff --git a/apps/edr-freight-api/src/migrations/1900000000000-AddEmailToOtpVerifications.ts b/apps/edr-freight-api/src/migrations/1900000000000-AddEmailToOtpVerifications.ts new file mode 100644 index 000000000..1bd3bbc27 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1900000000000-AddEmailToOtpVerifications.ts @@ -0,0 +1,35 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Support email as a second OTP channel alongside phone (e.g. signup lets the + * user choose which one to verify). `phone` becomes nullable since an + * email-channel row has none, and `email` is added as a nullable unique column + * mirroring `phone`'s shape. + */ +export class AddEmailToOtpVerifications1900000000000 + implements MigrationInterface +{ + name = "AddEmailToOtpVerifications1900000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE public.otp_verifications + ALTER COLUMN phone DROP NOT NULL + `); + await queryRunner.query(` + ALTER TABLE public.otp_verifications + ADD COLUMN IF NOT EXISTS email varchar UNIQUE + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE public.otp_verifications + DROP COLUMN IF EXISTS email + `); + await queryRunner.query(` + ALTER TABLE public.otp_verifications + ALTER COLUMN phone SET NOT NULL + `); + } +} From d3707fe704ea444bd01614fe80c8a8480a3a65c9 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Fri, 3 Jul 2026 12:09:35 +0000 Subject: [PATCH 08/38] feat: merge the role and nationality step --- .../onboarding/OnboardingWizardDialog.tsx | 79 +++++-------------- 1 file changed, 19 insertions(+), 60 deletions(-) diff --git a/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx b/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx index d6c965053..ab00965fa 100644 --- a/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx +++ b/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx @@ -10,10 +10,8 @@ import { } from "@mantine/core"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { - ArrowLeft, ArrowRight, Building2, - CheckCircle2, Clock, FileText, Globe2, @@ -42,42 +40,29 @@ import type { UpdateProfilePayload } from "@/types/profile"; import { extractApiError } from "@/utils/result"; /** Form steps rendered by CompanyProfileForm. */ -type FormStep = - | "company" - | "personnel" - | "contact" - | "poa" - | "documents" - | "additional"; +type FormStep = "company" | "personnel" | "contact" | "poa" | "documents"; const FORM_STEPS: FormStep[] = [ "company", "personnel", "contact", "poa", "documents", - "additional", ]; /** The full onboarding journey: the two pre-form phases + the form steps. */ -type WizardStep = "nationality" | "role" | FormStep; -const WIZARD_STEPS: WizardStep[] = ["nationality", "role", ...FORM_STEPS]; +type WizardStep = "nationality-role" | FormStep; +const WIZARD_STEPS: WizardStep[] = ["nationality-role", ...FORM_STEPS]; /** Icon + title + description shown in the global dialog header per step. */ const STEP_META: Record< WizardStep, { icon: ReactNode; title: string; description: string } > = { - nationality: { + "nationality-role": { icon: , - title: "Where is your company registered?", + title: "Tell us about your company", description: "This determines the documents we'll ask you to provide.", }, - role: { - icon: , - title: "What does your company do?", - description: - "Pick any combination of Importer, Exporter and Freight Forwarder — each is set up with its own business license.", - }, company: { icon: , title: "Company Information", @@ -103,11 +88,6 @@ const STEP_META: Record< title: "Upload Documents", description: "Provide the required company documents.", }, - additional: { - icon: , - title: "Business License", - description: "Upload a business license for each operational profile.", - }, }; interface OnboardingWizardDialogProps { @@ -165,12 +145,8 @@ export default function OnboardingWizardDialog({ // Phases: nationality → role → form. If a draft already exists, resume // straight into the form with nationality + roles pre-selected. - const [phase, setPhase] = useState<"nationality" | "role" | "form">( - companyAlreadyStarted - ? hasOperationalProfiles - ? "form" - : "role" - : "nationality", + const [phase, setPhase] = useState<"nationality-role" | "form">( + companyAlreadyStarted ? "form" : "nationality-role", ); const [nationality, setNationality] = useState( savedNationality, @@ -295,16 +271,12 @@ export default function OnboardingWizardDialog({ setNationality(savedNationality); // Resume into the form only when profiles exist; otherwise send the user to // role selection so the missing operational profiles get created. - setPhase(hasOperationalProfiles ? "form" : "role"); + setPhase(hasOperationalProfiles ? "form" : "nationality-role"); const idx = FORM_STEPS.indexOf(resumeFormStep); if (idx > furthestIdxRef.current) furthestIdxRef.current = idx; // eslint-disable-next-line react-hooks/exhaustive-deps }, [companyAlreadyStarted, resumeFormStep]); - const handleNationalityContinue = useCallback(() => { - if (nationality) setPhase("role"); - }, [nationality]); - const handleRolesContinue = useCallback(() => { setStartError(null); startMutation.mutate({ @@ -387,6 +359,7 @@ export default function OnboardingWizardDialog({ // The active step across the whole journey, driving the header + progress pill. const activeStep: WizardStep = phase === "form" ? formStep : phase; const stepMeta = STEP_META[activeStep]; + console.log({ stepMeta, activeStep, STEP_META }); const activeIdx = WIZARD_STEPS.indexOf(activeStep); // Closing from the congratulations panel also clears the completed flag so a @@ -418,7 +391,7 @@ export default function OnboardingWizardDialog({ ); const effectiveResumeStep: FormStep = requiredDocsMissing && - FORM_STEPS.indexOf(resumeFormStep) > FORM_STEPS.indexOf("documents") + FORM_STEPS.indexOf(resumeFormStep) > FORM_STEPS.indexOf("documents") ? "documents" : resumeFormStep; @@ -490,26 +463,19 @@ export default function OnboardingWizardDialog({ ) : ( - {phase === "nationality" ? ( + {phase === "nationality-role" ? ( + + Where is your company registered? + - - - - - ) : phase === "role" ? ( - + + What does your company do?(multiple) + )} - - + ); From 79731e58ec7ed5d1456266f119219b8ee4c205f2 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Fri, 3 Jul 2026 12:19:07 +0000 Subject: [PATCH 09/38] feat: add otp to contract --- .../contracts/contract-transition.service.ts | 8 + .../src/modules/contracts/contracts.module.ts | 2 + .../contracts/dto/sign-contract.dto.ts | 17 +- .../src/pages/accounts/CompanyProfileForm.tsx | 63 +- .../portal/src/pages/accounts/SignupPage.tsx | 539 +++++++++++------- .../src/pages/contracts/ContractViewPage.tsx | 158 ++++- .../portal/src/services/bookings.service.ts | 4 + apps/edr-freight-web/portal/src/types/auth.ts | 4 +- 8 files changed, 553 insertions(+), 242 deletions(-) diff --git a/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts index e87112bc2..9bb4b2de6 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts @@ -19,6 +19,7 @@ import { CargoTypesService } from '../rule-engine/services/cargo-types.service'; import { DropdownSettingsService } from '../dropdown-settings/dropdown-settings.service'; import { FilesService } from '../files/files.service'; import { SignaturesService } from '../signatures/signatures.service'; +import { OtpService } from '../otp/otp.service'; import { ContractPricingService } from './contract-pricing.service'; import { ClearanceMilestoneService } from './clearance-milestone.service'; import { ContractsRepository } from './contracts.repository'; @@ -63,6 +64,7 @@ export class ContractTransitionService { private readonly renderer: ContractRendererService, private readonly pdfService: ContractPdfService, private readonly minioService: MinioService, + private readonly otpService: OtpService, ) {} /** Customer submits the contract for approval → SUBMITTED; freeze unit rates. */ @@ -520,6 +522,12 @@ export class ContractTransitionService { if (existing) { throw new BadRequestException('Customer has already signed this contract'); } + // Sudo-mode gate: a fresh, single-use OTP (SMS'd to the customer's phone) + // must be verified before the signature is applied. + if (!dto.otpPhone || !dto.otp) { + throw new BadRequestException('OTP verification is required to sign the contract'); + } + await this.otpService.verifyOtpForAction(dto.otpPhone, dto.otp); await this.applySignature(contract, dto, options); await this.contractsRepository.update(contractId, { status: 'SIGNED_CUSTOMER', diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.module.ts b/apps/edr-freight-api/src/modules/contracts/contracts.module.ts index e0eece986..5bf6ddb4f 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.module.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.module.ts @@ -11,6 +11,7 @@ import { RuleEngineModule } from '../rule-engine/rule-engine.module'; import { FileUploadSettingsModule } from '../file-upload-settings/file-upload-settings.module'; import { DropdownSettingsModule } from '../dropdown-settings/dropdown-settings.module'; import { SignaturesModule } from '../signatures/signatures.module'; +import { OtpModule } from '../otp/otp.module'; import { BookingsModule } from '../bookings/bookings.module'; import { ContractsController } from './contracts.controller'; @@ -72,6 +73,7 @@ import { ContractDocumentViewModelBuilder } from '../../contracts/contract-docum FilesModule, MinioModule, SignaturesModule, + OtpModule, CompaniesModule, // BookingsModule provides BookingsRepository/BookingPricingService used by the // contract PDF builders (they read a Booking today — see docs/new-doc.md §3.3). diff --git a/apps/edr-freight-api/src/modules/contracts/dto/sign-contract.dto.ts b/apps/edr-freight-api/src/modules/contracts/dto/sign-contract.dto.ts index febe7a83b..f0676b629 100644 --- a/apps/edr-freight-api/src/modules/contracts/dto/sign-contract.dto.ts +++ b/apps/edr-freight-api/src/modules/contracts/dto/sign-contract.dto.ts @@ -1,5 +1,5 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { IsIn, IsOptional, IsString, MinLength } from 'class-validator'; +import { IsIn, IsOptional, IsString, Matches, MinLength } from 'class-validator'; export class SignContractDto { @ApiProperty({ enum: ['CUSTOMER', 'STAFF', 'DIRECTOR', 'CEO'] }) @@ -26,4 +26,19 @@ export class SignContractDto { @IsOptional() @IsString() consentText?: string; + + // Sudo-mode OTP challenge. Required when role=CUSTOMER: a fresh 6-digit code + // SMS'd to the signer's phone, verified server-side before the signature is + // applied. `otpPhone` is the number the code was sent to (the signed-in + // customer's registered phone). + @ApiPropertyOptional({ description: '6-digit OTP; required when role=CUSTOMER' }) + @IsOptional() + @IsString() + @Matches(/^\d{6}$/, { message: 'otp must be 6 digits' }) + otp?: string; + + @ApiPropertyOptional({ description: 'Phone the OTP was sent to; required when role=CUSTOMER' }) + @IsOptional() + @IsString() + otpPhone?: string; } diff --git a/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx b/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx index 3c8ad0271..85af9bfdd 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx @@ -11,12 +11,7 @@ import { } from "@mantine/core"; import { zodResolver } from "@hookform/resolvers/zod"; import { useQuery } from "@tanstack/react-query"; -import { - AlertCircle, - ArrowLeft, - ArrowRight, - UserCheck, -} from "lucide-react"; +import { AlertCircle, ArrowLeft, ArrowRight, UserCheck } from "lucide-react"; import { useEffect, useRef, useState } from "react"; import { useForm } from "react-hook-form"; @@ -288,6 +283,7 @@ export default function CompanyProfileForm({ const useOwnerAsManager = () => { if (!etradeOwner) return; setValue("generalManagerName", etradeOwner.name); + setValue("generalManagerEmail", user.email); setValue("generalManagerPhone", etradeOwner.phone ?? "", { shouldValidate: true, }); @@ -367,7 +363,6 @@ export default function CompanyProfileForm({ "contact", "poa", "documents", - "additional", ]; const currentIdx = stepOrder.indexOf(step); @@ -398,16 +393,6 @@ export default function CompanyProfileForm({ const nextStep = async () => { userNavigatedRef.current = true; - if (step === "additional") { - if (!licenseComplete) { - setSaveError( - "Please upload a business license for each of your operational profiles.", - ); - return; - } - handleSubmit((data) => onSubmit(buildPayload(data, user)))(); - return; - } // The documents step auto-uploads whatever the user selected as they // continue (partial uploads are allowed — required-doc completeness is // re-checked on resume). A failed upload holds them on the step. @@ -424,8 +409,15 @@ export default function CompanyProfileForm({ setSaving(false); } } + + if (!licenseComplete) { + setSaveError( + "Please upload a business license for each of your operational profiles.", + ); + return; + } setSaveError(null); - setStep(stepOrder[currentIdx + 1]); + handleSubmit((data) => onSubmit(buildPayload(data, user)))(); return; } // Field steps validate + save before advancing. @@ -450,10 +442,7 @@ export default function CompanyProfileForm({
e.preventDefault()}> {step === "company" && ( - <> - - Enter your TIN to auto-fill company information from eTrade - + - + )} {step === "personnel" && ( @@ -752,15 +741,13 @@ export default function CompanyProfileForm({ onChange={setDocumentFiles} /> )} - - )} - {step === "additional" && ( - { })} - /> + { })} + /> + )} {saveError && ( @@ -768,11 +755,7 @@ export default function CompanyProfileForm({ color="red" variant="light" icon={} - title={ - step === "additional" - ? "Business license required" - : "Couldn't save this step" - } + title={"Couldn't save this step"} > {saveError} @@ -796,7 +779,7 @@ export default function CompanyProfileForm({ onClick={prevStep} leftSection={} > - {step === "additional" ? "Back to Documents" : "Back"} + Back ) : ( @@ -811,12 +794,10 @@ export default function CompanyProfileForm({ } loading={isPending || saving} rightSection={ - !isPending && !saving && step !== "additional" ? ( - - ) : undefined + !isPending && !saving ? : undefined } > - {step === "additional" ? "Submit for review" : "Continue"} + {step === "documents" ? "Submit for review" : "Continue"} diff --git a/apps/edr-freight-web/portal/src/pages/accounts/SignupPage.tsx b/apps/edr-freight-web/portal/src/pages/accounts/SignupPage.tsx index 723591597..50320f699 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/SignupPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/SignupPage.tsx @@ -1,18 +1,38 @@ -import { useState } from "react"; +import { useEffect, useState } from "react"; import { zodResolver } from "@hookform/resolvers/zod"; -import { ArrowRight, Check, Eye, EyeOff, X } from "lucide-react"; -import { Controller, useForm } from "react-hook-form"; +import { + Alert, + Button, + PasswordInput, + PinInput, + SegmentedControl, + SimpleGrid, + Stack, + Text, + TextInput, +} from "@mantine/core"; +import { + AlertCircle, + ArrowLeft, + ArrowRight, + Check, + Mail, + RotateCw, + ShieldCheck, + Smartphone, + X, +} from "lucide-react"; +import { useForm } from "react-hook-form"; import { useNavigate } from "react-router-dom"; import { z } from "zod"; -import RPNInput from "react-phone-number-input"; -import "react-phone-number-input/style.css"; import { userType } from "@/enums/userType"; import useAuth from "@/hooks/useAuth"; import type { SignupPayload } from "@/types/auth"; -import AuthShell, { fieldClass, primaryButtonClass } from "@/components/auth/AuthShell"; -import { isValidPhone } from "@/components/PhoneField"; -import "@/components/phone-field.css"; +import AuthShell from "@/components/auth/AuthShell"; +import { ControlledPhoneField, isValidPhone } from "@/components/PhoneField"; +import { api } from "@/services/api"; +import { extractApiError } from "@/utils/result"; const EDR_LOGO = "/assets/edr-logo.png"; @@ -50,16 +70,46 @@ const userSchema = z type FormData = z.infer; -const errorText = (msg?: string) => - msg ?

{msg}

: null; +/** Mask all but the first 7 chars of an E.164 phone for display. */ +const maskPhone = (p: string) => + p.length > 4 ? `${p.slice(0, 7)}${"*".repeat(Math.max(0, p.length - 7))}` : p; + +/** Mask the local part of an email for display (j***e@example.com). */ +const maskEmail = (email: string) => { + const [local, domain] = email.split("@"); + if (!local || !domain) return email; + if (local.length <= 2) return `${local[0] ?? ""}***@${domain}`; + return `${local[0]}***${local[local.length - 1]}@${domain}`; +}; + +type OtpChannel = "phone" | "email"; export default function SignupPage() { const navigate = useNavigate(); const { signup } = useAuth(); const [error, setError] = useState(null); - const [loading, setLoading] = useState(false); - const [showPassword, setShowPassword] = useState(false); - const [showConfirm, setShowConfirm] = useState(false); + + // Two-stage signup: fill the form, then a mandatory SMS OTP challenge on the + // phone number before the account is actually created. The account is only + // created after the code is verified — the OTP is a hard requirement. + const [stage, setStage] = useState<"form" | "otp">("form"); + const [pendingData, setPendingData] = useState(null); + // Which contact method the code was sent to — chosen on the form, locked in + // once the challenge is sent. + const [channel, setChannel] = useState("phone"); + const [otpChannel, setOtpChannel] = useState("phone"); + const [sending, setSending] = useState(false); + const [verifying, setVerifying] = useState(false); + const [otpCode, setOtpCode] = useState(""); + const [otpError, setOtpError] = useState(null); + const [resendIn, setResendIn] = useState(0); + + // Resend cooldown countdown (pure setTimeout ticks — no Date.now needed). + useEffect(() => { + if (resendIn <= 0) return; + const t = setTimeout(() => setResendIn((s) => s - 1), 1000); + return () => clearTimeout(t); + }, [resendIn]); const { register, @@ -80,226 +130,329 @@ export default function SignupPage() { }, }); - const onSubmit = async (data: FormData) => { + const passwordValue = watch("password") ?? ""; + + // Step 1 — form is valid: send a fresh code to the chosen channel, then + // move to the OTP challenge. + const requestOtp = async (data: FormData) => { setError(null); - setLoading(true); + setSending(true); try { + await api.auth.sendOTP.call( + channel === "email" ? { email: data.email } : { phone: data.phone }, + ); + setPendingData(data); + setOtpChannel(channel); + setOtpCode(""); + setOtpError(null); + setResendIn(60); + setStage("otp"); + } catch (err) { + setError(extractApiError(err).message); + } finally { + setSending(false); + } + }; + + const resendOtp = async () => { + if (!pendingData) return; + setOtpError(null); + setSending(true); + try { + await api.auth.sendOTP.call( + otpChannel === "email" + ? { email: pendingData.email } + : { phone: pendingData.phone }, + ); + setOtpCode(""); + setResendIn(60); + } catch (err) { + setOtpError(extractApiError(err).message); + } finally { + setSending(false); + } + }; + + // Step 2 — verify the code, then (only on success) create the account. + const confirmOtp = async () => { + if (!pendingData) return; + setOtpError(null); + if (otpCode.trim().length !== 6) { + setOtpError("Enter the 6-digit code we sent you."); + return; + } + setVerifying(true); + try { + await api.auth.verifyOTP.call({ + ...(otpChannel === "email" + ? { email: pendingData.email } + : { phone: pendingData.phone }), + otp: otpCode.trim(), + }); const payload: SignupPayload = { - email: data.email, - username: data.email, + email: pendingData.email, + username: pendingData.email, // Already a canonical E.164 string from the phone field (e.g. +251912345678). - phoneNumber: data.phone, - userType: data.userType, + phoneNumber: pendingData.phone, + userType: pendingData.userType, name: { - en: `${data.firstName.en} ${data.lastName.en}`, - am: `${data.firstName.en} ${data.lastName.en}`, + en: `${pendingData.firstName.en} ${pendingData.lastName.en}`, + am: `${pendingData.firstName.en} ${pendingData.lastName.en}`, }, - password: data.password, - confirmPassword: data.confirmPassword, + password: pendingData.password, + confirmPassword: pendingData.confirmPassword, }; const result = await signup(payload); if (result.success) { navigate("/portal"); } else { - setError(result.error.message); + setOtpError(result.error.message); } - } catch { - setError("An unexpected error occurred"); + } catch (err) { + setOtpError(extractApiError(err).message); } finally { - setLoading(false); + setVerifying(false); } }; - const passwordValue = watch("password") ?? ""; - return ( - +
EDR Freight
-
-

- Create account -

-

- Register to access EDR Freight services. -

-
- -
-
-
- - - {errorText(errors.firstName?.en?.message)} + {stage === "form" ? ( + +
+

+ Create account +

+

+ Register to access EDR Freight services. +

-
- - + + + + + + - {errorText(errors.lastName?.en?.message)} -
-
-
- - - {errorText(errors.email?.message)} -
- -
- - ( -
- field.onChange(v ?? "")} - onBlur={field.onBlur} - /> -
- )} - /> - {errorText(errors.phone?.message)} -
- -
- -
- - -
- {errorText(errors.password?.message)} - {passwordValue.length > 0 ? ( -
- {passwordRequirements.map((req) => { - const met = req.test(passwordValue); - return ( -
- - {met ? : } - - - {req.label} - -
- ); - })} + +
+ + Send verification code via + + setChannel(v as OtpChannel)} + data={[ + { + value: "phone", + label: ( + + Phone + + ), + }, + { + value: "email", + label: ( + + Email + + ), + }, + ]} + />
- ) : null} -
-
- -
- + + {passwordValue.length > 0 ? ( +
+ {passwordRequirements.map((req) => { + const met = req.test(passwordValue); + return ( +
+ + {met ? : } + + + {req.label} + +
+ ); + })} +
+ ) : null} +
+ + - + Continue + + +

+ Already have an account?{" "} + +

+ + + ) : ( + +
+ + +
- {errorText(errors.confirmPassword?.message)} -
- - {error ? ( -
- {error} +
+

+ Verify your {otpChannel === "email" ? "email" : "phone"} +

+

+ We sent a 6-digit code to{" "} + + {otpChannel === "email" + ? maskEmail(pendingData?.email ?? "") + : maskPhone(pendingData?.phone ?? "")} + + . Enter it to finish creating your account. +

- ) : null} - + {otpError ? ( + }> + {otpError} + + ) : null} -

- Already have an account?{" "} - -

-
- + Verify & create account + + +
+ + +
+ + )} +
); } diff --git a/apps/edr-freight-web/portal/src/pages/contracts/ContractViewPage.tsx b/apps/edr-freight-web/portal/src/pages/contracts/ContractViewPage.tsx index 29bdef371..f1e5da228 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/ContractViewPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/ContractViewPage.tsx @@ -11,17 +11,27 @@ import { Loader, Modal, Paper, + PinInput, Stack, Text, TextInput, } from "@mantine/core"; -import { ArrowLeft, Download, FileSignature, Printer } from "lucide-react"; +import { + ArrowLeft, + Download, + FileSignature, + Printer, + RotateCw, + ShieldCheck, +} from "lucide-react"; import toast from "react-hot-toast"; import { ContractSignSuccessModal } from "@/components/contracts/ContractSignSuccessModal"; import { ContractSignaturePad } from "@/components/bookings/ContractSignaturePad"; import { contractsService } from "@/services/contracts.service"; import { api } from "@/services/api"; +import useAuth from "@/hooks/useAuth"; +import { extractApiError } from "@/utils/result"; const CONSENT_TEXT = "I have read the entire contract and agree to its terms."; @@ -34,9 +44,13 @@ export default function ContractViewPage() { const { id } = useParams<{ id: string }>(); const navigate = useNavigate(); const qc = useQueryClient(); + const { user } = useAuth(); const iframeRef = useRef(null); const [signOpen, setSignOpen] = useState(false); + const [otpOpen, setOtpOpen] = useState(false); + const [otpCode, setOtpCode] = useState(""); + const [otpError, setOtpError] = useState(null); const [successOpen, setSuccessOpen] = useState(false); const [signerName, setSignerName] = useState(""); const [signatureData, setSignatureData] = useState(null); @@ -44,6 +58,15 @@ export default function ContractViewPage() { const [hasScrolledToBottom, setHasScrolledToBottom] = useState(false); const [agreedToTerms, setAgreedToTerms] = useState(false); + // The signed-in customer's registered phone — where the sudo-mode OTP is sent. + const customerPhone = user?.phoneNumber ?? ""; + const maskedPhone = + customerPhone.length > 4 + ? `${customerPhone.slice(0, 4)}${"*".repeat( + Math.max(customerPhone.length - 6, 0), + )}${customerPhone.slice(-2)}` + : customerPhone; + const { data, isLoading, isError, refetch } = useQuery({ queryKey: ["contract-view", id], queryFn: () => contractsService.getContractView(id!), @@ -95,6 +118,18 @@ export default function ContractViewPage() { }; }, [checkScrollBottom]); + // Send (or resend) the fresh OTP challenge to the customer's phone. On success + // we swap the signature modal for the OTP entry modal. + const sendOtpMutation = useMutation({ + mutationFn: () => api.auth.sendOTP.call({ phone: customerPhone }), + onSuccess: () => { + setSignOpen(false); + setOtpError(null); + setOtpOpen(true); + }, + onError: () => toast.error("Failed to send verification code"), + }); + const signMutation = useMutation({ mutationFn: () => contractsService.signContract(id!, { @@ -104,16 +139,22 @@ export default function ContractViewPage() { : (signatureData as string), signerDisplayName: signerName.trim(), consentText: CONSENT_TEXT, + otp: otpCode.trim(), + otpPhone: customerPhone, }), onSuccess: () => { - setSignOpen(false); + setOtpOpen(false); + setOtpCode(""); setSuccessOpen(true); void refetch(); void qc.invalidateQueries({ queryKey: api.contracts.get.queryKey({ id: id! }), }); }, - onError: () => toast.error("Failed to sign contract"), + onError: (err) => + setOtpError( + extractApiError(err).message ?? "Failed to verify code and sign", + ), }); const openSign = () => { @@ -128,6 +169,17 @@ export default function ContractViewPage() { if (!signerName.trim()) return; const image = usingSaved ? savedSignatureImage : signatureData; if (!image) return; + if (!customerPhone) { + toast.error("No phone number on file to verify your signature."); + return; + } + setOtpCode(""); + sendOtpMutation.mutate(); + }; + + const confirmOtp = () => { + if (otpCode.trim().length !== 6) return; + setOtpError(null); signMutation.mutate(); }; @@ -315,20 +367,114 @@ export default function ContractViewPage() { + setOtpOpen(false)} + title="Verify it's you" + centered + radius="lg" + > + + + + + + + For security, enter the 6-digit code we sent by SMS to{" "} + + {maskedPhone} + {" "} + to confirm and apply your signature. + + + + {otpError && ( + + {otpError} + + )} + + + + Verification code + + + + + + + + + + + + + + Date: Fri, 3 Jul 2026 12:22:46 +0000 Subject: [PATCH 10/38] chore: add logger to mark-paid --- .../payment/internal-payment.controller.ts | 32 +++++++++++-------- 1 file changed, 18 insertions(+), 14 deletions(-) diff --git a/apps/edr-freight-api/src/modules/payment/internal-payment.controller.ts b/apps/edr-freight-api/src/modules/payment/internal-payment.controller.ts index 57c95eab3..0fc5a6ba5 100644 --- a/apps/edr-freight-api/src/modules/payment/internal-payment.controller.ts +++ b/apps/edr-freight-api/src/modules/payment/internal-payment.controller.ts @@ -1,9 +1,10 @@ import { - Body, - Controller, - HttpCode, - HttpStatus, - Post, + Body, + Controller, + HttpCode, + HttpStatus, + Logger, + Post, } from "@nestjs/common"; import { ApiOperation, ApiTags } from "@nestjs/swagger"; import { Public } from "@edr/api-common"; @@ -22,14 +23,17 @@ import { PaymentService } from "./payment.service"; @Public() @Controller("internal/payments") export class InternalPaymentController { - constructor(private readonly paymentService: PaymentService) { } + private readonly logger = new Logger(InternalPaymentController.name); + constructor(private readonly paymentService: PaymentService) { } - @Post("mark-paid") - @HttpCode(HttpStatus.OK) - @ApiOperation({ - summary: "Apply a payment.succeeded / payment.failed event from the payment service (idempotent)", - }) - async markPaid(@Body() event: PaymentEventDto): Promise { - return this.paymentService.handlePaymentEvent(event); - } + @Post("mark-paid") + @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: + "Apply a payment.succeeded / payment.failed event from the payment service (idempotent)", + }) + async markPaid(@Body() event: PaymentEventDto): Promise { + this.logger.log(`Marking payment ${event} as PAID`); + return this.paymentService.handlePaymentEvent(event); + } } From 209701aa34b43b7b5ae7ad24dfc3dbf188b764cb Mon Sep 17 00:00:00 2001 From: Abubeker Yasin Date: Fri, 3 Jul 2026 15:23:32 +0300 Subject: [PATCH 11/38] fix: passenger dob error --- .../src/modules/bookings/bookings.dto.ts | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/apps/edr-passenger-api/src/modules/bookings/bookings.dto.ts b/apps/edr-passenger-api/src/modules/bookings/bookings.dto.ts index 4062acddc..5df5f7107 100644 --- a/apps/edr-passenger-api/src/modules/bookings/bookings.dto.ts +++ b/apps/edr-passenger-api/src/modules/bookings/bookings.dto.ts @@ -1,4 +1,4 @@ -import { IsString, IsArray, ValidateNested, IsOptional, IsInt, IsEnum, IsDateString, MaxDate } from 'class-validator'; +import { IsString, IsArray, ValidateNested, IsOptional, IsInt, IsEnum, IsDate, MaxDate } from 'class-validator'; import { Type, Transform } from 'class-transformer'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { Currency, IdDocumentType } from '@prisma/client'; @@ -10,10 +10,12 @@ export class PassengerInputDto { @ApiPropertyOptional({ example: 'ret-leg2-seat-uuid', description: '**ROUND_TRIP_TRANSIT:** Return leg-2 seat ID' }) @IsOptional() @IsString() returnLeg2SeatId?: string; @ApiProperty({ example: 'Abebe Kebede' }) @IsString() passengerName: string; @ApiProperty({ example: '1990-05-15', description: 'Date of birth (YYYY-MM-DD). Must not be a future date.' }) - @IsDateString() - @Transform(({ value }) => value) + // Incoming value is an ISO date string (YYYY-MM-DD); transform to a Date so + // @MaxDate (which requires an actual Date instance) evaluates correctly. + @Transform(({ value }) => (value ? new Date(value) : value)) + @IsDate() @MaxDate(() => new Date(), { message: 'Date of birth cannot be in the future' }) - dateOfBirth: string; + dateOfBirth: Date; @ApiProperty({ example: 'NATIONAL_ID', enum: IdDocumentType, description: 'NATIONAL_ID for Ethiopians (Verifayda verified), PASSPORT for others' }) @IsEnum(IdDocumentType) idDocumentType: IdDocumentType; @ApiPropertyOptional({ example: 'ET123456789', description: 'Ethiopian national ID - verified via Verifayda 2.0 (NOT stored in database)' }) @IsOptional() @IsString() idDocumentNumber?: string; @ApiPropertyOptional({ example: 'P1234567', description: 'Passport number for non-Ethiopian passengers (no verification)' }) @IsOptional() @IsString() passportNumber?: string; @@ -44,10 +46,12 @@ export class RoundTripPassengerDto { example: '1990-05-15', description: 'Date of birth (YYYY-MM-DD). Must not be a future date.' }) - @IsDateString() - @Transform(({ value }) => value) + // Incoming value is an ISO date string (YYYY-MM-DD); transform to a Date so + // @MaxDate (which requires an actual Date instance) evaluates correctly. + @Transform(({ value }) => (value ? new Date(value) : value)) + @IsDate() @MaxDate(() => new Date(), { message: 'Date of birth cannot be in the future' }) - dateOfBirth: string; + dateOfBirth: Date; @ApiProperty({ example: 'NATIONAL_ID', From a63a16a0b7931e154a560ece22fc6425ab92c490 Mon Sep 17 00:00:00 2001 From: yaschalew Date: Fri, 3 Jul 2026 16:36:08 +0300 Subject: [PATCH 12/38] fix --- .../src/modules/verifayda/fayda-callback.controller.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/edr-freight-api/src/modules/verifayda/fayda-callback.controller.ts b/apps/edr-freight-api/src/modules/verifayda/fayda-callback.controller.ts index 71b0dc4b0..1c5569750 100644 --- a/apps/edr-freight-api/src/modules/verifayda/fayda-callback.controller.ts +++ b/apps/edr-freight-api/src/modules/verifayda/fayda-callback.controller.ts @@ -29,3 +29,5 @@ export class FaydaCallbackController { }; } } + +// return res.redirect(url.toString()); From e2867e30864a7c3ab2061b24417f089517c8ee66 Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Fri, 3 Jul 2026 13:47:45 +0000 Subject: [PATCH 13/38] import loading backend --- ...AddLoadingStatusToTrainScheduleBookings.ts | 25 ++++++ .../entities/train-schedule-booking.entity.ts | 9 ++ .../train-schedule-bookings.repository.ts | 23 +++++ .../dto/update-import-loading-status.dto.ts | 15 ++++ .../train-scheduling.controller.ts | 23 +++++ .../train-scheduling.service.ts | 83 +++++++++++++++++++ .../warehouses/dto/filter-inventory.dto.ts | 5 ++ .../warehouses/warehouse-inventory.service.ts | 1 + apps/edr-freight-web/backoffice/src/App.tsx | 4 +- .../backoffice/src/constants/QUERY_KEYS.ts | 2 + .../backoffice/src/constants/URLS.ts | 4 + .../warehouses/WarehouseInventoryPage.tsx | 13 ++- .../backoffice/src/services/api.ts | 21 +++++ .../src/services/trainScheduling.service.ts | 22 +++++ .../backoffice/src/types/trainScheduling.ts | 15 ++++ .../backoffice/src/types/warehouse.ts | 1 + packages/types/src/freight/index.ts | 5 ++ 17 files changed, 266 insertions(+), 5 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/1900000000000-AddLoadingStatusToTrainScheduleBookings.ts create mode 100644 apps/edr-freight-api/src/modules/train-scheduling/dto/update-import-loading-status.dto.ts diff --git a/apps/edr-freight-api/src/migrations/1900000000000-AddLoadingStatusToTrainScheduleBookings.ts b/apps/edr-freight-api/src/migrations/1900000000000-AddLoadingStatusToTrainScheduleBookings.ts new file mode 100644 index 000000000..6c7235e3b --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1900000000000-AddLoadingStatusToTrainScheduleBookings.ts @@ -0,0 +1,25 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Track per-booking loading confirmation (LOADED/UNLOADED) on train_schedule_bookings. + * Tracking only — does not gate dispatch. + */ +export class AddLoadingStatusToTrainScheduleBookings1900000000000 + implements MigrationInterface +{ + name = "AddLoadingStatusToTrainScheduleBookings1900000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.train_schedule_bookings + ADD COLUMN IF NOT EXISTS loading_status varchar(20) NOT NULL DEFAULT 'UNLOADED' + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.train_schedule_bookings + DROP COLUMN IF EXISTS loading_status + `); + } +} diff --git a/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule-booking.entity.ts b/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule-booking.entity.ts index 4ffecea26..352704eea 100644 --- a/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule-booking.entity.ts +++ b/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule-booking.entity.ts @@ -1,9 +1,15 @@ import { BaseEntity } from '@edr/api-common'; +import { LoadingStatus } from '@edr/types'; import { Entity, Index, JoinColumn, ManyToOne, Column } from 'typeorm'; import { Booking } from '../../bookings/entities/booking.entity'; import { TrainSchedule } from './train-schedule.entity'; +export const TRAIN_SCHEDULE_BOOKING_LOADING_STATUSES = [ + LoadingStatus.Unloaded, + LoadingStatus.Loaded, +] as const; + @Entity({ schema: 'freight', name: 'train_schedule_bookings' }) @Index(['trainScheduleId', 'bookingId'], { unique: true }) @Index(['bookingId'], { unique: true }) @@ -23,4 +29,7 @@ export class TrainScheduleBooking extends BaseEntity { @ManyToOne(() => Booking) @JoinColumn({ name: 'booking_id' }) booking?: Booking; + + @Column({ name: 'loading_status', type: 'varchar', length: 20, default: 'UNLOADED' }) + loadingStatus!: string; } diff --git a/apps/edr-freight-api/src/modules/train-schedules/train-schedule-bookings.repository.ts b/apps/edr-freight-api/src/modules/train-schedules/train-schedule-bookings.repository.ts index 4ccfcd469..64607ecb8 100644 --- a/apps/edr-freight-api/src/modules/train-schedules/train-schedule-bookings.repository.ts +++ b/apps/edr-freight-api/src/modules/train-schedules/train-schedule-bookings.repository.ts @@ -47,4 +47,27 @@ export class TrainScheduleBookingsRepository extends BaseRepository { + return this.repo(manager).find({ + where: { trainScheduleId }, + select: { id: true, bookingId: true, trainScheduleId: true, loadingStatus: true }, + }); + } + + async updateLoadingStatusMany( + trainScheduleId: string, + bookingIds: string[], + loadingStatus: string, + manager?: EntityManager, + ): Promise { + if (!bookingIds.length) return; + await this.repo(manager).update( + { trainScheduleId, bookingId: In(bookingIds) }, + { loadingStatus }, + ); + } } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/update-import-loading-status.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/update-import-loading-status.dto.ts new file mode 100644 index 000000000..25943050c --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/update-import-loading-status.dto.ts @@ -0,0 +1,15 @@ +import { LoadingStatus } from '@edr/types'; +import { ApiProperty } from '@nestjs/swagger'; +import { ArrayMinSize, IsArray, IsEnum, IsUUID } from 'class-validator'; + +export class UpdateImportLoadingStatusDto { + @ApiProperty({ type: [String] }) + @IsArray() + @ArrayMinSize(1) + @IsUUID('4', { each: true }) + bookingIds!: string[]; + + @ApiProperty({ enum: LoadingStatus }) + @IsEnum(LoadingStatus) + loadingStatus!: LoadingStatus; +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts index 8887fb4c6..d0595c0da 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts @@ -28,6 +28,7 @@ import { GetEligibleBulkBookingsDto } from "./dto/get-eligible-bulk-bookings.dto import { GetEligibleContainerBookingsDto } from "./dto/get-eligible-container-bookings.dto"; import { PinWagonsDto } from "./dto/pin-wagons.dto"; import { UpdateContainerItemDto } from "./dto/update-container-item.dto"; +import { UpdateImportLoadingStatusDto } from "./dto/update-import-loading-status.dto"; import { PreviewBulkTrainScheduleDto } from "./dto/preview-bulk-train-schedule.dto"; import { PreviewContainerTrainScheduleDto } from "./dto/preview-container-train-schedule.dto"; import { PreviewTrainScheduleDto } from "./dto/preview-train-schedule.dto"; @@ -325,6 +326,28 @@ export class TrainSchedulingController { return this.trainSchedulingService.getCompositionRemovals(id); } + @Get("schedules/:id/import-loading-bookings") + @TrainSchedulingView() + @ApiOperation({ + summary: "List import bookings eligible for loading confirmation on this schedule", + }) + getImportLoadingBookings(@Param("id", ParseUUIDPipe) id: string) { + return this.trainSchedulingService.getImportLoadingBookings(id); + } + + @Patch("schedules/:id/import-loading-status") + @TrainSchedulingManage() + @ApiOperation({ + summary: + "Mark import bookings loaded/unloaded on this schedule (tracking only, does not affect dispatch)", + }) + updateImportLoadingStatus( + @Param("id", ParseUUIDPipe) id: string, + @Body() dto: UpdateImportLoadingStatusDto, + ) { + return this.trainSchedulingService.updateImportLoadingStatus(id, dto); + } + @Post("schedules/:id/pin-wagons") @TrainSchedulingManage() @ApiOperation({ summary: "Pin physical wagons to train set slots" }) diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts index ce835e1e4..cdcd8aca8 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts @@ -1,5 +1,6 @@ import { AllocationLoadType, + LoadingStatus, SchedulingStatus, TrainCheckpointKind, TrainScheduleStatus as TrainScheduleStatusEnum, @@ -45,6 +46,7 @@ import { GetEligibleBulkBookingsDto } from './dto/get-eligible-bulk-bookings.dto import { GetEligibleContainerBookingsDto } from './dto/get-eligible-container-bookings.dto'; import { PinWagonsDto } from './dto/pin-wagons.dto'; import { UpdateContainerItemDto } from './dto/update-container-item.dto'; +import { UpdateImportLoadingStatusDto } from './dto/update-import-loading-status.dto'; import { PreviewBulkTrainScheduleDto } from './dto/preview-bulk-train-schedule.dto'; import { PreviewContainerTrainScheduleDto } from './dto/preview-container-train-schedule.dto'; import { PreviewTrainScheduleDto } from './dto/preview-train-schedule.dto'; @@ -744,6 +746,87 @@ export class TrainSchedulingService { ); } + async getImportLoadingBookings(scheduleId: string) { + const schedule = await this.trainSchedulesRepository.findById(scheduleId); + if (!schedule) { + throw new NotFoundException(`Train schedule ${scheduleId} not found`); + } + + const [scheduleBookings, allocations] = await Promise.all([ + this.trainScheduleBookingsRepository.findByScheduleId(scheduleId), + this.wagonBookingAllocationsRepository.findByScheduleId(scheduleId), + ]); + if (!scheduleBookings.length) { + return { count: 0, items: [] }; + } + + const allocatedBookingIds = new Set(allocations.map((a) => a.bookingId)); + const statusByBookingId = new Map( + scheduleBookings.map((sb) => [sb.bookingId, sb.loadingStatus]), + ); + const candidateIds = scheduleBookings + .map((sb) => sb.bookingId) + .filter((id) => allocatedBookingIds.has(id)); + if (!candidateIds.length) { + return { count: 0, items: [] }; + } + + const bookings = await this.bookingsRepository.findByIdsForScheduling(candidateIds); + const items = bookings + .filter((b) => b.tradeDirection === 'IMPORT' && b.paymentStatus === 'PAID') + .map((b) => ({ + id: b.id, + reference: b.reference ?? null, + customer: b.company?.name ?? null, + weightTons: b.cargoTotalWeightVgm, + loadingStatus: statusByBookingId.get(b.id) ?? LoadingStatus.Unloaded, + })); + return { count: items.length, items }; + } + + async updateImportLoadingStatus(scheduleId: string, dto: UpdateImportLoadingStatusDto) { + const schedule = await this.trainSchedulesRepository.findById(scheduleId); + if (!schedule) { + throw new NotFoundException(`Train schedule ${scheduleId} not found`); + } + + const [scheduleBookings, allocations, bookings] = await Promise.all([ + this.trainScheduleBookingsRepository.findByScheduleId(scheduleId), + this.wagonBookingAllocationsRepository.findByScheduleId(scheduleId), + this.bookingsRepository.findByIdsForScheduling(dto.bookingIds), + ]); + + const scheduledIds = new Set(scheduleBookings.map((sb) => sb.bookingId)); + const allocatedIds = new Set(allocations.map((a) => a.bookingId)); + const bookingById = new Map(bookings.map((b) => [b.id, b])); + + const invalid: string[] = []; + for (const id of dto.bookingIds) { + const booking = bookingById.get(id); + if ( + !scheduledIds.has(id) || + !allocatedIds.has(id) || + !booking || + booking.tradeDirection !== 'IMPORT' || + booking.paymentStatus !== 'PAID' + ) { + invalid.push(id); + } + } + if (invalid.length) { + throw new BadRequestException( + `Not eligible for import loading confirmation on this schedule: ${invalid.join(', ')}`, + ); + } + + await this.trainScheduleBookingsRepository.updateLoadingStatusMany( + scheduleId, + dto.bookingIds, + dto.loadingStatus, + ); + return this.getImportLoadingBookings(scheduleId); + } + async pinWagons(scheduleId: string, dto: PinWagonsDto) { const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); if (!schedule) { diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/filter-inventory.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/filter-inventory.dto.ts index 9f89e7c2c..49fb22fde 100644 --- a/apps/edr-freight-api/src/modules/warehouses/dto/filter-inventory.dto.ts +++ b/apps/edr-freight-api/src/modules/warehouses/dto/filter-inventory.dto.ts @@ -52,6 +52,11 @@ export class FilterWarehouseInventoryDto { @IsEnum(WAREHOUSE_INVENTORY_STATUSES) status?: WarehouseInventoryStatus; + @ApiPropertyOptional({ enum: ['IMPORT', 'EXPORT'] }) + @IsOptional() + @IsEnum(['IMPORT', 'EXPORT']) + direction?: 'IMPORT' | 'EXPORT'; + @ApiPropertyOptional() @IsOptional() @IsString() diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts index 11f34c892..3b27c7213 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts @@ -430,6 +430,7 @@ export class WarehouseInventoryService { ...(filter.status ? { status: filter.status } : {}), ...(createdAt ? { createdAt } : {}), ...(filter.facilityId ? { warehouse: { stationId: filter.facilityId } } : {}), + ...(filter.direction ? { booking: { tradeDirection: filter.direction } } : {}), }; const search = filter.search?.trim(); diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index 8c2453e4d..1558eb727 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -309,7 +309,7 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ }, { label: "Terminal Inventory", - href: "/dashboard/warehouse-inventory", + href: "/dashboard/warehouse-inventory?direction=IMPORT", icon: , }, { @@ -356,7 +356,7 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ }, { label: "Terminal Inventory", - href: "/dashboard/warehouse-inventory", + href: "/dashboard/warehouse-inventory?direction=EXPORT", icon: , }, ], diff --git a/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts b/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts index eecee533b..8ee0a53be 100644 --- a/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts @@ -94,6 +94,8 @@ export const QUERY_KEYS = { ["train-scheduling", "unassigned", id] as const, compositionRemovals: (id: string) => ["train-scheduling", "removals", id] as const, + importLoadingBookings: (id: string) => + ["train-scheduling", "import-loading-bookings", id] as const, }, FLEET: { diff --git a/apps/edr-freight-web/backoffice/src/constants/URLS.ts b/apps/edr-freight-web/backoffice/src/constants/URLS.ts index c59be5d7d..d33603d24 100644 --- a/apps/edr-freight-web/backoffice/src/constants/URLS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/URLS.ts @@ -301,6 +301,10 @@ export const URL_CONSTANTS = { PIN_WAGONS: (id: string) => `/train-scheduling/schedules/${id}/pin-wagons`, FINALIZE: (id: string) => `/train-scheduling/schedules/${id}/finalize`, DISPATCH: (id: string) => `/train-scheduling/schedules/${id}/dispatch`, + IMPORT_LOADING_BOOKINGS: (id: string) => + `/train-scheduling/schedules/${id}/import-loading-bookings`, + IMPORT_LOADING_STATUS: (id: string) => + `/train-scheduling/schedules/${id}/import-loading-status`, IMPORT_DJIBOUTI: (id: string) => `/train-scheduling/schedules/${id}/import-djibouti`, IMPORT_DJIBOUTI_DOCUMENTS: (id: string) => diff --git a/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseInventoryPage.tsx b/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseInventoryPage.tsx index 7133c47ab..6f0d27580 100644 --- a/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseInventoryPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseInventoryPage.tsx @@ -21,6 +21,7 @@ export default function WarehouseInventoryPage() { const navigate = useNavigate(); const [searchParams] = useSearchParams(); const initialStatus = (searchParams.get('status') as InventoryStatus | null) ?? undefined; + const direction = (searchParams.get('direction') as 'IMPORT' | 'EXPORT' | null) ?? undefined; const [filter, setFilter] = useState( initialStatus ? { status: initialStatus } : {}, ); @@ -28,8 +29,8 @@ export default function WarehouseInventoryPage() { const [debouncedSearch] = useDebouncedValue(search, 300); const queryFilter = useMemo( - () => ({ ...filter, search: debouncedSearch || undefined }), - [filter, debouncedSearch], + () => ({ ...filter, direction, search: debouncedSearch || undefined }), + [filter, direction, debouncedSearch], ); const warehousesQuery = useWarehouses(); @@ -53,7 +54,13 @@ export default function WarehouseInventoryPage() { return ( diff --git a/apps/edr-freight-web/backoffice/src/services/api.ts b/apps/edr-freight-web/backoffice/src/services/api.ts index a239b7ee4..fb7d733e3 100644 --- a/apps/edr-freight-web/backoffice/src/services/api.ts +++ b/apps/edr-freight-web/backoffice/src/services/api.ts @@ -43,6 +43,8 @@ import type { CreateTrainSchedulePayload, EligibleContainerBookingsResponse, FreightType, + ImportLoadingBookingsResponse, + LoadingStatus, LocomotiveRecord, PinWagonsPayload, RecordCheckpointPayload, @@ -343,6 +345,25 @@ export const api = { QUERY_KEYS.TRAIN_SCHEDULING.compositionRemovals(scheduleId), ), + importLoadingBookings: endpoint<{ id: string }, ImportLoadingBookingsResponse>( + "train-scheduling", + "import-loading-bookings", + ({ id }) => trainSchedulingService.getImportLoadingBookings(id), + ({ id }) => QUERY_KEYS.TRAIN_SCHEDULING.importLoadingBookings(id), + ), + + updateImportLoadingStatus: endpoint< + { id: string; bookingIds: string[]; loadingStatus: LoadingStatus }, + ImportLoadingBookingsResponse + >( + "train-scheduling", + "update-import-loading-status", + ({ id, bookingIds, loadingStatus }) => + trainSchedulingService.updateImportLoadingStatus(id, { bookingIds, loadingStatus }), + undefined, + ({ id }) => [QUERY_KEYS.TRAIN_SCHEDULING.importLoadingBookings(id)], + ), + // ── Mutations ────────────────────────────────────────────────────────── runAllocation: endpoint<{ scheduleId: string }, WagonAllocationAttemptResult>( "train-scheduling", diff --git a/apps/edr-freight-web/backoffice/src/services/trainScheduling.service.ts b/apps/edr-freight-web/backoffice/src/services/trainScheduling.service.ts index 0d6ef6cc9..5e0310bbe 100644 --- a/apps/edr-freight-web/backoffice/src/services/trainScheduling.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/trainScheduling.service.ts @@ -15,6 +15,8 @@ import type { ImportDjiboutiActionPayload, ImportDjiboutiLoadList, ImportDjiboutiOperation, + ImportLoadingBookingsResponse, + LoadingStatus, LocomotiveRecord, PinWagonsPayload, RecordCheckpointPayload, @@ -298,6 +300,26 @@ export const trainSchedulingService = { return unwrap(response.data); }, + getImportLoadingBookings: async ( + scheduleId: string, + ): Promise => { + const response = await client.get( + URL_CONSTANTS.TRAIN_SCHEDULING.IMPORT_LOADING_BOOKINGS(scheduleId), + ); + return unwrap(response.data); + }, + + updateImportLoadingStatus: async ( + scheduleId: string, + payload: { bookingIds: string[]; loadingStatus: LoadingStatus }, + ): Promise => { + const response = await client.patch( + URL_CONSTANTS.TRAIN_SCHEDULING.IMPORT_LOADING_STATUS(scheduleId), + payload, + ); + return unwrap(response.data); + }, + getImportDjiboutiOperation: async ( scheduleId: string, ): Promise => { diff --git a/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts b/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts index 2c9a54dbd..19926a69e 100644 --- a/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts +++ b/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts @@ -453,6 +453,21 @@ export interface ImportDjiboutiDocumentRecord { notes?: string | null; } +export type LoadingStatus = "LOADED" | "UNLOADED"; + +export interface ImportLoadingBooking { + id: string; + reference: string | null; + customer: string | null; + weightTons: number; + loadingStatus: LoadingStatus; +} + +export interface ImportLoadingBookingsResponse { + count: number; + items: ImportLoadingBooking[]; +} + export interface ImportDjiboutiOperation { trainScheduleId: string; trainNumber: string | null; diff --git a/apps/edr-freight-web/backoffice/src/types/warehouse.ts b/apps/edr-freight-web/backoffice/src/types/warehouse.ts index fdaeee556..5e116d1ca 100644 --- a/apps/edr-freight-web/backoffice/src/types/warehouse.ts +++ b/apps/edr-freight-web/backoffice/src/types/warehouse.ts @@ -1013,6 +1013,7 @@ export interface InventoryFilter { containerId?: string; goodsId?: string; status?: InventoryStatus; + direction?: 'IMPORT' | 'EXPORT'; search?: string; dateFrom?: string; dateTo?: string; diff --git a/packages/types/src/freight/index.ts b/packages/types/src/freight/index.ts index 261f22d0b..3afc21a43 100644 --- a/packages/types/src/freight/index.ts +++ b/packages/types/src/freight/index.ts @@ -198,6 +198,11 @@ export enum TrainSetWagonStatus { Departed = "DEPARTED", } +export enum LoadingStatus { + Loaded = "LOADED", + Unloaded = "UNLOADED", +} + export enum WagonStatus { Available = "AVAILABLE", Assigned = "ASSIGNED", From 782f4184ef0e344df6c7a1fc9643f5289886120c Mon Sep 17 00:00:00 2001 From: natib21 Date: Fri, 3 Jul 2026 13:49:55 +0000 Subject: [PATCH 14/38] fix --- ...000000004-AddLastMileVehicleAssignments.ts | 39 +++++++++++++++++++ .../1890000000005-AddDriverGender.ts | 24 ++++++++++++ .../modules/drivers/dto/create-driver.dto.ts | 6 ++- .../modules/drivers/entities/driver.entity.ts | 9 +++++ .../last-mile-vehicle-assignment.entity.ts | 30 ++++++++++++++ .../last-mile/entities/last-mile.entity.ts | 4 ++ .../src/components/fleet/FleetFormDialog.tsx | 23 ++++++++++- .../src/pages/fleet/config/drivers.ts | 9 +++++ 8 files changed, 142 insertions(+), 2 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/1890000000004-AddLastMileVehicleAssignments.ts create mode 100644 apps/edr-freight-api/src/migrations/1890000000005-AddDriverGender.ts create mode 100644 apps/edr-freight-api/src/modules/last-mile/entities/last-mile-vehicle-assignment.entity.ts diff --git a/apps/edr-freight-api/src/migrations/1890000000004-AddLastMileVehicleAssignments.ts b/apps/edr-freight-api/src/migrations/1890000000004-AddLastMileVehicleAssignments.ts new file mode 100644 index 000000000..7c1413617 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1890000000004-AddLastMileVehicleAssignments.ts @@ -0,0 +1,39 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Allow more than one vehicle per last-mile delivery. Junction table joins + * last_mile ⇄ vehicles; existing single vehicle_id values are backfilled as + * the first assignment so nothing is lost. + */ +export class AddLastMileVehicleAssignments1890000000004 implements MigrationInterface { + name = "AddLastMileVehicleAssignments1890000000004"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.last_mile_vehicle_assignments ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + last_mile_id uuid NOT NULL REFERENCES freight.last_mile(id) ON DELETE CASCADE, + vehicle_id uuid NOT NULL REFERENCES freight.vehicles(id), + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + CONSTRAINT "UQ_LAST_MILE_VEHICLE" UNIQUE (last_mile_id, vehicle_id) + ) + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "IDX_LM_VEHICLE_ASSIGNMENTS_VEHICLE" + ON freight.last_mile_vehicle_assignments (vehicle_id) + `); + // Backfill: existing single-vehicle assignments become the first row + await queryRunner.query(` + INSERT INTO freight.last_mile_vehicle_assignments (last_mile_id, vehicle_id) + SELECT id, vehicle_id FROM freight.last_mile + WHERE vehicle_id IS NOT NULL AND deleted_at IS NULL + ON CONFLICT (last_mile_id, vehicle_id) DO NOTHING + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS freight.last_mile_vehicle_assignments`); + } +} diff --git a/apps/edr-freight-api/src/migrations/1890000000005-AddDriverGender.ts b/apps/edr-freight-api/src/migrations/1890000000005-AddDriverGender.ts new file mode 100644 index 000000000..2ec26e034 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1890000000005-AddDriverGender.ts @@ -0,0 +1,24 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Store the driver's gender. Prefilled from the Fayda VERIFY response + * (Male/Female) but editable; nullable so existing rows and manual, + * non-Fayda driver records stay valid. + */ +export class AddDriverGender1890000000005 implements MigrationInterface { + name = "AddDriverGender1890000000005"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.drivers + ADD COLUMN IF NOT EXISTS gender varchar + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.drivers + DROP COLUMN IF EXISTS gender + `); + } +} diff --git a/apps/edr-freight-api/src/modules/drivers/dto/create-driver.dto.ts b/apps/edr-freight-api/src/modules/drivers/dto/create-driver.dto.ts index 4bf1d640d..c2f0bca81 100644 --- a/apps/edr-freight-api/src/modules/drivers/dto/create-driver.dto.ts +++ b/apps/edr-freight-api/src/modules/drivers/dto/create-driver.dto.ts @@ -1,5 +1,5 @@ import { IsString, IsEmail, IsDateString, IsEnum, IsOptional, IsArray, IsBoolean } from 'class-validator'; -import { DriverStatus } from '../entities/driver.entity'; +import { DriverStatus, DriverGender } from '../entities/driver.entity'; export class CreateDriverDto { @IsString() @@ -20,6 +20,10 @@ export class CreateDriverDto { @IsDateString() dateOfBirth!: string; + @IsOptional() + @IsEnum(DriverGender) + gender?: DriverGender; + @IsDateString() licenseExpiryDate!: string; diff --git a/apps/edr-freight-api/src/modules/drivers/entities/driver.entity.ts b/apps/edr-freight-api/src/modules/drivers/entities/driver.entity.ts index c345938a4..889b688cb 100644 --- a/apps/edr-freight-api/src/modules/drivers/entities/driver.entity.ts +++ b/apps/edr-freight-api/src/modules/drivers/entities/driver.entity.ts @@ -8,6 +8,12 @@ export enum DriverStatus { ON_LEAVE = 'ON_LEAVE', } +export enum DriverGender { + MALE = 'MALE', + FEMALE = 'FEMALE', + OTHER = 'OTHER', +} + @Entity({ name: 'drivers', schema: 'freight' }) export class Driver extends BaseEntity { @Column({ name: 'license_number', unique: true, nullable: true }) @@ -28,6 +34,9 @@ export class Driver extends BaseEntity { @Column({ name: 'date_of_birth', type: 'date', nullable: true }) dateOfBirth?: Date; + @Column({ type: 'varchar', nullable: true }) + gender?: DriverGender | null; + @Column({ name: 'license_expiry_date', type: 'date', nullable: true }) licenseExpiryDate?: Date; diff --git a/apps/edr-freight-api/src/modules/last-mile/entities/last-mile-vehicle-assignment.entity.ts b/apps/edr-freight-api/src/modules/last-mile/entities/last-mile-vehicle-assignment.entity.ts new file mode 100644 index 000000000..0ba0f7d06 --- /dev/null +++ b/apps/edr-freight-api/src/modules/last-mile/entities/last-mile-vehicle-assignment.entity.ts @@ -0,0 +1,30 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index, JoinColumn, ManyToOne, Unique } from 'typeorm'; + +import { Vehicle } from '../../vehicles/entities/vehicle.entity'; +import { LastMile } from './last-mile.entity'; + +/** + * One row per vehicle assigned to a last-mile delivery. A delivery can be + * served by several vehicles at once (multi-truck bookings); the legacy + * `last_mile.vehicle_id` column keeps pointing at the first assignment for + * backward compatibility. + */ +@Entity({ name: 'last_mile_vehicle_assignments', schema: 'freight' }) +@Unique(['lastMileId', 'vehicleId']) +@Index(['vehicleId']) +export class LastMileVehicleAssignment extends BaseEntity { + @Column({ name: 'last_mile_id', type: 'uuid' }) + lastMileId!: string; + + @ManyToOne(() => LastMile, (lm) => lm.vehicleAssignments, { nullable: false, onDelete: 'CASCADE' }) + @JoinColumn({ name: 'last_mile_id' }) + lastMile?: LastMile; + + @Column({ name: 'vehicle_id', type: 'uuid' }) + vehicleId!: string; + + @ManyToOne(() => Vehicle, { nullable: false, eager: false }) + @JoinColumn({ name: 'vehicle_id' }) + vehicle?: Vehicle; +} diff --git a/apps/edr-freight-api/src/modules/last-mile/entities/last-mile.entity.ts b/apps/edr-freight-api/src/modules/last-mile/entities/last-mile.entity.ts index 85d01b7f0..1f8bda8fc 100644 --- a/apps/edr-freight-api/src/modules/last-mile/entities/last-mile.entity.ts +++ b/apps/edr-freight-api/src/modules/last-mile/entities/last-mile.entity.ts @@ -4,6 +4,7 @@ import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm import { Booking } from '../../bookings/entities/booking.entity'; import { Vehicle } from '../../vehicles/entities/vehicle.entity'; import { LastMileContainerAllocation } from './last-mile-container-allocation.entity'; +import { LastMileVehicleAssignment } from './last-mile-vehicle-assignment.entity'; export const LAST_MILE_STATUSES = [ 'PAYMENT_PENDING', @@ -57,4 +58,7 @@ export class LastMile extends BaseEntity { @OneToMany(() => LastMileContainerAllocation, (ca) => ca.lastMile) containerAllocations?: LastMileContainerAllocation[]; + + @OneToMany(() => LastMileVehicleAssignment, (va) => va.lastMile) + vehicleAssignments?: LastMileVehicleAssignment[]; } diff --git a/apps/edr-freight-web/backoffice/src/components/fleet/FleetFormDialog.tsx b/apps/edr-freight-web/backoffice/src/components/fleet/FleetFormDialog.tsx index c75e8c7b3..82a89c33e 100644 --- a/apps/edr-freight-web/backoffice/src/components/fleet/FleetFormDialog.tsx +++ b/apps/edr-freight-web/backoffice/src/components/fleet/FleetFormDialog.tsx @@ -45,6 +45,24 @@ export interface FleetFormDialogProps { verifyWithFayda?: boolean; } +// Fayda returns gender as "Male"/"Female"; snap it onto the form's uppercase +// option values (MALE/FEMALE/OTHER) so the Select prefills instead of rendering +// blank. Unknown/empty values fall through to undefined (field left untouched). +const normalizeGender = (raw?: string): string | undefined => { + const up = (raw ?? "").trim().toUpperCase(); + if (up === "MALE" || up === "M") return "MALE"; + if (up === "FEMALE" || up === "F") return "FEMALE"; + return up ? "OTHER" : undefined; +}; + +// Fayda may return the birthdate as "2001/12/01" (slashes), but the date input +// and validator expect ISO "2001-12-01". Normalize separators + trim to 10 chars +// so the DOB field prefills instead of silently staying blank. +const normalizeBirthdate = (raw?: string): string | undefined => { + const iso = (raw ?? "").trim().replace(/\//g, "-").slice(0, 10); + return /^\d{4}-\d{2}-\d{2}$/.test(iso) ? iso : undefined; +}; + const buildInitialValues = ( fields: FleetFormFieldDef[], emptyValues: Record, @@ -131,13 +149,16 @@ const FleetFormDialog = ({ } const nameParts = (result.fullName ?? "").trim().split(/\s+/).filter(Boolean); const [firstName, ...rest] = nameParts; + const gender = normalizeGender(result.gender); + const dateOfBirth = normalizeBirthdate(result.birthdate); setValues((current) => ({ ...current, ...(firstName ? { firstName } : {}), ...(rest.length ? { lastName: rest.join(" ") } : {}), ...(result.email ? { email: result.email } : {}), ...(result.phoneNumber ? { phoneNumber: result.phoneNumber } : {}), - ...(result.birthdate ? { dateOfBirth: result.birthdate } : {}), + ...(dateOfBirth ? { dateOfBirth } : {}), + ...(gender ? { gender } : {}), faydaVerified: true, ...(result.iamUserId ? { faydaSub: result.iamUserId } : {}), })); diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/config/drivers.ts b/apps/edr-freight-web/backoffice/src/pages/fleet/config/drivers.ts index 6331e5a50..2f3fb5aeb 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/config/drivers.ts +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/config/drivers.ts @@ -8,6 +8,12 @@ const DRIVER_STATUS_OPTIONS = [ { label: "On leave", value: "ON_LEAVE" }, ]; +const DRIVER_GENDER_OPTIONS = [ + { label: "Male", value: "MALE" }, + { label: "Female", value: "FEMALE" }, + { label: "Other", value: "OTHER" }, +]; + export const driversConfig: FleetResourceConfig = { slug: "drivers", label: "Drivers", @@ -37,6 +43,7 @@ export const driversConfig: FleetResourceConfig = { { id: "lastName", header: "Last Name", accessorKey: "lastName", format: "code", size: 120 }, { id: "email", header: "Email", accessorKey: "email", format: "code", size: 180 }, { id: "phoneNumber", header: "Phone", accessorKey: "phoneNumber", format: "code", size: 120 }, + { id: "gender", header: "Gender", accessorKey: "gender", format: "code", size: 90 }, { id: "licenseExpiryDate", header: "License Expiry", accessorKey: "licenseExpiryDate", format: "code", size: 130 }, { id: "status", header: "Status", accessorKey: "status", format: "statusBadge", size: 100 }, { id: "faydaVerified", header: "Fayda", accessorKey: "faydaVerified", format: "verifiedBadge", size: 90 }, @@ -48,6 +55,7 @@ export const driversConfig: FleetResourceConfig = { { name: "email", label: "Email", type: "email", required: true }, { name: "phoneNumber", label: "Phone Number", type: "text", required: true }, { name: "dateOfBirth", label: "Date of Birth", type: "date", required: true }, + { name: "gender", label: "Gender", type: "select", options: DRIVER_GENDER_OPTIONS }, { name: "licenseExpiryDate", label: "License Expiry Date", type: "date", required: true }, { name: "status", label: "Status", type: "select", required: true, options: DRIVER_STATUS_OPTIONS }, { name: "vehicleTypesAuthorized", label: "Authorized Vehicle Types", type: "multiselect", options: VEHICLE_TYPE_OPTIONS }, @@ -62,6 +70,7 @@ export const driversConfig: FleetResourceConfig = { email: "", phoneNumber: "", dateOfBirth: "", + gender: "", licenseExpiryDate: "", status: "ACTIVE", vehicleTypesAuthorized: [], From bf5b0b5dc883c735e881727c54af4b95b4d1bc60 Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Fri, 3 Jul 2026 14:03:02 +0000 Subject: [PATCH 15/38] =?UTF-8?q?approve-delivery=20exit-gate=20fix=20+=20?= =?UTF-8?q?Import=20Loading=20Confirmation=20frontend=20panel=20=E2=80=94?= =?UTF-8?q?=20done=20this=20session,=20not=20yet=20committed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../warehouses/warehouse-inventory.service.ts | 16 ++ .../ImportLoadingConfirmationPanel.tsx | 172 ++++++++++++++++++ .../TrainScheduleV2DetailPage.tsx | 25 +++ 3 files changed, 213 insertions(+) create mode 100644 apps/edr-freight-web/backoffice/src/components/trainScheduling/ImportLoadingConfirmationPanel.tsx diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts index 3b27c7213..ac6b71c89 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts @@ -2033,6 +2033,22 @@ export class WarehouseInventoryService { const isTruckLeaving = dto.grossWeight !== undefined && Boolean(dto.gateOutTime); if (isTruckLeaving) { await this.invoices.assertClearanceAllowed(id); + + if (item.bookingId) { + const [truckInfo]: Array<{ customerTruckAssignedAt: string | null }> = + await this.dataSource.query( + `SELECT customer_truck_assigned_at AS "customerTruckAssignedAt" + FROM freight.bookings + WHERE id = $1 AND deleted_at IS NULL`, + [item.bookingId], + ); + const usesCustomerTruck = Boolean(truckInfo?.customerTruckAssignedAt); + if (usesCustomerTruck && !this.extractCustomerDeliveryApproval(item.notes)) { + throw new BadRequestException( + 'Customer must approve delivery (sign the handover) before the exit paper can be generated', + ); + } + } } const releaseDate = isTruckLeaving ? dto.releaseDate ? new Date(dto.releaseDate) : new Date() diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/ImportLoadingConfirmationPanel.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/ImportLoadingConfirmationPanel.tsx new file mode 100644 index 000000000..ab1d907e6 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/ImportLoadingConfirmationPanel.tsx @@ -0,0 +1,172 @@ +import { useMemo, useState } from "react"; +import { PackageCheck } from "lucide-react"; +import { Badge, Button, Checkbox, Group, Loader, Paper, Stack, Text } from "@mantine/core"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import toast from "react-hot-toast"; + +import { api } from "@/services/api"; +import type { + ImportLoadingBooking, + ImportLoadingBookingsResponse, + LoadingStatus, +} from "@/types/trainScheduling"; + +function ImportLoadingBookingRow({ + booking, + selected, + onToggle, +}: { + booking: ImportLoadingBooking; + selected: boolean; + onToggle: () => void; +}) { + return ( + + + + + + + {booking.reference ?? booking.id} + + + {booking.loadingStatus} + + + + {booking.customer ?? "Unknown customer"} + + + {booking.weightTons}T + + + + ); +} + +export function ImportLoadingConfirmationPanel({ + scheduleId, + items, + isLoading, +}: { + scheduleId: string; + items: ImportLoadingBooking[]; + isLoading?: boolean; +}) { + const [selectedIds, setSelectedIds] = useState([]); + const queryClient = useQueryClient(); + + const updateStatus = useMutation< + ImportLoadingBookingsResponse, + Error, + { id: string; bookingIds: string[]; loadingStatus: LoadingStatus } + >({ + ...api.trainScheduling.updateImportLoadingStatus.mutationOptions(), + onSuccess: () => { + setSelectedIds([]); + queryClient.invalidateQueries({ + queryKey: api.trainScheduling.importLoadingBookings.queryKey({ id: scheduleId }), + }); + }, + onError: (error) => { + toast.error(error instanceof Error ? error.message : "Could not update loading status"); + }, + }); + + const toggle = (id: string) => { + setSelectedIds((prev) => + prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id], + ); + }; + + const allIds = useMemo(() => items.map((b) => b.id), [items]); + + if (isLoading) { + return ( + + + + Loading import bookings… + + + ); + } + + if (!items.length) { + return ( + + + No paid import bookings with wagons allocated on this schedule + + + ); + } + + return ( + + + + Import bookings ({items.length}) + + + + + + + + + {items.map((booking) => ( + toggle(booking.id)} + /> + ))} + + + + + + + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx index 9319e6a01..c02eb2c39 100644 --- a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx @@ -42,6 +42,7 @@ import { } from "@/components/trainScheduling/containerPlacement.util"; import { ContainerPlacementGrid } from "@/components/trainScheduling/ContainerPlacementGrid"; import { FleetAvailabilitySummary } from "@/components/trainScheduling/FleetAvailabilitySummary"; +import { ImportLoadingConfirmationPanel } from "@/components/trainScheduling/ImportLoadingConfirmationPanel"; import { RescheduleTrainDialog } from "@/components/trainScheduling/RescheduleTrainDialog"; import { ScheduleBatchPanel } from "@/components/trainScheduling/ScheduleBatchPanel"; import { ScheduleBookingsStep } from "@/components/trainScheduling/ScheduleBookingsStep"; @@ -141,6 +142,13 @@ export default function TrainScheduleV2DetailPage() { }, }); + const importLoadingQuery = useQuery( + api.trainScheduling.importLoadingBookings.queryOptions({ + input: { id: scheduleId ?? "" }, + enabled: Boolean(scheduleId && schedule?.direction === "IMPORT"), + }), + ); + const eligibleFilters = useMemo( () => schedule @@ -951,6 +959,23 @@ export default function TrainScheduleV2DetailPage() { ]} /> + {schedule?.direction === "IMPORT" ? ( + + + Import loading confirmation + + Paid import bookings with a wagon allocated on this schedule. Marking loaded/unloaded + is tracking only — it does not block dispatch. + + + + + ) : null} + {gatepassApplies ? ( From e14e02e7f2dc4a2512f7ec0ce36d8e4714859a0f Mon Sep 17 00:00:00 2001 From: natib21 Date: Fri, 3 Jul 2026 14:07:43 +0000 Subject: [PATCH 16/38] fix --- .../1890000000006-AddDriverFaydaSubUnique.ts | 23 ++++++++++++ .../src/modules/drivers/drivers.service.ts | 37 ++++++++++++++++++- .../modules/drivers/entities/driver.entity.ts | 5 ++- .../src/components/fleet/FleetFormDialog.tsx | 25 +++++++++---- .../src/pages/fleet/config/drivers.ts | 12 +++--- .../src/pages/fleet/config/resources.ts | 5 +++ 6 files changed, 91 insertions(+), 16 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/1890000000006-AddDriverFaydaSubUnique.ts diff --git a/apps/edr-freight-api/src/migrations/1890000000006-AddDriverFaydaSubUnique.ts b/apps/edr-freight-api/src/migrations/1890000000006-AddDriverFaydaSubUnique.ts new file mode 100644 index 000000000..04c9a7000 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1890000000006-AddDriverFaydaSubUnique.ts @@ -0,0 +1,23 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Enforce one driver record per verified Fayda identity. A unique index on + * fayda_sub blocks a second driver from being created against the same Fayda + * OIDC subject; NULLs stay distinct so legacy/unverified rows are unaffected. + */ +export class AddDriverFaydaSubUnique1890000000006 implements MigrationInterface { + name = "AddDriverFaydaSubUnique1890000000006"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS "UQ_DRIVERS_FAYDA_SUB" + ON freight.drivers (fayda_sub) + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + DROP INDEX IF EXISTS freight."UQ_DRIVERS_FAYDA_SUB" + `); + } +} diff --git a/apps/edr-freight-api/src/modules/drivers/drivers.service.ts b/apps/edr-freight-api/src/modules/drivers/drivers.service.ts index 2464a26ac..cbfb6787d 100644 --- a/apps/edr-freight-api/src/modules/drivers/drivers.service.ts +++ b/apps/edr-freight-api/src/modules/drivers/drivers.service.ts @@ -1,4 +1,4 @@ -import { Injectable, NotFoundException, ConflictException } from '@nestjs/common'; +import { Injectable, NotFoundException, ConflictException, BadRequestException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { CreateDriverDto } from './dto/create-driver.dto'; @@ -13,6 +13,12 @@ export class DriversService { ) {} async create(dto: CreateDriverDto): Promise { + if (dto.faydaVerified !== true) { + throw new BadRequestException( + 'Driver identity must be verified with Fayda before saving', + ); + } + const existing = await this.driverRepo.findOne({ where: [ { licenseNumber: dto.licenseNumber }, @@ -33,6 +39,17 @@ export class DriversService { } } + if (dto.faydaSub) { + const dupe = await this.driverRepo.findOne({ + where: { faydaSub: dto.faydaSub }, + }); + if (dupe) { + throw new ConflictException( + 'A driver is already registered for this Fayda identity', + ); + } + } + const driver = this.driverRepo.create(dto); return this.driverRepo.save(driver); } @@ -106,7 +123,25 @@ export class DriversService { } } + if (dto.faydaSub && dto.faydaSub !== driver.faydaSub) { + const dupe = await this.driverRepo.findOne({ + where: { faydaSub: dto.faydaSub }, + }); + if (dupe) { + throw new ConflictException( + 'A driver is already registered for this Fayda identity', + ); + } + } + Object.assign(driver, dto); + + if (driver.faydaVerified !== true) { + throw new BadRequestException( + 'Driver identity must be verified with Fayda before saving', + ); + } + return this.driverRepo.save(driver); } diff --git a/apps/edr-freight-api/src/modules/drivers/entities/driver.entity.ts b/apps/edr-freight-api/src/modules/drivers/entities/driver.entity.ts index 889b688cb..58a08b849 100644 --- a/apps/edr-freight-api/src/modules/drivers/entities/driver.entity.ts +++ b/apps/edr-freight-api/src/modules/drivers/entities/driver.entity.ts @@ -64,7 +64,8 @@ export class Driver extends BaseEntity { @Column({ name: 'fayda_verified', type: 'boolean', default: false, nullable: true }) faydaVerified?: boolean; - /** Fayda OIDC subject the identity was verified against. */ - @Column({ name: 'fayda_sub', type: 'varchar', nullable: true }) + /** Fayda OIDC subject the identity was verified against. Unique — one driver + * record per verified Fayda identity (NULLs allowed for legacy/unverified). */ + @Column({ name: 'fayda_sub', type: 'varchar', unique: true, nullable: true }) faydaSub?: string | null; } diff --git a/apps/edr-freight-web/backoffice/src/components/fleet/FleetFormDialog.tsx b/apps/edr-freight-web/backoffice/src/components/fleet/FleetFormDialog.tsx index 82a89c33e..f07659dd8 100644 --- a/apps/edr-freight-web/backoffice/src/components/fleet/FleetFormDialog.tsx +++ b/apps/edr-freight-web/backoffice/src/components/fleet/FleetFormDialog.tsx @@ -258,6 +258,12 @@ const FleetFormDialog = ({ }, [fields]); const handleSubmit = () => { + // Hard gate: a driver record cannot be saved until its identity is verified + // with Fayda. Mirrored server-side in DriversService. + if (verifyWithFayda && !faydaVerified) { + setFaydaError("Verify the driver's identity with Fayda before saving."); + return; + } if (!validate()) return; const payload = Object.fromEntries( Object.entries(values) @@ -278,6 +284,9 @@ const FleetFormDialog = ({ const renderField = (field: FleetFormFieldDef) => { const value = values[field.name]; const error = errors[field.name]; + // Fayda-owned identity fields (name/email/phone/DOB/gender) are populated + // only by verification and never hand-edited. + const isDisabled = Boolean(field.disabled || field.faydaLocked); if (field.type === "select") { return ( @@ -297,7 +306,7 @@ const FleetFormDialog = ({ } error={error} searchable - disabled={selectOptionsLoading} + disabled={selectOptionsLoading || isDisabled} rightSection={ selectOptionsLoading ? ( @@ -327,7 +336,7 @@ const FleetFormDialog = ({ error={error} searchable clearable - disabled={selectOptionsLoading} + disabled={selectOptionsLoading || isDisabled} rightSection={ selectOptionsLoading ? ( @@ -351,7 +360,7 @@ const FleetFormDialog = ({ })) } error={error} - disabled={field.disabled} + disabled={isDisabled} /> ); } @@ -371,7 +380,7 @@ const FleetFormDialog = ({ } error={error} minRows={3} - disabled={field.disabled} + disabled={isDisabled} /> ); } @@ -391,7 +400,7 @@ const FleetFormDialog = ({ })) } error={error} - disabled={field.disabled} + disabled={isDisabled} description={field.description || "Select a date"} rightSection={ @@ -429,7 +438,7 @@ const FleetFormDialog = ({ })) } error={error} - disabled={field.disabled} + disabled={isDisabled} /> ); }; @@ -457,7 +466,8 @@ const FleetFormDialog = ({ ) : ( - Verify the driver's identity with Fayda to prefill their details. + Identity must be verified with Fayda before this driver can be + saved. )} diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/config/drivers.ts b/apps/edr-freight-web/backoffice/src/pages/fleet/config/drivers.ts index 2f3fb5aeb..ec242906e 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/config/drivers.ts +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/config/drivers.ts @@ -50,12 +50,12 @@ export const driversConfig: FleetResourceConfig = { ], formFields: [ { name: "licenseNumber", label: "License Number", type: "text", required: true }, - { name: "firstName", label: "First Name", type: "text", required: true }, - { name: "lastName", label: "Last Name", type: "text", required: true }, - { name: "email", label: "Email", type: "email", required: true }, - { name: "phoneNumber", label: "Phone Number", type: "text", required: true }, - { name: "dateOfBirth", label: "Date of Birth", type: "date", required: true }, - { name: "gender", label: "Gender", type: "select", options: DRIVER_GENDER_OPTIONS }, + { name: "firstName", label: "First Name", type: "text", required: true, faydaLocked: true }, + { name: "lastName", label: "Last Name", type: "text", required: true, faydaLocked: true }, + { name: "email", label: "Email", type: "email", required: true, faydaLocked: true }, + { name: "phoneNumber", label: "Phone Number", type: "text", required: true, faydaLocked: true }, + { name: "dateOfBirth", label: "Date of Birth", type: "date", required: true, faydaLocked: true }, + { name: "gender", label: "Gender", type: "select", options: DRIVER_GENDER_OPTIONS, faydaLocked: true }, { name: "licenseExpiryDate", label: "License Expiry Date", type: "date", required: true }, { name: "status", label: "Status", type: "select", required: true, options: DRIVER_STATUS_OPTIONS }, { name: "vehicleTypesAuthorized", label: "Authorized Vehicle Types", type: "multiselect", options: VEHICLE_TYPE_OPTIONS }, diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/config/resources.ts b/apps/edr-freight-web/backoffice/src/pages/fleet/config/resources.ts index c38c0c118..29a8795e7 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/config/resources.ts +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/config/resources.ts @@ -40,6 +40,11 @@ export interface FleetResourceColumn { export interface FleetFormFieldDef extends FormFieldDef { dynamicOptions?: FleetDynamicOptions; noneOption?: boolean; + /** + * Field is owned by the Fayda identity — populated only by verification and + * never hand-edited. Rendered disabled in the form. + */ + faydaLocked?: boolean; } export interface FleetListFilterDef { From c2535bec5e6063ca7c47a16f6e572cace333791e Mon Sep 17 00:00:00 2001 From: natib21 Date: Fri, 3 Jul 2026 14:14:35 +0000 Subject: [PATCH 17/38] fix --- .../src/components/fleet/FleetFormDialog.tsx | 14 +++++++------- .../backoffice/src/pages/fleet/config/drivers.ts | 6 +++--- .../backoffice/src/pages/fleet/config/resources.ts | 5 +++++ 3 files changed, 15 insertions(+), 10 deletions(-) diff --git a/apps/edr-freight-web/backoffice/src/components/fleet/FleetFormDialog.tsx b/apps/edr-freight-web/backoffice/src/components/fleet/FleetFormDialog.tsx index f07659dd8..f314ab35e 100644 --- a/apps/edr-freight-web/backoffice/src/components/fleet/FleetFormDialog.tsx +++ b/apps/edr-freight-web/backoffice/src/components/fleet/FleetFormDialog.tsx @@ -1,5 +1,5 @@ import { useEffect, useMemo, useState } from "react"; -import { Loader2, Calendar, ShieldCheck } from "lucide-react"; +import { Loader2, ShieldCheck } from "lucide-react"; import { Alert, Badge, @@ -14,7 +14,6 @@ import { Text, Textarea, TextInput, - ActionIcon, } from "@mantine/core"; import { @@ -238,6 +237,12 @@ const FleetFormDialog = ({ const date = new Date(stringValue + "T00:00:00Z"); if (isNaN(date.getTime())) { next[field.name] = `${field.label} is not a valid date`; + } else if (field.dateBound === "future") { + const startOfToday = new Date(); + startOfToday.setUTCHours(0, 0, 0, 0); + if (date <= startOfToday) { + next[field.name] = `${field.label} must be in the future`; + } } else if (date > new Date()) { next[field.name] = `${field.label} cannot be in the future`; } @@ -402,11 +407,6 @@ const FleetFormDialog = ({ error={error} disabled={isDisabled} description={field.description || "Select a date"} - rightSection={ - - - - } size="sm" radius="md" styles={{ diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/config/drivers.ts b/apps/edr-freight-web/backoffice/src/pages/fleet/config/drivers.ts index ec242906e..f240bda09 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/config/drivers.ts +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/config/drivers.ts @@ -38,7 +38,7 @@ export const driversConfig: FleetResourceConfig = { faydaVerification: true, searchKeys: ["firstName", "lastName", "email", "phoneNumber", "licenseNumber", "status"], columns: [ - { id: "licenseNumber", header: "License Number", accessorKey: "licenseNumber", format: "code", size: 140 }, + { id: "licenseNumber", header: "Driver's License Number", accessorKey: "licenseNumber", format: "code", size: 180 }, { id: "firstName", header: "First Name", accessorKey: "firstName", format: "code", size: 120 }, { id: "lastName", header: "Last Name", accessorKey: "lastName", format: "code", size: 120 }, { id: "email", header: "Email", accessorKey: "email", format: "code", size: 180 }, @@ -49,14 +49,14 @@ export const driversConfig: FleetResourceConfig = { { id: "faydaVerified", header: "Fayda", accessorKey: "faydaVerified", format: "verifiedBadge", size: 90 }, ], formFields: [ - { name: "licenseNumber", label: "License Number", type: "text", required: true }, + { name: "licenseNumber", label: "Driver's License Number", type: "text", required: true }, { name: "firstName", label: "First Name", type: "text", required: true, faydaLocked: true }, { name: "lastName", label: "Last Name", type: "text", required: true, faydaLocked: true }, { name: "email", label: "Email", type: "email", required: true, faydaLocked: true }, { name: "phoneNumber", label: "Phone Number", type: "text", required: true, faydaLocked: true }, { name: "dateOfBirth", label: "Date of Birth", type: "date", required: true, faydaLocked: true }, { name: "gender", label: "Gender", type: "select", options: DRIVER_GENDER_OPTIONS, faydaLocked: true }, - { name: "licenseExpiryDate", label: "License Expiry Date", type: "date", required: true }, + { name: "licenseExpiryDate", label: "License Expiry Date", type: "date", required: true, dateBound: "future" }, { name: "status", label: "Status", type: "select", required: true, options: DRIVER_STATUS_OPTIONS }, { name: "vehicleTypesAuthorized", label: "Authorized Vehicle Types", type: "multiselect", options: VEHICLE_TYPE_OPTIONS }, { name: "address", label: "Address", type: "textarea" }, diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/config/resources.ts b/apps/edr-freight-web/backoffice/src/pages/fleet/config/resources.ts index 29a8795e7..0e7c9934d 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/config/resources.ts +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/config/resources.ts @@ -45,6 +45,11 @@ export interface FleetFormFieldDef extends FormFieldDef { * never hand-edited. Rendered disabled in the form. */ faydaLocked?: boolean; + /** + * Direction a `date` field is constrained to. "future" = must be after today + * (e.g. a license expiry); "past" (default) = cannot be in the future. + */ + dateBound?: "past" | "future"; } export interface FleetListFilterDef { From 4a6e42ccd97960e2aee77e080ab2dfec0000f789 Mon Sep 17 00:00:00 2001 From: ghost2023 Date: Fri, 3 Jul 2026 17:18:07 +0300 Subject: [PATCH 18/38] fix: logger --- apps/edr-freight-api/src/app.module.ts | 39 ++++++++++++------- apps/edr-freight-api/src/logger.middleware.ts | 21 ++++++++++ 2 files changed, 45 insertions(+), 15 deletions(-) create mode 100644 apps/edr-freight-api/src/logger.middleware.ts diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index f40e4f40f..f20184e59 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -1,4 +1,8 @@ -import { Module, OnApplicationBootstrap } from "@nestjs/common"; +import { + MiddlewareConsumer, + Module, + OnApplicationBootstrap, +} from "@nestjs/common"; import { ConfigModule, ConfigService } from "@nestjs/config"; import { TypeOrmModule, TypeOrmModuleOptions } from "@nestjs/typeorm"; import { ScheduleModule } from "@nestjs/schedule"; @@ -61,20 +65,21 @@ import { GovCompaniesSeeder } from "./seed/gov-companies.seeder"; import { ApprovedFirstLastMileDemoBookingsSeeder } from "./seed/approved-first-lastmile-demo-bookings.seeder"; //New Trains, Wagons, Container and Cargo management modules import { TrainsModule } from "./modules/trains/trains.module"; -import { WagonsModule } from './modules/wagons/wagons.module'; -import { ContainersModule } from './modules/container-management/containers.module'; -import { CargoesModule } from './modules/cargoes/cargoes.module'; -import { RoutesModule } from './modules/routes/routes.module'; -import { WarehousesModule } from './modules/warehouses/warehouses.module'; -import { OverviewModule } from './modules/overview/overview.module'; -import { VehiclesModule } from './modules/vehicles/vehicles.module'; -import { DriversModule } from './modules/drivers/drivers.module'; -import { FuelModule } from './modules/fuel/fuel.module'; -import { MaintenanceModule } from './modules/maintenance/maintenance.module'; -import { FirstMileModule } from './modules/first-mile/first-mile.module'; -import { LastMileModule } from './modules/last-mile/last-mile.module'; -import { InterchangeDocumentsModule } from './modules/interchange-documents/interchange-documents.module'; -import { ImportOperationsModule } from './modules/import-operations/import-operations.module'; +import { WagonsModule } from "./modules/wagons/wagons.module"; +import { ContainersModule } from "./modules/container-management/containers.module"; +import { CargoesModule } from "./modules/cargoes/cargoes.module"; +import { RoutesModule } from "./modules/routes/routes.module"; +import { WarehousesModule } from "./modules/warehouses/warehouses.module"; +import { OverviewModule } from "./modules/overview/overview.module"; +import { VehiclesModule } from "./modules/vehicles/vehicles.module"; +import { DriversModule } from "./modules/drivers/drivers.module"; +import { FuelModule } from "./modules/fuel/fuel.module"; +import { MaintenanceModule } from "./modules/maintenance/maintenance.module"; +import { FirstMileModule } from "./modules/first-mile/first-mile.module"; +import { LastMileModule } from "./modules/last-mile/last-mile.module"; +import { InterchangeDocumentsModule } from "./modules/interchange-documents/interchange-documents.module"; +import { ImportOperationsModule } from "./modules/import-operations/import-operations.module"; +import { LoggerMiddleware } from "./logger.middleware"; @Module({ imports: [ @@ -211,4 +216,8 @@ export class AppModule implements OnApplicationBootstrap { // bookings bill to. Idempotent — keyed by fixed IDs. await this.govCompaniesSeeder.run(); } + + configure(consumer: MiddlewareConsumer) { + consumer.apply(LoggerMiddleware).forRoutes("*"); + } } diff --git a/apps/edr-freight-api/src/logger.middleware.ts b/apps/edr-freight-api/src/logger.middleware.ts new file mode 100644 index 000000000..dd7532ec8 --- /dev/null +++ b/apps/edr-freight-api/src/logger.middleware.ts @@ -0,0 +1,21 @@ +import { Injectable, NestMiddleware, Logger } from "@nestjs/common"; +import { Request, Response, NextFunction } from "express"; + +@Injectable() +export class LoggerMiddleware implements NestMiddleware { + private readonly logger = new Logger("HTTP"); + + use(req: Request, res: Response, next: NextFunction) { + const start = Date.now(); + + res.on("finish", () => { + const duration = Date.now() - start; + + this.logger.log( + `${req.method} ${req.originalUrl} ${res.statusCode} ${duration}ms`, + ); + }); + + next(); + } +} From e881e8de842f194fc91b3a0c0e1cda219a3b723f Mon Sep 17 00:00:00 2001 From: natib21 Date: Fri, 3 Jul 2026 14:21:46 +0000 Subject: [PATCH 19/38] fix --- ...000000007-DriverUniquePartialSoftDelete.ts | 62 +++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 apps/edr-freight-api/src/migrations/1890000000007-DriverUniquePartialSoftDelete.ts diff --git a/apps/edr-freight-api/src/migrations/1890000000007-DriverUniquePartialSoftDelete.ts b/apps/edr-freight-api/src/migrations/1890000000007-DriverUniquePartialSoftDelete.ts new file mode 100644 index 000000000..77d0a3043 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1890000000007-DriverUniquePartialSoftDelete.ts @@ -0,0 +1,62 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Make driver uniqueness soft-delete aware. The original table used plain + * column UNIQUE constraints (drivers_email_key, etc.) which count soft-deleted + * rows, so deleting a driver then re-adding the same email/phone/license/Fayda + * identity failed at the DB with a raw 500 — even though the service's own + * (deleted_at-excluding) duplicate check saw nothing. Replace them with partial + * unique indexes scoped to live rows (deleted_at IS NULL) so uniqueness matches + * what the service enforces and freed values become reusable after deletion. + */ +export class DriverUniquePartialSoftDelete1890000000007 implements MigrationInterface { + name = "DriverUniquePartialSoftDelete1890000000007"; + + public async up(queryRunner: QueryRunner): Promise { + // Drop the full-table unique constraints from CreateDriversTable... + await queryRunner.query(` + ALTER TABLE freight.drivers + DROP CONSTRAINT IF EXISTS drivers_email_key, + DROP CONSTRAINT IF EXISTS drivers_phone_number_key, + DROP CONSTRAINT IF EXISTS drivers_license_number_key + `); + // ...and the plain fayda_sub unique index from 1890000000006. + await queryRunner.query(`DROP INDEX IF EXISTS freight."UQ_DRIVERS_FAYDA_SUB"`); + + // Re-add each as a partial unique index scoped to non-deleted rows. + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS "UQ_DRIVERS_EMAIL_ACTIVE" + ON freight.drivers (email) WHERE deleted_at IS NULL + `); + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS "UQ_DRIVERS_PHONE_ACTIVE" + ON freight.drivers (phone_number) WHERE deleted_at IS NULL + `); + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS "UQ_DRIVERS_LICENSE_ACTIVE" + ON freight.drivers (license_number) WHERE deleted_at IS NULL + `); + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS "UQ_DRIVERS_FAYDA_SUB_ACTIVE" + ON freight.drivers (fayda_sub) WHERE deleted_at IS NULL + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX IF EXISTS freight."UQ_DRIVERS_EMAIL_ACTIVE"`); + await queryRunner.query(`DROP INDEX IF EXISTS freight."UQ_DRIVERS_PHONE_ACTIVE"`); + await queryRunner.query(`DROP INDEX IF EXISTS freight."UQ_DRIVERS_LICENSE_ACTIVE"`); + await queryRunner.query(`DROP INDEX IF EXISTS freight."UQ_DRIVERS_FAYDA_SUB_ACTIVE"`); + + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS "UQ_DRIVERS_FAYDA_SUB" + ON freight.drivers (fayda_sub) + `); + await queryRunner.query(` + ALTER TABLE freight.drivers + ADD CONSTRAINT drivers_email_key UNIQUE (email), + ADD CONSTRAINT drivers_phone_number_key UNIQUE (phone_number), + ADD CONSTRAINT drivers_license_number_key UNIQUE (license_number) + `); + } +} From 83887471a4fbc5ca4c198db2df95a753edc134be Mon Sep 17 00:00:00 2001 From: yaschalew Date: Fri, 3 Jul 2026 17:25:33 +0300 Subject: [PATCH 20/38] fix --- apps/edr-freight-api/.env.example | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/apps/edr-freight-api/.env.example b/apps/edr-freight-api/.env.example index 79fea03e9..49ea7c637 100644 --- a/apps/edr-freight-api/.env.example +++ b/apps/edr-freight-api/.env.example @@ -61,6 +61,7 @@ RABBITMQ_ENABLED=false RABBITMQ_URL=amqp://localhost:5672 SMS_QUEUE=sms_queue + # ── VeriFayda 2.0 (eSignet OIDC) identity verification ────────────────────── # Disabled by default; /fayda/verification/start returns 503 until enabled. FAYDA_ENABLED=false @@ -71,10 +72,13 @@ FAYDA_USERINFO_ENDPOINT= # Base64-encoded RSA private JWK used for the private_key_jwt client assertion FAYDA_PRIVATE_KEY_BASE64= # OAuth redirect_uri for MOBILE clients (must be registered with eSignet) -FAYDA_REDIRECT_URI= +FAYDA_REDIRECT_URI=http://localhost:3001/api/fayda/verification/complete # OAuth redirect_uri for WEB clients. Defaults to FAYDA_REDIRECT_URI when unset. -FAYDA_WEB_REDIRECT_URI= +FAYDA_WEB_REDIRECT_URI=http://localhost:3000/callback +CLIENT_ASSERTION_TYPE=urn:ietf:params:oauth:client-assertion-type:jwt-bearer FAYDA_SCOPE=openid profile email phone address FAYDA_ACR_VALUES=mosip:idp:acr:generated-code FAYDA_CLAIMS_LOCALES=en am FAYDA_SESSION_TTL_MINUTES=10 +EXPIRATION_TIME=15 +ALGORITHM=RS256 \ No newline at end of file From 44317a6bbcd13eeb2c41f5e1a330258a0db12965 Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Fri, 3 Jul 2026 14:39:47 +0000 Subject: [PATCH 21/38] Naming convention no digit or symbol allowed --- .../src/modules/warehouses/dto/allocation-rule.dto.ts | 3 ++- .../src/modules/warehouses/dto/create-warehouse.dto.ts | 3 ++- .../src/modules/warehouses/dto/fee-rule.dto.ts | 3 ++- .../src/components/warehouses/CreateWarehouseModal.tsx | 4 ++-- .../backoffice/src/components/warehouses/options.ts | 3 +++ .../backoffice/src/pages/warehouses/WarehouseRulesPage.tsx | 6 +++--- 6 files changed, 14 insertions(+), 8 deletions(-) diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/allocation-rule.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/allocation-rule.dto.ts index 43cd6f61a..8d18d2a1f 100644 --- a/apps/edr-freight-api/src/modules/warehouses/dto/allocation-rule.dto.ts +++ b/apps/edr-freight-api/src/modules/warehouses/dto/allocation-rule.dto.ts @@ -1,9 +1,10 @@ import { ApiProperty, ApiPropertyOptional, PartialType } from '@nestjs/swagger'; -import { IsBoolean, IsInt, IsOptional, IsString } from 'class-validator'; +import { IsBoolean, IsInt, IsOptional, IsString, Matches } from 'class-validator'; export class CreateAllocationRuleDto { @ApiProperty() @IsString() + @Matches(/^[A-Za-z\s]+$/, { message: 'name may only contain letters and spaces' }) name!: string; @ApiPropertyOptional({ default: 100 }) diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/create-warehouse.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/create-warehouse.dto.ts index 788c798bf..5a8025948 100644 --- a/apps/edr-freight-api/src/modules/warehouses/dto/create-warehouse.dto.ts +++ b/apps/edr-freight-api/src/modules/warehouses/dto/create-warehouse.dto.ts @@ -1,5 +1,5 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { IsEnum, IsNumber, IsOptional, IsString, IsUUID, MaxLength, Min } from 'class-validator'; +import { IsEnum, IsNumber, IsOptional, IsString, IsUUID, Matches, MaxLength, Min } from 'class-validator'; import { WAREHOUSE_TYPES, WarehouseType } from '../entities/warehouse.entity'; @@ -7,6 +7,7 @@ export class CreateWarehouseDto { @ApiProperty() @IsString() @MaxLength(160) + @Matches(/^[A-Za-z\s]+$/, { message: 'name may only contain letters and spaces' }) name!: string; @ApiProperty() diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/fee-rule.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/fee-rule.dto.ts index e2c20901d..22e9f9491 100644 --- a/apps/edr-freight-api/src/modules/warehouses/dto/fee-rule.dto.ts +++ b/apps/edr-freight-api/src/modules/warehouses/dto/fee-rule.dto.ts @@ -1,6 +1,6 @@ import { ApiProperty, ApiPropertyOptional, PartialType } from '@nestjs/swagger'; import { Type } from 'class-transformer'; -import { IsArray, IsEnum, IsInt, IsNumber, IsOptional, IsString, IsUUID, Min, ValidateNested } from 'class-validator'; +import { IsArray, IsEnum, IsInt, IsNumber, IsOptional, IsString, IsUUID, Matches, Min, ValidateNested } from 'class-validator'; import { FEE_RULE_TYPES, FeeRuleType } from '../entities/warehouse-fee-rule.entity'; @@ -25,6 +25,7 @@ export class FeeRuleTierDto { export class CreateFeeRuleDto { @ApiProperty() @IsString() + @Matches(/^[A-Za-z\s]+$/, { message: 'name may only contain letters and spaces' }) name!: string; @ApiProperty({ enum: FEE_RULE_TYPES }) diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/CreateWarehouseModal.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/CreateWarehouseModal.tsx index ad8b4109d..6494906b4 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/CreateWarehouseModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/CreateWarehouseModal.tsx @@ -14,7 +14,7 @@ import { useMutation, useQuery } from '@tanstack/react-query'; import { api } from '@/services/api'; import { useToast } from '@/hooks/use-toast'; import type { SaveWarehousePayload, Warehouse, WarehouseType } from '@/types/warehouse'; -import { extractErrorMessage, statusOptions, warehouseTypeOptions } from './options'; +import { extractErrorMessage, lettersOnly, statusOptions, warehouseTypeOptions } from './options'; interface CreateWarehouseModalProps { opened: boolean; @@ -120,7 +120,7 @@ export function CreateWarehouseModal({ opened, onClose, warehouse }: CreateWareh placeholder="Modjo Open Warehouse" required value={form.name} - onChange={(e) => { const v = e.currentTarget.value; setForm((f) => ({ ...f, name: v })); }} + onChange={(e) => { const v = lettersOnly(e.currentTarget.value); setForm((f) => ({ ...f, name: v })); }} /> { }); }; +// Name fields (warehouse / fee rule / allocation rule) accept letters and spaces only — no numbers. +export const lettersOnly = (value: string) => value.replace(/[^A-Za-z\s]/g, ''); + export const extractErrorMessage = (error: unknown, fallback = 'Something went wrong') => { const responseData = (error as { response?: { data?: unknown } })?.response?.data; const data = responseData && typeof responseData === 'object' ? (responseData as Record) : undefined; diff --git a/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseRulesPage.tsx b/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseRulesPage.tsx index 7d1dbc555..b5c44e4d4 100644 --- a/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseRulesPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseRulesPage.tsx @@ -33,7 +33,7 @@ import { } from '@/hooks/useWarehouses'; import { api } from '@/services/api'; import { FEE_RULE_TYPES, type FeeRuleType } from '@/types/warehouse'; -import { extractErrorMessage } from '@/components/warehouses/options'; +import { extractErrorMessage, lettersOnly } from '@/components/warehouses/options'; const FREIGHT = [ { value: 'CONTAINER', label: 'Container' }, @@ -253,7 +253,7 @@ function AllocationRules() { required value={form.name} onChange={(e) => { - const value = e.currentTarget.value; + const value = lettersOnly(e.currentTarget.value); setForm((f) => ({ ...f, name: value })); }} /> @@ -569,7 +569,7 @@ function FeeRules() { required value={form.name} onChange={(e) => { - const value = e.currentTarget.value; + const value = lettersOnly(e.currentTarget.value); setForm((f) => ({ ...f, name: value })); }} /> From 0e6ebda6f6427eaca4a0d4d83b7b02b86c62ab8a Mon Sep 17 00:00:00 2001 From: natib21 Date: Fri, 3 Jul 2026 15:21:23 +0000 Subject: [PATCH 22/38] fix --- apps/edr-freight-api/src/app.module.ts | 2 + .../1890000000008-AddFleetEvents.ts | 43 +++++ .../src/modules/drivers/drivers.controller.ts | 12 +- .../src/modules/drivers/drivers.service.ts | 14 +- .../modules/first-mile/first-mile.service.ts | 69 +++++++ .../entities/fleet-event.entity.ts | 55 ++++++ .../fleet-history/fleet-history.module.ts | 17 ++ .../fleet-history/fleet-history.service.ts | 54 ++++++ .../modules/last-mile/last-mile.service.ts | 66 ++++++- .../modules/vehicles/vehicles.controller.ts | 12 +- .../src/modules/vehicles/vehicles.service.ts | 85 ++++++++- .../components/fleet/FleetHistoryModal.tsx | 176 ++++++++++++++++++ .../components/fleet/FleetRecordActions.tsx | 15 +- .../src/pages/fleet/FleetResourcePage.tsx | 10 + .../src/services/fleet-history.service.ts | 38 ++++ 15 files changed, 661 insertions(+), 7 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/1890000000008-AddFleetEvents.ts create mode 100644 apps/edr-freight-api/src/modules/fleet-history/entities/fleet-event.entity.ts create mode 100644 apps/edr-freight-api/src/modules/fleet-history/fleet-history.module.ts create mode 100644 apps/edr-freight-api/src/modules/fleet-history/fleet-history.service.ts create mode 100644 apps/edr-freight-web/backoffice/src/components/fleet/FleetHistoryModal.tsx create mode 100644 apps/edr-freight-web/backoffice/src/services/fleet-history.service.ts diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index d72c8b720..b5d63194c 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -77,6 +77,7 @@ import { LastMileModule } from './modules/last-mile/last-mile.module'; import { InterchangeDocumentsModule } from './modules/interchange-documents/interchange-documents.module'; import { ImportOperationsModule } from './modules/import-operations/import-operations.module'; import { VerifaydaModule } from './modules/verifayda/verifayda.module'; +import { FleetHistoryModule } from './modules/fleet-history/fleet-history.module'; @Module({ imports: [ @@ -144,6 +145,7 @@ import { VerifaydaModule } from './modules/verifayda/verifayda.module'; InterchangeDocumentsModule, ImportOperationsModule, VerifaydaModule, + FleetHistoryModule, ], providers: [ EdrOrgSeeder, diff --git a/apps/edr-freight-api/src/migrations/1890000000008-AddFleetEvents.ts b/apps/edr-freight-api/src/migrations/1890000000008-AddFleetEvents.ts new file mode 100644 index 000000000..8fbb688d3 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1890000000008-AddFleetEvents.ts @@ -0,0 +1,43 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Append-only audit log for fleet activity (driver↔vehicle assignments, vehicle + * status/availability transitions, first/last-mile vehicle assignments + mile + * status changes). Queried by vehicle_id or driver_id to build a per-record + * timeline. Populated going forward — existing records have no back-history. + */ +export class AddFleetEvents1890000000008 implements MigrationInterface { + name = "AddFleetEvents1890000000008"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.fleet_events ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + event_type varchar NOT NULL, + vehicle_id uuid, + driver_id uuid, + first_mile_id uuid, + last_mile_id uuid, + from_value varchar, + to_value varchar, + label varchar, + metadata jsonb, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz + ) + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "IDX_FLEET_EVENTS_VEHICLE" + ON freight.fleet_events (vehicle_id, created_at) + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "IDX_FLEET_EVENTS_DRIVER" + ON freight.fleet_events (driver_id, created_at) + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS freight.fleet_events`); + } +} diff --git a/apps/edr-freight-api/src/modules/drivers/drivers.controller.ts b/apps/edr-freight-api/src/modules/drivers/drivers.controller.ts index eb0628b96..b4da558e2 100644 --- a/apps/edr-freight-api/src/modules/drivers/drivers.controller.ts +++ b/apps/edr-freight-api/src/modules/drivers/drivers.controller.ts @@ -14,13 +14,17 @@ import { FleetManage, FleetView } from '../../common/booking-guards'; import { DriversService } from './drivers.service'; import { CreateDriverDto } from './dto/create-driver.dto'; import { UpdateDriverDto } from './dto/update-driver.dto'; +import { FleetHistoryService } from '../fleet-history/fleet-history.service'; @ApiTags('drivers') @ApiBearerAuth() @Controller('drivers') @FleetView() export class DriversController { - constructor(private readonly driversService: DriversService) {} + constructor( + private readonly driversService: DriversService, + private readonly fleetHistory: FleetHistoryService, + ) {} @Post() @FleetManage() @@ -55,6 +59,12 @@ export class DriversController { return this.driversService.findById(id); } + @Get(':id/history') + @ApiOperation({ summary: 'Get driver assignment & activity history' }) + history(@Param('id', ParseUUIDPipe) id: string) { + return this.fleetHistory.getDriverHistory(id); + } + @Patch(':id') @FleetManage() @ApiOperation({ summary: 'Update a driver' }) diff --git a/apps/edr-freight-api/src/modules/drivers/drivers.service.ts b/apps/edr-freight-api/src/modules/drivers/drivers.service.ts index cbfb6787d..6e7c1f69c 100644 --- a/apps/edr-freight-api/src/modules/drivers/drivers.service.ts +++ b/apps/edr-freight-api/src/modules/drivers/drivers.service.ts @@ -4,12 +4,15 @@ import { Repository } from 'typeorm'; import { CreateDriverDto } from './dto/create-driver.dto'; import { UpdateDriverDto } from './dto/update-driver.dto'; import { Driver, DriverStatus } from './entities/driver.entity'; +import { FleetHistoryService } from '../fleet-history/fleet-history.service'; +import { FleetEventType } from '../fleet-history/entities/fleet-event.entity'; @Injectable() export class DriversService { constructor( @InjectRepository(Driver) private readonly driverRepo: Repository, + private readonly history: FleetHistoryService, ) {} async create(dto: CreateDriverDto): Promise { @@ -51,7 +54,16 @@ export class DriversService { } const driver = this.driverRepo.create(dto); - return this.driverRepo.save(driver); + const saved = await this.driverRepo.save(driver); + + await this.history.record({ + eventType: FleetEventType.DRIVER_REGISTERED, + driverId: saved.id, + label: `${saved.firstName ?? ''} ${saved.lastName ?? ''}`.trim() || null, + toValue: saved.status ?? null, + }); + + return saved; } async findAll(query: { diff --git a/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts b/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts index 40d163220..419b69969 100644 --- a/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts +++ b/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts @@ -14,6 +14,8 @@ import { FirstMileContainerAllocation } from "./entities/first-mile-container-al import { FirstMileRepository } from "./first-mile.repository"; import { OnEvent } from "@nestjs/event-emitter"; import { InvoiceEventPayload } from "../billing/billing.service"; +import { FleetHistoryService } from "../fleet-history/fleet-history.service"; +import { FleetEventType } from "../fleet-history/entities/fleet-event.entity"; type FirstMileListFilter = { status?: FirstMileStatus; @@ -43,8 +45,21 @@ export class FirstMileService { private readonly vehiclesService: VehiclesService, private readonly driversService: DriversService, private readonly smsClient: SmsClientService, + private readonly history: FleetHistoryService, ) { } + /** Resolve the driver currently assigned to a vehicle, for stamping mile + * events onto that driver's timeline. Best-effort — never throws. */ + private async resolveDriverId(vehicleId?: string | null): Promise { + if (!vehicleId) return null; + try { + const vehicle = await this.vehiclesService.findById(vehicleId); + return vehicle.assignedDriverId ?? null; + } catch { + return null; + } + } + /** * Look up a booking by its human-readable reference and confirm it has been * paid before any first-mile work proceeds. Throws if the reference is @@ -210,6 +225,14 @@ export class FirstMileService { if (dto.vehicleId) { await this.vehiclesService.setAvailability(dto.vehicleId, VehicleAvailability.BUSY); + await this.history.record({ + eventType: FleetEventType.MILE_VEHICLE_ASSIGNED, + vehicleId: dto.vehicleId, + firstMileId: record.id, + driverId: await this.resolveDriverId(dto.vehicleId), + label: record.status, + metadata: { mile: 'FIRST' }, + }); } return record; @@ -278,6 +301,26 @@ export class FirstMileService { if (existing.vehicleId) { await this.vehiclesService.releaseIfUnused([existing.vehicleId]); } + // Audit the mile↔vehicle (re)assignment on both vehicle and driver lines. + if (existing.vehicleId) { + await this.history.record({ + eventType: FleetEventType.MILE_VEHICLE_RELEASED, + vehicleId: existing.vehicleId, + firstMileId: id, + driverId: await this.resolveDriverId(existing.vehicleId), + metadata: { mile: 'FIRST' }, + }); + } + if (dto.vehicleId) { + await this.history.record({ + eventType: FleetEventType.MILE_VEHICLE_ASSIGNED, + vehicleId: dto.vehicleId, + firstMileId: id, + driverId: await this.resolveDriverId(dto.vehicleId), + label: updated.status, + metadata: { mile: 'FIRST' }, + }); + } } // Notify assigned driver on every explicit vehicle assignment or reassignment @@ -285,6 +328,19 @@ export class FirstMileService { void this.notifyDriverAssignment(dto.vehicleId, existing); } + if (dto.status !== undefined && dto.status !== existing.status) { + const vehicleId = updated.vehicleId ?? existing.vehicleId ?? null; + await this.history.record({ + eventType: FleetEventType.MILE_STATUS_CHANGED, + firstMileId: id, + vehicleId, + driverId: await this.resolveDriverId(vehicleId), + fromValue: existing.status, + toValue: dto.status, + metadata: { mile: 'FIRST' }, + }); + } + // Trip finished — release the vehicles it was holding if (dto.status === 'RECEIVED_TO_PORT' && existing.status !== 'RECEIVED_TO_PORT') { await this.releaseVehicles(updated); @@ -301,6 +357,19 @@ export class FirstMileService { throw new NotFoundException(`First-mile record ${id} not found`); } + if (status !== existing.status) { + const vehicleId = updated.vehicleId ?? existing.vehicleId ?? null; + await this.history.record({ + eventType: FleetEventType.MILE_STATUS_CHANGED, + firstMileId: id, + vehicleId, + driverId: await this.resolveDriverId(vehicleId), + fromValue: existing.status, + toValue: status, + metadata: { mile: 'FIRST' }, + }); + } + if (status === 'RECEIVED_TO_PORT' && existing.status !== 'RECEIVED_TO_PORT') { await this.releaseVehicles(updated); } diff --git a/apps/edr-freight-api/src/modules/fleet-history/entities/fleet-event.entity.ts b/apps/edr-freight-api/src/modules/fleet-history/entities/fleet-event.entity.ts new file mode 100644 index 000000000..5096adbc6 --- /dev/null +++ b/apps/edr-freight-api/src/modules/fleet-history/entities/fleet-event.entity.ts @@ -0,0 +1,55 @@ +import { Entity, Column, Index } from 'typeorm'; +import { BaseEntity } from '@edr/api-common'; + +/** + * Append-only audit log for fleet activity. One row per transition. Queried by + * `vehicleId` (vehicle timeline) or `driverId` (driver timeline); an event may + * carry both so a driver↔vehicle assignment or a mile assignment shows on both. + * `createdAt` (from BaseEntity) is the event time. + */ +export enum FleetEventType { + DRIVER_REGISTERED = 'DRIVER_REGISTERED', + VEHICLE_REGISTERED = 'VEHICLE_REGISTERED', + DRIVER_ASSIGNED = 'DRIVER_ASSIGNED', + DRIVER_UNASSIGNED = 'DRIVER_UNASSIGNED', + VEHICLE_STATUS_CHANGED = 'VEHICLE_STATUS_CHANGED', + VEHICLE_AVAILABILITY_CHANGED = 'VEHICLE_AVAILABILITY_CHANGED', + MILE_VEHICLE_ASSIGNED = 'MILE_VEHICLE_ASSIGNED', + MILE_VEHICLE_RELEASED = 'MILE_VEHICLE_RELEASED', + MILE_STATUS_CHANGED = 'MILE_STATUS_CHANGED', +} + +@Entity({ name: 'fleet_events', schema: 'freight' }) +export class FleetEvent extends BaseEntity { + @Column({ name: 'event_type', type: 'varchar' }) + eventType!: FleetEventType; + + @Index() + @Column({ name: 'vehicle_id', type: 'uuid', nullable: true }) + vehicleId?: string | null; + + @Index() + @Column({ name: 'driver_id', type: 'uuid', nullable: true }) + driverId?: string | null; + + @Column({ name: 'first_mile_id', type: 'uuid', nullable: true }) + firstMileId?: string | null; + + @Column({ name: 'last_mile_id', type: 'uuid', nullable: true }) + lastMileId?: string | null; + + /** Previous value for a transition (e.g. old status/availability). */ + @Column({ name: 'from_value', type: 'varchar', nullable: true }) + fromValue?: string | null; + + /** New value for a transition (e.g. new status/availability). */ + @Column({ name: 'to_value', type: 'varchar', nullable: true }) + toValue?: string | null; + + /** Human-readable summary token (driver name, plate, booking ref, mile). */ + @Column({ name: 'label', type: 'varchar', nullable: true }) + label?: string | null; + + @Column({ name: 'metadata', type: 'jsonb', nullable: true }) + metadata?: Record | null; +} diff --git a/apps/edr-freight-api/src/modules/fleet-history/fleet-history.module.ts b/apps/edr-freight-api/src/modules/fleet-history/fleet-history.module.ts new file mode 100644 index 000000000..14828e0a9 --- /dev/null +++ b/apps/edr-freight-api/src/modules/fleet-history/fleet-history.module.ts @@ -0,0 +1,17 @@ +import { Global, Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { FleetEvent } from './entities/fleet-event.entity'; +import { FleetHistoryService } from './fleet-history.service'; + +/** + * Global so any fleet-touching service (vehicles, drivers, first/last-mile) can + * inject FleetHistoryService to append audit events without each module having + * to import this one. + */ +@Global() +@Module({ + imports: [TypeOrmModule.forFeature([FleetEvent])], + providers: [FleetHistoryService], + exports: [FleetHistoryService], +}) +export class FleetHistoryModule {} diff --git a/apps/edr-freight-api/src/modules/fleet-history/fleet-history.service.ts b/apps/edr-freight-api/src/modules/fleet-history/fleet-history.service.ts new file mode 100644 index 000000000..9c61119b9 --- /dev/null +++ b/apps/edr-freight-api/src/modules/fleet-history/fleet-history.service.ts @@ -0,0 +1,54 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { FleetEvent, FleetEventType } from './entities/fleet-event.entity'; + +export interface FleetEventInput { + eventType: FleetEventType; + vehicleId?: string | null; + driverId?: string | null; + firstMileId?: string | null; + lastMileId?: string | null; + fromValue?: string | null; + toValue?: string | null; + label?: string | null; + metadata?: Record | null; +} + +@Injectable() +export class FleetHistoryService { + private readonly logger = new Logger(FleetHistoryService.name); + + constructor( + @InjectRepository(FleetEvent) + private readonly eventRepo: Repository, + ) {} + + /** + * Append an audit event. Best-effort: recording history must never break the + * business operation that triggered it, so failures are logged and swallowed. + */ + async record(input: FleetEventInput): Promise { + try { + await this.eventRepo.save(this.eventRepo.create(input)); + } catch (err) { + this.logger.error( + `Failed to record fleet event ${input.eventType}: ${String(err)}`, + ); + } + } + + getVehicleHistory(vehicleId: string): Promise { + return this.eventRepo.find({ + where: { vehicleId }, + order: { createdAt: 'DESC' }, + }); + } + + getDriverHistory(driverId: string): Promise { + return this.eventRepo.find({ + where: { driverId }, + order: { createdAt: 'DESC' }, + }); + } +} diff --git a/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts b/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts index 69eec29ae..f0d39d51c 100644 --- a/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts +++ b/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts @@ -12,6 +12,8 @@ import { LastMileContainerAllocation } from './entities/last-mile-container-allo import { LastMileRepository } from './last-mile.repository'; import { InvoiceEventPayload } from '../billing/billing.service'; import { OnEvent } from '@nestjs/event-emitter'; +import { FleetHistoryService } from '../fleet-history/fleet-history.service'; +import { FleetEventType } from '../fleet-history/entities/fleet-event.entity'; type LastMileListFilter = { status?: LastMileStatus; @@ -41,8 +43,21 @@ export class LastMileService { private readonly driversService: DriversService, private readonly smsClient: SmsClientService, private readonly dataSource: DataSource, + private readonly history: FleetHistoryService, ) {} + /** Resolve the driver currently assigned to a vehicle, for stamping mile + * events onto that driver's timeline. Best-effort — never throws. */ + private async resolveDriverId(vehicleId?: string | null): Promise { + if (!vehicleId) return null; + try { + const vehicle = await this.vehiclesService.findById(vehicleId); + return vehicle.assignedDriverId ?? null; + } catch { + return null; + } + } + async acceptBooking(bookingReference: string): Promise { const booking = await this.bookingsRepository.findByReference(bookingReference); @@ -132,7 +147,7 @@ export class LastMileService { } async create(dto: CreateLastMileDto): Promise { - return this.lastMileRepository.create({ + const record = await this.lastMileRepository.create({ bookingId: dto.bookingId, status: dto.status ?? 'READY_TO_TRANSIT', advancedPayment: dto.advancedPayment ?? 0, @@ -142,6 +157,19 @@ export class LastMileService { vehicleId: dto.vehicleId ?? null, paid: (dto as any).paid ?? false, }); + + if (dto.vehicleId) { + await this.history.record({ + eventType: FleetEventType.MILE_VEHICLE_ASSIGNED, + vehicleId: dto.vehicleId, + lastMileId: record.id, + driverId: await this.resolveDriverId(dto.vehicleId), + label: record.status, + metadata: { mile: 'LAST' }, + }); + } + + return record; } @OnEvent("lastmile.invoice.paid") @@ -180,6 +208,42 @@ export class LastMileService { void this.notifyDriverAssignment(dto.vehicleId, existing); } + // Audit the mile↔vehicle (re)assignment on both vehicle and driver lines. + if (dto.vehicleId !== undefined && dto.vehicleId !== existing.vehicleId) { + if (existing.vehicleId) { + await this.history.record({ + eventType: FleetEventType.MILE_VEHICLE_RELEASED, + vehicleId: existing.vehicleId, + lastMileId: id, + driverId: await this.resolveDriverId(existing.vehicleId), + metadata: { mile: 'LAST' }, + }); + } + if (dto.vehicleId) { + await this.history.record({ + eventType: FleetEventType.MILE_VEHICLE_ASSIGNED, + vehicleId: dto.vehicleId, + lastMileId: id, + driverId: await this.resolveDriverId(dto.vehicleId), + label: updated.status, + metadata: { mile: 'LAST' }, + }); + } + } + + if (dto.status !== undefined && dto.status !== existing.status) { + const vehicleId = updated.vehicleId ?? existing.vehicleId ?? null; + await this.history.record({ + eventType: FleetEventType.MILE_STATUS_CHANGED, + lastMileId: id, + vehicleId, + driverId: await this.resolveDriverId(vehicleId), + fromValue: existing.status, + toValue: dto.status, + metadata: { mile: 'LAST' }, + }); + } + return updated; } diff --git a/apps/edr-freight-api/src/modules/vehicles/vehicles.controller.ts b/apps/edr-freight-api/src/modules/vehicles/vehicles.controller.ts index 8e6d8a0a8..f0a77791a 100644 --- a/apps/edr-freight-api/src/modules/vehicles/vehicles.controller.ts +++ b/apps/edr-freight-api/src/modules/vehicles/vehicles.controller.ts @@ -14,13 +14,17 @@ import { FleetManage, FleetView } from '../../common/booking-guards'; import { VehiclesService } from './vehicles.service'; import { CreateVehicleDto } from './dto/create-vehicle.dto'; import { UpdateVehicleDto } from './dto/update-vehicle.dto'; +import { FleetHistoryService } from '../fleet-history/fleet-history.service'; @ApiTags('vehicles') @ApiBearerAuth() @Controller('vehicles') @FleetView() export class VehiclesController { - constructor(private readonly vehiclesService: VehiclesService) {} + constructor( + private readonly vehiclesService: VehiclesService, + private readonly fleetHistory: FleetHistoryService, + ) {} @Post() @FleetManage() @@ -57,6 +61,12 @@ export class VehiclesController { return this.vehiclesService.findById(id); } + @Get(':id/history') + @ApiOperation({ summary: 'Get vehicle assignment, status & mile history' }) + history(@Param('id', ParseUUIDPipe) id: string) { + return this.fleetHistory.getVehicleHistory(id); + } + @Patch(':id') @FleetManage() @ApiOperation({ summary: 'Update a vehicle' }) diff --git a/apps/edr-freight-api/src/modules/vehicles/vehicles.service.ts b/apps/edr-freight-api/src/modules/vehicles/vehicles.service.ts index 69568e483..345d4f896 100644 --- a/apps/edr-freight-api/src/modules/vehicles/vehicles.service.ts +++ b/apps/edr-freight-api/src/modules/vehicles/vehicles.service.ts @@ -9,12 +9,15 @@ import { FirstMileContainerAllocation } from '../first-mile/entities/first-mile- import { LastMile, LastMileStatus } from '../last-mile/entities/last-mile.entity'; import { LastMileContainerAllocation } from '../last-mile/entities/last-mile-container-allocation.entity'; import { BookingContainerAllocation } from '../bookings/entities/booking-container-allocation.entity'; +import { FleetHistoryService } from '../fleet-history/fleet-history.service'; +import { FleetEventType } from '../fleet-history/entities/fleet-event.entity'; @Injectable() export class VehiclesService { constructor( @InjectRepository(Vehicle) private readonly vehicleRepo: Repository, + private readonly history: FleetHistoryService, ) {} async create(dto: CreateVehicleDto): Promise { @@ -34,7 +37,24 @@ export class VehiclesService { registrationNumber, }); - return this.vehicleRepo.save(vehicle); + const saved = await this.vehicleRepo.save(vehicle); + + await this.history.record({ + eventType: FleetEventType.VEHICLE_REGISTERED, + vehicleId: saved.id, + label: saved.plateNumber ?? saved.code ?? null, + toValue: saved.availability ?? null, + }); + if (saved.assignedDriverId) { + await this.history.record({ + eventType: FleetEventType.DRIVER_ASSIGNED, + vehicleId: saved.id, + driverId: saved.assignedDriverId, + label: saved.assignedDriverName ?? null, + }); + } + + return saved; } async findAll(query: { @@ -97,12 +117,73 @@ export class VehiclesService { } } + const prev = { + assignedDriverId: vehicle.assignedDriverId, + assignedDriverName: vehicle.assignedDriverName, + status: vehicle.status, + availability: vehicle.availability, + }; + Object.assign(vehicle, dto); - return this.vehicleRepo.save(vehicle); + const saved = await this.vehicleRepo.save(vehicle); + + // Driver (re)assignment — emit an unassign for the old driver and/or an + // assign for the new one so both drivers' timelines and the vehicle's line up. + if ( + dto.assignedDriverId !== undefined && + dto.assignedDriverId !== prev.assignedDriverId + ) { + if (prev.assignedDriverId) { + await this.history.record({ + eventType: FleetEventType.DRIVER_UNASSIGNED, + vehicleId: id, + driverId: prev.assignedDriverId, + label: prev.assignedDriverName ?? null, + }); + } + if (saved.assignedDriverId) { + await this.history.record({ + eventType: FleetEventType.DRIVER_ASSIGNED, + vehicleId: id, + driverId: saved.assignedDriverId, + label: saved.assignedDriverName ?? null, + }); + } + } + if (dto.status !== undefined && dto.status !== prev.status) { + await this.history.record({ + eventType: FleetEventType.VEHICLE_STATUS_CHANGED, + vehicleId: id, + fromValue: prev.status ?? null, + toValue: saved.status ?? null, + }); + } + if (dto.availability !== undefined && dto.availability !== prev.availability) { + await this.history.record({ + eventType: FleetEventType.VEHICLE_AVAILABILITY_CHANGED, + vehicleId: id, + fromValue: prev.availability ?? null, + toValue: saved.availability ?? null, + }); + } + + return saved; } async setAvailability(id: string, availability: VehicleAvailability): Promise { + // Read the current value so the audit event records an accurate from→to and + // we skip logging no-op writes (setAvailability is called in release loops). + const vehicle = await this.vehicleRepo.findOne({ where: { id } }); + const previous = vehicle?.availability; await this.vehicleRepo.update(id, { availability }); + if (previous !== availability) { + await this.history.record({ + eventType: FleetEventType.VEHICLE_AVAILABILITY_CHANGED, + vehicleId: id, + fromValue: previous ?? null, + toValue: availability, + }); + } } /** diff --git a/apps/edr-freight-web/backoffice/src/components/fleet/FleetHistoryModal.tsx b/apps/edr-freight-web/backoffice/src/components/fleet/FleetHistoryModal.tsx new file mode 100644 index 000000000..868ed347d --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/fleet/FleetHistoryModal.tsx @@ -0,0 +1,176 @@ +import { Center, Loader, Modal, Text, Timeline } from "@mantine/core"; +import { useQuery } from "@tanstack/react-query"; +import { + Activity, + CircleDot, + Route, + Truck, + UserCheck, + UserMinus, + UserPlus, +} from "lucide-react"; + +import { + fleetHistoryService, + type FleetHistoryEvent, +} from "@/services/fleet-history.service"; +import type { FleetRecord } from "@/services/fleet/fleet.service"; + +export interface FleetHistoryModalProps { + opened: boolean; + onClose: () => void; + entity: "driver" | "vehicle"; + record: FleetRecord | null; +} + +const asObj = (r: FleetRecord | null) => (r ?? {}) as Record; + +const titleFor = (entity: "driver" | "vehicle", record: FleetRecord | null) => { + const r = asObj(record); + if (entity === "vehicle") { + return `Vehicle history — ${r.plateNumber ?? r.code ?? ""}`.trim(); + } + return `Driver history — ${[r.firstName, r.lastName] + .filter(Boolean) + .join(" ")}`.trim(); +}; + +const mileLabel = (e: FleetHistoryEvent) => + e.metadata?.mile === "LAST" ? "Last-mile" : "First-mile"; + +const arrow = (from?: string | null, to?: string | null) => + `${from ?? "—"} → ${to ?? "—"}`; + +function describe(e: FleetHistoryEvent, entity: "driver" | "vehicle") { + switch (e.eventType) { + case "DRIVER_REGISTERED": + return { + icon: , + title: "Driver registered", + text: e.toValue ? `Status: ${e.toValue}` : "", + }; + case "VEHICLE_REGISTERED": + return { + icon: , + title: "Vehicle registered", + text: e.toValue ? `Availability: ${e.toValue}` : "", + }; + case "DRIVER_ASSIGNED": + return { + icon: , + title: + entity === "vehicle" + ? `Driver assigned${e.label ? `: ${e.label}` : ""}` + : "Assigned to a vehicle", + text: "", + }; + case "DRIVER_UNASSIGNED": + return { + icon: , + title: + entity === "vehicle" + ? `Driver unassigned${e.label ? `: ${e.label}` : ""}` + : "Unassigned from a vehicle", + text: "", + }; + case "VEHICLE_STATUS_CHANGED": + return { + icon: , + title: "Status changed", + text: arrow(e.fromValue, e.toValue), + }; + case "VEHICLE_AVAILABILITY_CHANGED": + return { + icon: , + title: `Marked ${e.toValue ?? ""}`.trim(), + text: e.fromValue ? arrow(e.fromValue, e.toValue) : "", + }; + case "MILE_VEHICLE_ASSIGNED": + return { + icon: , + title: `${mileLabel(e)}: vehicle assigned`, + text: e.label ? `Status: ${e.label}` : "", + }; + case "MILE_VEHICLE_RELEASED": + return { + icon: , + title: `${mileLabel(e)}: vehicle released`, + text: "", + }; + case "MILE_STATUS_CHANGED": + return { + icon: , + title: `${mileLabel(e)} status`, + text: arrow(e.fromValue, e.toValue), + }; + default: + return { icon: , title: e.eventType, text: "" }; + } +} + +const fmt = (iso: string) => { + const d = new Date(iso); + return Number.isNaN(d.getTime()) ? iso : d.toLocaleString(); +}; + +const FleetHistoryModal = ({ + opened, + onClose, + entity, + record, +}: FleetHistoryModalProps) => { + const id = asObj(record).id ? String(asObj(record).id) : ""; + + const { data, isLoading } = useQuery({ + queryKey: ["fleet-history", entity, id], + queryFn: () => + entity === "vehicle" + ? fleetHistoryService.vehicle(id) + : fleetHistoryService.driver(id), + enabled: opened && Boolean(id), + }); + + const events = data ?? []; + + return ( + {titleFor(entity, record)}} + radius="lg" + size="lg" + centered + > + {isLoading ? ( +
+ +
+ ) : events.length === 0 ? ( + + No history recorded yet. Activity appears here as this{" "} + {entity} is assigned, reassigned, or its status changes. + + ) : ( + + {events.map((e) => { + const d = describe(e, entity); + return ( + + {d.text && ( + + {d.text} + + )} + + {fmt(e.createdAt)} + + + ); + })} + + )} +
+ ); +}; + +export default FleetHistoryModal; diff --git a/apps/edr-freight-web/backoffice/src/components/fleet/FleetRecordActions.tsx b/apps/edr-freight-web/backoffice/src/components/fleet/FleetRecordActions.tsx index 9096f52a1..d499d030c 100644 --- a/apps/edr-freight-web/backoffice/src/components/fleet/FleetRecordActions.tsx +++ b/apps/edr-freight-web/backoffice/src/components/fleet/FleetRecordActions.tsx @@ -1,4 +1,4 @@ -import { Edit2, Trash2, Eye, Users, MoreVertical } from "lucide-react"; +import { Edit2, Trash2, Eye, Users, MoreVertical, History } from "lucide-react"; import { ActionIcon, Menu, MenuItem, Tooltip } from "@mantine/core"; import { useNavigate } from "react-router-dom"; @@ -11,6 +11,7 @@ export interface FleetRecordActionsProps { onEdit: (record: FleetRecord) => void; onRemove: (record: FleetRecord) => void; onAssignDriver?: (record: FleetRecord) => void; + onHistory?: (record: FleetRecord) => void; layout?: "row" | "compact"; } @@ -20,12 +21,16 @@ const FleetRecordActions = ({ onEdit, onRemove, onAssignDriver, + onHistory, layout = "row", }: FleetRecordActionsProps) => { const navigate = useNavigate(); const removeLabel = config.removeActionLabel ?? "Delete"; const showDetail = Boolean(config.detailPath && "id" in record); const isVehicle = config.slug === "vehicles"; + const showHistory = + Boolean(onHistory) && + (config.slug === "drivers" || config.slug === "vehicles"); const handleDetail = () => { if (!config.detailPath || !("id" in record)) return; @@ -57,6 +62,14 @@ const FleetRecordActions = ({ > Edit + {showHistory ? ( + onHistory?.(record)} + leftSection={} + > + History + + ) : null} {showDetail ? ( { const [editing, setEditing] = useState(null); const [removeTarget, setRemoveTarget] = useState(null); const [assigningDriver, setAssigningDriver] = useState(null); + const [historyTarget, setHistoryTarget] = useState(null); const [selectedDriver, setSelectedDriver] = useState(""); const { viewMode, setViewMode } = useFleetViewMode(slug); @@ -273,6 +275,7 @@ const FleetResourcePage = () => { }} onRemove={setRemoveTarget} onAssignDriver={setAssigningDriver} + onHistory={setHistoryTarget} />
), @@ -581,6 +584,13 @@ const FleetResourcePage = () => { + + setHistoryTarget(null)} + entity={slug === "vehicles" ? "vehicle" : "driver"} + record={historyTarget} + /> ); }; diff --git a/apps/edr-freight-web/backoffice/src/services/fleet-history.service.ts b/apps/edr-freight-web/backoffice/src/services/fleet-history.service.ts new file mode 100644 index 000000000..5ffbe4a26 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/services/fleet-history.service.ts @@ -0,0 +1,38 @@ +import { api as apiClient } from "../auth/http"; + +export type FleetEventType = + | "DRIVER_REGISTERED" + | "VEHICLE_REGISTERED" + | "DRIVER_ASSIGNED" + | "DRIVER_UNASSIGNED" + | "VEHICLE_STATUS_CHANGED" + | "VEHICLE_AVAILABILITY_CHANGED" + | "MILE_VEHICLE_ASSIGNED" + | "MILE_VEHICLE_RELEASED" + | "MILE_STATUS_CHANGED"; + +export interface FleetHistoryEvent { + id: string; + eventType: FleetEventType; + vehicleId?: string | null; + driverId?: string | null; + firstMileId?: string | null; + lastMileId?: string | null; + fromValue?: string | null; + toValue?: string | null; + label?: string | null; + metadata?: Record | null; + createdAt: string; +} + +/** Timeline of fleet events for a driver or a vehicle (newest first). */ +export const fleetHistoryService = { + driver: (id: string) => + apiClient + .get(`/drivers/${id}/history`) + .then((r) => r.data), + vehicle: (id: string) => + apiClient + .get(`/vehicles/${id}/history`) + .then((r) => r.data), +}; From f2f6c89c0eacb7e1010d9a8a1de1bb73e0f3cd17 Mon Sep 17 00:00:00 2001 From: natib21 Date: Fri, 3 Jul 2026 15:37:24 +0000 Subject: [PATCH 23/38] fix --- .../modules/last-mile/last-mile.service.ts | 26 ++++++++++++++++--- .../components/fleet/FleetHistoryModal.tsx | 13 +++++++--- 2 files changed, 32 insertions(+), 7 deletions(-) diff --git a/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts b/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts index f0d39d51c..f5c9696e6 100644 --- a/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts +++ b/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts @@ -58,6 +58,23 @@ export class LastMileService { } } + /** Human booking reference for a last-mile record, for the history timeline. + * Uses the already-loaded relation when present, else looks it up. */ + private async resolveBookingRef( + record: LastMile, + ): Promise { + const loaded = (record as LastMile & { booking?: { reference?: string } }) + .booking?.reference; + if (loaded) return loaded; + if (!record.bookingId) return null; + try { + const booking = await this.bookingsRepository.findById(record.bookingId); + return (booking as { reference?: string } | null)?.reference ?? null; + } catch { + return null; + } + } + async acceptBooking(bookingReference: string): Promise { const booking = await this.bookingsRepository.findByReference(bookingReference); @@ -165,7 +182,7 @@ export class LastMileService { lastMileId: record.id, driverId: await this.resolveDriverId(dto.vehicleId), label: record.status, - metadata: { mile: 'LAST' }, + metadata: { mile: 'LAST', bookingRef: await this.resolveBookingRef(record) }, }); } @@ -209,6 +226,7 @@ export class LastMileService { } // Audit the mile↔vehicle (re)assignment on both vehicle and driver lines. + const bookingRef = await this.resolveBookingRef(existing); if (dto.vehicleId !== undefined && dto.vehicleId !== existing.vehicleId) { if (existing.vehicleId) { await this.history.record({ @@ -216,7 +234,7 @@ export class LastMileService { vehicleId: existing.vehicleId, lastMileId: id, driverId: await this.resolveDriverId(existing.vehicleId), - metadata: { mile: 'LAST' }, + metadata: { mile: 'LAST', bookingRef }, }); } if (dto.vehicleId) { @@ -226,7 +244,7 @@ export class LastMileService { lastMileId: id, driverId: await this.resolveDriverId(dto.vehicleId), label: updated.status, - metadata: { mile: 'LAST' }, + metadata: { mile: 'LAST', bookingRef }, }); } } @@ -240,7 +258,7 @@ export class LastMileService { driverId: await this.resolveDriverId(vehicleId), fromValue: existing.status, toValue: dto.status, - metadata: { mile: 'LAST' }, + metadata: { mile: 'LAST', bookingRef }, }); } diff --git a/apps/edr-freight-web/backoffice/src/components/fleet/FleetHistoryModal.tsx b/apps/edr-freight-web/backoffice/src/components/fleet/FleetHistoryModal.tsx index 868ed347d..0e7a0e65b 100644 --- a/apps/edr-freight-web/backoffice/src/components/fleet/FleetHistoryModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/fleet/FleetHistoryModal.tsx @@ -42,6 +42,13 @@ const arrow = (from?: string | null, to?: string | null) => `${from ?? "—"} → ${to ?? "—"}`; function describe(e: FleetHistoryEvent, entity: "driver" | "vehicle") { + const bookingRef = + typeof e.metadata?.bookingRef === "string" ? e.metadata.bookingRef : null; + const withBooking = (rest?: string) => + [bookingRef ? `Booking ${bookingRef}` : "", rest ?? ""] + .filter(Boolean) + .join(" · "); + switch (e.eventType) { case "DRIVER_REGISTERED": return { @@ -89,19 +96,19 @@ function describe(e: FleetHistoryEvent, entity: "driver" | "vehicle") { return { icon: , title: `${mileLabel(e)}: vehicle assigned`, - text: e.label ? `Status: ${e.label}` : "", + text: withBooking(e.label ? `Status: ${e.label}` : ""), }; case "MILE_VEHICLE_RELEASED": return { icon: , title: `${mileLabel(e)}: vehicle released`, - text: "", + text: withBooking(), }; case "MILE_STATUS_CHANGED": return { icon: , title: `${mileLabel(e)} status`, - text: arrow(e.fromValue, e.toValue), + text: withBooking(arrow(e.fromValue, e.toValue)), }; default: return { icon: , title: e.eventType, text: "" }; From 077667610aba6c56e064048666abfdb12a13eb80 Mon Sep 17 00:00:00 2001 From: Roba Boru Date: Fri, 3 Jul 2026 18:44:03 +0300 Subject: [PATCH 24/38] Add alternative schedule options if schedule not found --- .../portal/src/app/booking/results/page.tsx | 443 ++++++++++-------- 1 file changed, 260 insertions(+), 183 deletions(-) diff --git a/apps/edr-passenger-web/portal/src/app/booking/results/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/results/page.tsx index 810e0689a..d1952da23 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/results/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/results/page.tsx @@ -1,4 +1,4 @@ -'use client'; +'use client'; import { useSearchParams, useRouter } from 'next/navigation'; import { useQuery } from '@tanstack/react-query'; @@ -147,10 +147,16 @@ export default function ResultsPage() { // For one-way, check if outbound has results // For round-trip, check if BOTH outbound and inbound have results - const hasResults = isRoundTrip + const hasResults = isRoundTrip ? (outboundSchedules.length > 0 && inboundSchedules.length > 0) : outboundSchedules.length > 0; + // One-way searches that come back with an empty outbound list may still include + // date-shifted alternatives from the API — surface those instead of a dead end. + const isOneWayNoOutbound = !isRoundTrip && !!results && outboundSchedules.length === 0; + const alternativeOutbound: Schedule[] = isOneWayNoOutbound ? (results.alternativeOutbound || []) : []; + const requestedDate: string = (results && results.requestedDate) || searchData.date; + const handleSelectCoachType = (scheduleId: string, coachTypeId: string, coachTypeCode: string, coachTypeName: string, seatClassName: string) => { setSelectedCoachTypes(prev => ({ ...prev, [scheduleId]: { id: coachTypeId, code: coachTypeCode, name: coachTypeName, seatClassName } })); }; @@ -217,7 +223,190 @@ export default function ResultsPage() { router.push('/booking/auth-check'); }; - const renderScheduleCard = (schedule: Schedule, isOutbound: boolean = false) => { + // Shared "Choose Your Coach" drawer — used by both the normal results view and the + // Alternative Travel Options fallback, so selecting an alternative opens the exact + // same coach-type picker as a normal schedule. + const renderClassModal = () => { + if (!classModal) return null; + + const scheduleId = classModal.scheduleId || classModal.id || ''; + const selectedCoachType = selectedCoachTypes[scheduleId]; + const isOutbound = (classModal as any).isOutbound; + const coachTypes = classModal.coachTypes || []; + + const getCoachIcon = (typeName: string) => { + const lower = typeName.toLowerCase(); + if (lower.includes('soft') || lower.includes('vip')) return Star; + if (lower.includes('bed')) return Bed; + return Armchair; + }; + + return ( + <> +
setClassModal(null)} /> +
+
+
+

Choose Your Coach

+

+ + {classModal.trainNumber} + · + {classModal.origin?.name} → {classModal.destination?.name} +

+
+ +
+ +
+ {coachTypes.length > 0 ? ( +
+ {coachTypes.map((coachType: any, index: number) => { + const isSelected = selectedCoachType?.id === coachType.coachTypeId; + const minPrice = coachType.classes.length ? Math.min(...coachType.classes.map((c: any) => c.baseFareMinor)) : 0; + const coachCurrency = 'ETB'; + const CoachIcon = getCoachIcon(coachType.coachTypeName); + + return ( + + ); + })} +
+ ) : ( +
+
+ +
+

No coach types available for this journey

+
+ )} +
+ +
+
+ + {!selectedCoachType && ( +

+ + Select a coach type to continue +

+ )} +
+
+
+ + + ); + }; + + const renderScheduleCard = (schedule: Schedule, isOutbound: boolean = false, isAlternative: boolean = false) => { const scheduleId = schedule.scheduleId || schedule.id || ''; const selectedCoachType = selectedCoachTypes[scheduleId]; @@ -242,7 +431,22 @@ export default function ResultsPage() { const isNextDay = departureDate && arrivalDate && departureDate.toDateString() !== arrivalDate.toDateString(); return ( -
+
+ {isAlternative && ( +
+ + + Different date — {schedule.departureAt ? format(new Date(schedule.departureAt), 'EEEE, MMMM d, yyyy') : 'N/A'} + + + {schedule.hasAvailability ? 'Seats available' : 'Fully booked'} + +
+ )}
@@ -310,9 +514,10 @@ export default function ResultsPage() { )}
@@ -478,6 +683,54 @@ export default function ResultsPage() { } if (!hasResults) { + // ONE_WAY search with an explicit empty outbound list — surface any date-shifted + // alternatives the API suggests instead of a dead-end "no trains found" screen. + if (isOneWayNoOutbound) { + const requestedDateLabel = requestedDate + ? format(new Date(`${requestedDate}T00:00:00`), 'EEEE, MMMM d, yyyy') + : 'your selected date'; + const hasAlternatives = alternativeOutbound.length > 0; + + return ( +
+ {renderClassModal()} +
+
+
+
+ +
+

No trains available

+

+ No trains are available on {requestedDateLabel}. This + may be due to no scheduled service or full capacity. {hasAlternatives + ? 'Please check the alternative options below or try a different date.' + : 'Please try a different date.'} +

+ +
+ + {hasAlternatives && ( +
+
+

Alternative Travel Options

+

+ These trains run on different dates than requested — adjust your travel date to book one of them. +

+
+
+ {alternativeOutbound.map((schedule) => renderScheduleCard(schedule, true, true))} +
+
+ )} +
+
+
+ ); + } + return (
@@ -505,183 +758,7 @@ export default function ResultsPage() {
- {classModal && (() => { - const scheduleId = classModal.scheduleId || classModal.id || ''; - const selectedCoachType = selectedCoachTypes[scheduleId]; - const isOutbound = (classModal as any).isOutbound; - const coachTypes = classModal.coachTypes || []; - - const getCoachIcon = (typeName: string) => { - const lower = typeName.toLowerCase(); - if (lower.includes('soft') || lower.includes('vip')) return Star; - if (lower.includes('bed')) return Bed; - return Armchair; - }; - - return ( - <> -
setClassModal(null)} /> -
-
-
-

Choose Your Coach

-

- - {classModal.trainNumber} - · - {classModal.origin?.name} → {classModal.destination?.name} -

-
- -
- -
- {coachTypes.length > 0 ? ( -
- {coachTypes.map((coachType: any, index: number) => { - const isSelected = selectedCoachType?.id === coachType.coachTypeId; - const minPrice = coachType.classes.length ? Math.min(...coachType.classes.map((c: any) => c.baseFareMinor)) : 0; - const coachCurrency = 'ETB'; - const CoachIcon = getCoachIcon(coachType.coachTypeName); - - return ( - - ); - })} -
- ) : ( -
-
- -
-

No coach types available for this journey

-
- )} -
- -
-
- - {!selectedCoachType && ( -

- - Select a coach type to continue -

- )} -
-
-
- - - ); - })()} + {renderClassModal()} {promoData && (
From c643d30624825009a96d2f537f0c9fcfa39fc330 Mon Sep 17 00:00:00 2001 From: natib21 Date: Fri, 3 Jul 2026 15:47:12 +0000 Subject: [PATCH 25/38] fix --- .../modules/first-mile/first-mile.service.ts | 83 +++++++++++++++---- .../modules/last-mile/last-mile.service.ts | 60 ++++++++++---- .../src/modules/vehicles/vehicles.service.ts | 7 ++ .../components/fleet/FleetHistoryModal.tsx | 52 ++++++++---- 4 files changed, 157 insertions(+), 45 deletions(-) diff --git a/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts b/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts index 419b69969..43f02442a 100644 --- a/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts +++ b/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts @@ -48,13 +48,33 @@ export class FirstMileService { private readonly history: FleetHistoryService, ) { } - /** Resolve the driver currently assigned to a vehicle, for stamping mile - * events onto that driver's timeline. Best-effort — never throws. */ - private async resolveDriverId(vehicleId?: string | null): Promise { - if (!vehicleId) return null; + /** Resolve a vehicle's driver + human labels, for stamping mile events onto + * the driver's timeline and naming the vehicle. Best-effort — never throws. */ + private async vehicleInfo( + vehicleId?: string | null, + ): Promise<{ driverId: string | null; plate: string | null; driverName: string | null }> { + if (!vehicleId) return { driverId: null, plate: null, driverName: null }; try { - const vehicle = await this.vehiclesService.findById(vehicleId); - return vehicle.assignedDriverId ?? null; + const v = await this.vehiclesService.findById(vehicleId); + return { + driverId: v.assignedDriverId ?? null, + plate: v.plateNumber ?? v.code ?? null, + driverName: v.assignedDriverName ?? null, + }; + } catch { + return { driverId: null, plate: null, driverName: null }; + } + } + + /** Human booking reference for a first-mile record, for the history timeline. */ + private async resolveBookingRef(record: FirstMile): Promise { + const loaded = (record as FirstMile & { booking?: { reference?: string } }) + .booking?.reference; + if (loaded) return loaded; + if (!record.bookingId) return null; + try { + const b = await this.bookingsRepository.findById(record.bookingId); + return (b as { reference?: string } | null)?.reference ?? null; } catch { return null; } @@ -225,13 +245,19 @@ export class FirstMileService { if (dto.vehicleId) { await this.vehiclesService.setAvailability(dto.vehicleId, VehicleAvailability.BUSY); + const info = await this.vehicleInfo(dto.vehicleId); await this.history.record({ eventType: FleetEventType.MILE_VEHICLE_ASSIGNED, vehicleId: dto.vehicleId, firstMileId: record.id, - driverId: await this.resolveDriverId(dto.vehicleId), + driverId: info.driverId, label: record.status, - metadata: { mile: 'FIRST' }, + metadata: { + mile: 'FIRST', + bookingRef: await this.resolveBookingRef(record), + vehiclePlate: info.plate, + driverName: info.driverName, + }, }); } @@ -302,23 +328,36 @@ export class FirstMileService { await this.vehiclesService.releaseIfUnused([existing.vehicleId]); } // Audit the mile↔vehicle (re)assignment on both vehicle and driver lines. + const bookingRef = await this.resolveBookingRef(existing); if (existing.vehicleId) { + const info = await this.vehicleInfo(existing.vehicleId); await this.history.record({ eventType: FleetEventType.MILE_VEHICLE_RELEASED, vehicleId: existing.vehicleId, firstMileId: id, - driverId: await this.resolveDriverId(existing.vehicleId), - metadata: { mile: 'FIRST' }, + driverId: info.driverId, + metadata: { + mile: 'FIRST', + bookingRef, + vehiclePlate: info.plate, + driverName: info.driverName, + }, }); } if (dto.vehicleId) { + const info = await this.vehicleInfo(dto.vehicleId); await this.history.record({ eventType: FleetEventType.MILE_VEHICLE_ASSIGNED, vehicleId: dto.vehicleId, firstMileId: id, - driverId: await this.resolveDriverId(dto.vehicleId), + driverId: info.driverId, label: updated.status, - metadata: { mile: 'FIRST' }, + metadata: { + mile: 'FIRST', + bookingRef, + vehiclePlate: info.plate, + driverName: info.driverName, + }, }); } } @@ -330,14 +369,20 @@ export class FirstMileService { if (dto.status !== undefined && dto.status !== existing.status) { const vehicleId = updated.vehicleId ?? existing.vehicleId ?? null; + const info = await this.vehicleInfo(vehicleId); await this.history.record({ eventType: FleetEventType.MILE_STATUS_CHANGED, firstMileId: id, vehicleId, - driverId: await this.resolveDriverId(vehicleId), + driverId: info.driverId, fromValue: existing.status, toValue: dto.status, - metadata: { mile: 'FIRST' }, + metadata: { + mile: 'FIRST', + bookingRef: await this.resolveBookingRef(existing), + vehiclePlate: info.plate, + driverName: info.driverName, + }, }); } @@ -359,14 +404,20 @@ export class FirstMileService { if (status !== existing.status) { const vehicleId = updated.vehicleId ?? existing.vehicleId ?? null; + const info = await this.vehicleInfo(vehicleId); await this.history.record({ eventType: FleetEventType.MILE_STATUS_CHANGED, firstMileId: id, vehicleId, - driverId: await this.resolveDriverId(vehicleId), + driverId: info.driverId, fromValue: existing.status, toValue: status, - metadata: { mile: 'FIRST' }, + metadata: { + mile: 'FIRST', + bookingRef: await this.resolveBookingRef(existing), + vehiclePlate: info.plate, + driverName: info.driverName, + }, }); } diff --git a/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts b/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts index f5c9696e6..1e952b1ce 100644 --- a/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts +++ b/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts @@ -46,15 +46,21 @@ export class LastMileService { private readonly history: FleetHistoryService, ) {} - /** Resolve the driver currently assigned to a vehicle, for stamping mile - * events onto that driver's timeline. Best-effort — never throws. */ - private async resolveDriverId(vehicleId?: string | null): Promise { - if (!vehicleId) return null; + /** Resolve a vehicle's driver + human labels, for stamping mile events onto + * the driver's timeline and naming the vehicle. Best-effort — never throws. */ + private async vehicleInfo( + vehicleId?: string | null, + ): Promise<{ driverId: string | null; plate: string | null; driverName: string | null }> { + if (!vehicleId) return { driverId: null, plate: null, driverName: null }; try { - const vehicle = await this.vehiclesService.findById(vehicleId); - return vehicle.assignedDriverId ?? null; + const v = await this.vehiclesService.findById(vehicleId); + return { + driverId: v.assignedDriverId ?? null, + plate: v.plateNumber ?? v.code ?? null, + driverName: v.assignedDriverName ?? null, + }; } catch { - return null; + return { driverId: null, plate: null, driverName: null }; } } @@ -176,13 +182,19 @@ export class LastMileService { }); if (dto.vehicleId) { + const info = await this.vehicleInfo(dto.vehicleId); await this.history.record({ eventType: FleetEventType.MILE_VEHICLE_ASSIGNED, vehicleId: dto.vehicleId, lastMileId: record.id, - driverId: await this.resolveDriverId(dto.vehicleId), + driverId: info.driverId, label: record.status, - metadata: { mile: 'LAST', bookingRef: await this.resolveBookingRef(record) }, + metadata: { + mile: 'LAST', + bookingRef: await this.resolveBookingRef(record), + vehiclePlate: info.plate, + driverName: info.driverName, + }, }); } @@ -229,36 +241,54 @@ export class LastMileService { const bookingRef = await this.resolveBookingRef(existing); if (dto.vehicleId !== undefined && dto.vehicleId !== existing.vehicleId) { if (existing.vehicleId) { + const info = await this.vehicleInfo(existing.vehicleId); await this.history.record({ eventType: FleetEventType.MILE_VEHICLE_RELEASED, vehicleId: existing.vehicleId, lastMileId: id, - driverId: await this.resolveDriverId(existing.vehicleId), - metadata: { mile: 'LAST', bookingRef }, + driverId: info.driverId, + metadata: { + mile: 'LAST', + bookingRef, + vehiclePlate: info.plate, + driverName: info.driverName, + }, }); } if (dto.vehicleId) { + const info = await this.vehicleInfo(dto.vehicleId); await this.history.record({ eventType: FleetEventType.MILE_VEHICLE_ASSIGNED, vehicleId: dto.vehicleId, lastMileId: id, - driverId: await this.resolveDriverId(dto.vehicleId), + driverId: info.driverId, label: updated.status, - metadata: { mile: 'LAST', bookingRef }, + metadata: { + mile: 'LAST', + bookingRef, + vehiclePlate: info.plate, + driverName: info.driverName, + }, }); } } if (dto.status !== undefined && dto.status !== existing.status) { const vehicleId = updated.vehicleId ?? existing.vehicleId ?? null; + const info = await this.vehicleInfo(vehicleId); await this.history.record({ eventType: FleetEventType.MILE_STATUS_CHANGED, lastMileId: id, vehicleId, - driverId: await this.resolveDriverId(vehicleId), + driverId: info.driverId, fromValue: existing.status, toValue: dto.status, - metadata: { mile: 'LAST', bookingRef }, + metadata: { + mile: 'LAST', + bookingRef, + vehiclePlate: info.plate, + driverName: info.driverName, + }, }); } diff --git a/apps/edr-freight-api/src/modules/vehicles/vehicles.service.ts b/apps/edr-freight-api/src/modules/vehicles/vehicles.service.ts index 345d4f896..25260e86f 100644 --- a/apps/edr-freight-api/src/modules/vehicles/vehicles.service.ts +++ b/apps/edr-freight-api/src/modules/vehicles/vehicles.service.ts @@ -51,6 +51,10 @@ export class VehiclesService { vehicleId: saved.id, driverId: saved.assignedDriverId, label: saved.assignedDriverName ?? null, + metadata: { + vehiclePlate: saved.plateNumber ?? saved.code ?? null, + driverName: saved.assignedDriverName ?? null, + }, }); } @@ -133,12 +137,14 @@ export class VehiclesService { dto.assignedDriverId !== undefined && dto.assignedDriverId !== prev.assignedDriverId ) { + const vehiclePlate = saved.plateNumber ?? saved.code ?? null; if (prev.assignedDriverId) { await this.history.record({ eventType: FleetEventType.DRIVER_UNASSIGNED, vehicleId: id, driverId: prev.assignedDriverId, label: prev.assignedDriverName ?? null, + metadata: { vehiclePlate, driverName: prev.assignedDriverName ?? null }, }); } if (saved.assignedDriverId) { @@ -147,6 +153,7 @@ export class VehiclesService { vehicleId: id, driverId: saved.assignedDriverId, label: saved.assignedDriverName ?? null, + metadata: { vehiclePlate, driverName: saved.assignedDriverName ?? null }, }); } } diff --git a/apps/edr-freight-web/backoffice/src/components/fleet/FleetHistoryModal.tsx b/apps/edr-freight-web/backoffice/src/components/fleet/FleetHistoryModal.tsx index 0e7a0e65b..43f2bce4f 100644 --- a/apps/edr-freight-web/backoffice/src/components/fleet/FleetHistoryModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/fleet/FleetHistoryModal.tsx @@ -41,11 +41,24 @@ const mileLabel = (e: FleetHistoryEvent) => const arrow = (from?: string | null, to?: string | null) => `${from ?? "—"} → ${to ?? "—"}`; +const metaStr = (e: FleetHistoryEvent, key: string) => { + const v = e.metadata?.[key]; + return typeof v === "string" && v ? v : null; +}; + function describe(e: FleetHistoryEvent, entity: "driver" | "vehicle") { - const bookingRef = - typeof e.metadata?.bookingRef === "string" ? e.metadata.bookingRef : null; - const withBooking = (rest?: string) => - [bookingRef ? `Booking ${bookingRef}` : "", rest ?? ""] + const vehiclePlate = metaStr(e, "vehiclePlate"); + const driverName = metaStr(e, "driverName") ?? (e.label || null); + const bookingRef = metaStr(e, "bookingRef"); + + // Compose the detail line with whatever the current view doesn't already + // know: on a driver's timeline show which vehicle; always show the booking. + const detail = (extra?: string) => + [ + entity === "driver" && vehiclePlate ? `Vehicle ${vehiclePlate}` : "", + bookingRef ? `Booking ${bookingRef}` : "", + extra ?? "", + ] .filter(Boolean) .join(" · "); @@ -65,20 +78,31 @@ function describe(e: FleetHistoryEvent, entity: "driver" | "vehicle") { case "DRIVER_ASSIGNED": return { icon: , - title: + title: entity === "vehicle" ? "Driver assigned" : "Assigned to vehicle", + text: entity === "vehicle" - ? `Driver assigned${e.label ? `: ${e.label}` : ""}` - : "Assigned to a vehicle", - text: "", + ? driverName + ? `Driver ${driverName}` + : "" + : vehiclePlate + ? `Vehicle ${vehiclePlate}` + : "", }; case "DRIVER_UNASSIGNED": return { icon: , title: entity === "vehicle" - ? `Driver unassigned${e.label ? `: ${e.label}` : ""}` - : "Unassigned from a vehicle", - text: "", + ? "Driver unassigned" + : "Unassigned from vehicle", + text: + entity === "vehicle" + ? driverName + ? `Driver ${driverName}` + : "" + : vehiclePlate + ? `Vehicle ${vehiclePlate}` + : "", }; case "VEHICLE_STATUS_CHANGED": return { @@ -96,19 +120,19 @@ function describe(e: FleetHistoryEvent, entity: "driver" | "vehicle") { return { icon: , title: `${mileLabel(e)}: vehicle assigned`, - text: withBooking(e.label ? `Status: ${e.label}` : ""), + text: detail(e.label ? `Status: ${e.label}` : ""), }; case "MILE_VEHICLE_RELEASED": return { icon: , title: `${mileLabel(e)}: vehicle released`, - text: withBooking(), + text: detail(), }; case "MILE_STATUS_CHANGED": return { icon: , title: `${mileLabel(e)} status`, - text: withBooking(arrow(e.fromValue, e.toValue)), + text: detail(arrow(e.fromValue, e.toValue)), }; default: return { icon: , title: e.eventType, text: "" }; From 4ec38bf0289fd2c25534d4ff2efa37b0bac61dd6 Mon Sep 17 00:00:00 2001 From: natib21 Date: Fri, 3 Jul 2026 15:56:28 +0000 Subject: [PATCH 26/38] fix --- .../modules/first-mile/first-mile.service.ts | 28 +++++++++++ .../modules/last-mile/last-mile.service.ts | 46 ++++++++++++++++++- 2 files changed, 73 insertions(+), 1 deletion(-) diff --git a/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts b/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts index 43f02442a..20048c94d 100644 --- a/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts +++ b/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts @@ -550,6 +550,34 @@ export class FirstMileService { previousVehicleIds.filter((id) => !vehicleIds.has(id)), ); + // History: one event per vehicle actually added or removed by this + // multi-car (re)allocation, so reassignments show on every timeline. + const prevSet = new Set(previousVehicleIds); + const bookingRef = await this.resolveBookingRef(firstMile); + for (const vehicleId of vehicleIds) { + if (prevSet.has(vehicleId)) continue; + const info = await this.vehicleInfo(vehicleId); + await this.history.record({ + eventType: FleetEventType.MILE_VEHICLE_ASSIGNED, + vehicleId, + firstMileId, + driverId: info.driverId, + label: firstMile.status, + metadata: { mile: 'FIRST', bookingRef, vehiclePlate: info.plate, driverName: info.driverName }, + }); + } + for (const vehicleId of previousVehicleIds) { + if (vehicleIds.has(vehicleId)) continue; + const info = await this.vehicleInfo(vehicleId); + await this.history.record({ + eventType: FleetEventType.MILE_VEHICLE_RELEASED, + vehicleId, + firstMileId, + driverId: info.driverId, + metadata: { mile: 'FIRST', bookingRef, vehiclePlate: info.plate, driverName: info.driverName }, + }); + } + return { success: true, allocated: allocations.length, diff --git a/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts b/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts index 1e952b1ce..811ddd36b 100644 --- a/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts +++ b/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts @@ -1,5 +1,5 @@ import { Injectable, Logger, NotFoundException } from '@nestjs/common'; -import { DataSource, FindOptionsWhere } from 'typeorm'; +import { DataSource, FindOptionsWhere, In } from 'typeorm'; import { BookingsRepository } from '../bookings/bookings.repository'; import { DriversService } from '../drivers/drivers.service'; @@ -348,6 +348,21 @@ export class LastMileService { throw new NotFoundException(`Last-mile record ${lastMileId} not found`); } + // Capture the vehicles currently on these containers so a reallocation can + // be diffed into assigned/released history events below. + const previousAllocations = await this.dataSource.manager.find( + LastMileContainerAllocation, + { + where: { + lastMileId, + containerId: In(allocations.map((a) => a.containerId)), + }, + }, + ); + const previousVehicleIds = previousAllocations + .map((a) => a.vehicleId) + .filter((id): id is string => Boolean(id)); + await this.dataSource.transaction(async (manager) => { for (const allocation of allocations) { await manager.delete(LastMileContainerAllocation, { @@ -364,6 +379,35 @@ export class LastMileService { } }); + // History: one event per vehicle actually added or removed by this + // multi-car (re)allocation, so reassignments show on every timeline. + const vehicleIds = new Set(allocations.map((a) => a.vehicleId)); + const prevSet = new Set(previousVehicleIds); + const bookingRef = await this.resolveBookingRef(lastMile); + for (const vehicleId of vehicleIds) { + if (prevSet.has(vehicleId)) continue; + const info = await this.vehicleInfo(vehicleId); + await this.history.record({ + eventType: FleetEventType.MILE_VEHICLE_ASSIGNED, + vehicleId, + lastMileId, + driverId: info.driverId, + label: lastMile.status, + metadata: { mile: 'LAST', bookingRef, vehiclePlate: info.plate, driverName: info.driverName }, + }); + } + for (const vehicleId of previousVehicleIds) { + if (vehicleIds.has(vehicleId)) continue; + const info = await this.vehicleInfo(vehicleId); + await this.history.record({ + eventType: FleetEventType.MILE_VEHICLE_RELEASED, + vehicleId, + lastMileId, + driverId: info.driverId, + metadata: { mile: 'LAST', bookingRef, vehiclePlate: info.plate, driverName: info.driverName }, + }); + } + return { success: true, allocated: allocations.length, From c61bb787f2e95143b532890df247d3175b482a06 Mon Sep 17 00:00:00 2001 From: natib21 Date: Fri, 3 Jul 2026 16:01:59 +0000 Subject: [PATCH 27/38] fix --- .../modules/last-mile/last-mile.service.ts | 46 ++++++++++++++++++- 1 file changed, 45 insertions(+), 1 deletion(-) diff --git a/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts b/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts index 811ddd36b..ade7402a9 100644 --- a/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts +++ b/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts @@ -5,6 +5,7 @@ import { BookingsRepository } from '../bookings/bookings.repository'; import { DriversService } from '../drivers/drivers.service'; import { SmsClientService } from '../notifications/sms-client.service'; import { VehiclesService } from '../vehicles/vehicles.service'; +import { VehicleAvailability } from '../vehicles/entities/vehicle.entity'; import { CreateLastMileDto } from './dto/create-last-mile.dto'; import { UpdateLastMileDto } from './dto/update-last-mile.dto'; import { LastMile, LastMileStatus } from './entities/last-mile.entity'; @@ -182,6 +183,7 @@ export class LastMileService { }); if (dto.vehicleId) { + await this.vehiclesService.setAvailability(dto.vehicleId, VehicleAvailability.BUSY); const info = await this.vehicleInfo(dto.vehicleId); await this.history.record({ eventType: FleetEventType.MILE_VEHICLE_ASSIGNED, @@ -240,6 +242,14 @@ export class LastMileService { // Audit the mile↔vehicle (re)assignment on both vehicle and driver lines. const bookingRef = await this.resolveBookingRef(existing); if (dto.vehicleId !== undefined && dto.vehicleId !== existing.vehicleId) { + // Keep vehicle availability in sync: new vehicle goes BUSY, replaced one + // is freed if no other active trip still holds it. + if (dto.vehicleId) { + await this.vehiclesService.setAvailability(dto.vehicleId, VehicleAvailability.BUSY); + } + if (existing.vehicleId) { + await this.vehiclesService.releaseIfUnused([existing.vehicleId]); + } if (existing.vehicleId) { const info = await this.vehicleInfo(existing.vehicleId); await this.history.record({ @@ -292,9 +302,32 @@ export class LastMileService { }); } + // Delivery finished — free the vehicles this trip was holding. + if (dto.status === 'DELIVERED' && existing.status !== 'DELIVERED') { + await this.releaseVehicles(updated); + } + return updated; } + /** + * Free every vehicle held by this record (direct assignment + container + * allocations), unless still in use by another active trip. + */ + private async releaseVehicles(record: LastMile): Promise { + const recordAllocations = await this.dataSource.manager.find( + LastMileContainerAllocation, + { where: { lastMileId: record.id } }, + ); + const vehicleIds = recordAllocations + .map((a) => a.vehicleId) + .filter((id): id is string => Boolean(id)); + if (record.vehicleId) { + vehicleIds.push(record.vehicleId); + } + await this.vehiclesService.releaseIfUnused(vehicleIds); + } + private async notifyDriverAssignment(vehicleId: string, record: LastMile): Promise { try { const vehicle = await this.vehiclesService.findById(vehicleId); @@ -379,9 +412,20 @@ export class LastMileService { } }); + // Keep vehicle availability in sync: newly-allocated cars go BUSY, cars no + // longer on any of these containers are freed if unused elsewhere. + const vehicleIds = new Set(allocations.map((a) => a.vehicleId)); + await Promise.all( + [...vehicleIds].map((id) => + this.vehiclesService.setAvailability(id, VehicleAvailability.BUSY), + ), + ); + await this.vehiclesService.releaseIfUnused( + previousVehicleIds.filter((id) => !vehicleIds.has(id)), + ); + // History: one event per vehicle actually added or removed by this // multi-car (re)allocation, so reassignments show on every timeline. - const vehicleIds = new Set(allocations.map((a) => a.vehicleId)); const prevSet = new Set(previousVehicleIds); const bookingRef = await this.resolveBookingRef(lastMile); for (const vehicleId of vehicleIds) { From ed388042afe5627c5a85f574cbdc48631d51ccfc Mon Sep 17 00:00:00 2001 From: natib21 Date: Fri, 3 Jul 2026 16:14:33 +0000 Subject: [PATCH 28/38] fix --- .../src/modules/first-mile/first-mile-invoice.service.ts | 2 +- .../src/modules/first-mile/first-mile.controller.ts | 5 +++-- .../src/modules/last-mile/last-mile-invoice.service.ts | 2 +- .../src/modules/last-mile/last-mile.controller.ts | 7 ++++--- 4 files changed, 9 insertions(+), 7 deletions(-) diff --git a/apps/edr-freight-api/src/modules/first-mile/first-mile-invoice.service.ts b/apps/edr-freight-api/src/modules/first-mile/first-mile-invoice.service.ts index 22520aac1..f7d9ee11f 100644 --- a/apps/edr-freight-api/src/modules/first-mile/first-mile-invoice.service.ts +++ b/apps/edr-freight-api/src/modules/first-mile/first-mile-invoice.service.ts @@ -71,7 +71,7 @@ export class FirstMileInvoiceService { type: 'DELIVERY_FEE', companyId: fm.booking!.companyId, companyProfileId: fm.booking!.companyProfileId || '', - currency: 'ETB', + currency: fm.booking!.paymentCurrency || 'ETB', lines: [ { chargeType: 'DELIVERY', diff --git a/apps/edr-freight-api/src/modules/first-mile/first-mile.controller.ts b/apps/edr-freight-api/src/modules/first-mile/first-mile.controller.ts index d5f53ae0c..444cbee87 100644 --- a/apps/edr-freight-api/src/modules/first-mile/first-mile.controller.ts +++ b/apps/edr-freight-api/src/modules/first-mile/first-mile.controller.ts @@ -92,6 +92,7 @@ export class FirstMileController { const record = await this.firstMileService.update(id, dto); // Auto-generate invoice if distance or payment was updated const booking = await this.bookingsService.findById(record.bookingId); + const currency = booking.paymentCurrency || "ETB"; if (dto.exactKm !== undefined || dto.exactKm != record.exactKm || dto.remainingPayment !== undefined || dto.remainingPayment !== record.remainingPayment) { await this.billingService.generateInvoice({ source: Freight.InvoiceSource.FirstMile, @@ -99,7 +100,7 @@ export class FirstMileController { type: "FIRST_MILE", companyId: booking.companyId, companyProfileId: booking.companyProfileId, - currency: "ETB", + currency, lines: [ { @@ -108,7 +109,7 @@ export class FirstMileController { quantity: 1, unitRate: record.remainingPayment, amount: record.remainingPayment, - currency: "ETB", + currency, }, ], diff --git a/apps/edr-freight-api/src/modules/last-mile/last-mile-invoice.service.ts b/apps/edr-freight-api/src/modules/last-mile/last-mile-invoice.service.ts index c304a89e8..e40e94509 100644 --- a/apps/edr-freight-api/src/modules/last-mile/last-mile-invoice.service.ts +++ b/apps/edr-freight-api/src/modules/last-mile/last-mile-invoice.service.ts @@ -61,7 +61,7 @@ export class LastMileInvoiceService { type: 'DELIVERY_FEE', companyId: lm.booking!.companyId, companyProfileId: lm.booking!.companyProfileId || '', - currency: 'ETB', + currency: lm.booking!.paymentCurrency || 'ETB', lines: [ { chargeType: 'DELIVERY', diff --git a/apps/edr-freight-api/src/modules/last-mile/last-mile.controller.ts b/apps/edr-freight-api/src/modules/last-mile/last-mile.controller.ts index 88a96e6c2..9ad5a00bf 100644 --- a/apps/edr-freight-api/src/modules/last-mile/last-mile.controller.ts +++ b/apps/edr-freight-api/src/modules/last-mile/last-mile.controller.ts @@ -86,6 +86,7 @@ export class LastMileController { const record = await this.lastMileService.update(id, dto); // Auto-generate invoice if distance or payment was updated const booking = await this.bookingsService.findById(record.bookingId); + const currency = booking.paymentCurrency || "ETB"; if (dto.exactKm !== undefined || dto.exactKm != record.exactKm || dto.remainingPayment !== undefined || dto.remainingPayment !== record.remainingPayment) { await this.billingService.generateInvoice({ source: Freight.InvoiceSource.LastMile, @@ -93,8 +94,8 @@ export class LastMileController { type: "LAST_MILE", companyId: booking.companyId, companyProfileId: booking.companyProfileId, - currency: "ETB", - + currency, + lines: [ { chargeType: "LAST_MILE", @@ -102,7 +103,7 @@ export class LastMileController { quantity: 1, unitRate: record.remainingPayment, amount: record.remainingPayment, - currency: "ETB", + currency, }, ], From 86615863017be36b99219b0abb22778e3ce8213b Mon Sep 17 00:00:00 2001 From: natib21 Date: Fri, 3 Jul 2026 16:21:07 +0000 Subject: [PATCH 29/38] fix --- .../modules/first-mile/first-mile.service.ts | 38 ++++++++++++++++++- .../modules/last-mile/last-mile.service.ts | 29 +++++++++++++- .../src/pages/operations/FirstMilePage.tsx | 8 +++- .../src/pages/operations/LastMilePage.tsx | 8 +++- 4 files changed, 75 insertions(+), 8 deletions(-) diff --git a/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts b/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts index 20048c94d..1a7db810d 100644 --- a/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts +++ b/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts @@ -1,5 +1,5 @@ -import { Injectable, Logger, NotFoundException } from '@nestjs/common'; -import { FindOptionsWhere, In } from 'typeorm'; +import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common'; +import { FindOptionsWhere, In, IsNull, Not } from 'typeorm'; import { InjectDataSource } from '@nestjs/typeorm'; import { DataSource } from 'typeorm'; import { VehicleAvailability } from '../vehicles/entities/vehicle.entity'; @@ -66,6 +66,19 @@ export class FirstMileService { } } + /** A leg counts as having a vehicle if it has a direct assignment or at least + * one container allocation carrying a vehicle. Gates the IN_TRANSIT move. */ + private async hasAssignedVehicle( + recordId: string, + directVehicleId?: string | null, + ): Promise { + if (directVehicleId) return true; + const count = await this.dataSource.manager.count(FirstMileContainerAllocation, { + where: { firstMileId: recordId, vehicleId: Not(IsNull()) }, + }); + return count > 0; + } + /** Human booking reference for a first-mile record, for the history timeline. */ private async resolveBookingRef(record: FirstMile): Promise { const loaded = (record as FirstMile & { booking?: { reference?: string } }) @@ -297,6 +310,18 @@ export class FirstMileService { async update(id: string, dto: UpdateFirstMileDto): Promise { const existing = await this.findById(id); + // A leg can only go IN_TRANSIT once a vehicle is assigned (allowing a vehicle + // assigned in this same request). + if (dto.status === 'IN_TRANSIT' && existing.status !== 'IN_TRANSIT') { + const vehicleId = + dto.vehicleId !== undefined ? dto.vehicleId : existing.vehicleId; + if (!(await this.hasAssignedVehicle(id, vehicleId))) { + throw new BadRequestException( + 'Assign a vehicle before marking this first-mile leg in transit', + ); + } + } + const dtoAny = dto as any; const updated = await this.firstMileRepository.update(id, { ...(dto.bookingId !== undefined ? { bookingId: dto.bookingId } : {}), @@ -396,6 +421,15 @@ export class FirstMileService { async updateStatus(id: string, status: FirstMileStatus): Promise { const existing = await this.findById(id); + + if (status === 'IN_TRANSIT' && existing.status !== 'IN_TRANSIT') { + if (!(await this.hasAssignedVehicle(id, existing.vehicleId))) { + throw new BadRequestException( + 'Assign a vehicle before marking this first-mile leg in transit', + ); + } + } + const updated = await this.firstMileRepository.update(id, { status }); if (!updated) { diff --git a/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts b/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts index ade7402a9..884a02aae 100644 --- a/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts +++ b/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts @@ -1,5 +1,5 @@ -import { Injectable, Logger, NotFoundException } from '@nestjs/common'; -import { DataSource, FindOptionsWhere, In } from 'typeorm'; +import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common'; +import { DataSource, FindOptionsWhere, In, IsNull, Not } from 'typeorm'; import { BookingsRepository } from '../bookings/bookings.repository'; import { DriversService } from '../drivers/drivers.service'; @@ -65,6 +65,19 @@ export class LastMileService { } } + /** A leg counts as having a vehicle if it has a direct assignment or at least + * one container allocation carrying a vehicle. Gates the IN_TRANSIT move. */ + private async hasAssignedVehicle( + recordId: string, + directVehicleId?: string | null, + ): Promise { + if (directVehicleId) return true; + const count = await this.dataSource.manager.count(LastMileContainerAllocation, { + where: { lastMileId: recordId, vehicleId: Not(IsNull()) }, + }); + return count > 0; + } + /** Human booking reference for a last-mile record, for the history timeline. * Uses the already-loaded relation when present, else looks it up. */ private async resolveBookingRef( @@ -218,6 +231,18 @@ export class LastMileService { async update(id: string, dto: UpdateLastMileDto): Promise { const existing = await this.findById(id); + // A leg can only go IN_TRANSIT once a vehicle is assigned (allowing a vehicle + // assigned in this same request). + if (dto.status === 'IN_TRANSIT' && existing.status !== 'IN_TRANSIT') { + const vehicleId = + dto.vehicleId !== undefined ? dto.vehicleId : existing.vehicleId; + if (!(await this.hasAssignedVehicle(id, vehicleId))) { + throw new BadRequestException( + 'Assign a vehicle before marking this last-mile leg in transit', + ); + } + } + const dtoAny = dto as any; const updated = await this.lastMileRepository.update(id, { ...(dto.bookingId !== undefined ? { bookingId: dto.bookingId } : {}), diff --git a/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx b/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx index cec4d4351..bbd5acc0c 100644 --- a/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx @@ -879,10 +879,14 @@ const FirstMilePage = () => { } - disabled={!nextStatus} + disabled={!nextStatus || (nextStatus === "IN_TRANSIT" && !assigned)} onClick={() => handleAdvanceStatus(row.original)} > - {nextStatus ? `Mark ${STATUS_META[nextStatus].label}` : STATUS_META[row.original.status].label} + {nextStatus === "IN_TRANSIT" && !assigned + ? "Assign a vehicle first" + : nextStatus + ? `Mark ${STATUS_META[nextStatus].label}` + : STATUS_META[row.original.status].label} { } - disabled={!nextStatus} + disabled={!nextStatus || (nextStatus === "IN_TRANSIT" && !assigned)} onClick={() => handleAdvanceStatus(row.original)} > - {nextStatus ? `Mark ${STATUS_META[nextStatus].label}` : STATUS_META[row.original.status].label} + {nextStatus === "IN_TRANSIT" && !assigned + ? "Assign a vehicle first" + : nextStatus + ? `Mark ${STATUS_META[nextStatus].label}` + : STATUS_META[row.original.status].label} Date: Fri, 3 Jul 2026 16:34:06 +0000 Subject: [PATCH 30/38] fix --- .../components/operations/LastMileSteps.tsx | 93 +++++++++++++++++++ .../src/pages/operations/LastMilePage.tsx | 71 ++++++++++++++ 2 files changed, 164 insertions(+) create mode 100644 apps/edr-freight-web/backoffice/src/components/operations/LastMileSteps.tsx diff --git a/apps/edr-freight-web/backoffice/src/components/operations/LastMileSteps.tsx b/apps/edr-freight-web/backoffice/src/components/operations/LastMileSteps.tsx new file mode 100644 index 000000000..d4860a414 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/operations/LastMileSteps.tsx @@ -0,0 +1,93 @@ +import { Group, Stack, Text, Timeline, Tooltip } from "@mantine/core"; +import { Check } from "lucide-react"; + +/** One stage of the last-mile delivery workflow. */ +export interface LastMileStepState { + label: string; + done: boolean; + active: boolean; + /** Optional stamp/value shown next to the step (plate, time, distance…). */ + detail?: string | null; +} + +/** + * Compact 6-dot progress bar for a table row — filled = done, ringed = current, + * hollow = pending. Hover a dot for its label + stamp. + */ +export function LastMileStepBar({ steps }: { steps: LastMileStepState[] }) { + return ( + + {steps.map((s, i) => { + const color = s.done + ? "var(--mantine-color-green-6)" + : s.active + ? "var(--mantine-color-blue-5)" + : "var(--mantine-color-gray-4)"; + return ( + + + + ); + })} + + ); +} + +/** + * Vertical stepper for the detail view — completed steps bulleted + green, the + * current step highlighted, each showing its stamp/value when known. + */ +export function LastMileStepper({ steps }: { steps: LastMileStepState[] }) { + const activeIndex = steps.findIndex((s) => s.active); + // Timeline highlights items with index < `active`; count of done steps drives it. + const doneCount = steps.filter((s) => s.done).length; + return ( + + {steps.map((s, i) => ( + : undefined} + title={ + + {s.label} + + } + lineVariant={s.done ? "solid" : "dashed"} + > + + + {s.done ? "Done" : s.active ? "Current step" : "Pending"} + + {s.detail && ( + + {s.detail} + + )} + + + ))} + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx index 0a40e9d08..56fbcc3fc 100644 --- a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx @@ -49,6 +49,7 @@ import { driversService, type Driver } from "@/services/drivers.service"; import { ratesService } from "@/services/rates.service"; import { LastMileContainerAllocationTable, type LastMileContainerRow } from "@/components/LastMileContainerAllocationTable"; import { ReleaseOrderModal, type ReleaseOrderTruckPrefill } from "@/components/warehouses/ReleaseOrderModal"; +import { LastMileStepBar, LastMileStepper, type LastMileStepState } from "@/components/operations/LastMileSteps"; import { api } from "@/auth/http"; const formatPrice = (amount: number) => @@ -92,6 +93,50 @@ const vehicleLabel = (record: LastMileRecord) => { const isAssigned = (record: LastMileRecord) => Boolean(record.vehicleId); +const fmtStamp = (iso?: string | null) => { + if (!iso) return null; + const d = new Date(iso); + return Number.isNaN(d.getTime()) ? null : d.toLocaleString(); +}; + +/** + * Derive the 6-step last-mile workflow state for a record. Step completion is + * read from the record + its pickup-ready (warehouse release) row: + * assign→vehicleId, arrived→release order issued, leave→releaseDate, + * in-transit/delivered→status, distance→exactKm. + */ +const computeLastMileSteps = ( + record: LastMileRecord, + releaseRow?: ImportUnloadedItem, +): LastMileStepState[] => { + const exactKm = (record as { exactKm?: number | null }).exactKm; + const flags = [ + Boolean(record.vehicleId), + Boolean(releaseRow?.releaseOrderReference), + Boolean(releaseRow?.releaseDate), + record.status === "IN_TRANSIT" || record.status === "DELIVERED", + exactKm != null, + record.status === "DELIVERED", + ]; + // Current step = earliest incomplete one. + const activeIdx = flags.findIndex((f) => !f); + const labels = ["Assign vehicle", "Truck arrived", "Truck leave", "In transit", "Add distance", "Delivered"]; + const details: (string | null)[] = [ + record.vehicle?.plateNumber ?? null, + releaseRow?.releaseOrderReference ?? null, + fmtStamp(releaseRow?.releaseDate), + null, + exactKm != null ? `${exactKm} KM` : null, + fmtStamp(releaseRow?.deliveredAt), + ]; + return labels.map((label, i) => ({ + label, + done: flags[i], + active: i === activeIdx, + detail: details[i], + })); +}; + const bookingRef = (r: LastMileRecord) => r.booking?.reference ?? r.bookingId; const customerName = (r: LastMileRecord) => r.booking?.company?.name ?? "—"; const deliveryLocation = (r: LastMileRecord) => r.booking?.lastMileDeliveryAddress ?? "—"; @@ -943,6 +988,20 @@ const LastMilePage = () => { ), }, + { + id: "progress", + header: "Progress", + meta: { headerClassName, cellClassName }, + cell: ({ row }) => ( + + ), + }, { id: "actions", header: "Actions", @@ -1324,6 +1383,18 @@ const LastMilePage = () => { > {activeRecord && } + {activeRecord && ( + + Delivery steps + + + )} From aeb06e8a552f7acb6c009e2e9c24615ce87b072c Mon Sep 17 00:00:00 2001 From: natib21 Date: Fri, 3 Jul 2026 16:39:41 +0000 Subject: [PATCH 31/38] fix --- .../src/pages/operations/LastMilePage.tsx | 16 +--------------- 1 file changed, 1 insertion(+), 15 deletions(-) diff --git a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx index 56fbcc3fc..b55320e7a 100644 --- a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx @@ -49,7 +49,7 @@ import { driversService, type Driver } from "@/services/drivers.service"; import { ratesService } from "@/services/rates.service"; import { LastMileContainerAllocationTable, type LastMileContainerRow } from "@/components/LastMileContainerAllocationTable"; import { ReleaseOrderModal, type ReleaseOrderTruckPrefill } from "@/components/warehouses/ReleaseOrderModal"; -import { LastMileStepBar, LastMileStepper, type LastMileStepState } from "@/components/operations/LastMileSteps"; +import { LastMileStepper, type LastMileStepState } from "@/components/operations/LastMileSteps"; import { api } from "@/auth/http"; const formatPrice = (amount: number) => @@ -988,20 +988,6 @@ const LastMilePage = () => { ), }, - { - id: "progress", - header: "Progress", - meta: { headerClassName, cellClassName }, - cell: ({ row }) => ( - - ), - }, { id: "actions", header: "Actions", From 53229f3da9818e6403111865429f37f841e549ff Mon Sep 17 00:00:00 2001 From: natib21 Date: Fri, 3 Jul 2026 16:42:38 +0000 Subject: [PATCH 32/38] fix --- .../backoffice/src/pages/operations/LastMilePage.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx index b55320e7a..780c6f881 100644 --- a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx @@ -111,6 +111,7 @@ const computeLastMileSteps = ( ): LastMileStepState[] => { const exactKm = (record as { exactKm?: number | null }).exactKm; const flags = [ + record.status !== "PAYMENT_PENDING", Boolean(record.vehicleId), Boolean(releaseRow?.releaseOrderReference), Boolean(releaseRow?.releaseDate), @@ -120,8 +121,9 @@ const computeLastMileSteps = ( ]; // Current step = earliest incomplete one. const activeIdx = flags.findIndex((f) => !f); - const labels = ["Assign vehicle", "Truck arrived", "Truck leave", "In transit", "Add distance", "Delivered"]; + const labels = ["Ready to Transit", "Assign vehicle", "Truck arrived", "Truck leave", "In transit", "Add distance", "Delivered"]; const details: (string | null)[] = [ + null, record.vehicle?.plateNumber ?? null, releaseRow?.releaseOrderReference ?? null, fmtStamp(releaseRow?.releaseDate), From 482c569216270e4b37ace9d4480c6d89bdbdbc2c Mon Sep 17 00:00:00 2001 From: natib21 Date: Fri, 3 Jul 2026 16:43:58 +0000 Subject: [PATCH 33/38] fix --- .../backoffice/src/pages/operations/FirstMilePage.tsx | 2 +- .../backoffice/src/pages/operations/LastMilePage.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx b/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx index bbd5acc0c..68e9745f1 100644 --- a/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx @@ -958,7 +958,7 @@ const FirstMilePage = () => { }, [vehicleOptions]); return ( - + diff --git a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx index 780c6f881..4854ee7a7 100644 --- a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx @@ -1095,7 +1095,7 @@ const LastMilePage = () => { }, [vehicleOptions, pickupReadyByBooking]); return ( - + From b7f6c3150e78dedcd2a2ac56883459063e39eb91 Mon Sep 17 00:00:00 2001 From: natib21 Date: Fri, 3 Jul 2026 16:46:31 +0000 Subject: [PATCH 34/38] fix --- .../src/pages/operations/LastMilePage.tsx | 23 +++++++++++-------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx index 4854ee7a7..71964c038 100644 --- a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx @@ -1003,6 +1003,13 @@ const LastMilePage = () => { const releaseRow = pickupReadyByBooking.get(row.original.bookingId) ?? pickupReadyByBooking.get(bookingRef(row.original)); const truckArrivalLabel = releaseRow?.releaseOrderReference ? "Truck Leaving" : "Truck Arrival"; + // Only the current step's action is active; the rest stay disabled. + // Step order: 0 Ready·1 Assign·2 Arrived·3 Leave·4 In-transit·5 Distance·6 Delivered. + const activeStep = computeLastMileSteps(row.original, releaseRow).findIndex((s) => s.active); + const canAdvance = activeStep === 0 || activeStep === 4 || activeStep === 6; + const canAssignStep = activeStep === 1; + const canTruck = activeStep === 2 || activeStep === 3; + const canDistance = activeStep === 5; return ( @@ -1014,19 +1021,17 @@ const LastMilePage = () => { } - disabled={!nextStatus || (nextStatus === "IN_TRANSIT" && !assigned)} + disabled={!nextStatus || !canAdvance} onClick={() => handleAdvanceStatus(row.original)} > - {nextStatus === "IN_TRANSIT" && !assigned - ? "Assign a vehicle first" - : nextStatus - ? `Mark ${STATUS_META[nextStatus].label}` - : STATUS_META[row.original.status].label} + {nextStatus + ? `Mark ${STATUS_META[nextStatus].label}` + : STATUS_META[row.original.status].label} } - disabled={assigned || delivered} + disabled={!canAssignStep} onClick={() => openAssign(row.original.id)} > Assign @@ -1040,7 +1045,7 @@ const LastMilePage = () => { } - disabled={!assigned} + disabled={!canTruck} onClick={() => openTruckArrival(row.original)} > {truckArrivalLabel} @@ -1054,7 +1059,7 @@ const LastMilePage = () => { } - disabled={delivered} + disabled={!canDistance} onClick={() => openDistance(row.original.id)} > Add distance From f0035a3695475fba45c44596a4c5f09bf40e6ec3 Mon Sep 17 00:00:00 2001 From: natib21 Date: Fri, 3 Jul 2026 16:52:31 +0000 Subject: [PATCH 35/38] fix --- .../backoffice/src/pages/operations/FirstMilePage.tsx | 8 ++++++++ .../backoffice/src/pages/operations/LastMilePage.tsx | 8 ++++++++ 2 files changed, 16 insertions(+) diff --git a/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx b/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx index 68e9745f1..2ec99f496 100644 --- a/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx @@ -6,6 +6,7 @@ import { MoreHorizontal, PackageCheck, Printer, + Receipt, RefreshCw, Ruler, Trash, @@ -923,6 +924,13 @@ const FirstMilePage = () => { > Add distance + } + disabled={!(row.original.exactKm != null && row.original.exactKm > 0)} + onClick={() => openInvoice(row.original)} + > + Generate Invoice + {canPrint && ( } diff --git a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx index 71964c038..664a23069 100644 --- a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx @@ -4,6 +4,7 @@ import { Eye, MoreHorizontal, Printer, + Receipt, RefreshCw, Ruler, Trash, @@ -1064,6 +1065,13 @@ const LastMilePage = () => { > Add distance + } + disabled={!(row.original.exactKm != null && row.original.exactKm > 0)} + onClick={() => openInvoice(row.original)} + > + Generate Invoice + {canPrint && ( } From ac55a867c0314e68f0474903c9831edc0d45223c Mon Sep 17 00:00:00 2001 From: natib21 Date: Fri, 3 Jul 2026 16:56:23 +0000 Subject: [PATCH 36/38] fix --- .../src/pages/operations/LastMilePage.tsx | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx index 664a23069..dd173ba3e 100644 --- a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx @@ -1003,14 +1003,17 @@ const LastMilePage = () => { const delivered = row.original.status === "DELIVERED"; const releaseRow = pickupReadyByBooking.get(row.original.bookingId) ?? pickupReadyByBooking.get(bookingRef(row.original)); - const truckArrivalLabel = releaseRow?.releaseOrderReference ? "Truck Leaving" : "Truck Arrival"; // Only the current step's action is active; the rest stay disabled. // Step order: 0 Ready·1 Assign·2 Arrived·3 Leave·4 In-transit·5 Distance·6 Delivered. const activeStep = computeLastMileSteps(row.original, releaseRow).findIndex((s) => s.active); const canAdvance = activeStep === 0 || activeStep === 4 || activeStep === 6; const canAssignStep = activeStep === 1; - const canTruck = activeStep === 2 || activeStep === 3; const canDistance = activeStep === 5; + // Truck arrival/leaving are independent of the step sequence — each + // driven only by its own state: arrive once assigned & not arrived, + // leave once arrived & not departed. + const canArrive = assigned && !releaseRow?.releaseOrderReference; + const canLeave = Boolean(releaseRow?.releaseOrderReference) && !releaseRow?.releaseDate; return ( @@ -1046,10 +1049,17 @@ const LastMilePage = () => { } - disabled={!canTruck} + disabled={!canArrive} onClick={() => openTruckArrival(row.original)} > - {truckArrivalLabel} + Truck Arrival + + } + disabled={!canLeave} + onClick={() => openTruckArrival(row.original)} + > + Truck Leaving Date: Fri, 3 Jul 2026 16:58:42 +0000 Subject: [PATCH 37/38] fix --- .../backoffice/src/pages/operations/LastMilePage.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx index dd173ba3e..565edcc79 100644 --- a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx @@ -118,11 +118,12 @@ const computeLastMileSteps = ( Boolean(releaseRow?.releaseDate), record.status === "IN_TRANSIT" || record.status === "DELIVERED", exactKm != null, + exactKm != null, // Generate Invoice — auto-generated when distance is saved record.status === "DELIVERED", ]; // Current step = earliest incomplete one. const activeIdx = flags.findIndex((f) => !f); - const labels = ["Ready to Transit", "Assign vehicle", "Truck arrived", "Truck leave", "In transit", "Add distance", "Delivered"]; + const labels = ["Ready to Transit", "Assign vehicle", "Truck arrived", "Truck leave", "In transit", "Add distance", "Generate Invoice", "Delivered"]; const details: (string | null)[] = [ null, record.vehicle?.plateNumber ?? null, @@ -130,6 +131,7 @@ const computeLastMileSteps = ( fmtStamp(releaseRow?.releaseDate), null, exactKm != null ? `${exactKm} KM` : null, + exactKm != null ? "Invoice ready" : null, fmtStamp(releaseRow?.deliveredAt), ]; return labels.map((label, i) => ({ @@ -1004,9 +1006,9 @@ const LastMilePage = () => { const releaseRow = pickupReadyByBooking.get(row.original.bookingId) ?? pickupReadyByBooking.get(bookingRef(row.original)); // Only the current step's action is active; the rest stay disabled. - // Step order: 0 Ready·1 Assign·2 Arrived·3 Leave·4 In-transit·5 Distance·6 Delivered. + // Steps: 0 Ready·1 Assign·2 Arrived·3 Leave·4 In-transit·5 Distance·6 Invoice·7 Delivered. const activeStep = computeLastMileSteps(row.original, releaseRow).findIndex((s) => s.active); - const canAdvance = activeStep === 0 || activeStep === 4 || activeStep === 6; + const canAdvance = activeStep === 0 || activeStep === 4 || activeStep === 7; const canAssignStep = activeStep === 1; const canDistance = activeStep === 5; // Truck arrival/leaving are independent of the step sequence — each From aaaa38f2344f295ff6a64243ed00f4396b9b0b74 Mon Sep 17 00:00:00 2001 From: natib21 Date: Fri, 3 Jul 2026 17:04:41 +0000 Subject: [PATCH 38/38] fix --- .../src/pages/operations/LastMilePage.tsx | 35 ++++++++++++------- 1 file changed, 23 insertions(+), 12 deletions(-) diff --git a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx index 565edcc79..2fb05b0de 100644 --- a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx @@ -111,12 +111,16 @@ const computeLastMileSteps = ( releaseRow?: ImportUnloadedItem, ): LastMileStepState[] => { const exactKm = (record as { exactKm?: number | null }).exactKm; + // Truck arrival/leave live in the transient warehouse pickup-ready queue and + // vanish once the item is released. So once the leg is IN_TRANSIT/DELIVERED, + // treat both as done (the truck must have arrived + left to get there). + const past = record.status === "IN_TRANSIT" || record.status === "DELIVERED"; const flags = [ record.status !== "PAYMENT_PENDING", Boolean(record.vehicleId), - Boolean(releaseRow?.releaseOrderReference), - Boolean(releaseRow?.releaseDate), - record.status === "IN_TRANSIT" || record.status === "DELIVERED", + past || Boolean(releaseRow?.releaseOrderReference), + past || Boolean(releaseRow?.releaseDate), + past, exactKm != null, exactKm != null, // Generate Invoice — auto-generated when distance is saved record.status === "DELIVERED", @@ -1005,15 +1009,22 @@ const LastMilePage = () => { const delivered = row.original.status === "DELIVERED"; const releaseRow = pickupReadyByBooking.get(row.original.bookingId) ?? pickupReadyByBooking.get(bookingRef(row.original)); - // Only the current step's action is active; the rest stay disabled. - // Steps: 0 Ready·1 Assign·2 Arrived·3 Leave·4 In-transit·5 Distance·6 Invoice·7 Delivered. - const activeStep = computeLastMileSteps(row.original, releaseRow).findIndex((s) => s.active); - const canAdvance = activeStep === 0 || activeStep === 4 || activeStep === 7; - const canAssignStep = activeStep === 1; - const canDistance = activeStep === 5; - // Truck arrival/leaving are independent of the step sequence — each - // driven only by its own state: arrive once assigned & not arrived, - // leave once arrived & not departed. + // Gate on PERSISTENT state (status/vehicle/distance), not the truck + // arrival/leave signals — those live in the warehouse queue and vanish + // once the item is released, so they can't gate the status advance. + const status = row.original.status; + const hasDistance = row.original.exactKm != null; + // Advance: PAYMENT_PENDING→Ready, READY_TO_TRANSIT→In-transit (needs a + // vehicle), IN_TRANSIT→Delivered (needs distance/invoice). + const canAdvance = + status === "PAYMENT_PENDING" || + (status === "READY_TO_TRANSIT" && assigned) || + (status === "IN_TRANSIT" && hasDistance); + const canAssignStep = !assigned && status !== "DELIVERED"; + const canDistance = status === "IN_TRANSIT"; + // Truck arrival/leaving are independent — each driven only by its own + // warehouse state: arrive once assigned & not arrived, leave once + // arrived & not departed. const canArrive = assigned && !releaseRow?.releaseOrderReference; const canLeave = Boolean(releaseRow?.releaseOrderReference) && !releaseRow?.releaseDate; return (