From e0010695be5f81c894ea1f471fb2d8131c050cdc Mon Sep 17 00:00:00 2001
From: natib21
Date: Thu, 2 Jul 2026 09:41:52 +0000
Subject: [PATCH 01/86] fix
---
.../src/pages/operations/FirstMilePage.tsx | 73 ++++++++++---------
.../src/pages/operations/LastMilePage.tsx | 65 +++++++++--------
2 files changed, 72 insertions(+), 66 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 35618fa3b..01e5ded57 100644
--- a/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx
+++ b/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx
@@ -122,42 +122,45 @@ const InfoRow = ({ label, value }: { label: string; value: string }) => (
);
-const BookingInfo = ({ record }: { record: FirstMileRecord }) => (
-
-
-
- {bookingRef(record)}
-
-
- {STATUS_META[record.status].label}
-
-
- {isAssigned(record) ? "Assigned" : "Unassigned"}
-
+const BookingInfo = ({ record }: { record: FirstMileRecord }) => {
+ const hasPickupAddress = record.booking?.firstMilePickupAddress != null;
+ return (
+
+
+
+ {bookingRef(record)}
+
+
+ {STATUS_META[record.status].label}
+
+
+ {isAssigned(record) ? "Assigned" : "Unassigned"}
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-);
+
+
+
+ {hasPickupAddress && }
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
const tripSlipRows = (record: FirstMileRecord): [string, string][] => [
["Customer", customerName(record)],
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 70d9e9105..e85fbfd2d 100644
--- a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx
+++ b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx
@@ -117,38 +117,41 @@ const InfoRow = ({ label, value }: { label: string; value: string }) => (
);
-const BookingInfo = ({ record }: { record: LastMileRecord }) => (
-
-
-
- {bookingRef(record)}
-
-
- {STATUS_META[record.status].label}
-
-
- {isAssigned(record) ? "Assigned" : "Unassigned"}
-
+const BookingInfo = ({ record }: { record: LastMileRecord }) => {
+ const hasDeliveryAddress = record.booking?.lastMileDeliveryAddress != null;
+ return (
+
+
+
+ {bookingRef(record)}
+
+
+ {STATUS_META[record.status].label}
+
+
+ {isAssigned(record) ? "Assigned" : "Unassigned"}
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-);
+
+
+
+
+ {hasDeliveryAddress && }
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
const tripSlipRows = (record: LastMileRecord): [string, string][] => [
["Customer", customerName(record)],
From 10e442deb1a8a0a627fa2e97c6c79cdecf2b067b Mon Sep 17 00:00:00 2001
From: yaschalew
Date: Thu, 2 Jul 2026 13:18:49 +0300
Subject: [PATCH 02/86] fix
---
.../src/modules/first-mile/first-mile.controller.ts | 6 ++++++
.../src/modules/first-mile/first-mile.service.ts | 11 ++++-------
2 files changed, 10 insertions(+), 7 deletions(-)
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 680bb5d09..d5f53ae0c 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
@@ -65,6 +65,12 @@ export class FirstMileController {
return this.firstMileService.findById(id);
}
+ @Get('acceptitem/:id')
+ @ApiOperation({ summary: 'Get a first-mile accep by ID' })
+ acceptItem(@Param('id', ParseUUIDPipe) id: string) {
+ return this.firstMileService.acceptBooking(id);
+ }
+
@Post('accept/:reference')
@TrainSchedulingManage()
@ApiOperation({ summary: 'Accept a paid booking and create a first-mile leg' })
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 ae0ada831..a7a4f9100 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
@@ -43,7 +43,7 @@ export class FirstMileService {
private readonly vehiclesService: VehiclesService,
private readonly driversService: DriversService,
private readonly smsClient: SmsClientService,
- ) {}
+ ) { }
/**
* Look up a booking by its human-readable reference and confirm it has been
@@ -95,7 +95,7 @@ export class FirstMileService {
if (booking.paymentStatus !== 'PAID') {
throw new BadRequestException(`Booking ${label} is not paid`);
}
-
+ console.log("-------------------", booking)
if (!this.bookingRequestsFirstMile(booking)) {
throw new BadRequestException(`Booking ${label} does not require a first mile`);
}
@@ -212,11 +212,8 @@ export class FirstMileService {
}): boolean {
// Export bookings always need a first mile (pickup → origin yard); the
// pickup address is captured at assignment time, not required upfront.
- return Boolean(
- booking.tradeDirection === 'EXPORT' ||
- booking.firstMilePickupAddress?.trim() ||
- booking.serviceType?.includesFirstMile,
- );
+ return Boolean(booking.firstMilePickupAddress?.trim() ||
+ booking.serviceType?.includesFirstMile);
}
async update(id: string, dto: UpdateFirstMileDto): Promise {
From 1c88bd8d686b4b97cd5d26d39f0aa3a7bcef3adb Mon Sep 17 00:00:00 2001
From: natib21
Date: Thu, 2 Jul 2026 10:27:01 +0000
Subject: [PATCH 03/86] fix
---
.../backoffice/src/pages/operations/FirstMilePage.tsx | 11 ++++++++---
1 file changed, 8 insertions(+), 3 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 01e5ded57..9f6e82d03 100644
--- a/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx
+++ b/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx
@@ -469,9 +469,14 @@ const FirstMilePage = () => {
const firstMileEligiblePaidBookings = useMemo(
() =>
paidBookings.filter(
- (booking) =>
- booking.tradeDirection === "EXPORT" &&
- !existingFirstMileBookingIds.has(booking.id),
+ (booking) => {
+ if (existingFirstMileBookingIds.has(booking.id)) return false;
+ return (
+ booking.tradeDirection === "EXPORT" ||
+ (booking.firstMilePickupAddress?.trim() ?? false) ||
+ booking.serviceType?.includesFirstMile
+ );
+ },
),
[existingFirstMileBookingIds, paidBookings],
);
From 4519a1d75d4b7dc09646cec9be45404429a7d863 Mon Sep 17 00:00:00 2001
From: natib21
Date: Thu, 2 Jul 2026 10:33:32 +0000
Subject: [PATCH 04/86] fix
---
.../src/modules/first-mile/first-mile.service.ts | 9 +++++----
.../backoffice/src/pages/operations/FirstMilePage.tsx | 10 +++++-----
2 files changed, 10 insertions(+), 9 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 a7a4f9100..bc8801067 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
@@ -210,10 +210,11 @@ export class FirstMileService {
firstMilePickupAddress?: string | null;
serviceType?: { includesFirstMile?: boolean | null } | null;
}): boolean {
- // Export bookings always need a first mile (pickup → origin yard); the
- // pickup address is captured at assignment time, not required upfront.
- return Boolean(booking.firstMilePickupAddress?.trim() ||
- booking.serviceType?.includesFirstMile);
+ return Boolean(
+ booking.tradeDirection === 'EXPORT' &&
+ (booking.firstMilePickupAddress?.trim() ||
+ booking.serviceType?.includesFirstMile),
+ );
}
async update(id: string, dto: UpdateFirstMileDto): Promise {
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 9f6e82d03..93e3e51b0 100644
--- a/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx
+++ b/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx
@@ -471,11 +471,11 @@ const FirstMilePage = () => {
paidBookings.filter(
(booking) => {
if (existingFirstMileBookingIds.has(booking.id)) return false;
- return (
- booking.tradeDirection === "EXPORT" ||
- (booking.firstMilePickupAddress?.trim() ?? false) ||
- booking.serviceType?.includesFirstMile
- );
+ if (booking.paymentStatus !== "PAID") return false;
+ if (booking.tradeDirection !== "EXPORT") return false;
+ const hasPickupAddress = booking.firstMilePickupAddress?.trim() ?? false;
+ const includesFirstMile = booking.serviceType?.includesFirstMile ?? false;
+ return hasPickupAddress || includesFirstMile;
},
),
[existingFirstMileBookingIds, paidBookings],
From f4f98575f118216219c4dfbcda4eaf0dbab052f9 Mon Sep 17 00:00:00 2001
From: natib21
Date: Thu, 2 Jul 2026 10:48:31 +0000
Subject: [PATCH 05/86] fix error
---
.../backoffice/src/pages/operations/LastMilePage.tsx | 11 ++++++++---
1 file changed, 8 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 e85fbfd2d..9d54326e1 100644
--- a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx
+++ b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx
@@ -354,6 +354,10 @@ const LastMilePage = () => {
});
const records = listData?.data ?? [];
+ const existingLastMileBookingIds = useMemo(
+ () => new Set(records.map((record) => record.bookingId)),
+ [records],
+ );
const vehicleOptions = useMemo(
() =>
@@ -414,15 +418,16 @@ const LastMilePage = () => {
const arrivalQueue = arrivalQueueData ?? [];
const filteredArrivalQueue = useMemo(() => {
+ let filtered = arrivalQueue.filter((item) => !existingLastMileBookingIds.has(item.bookingId));
const term = arrivalSearch.trim().toLowerCase();
- if (!term) return arrivalQueue;
- return arrivalQueue.filter((item) =>
+ if (!term) return filtered;
+ return filtered.filter((item) =>
[item.bookingReference, item.customer, item.cargo, item.warehouse, item.yard]
.join(" ")
.toLowerCase()
.includes(term),
);
- }, [arrivalQueue, arrivalSearch]);
+ }, [arrivalQueue, arrivalSearch, existingLastMileBookingIds]);
const acceptMutation = useMutation({
mutationFn: async ({ items, vehicleId }: { items: ArrivalQueueItem[]; vehicleId: string | null }) => {
From e59a8f980244fa15f90944ffb548657f11a7dd15 Mon Sep 17 00:00:00 2001
From: natib21
Date: Thu, 2 Jul 2026 10:54:43 +0000
Subject: [PATCH 06/86] fix delete api
---
.../src/pages/operations/FirstMilePage.tsx | 29 +++++++++++++++++++
.../src/pages/operations/LastMilePage.tsx | 29 +++++++++++++++++++
2 files changed, 58 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 93e3e51b0..198fe50a3 100644
--- a/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx
+++ b/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx
@@ -7,6 +7,7 @@ import {
Printer,
RefreshCw,
Ruler,
+ Trash,
Truck,
} from "lucide-react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
@@ -420,6 +421,17 @@ const FirstMilePage = () => {
},
});
+ const deleteMutation = useMutation({
+ mutationFn: (id: string) => firstMileService.remove(id),
+ onSuccess: () => {
+ void qc.invalidateQueries({ queryKey: QUERY_KEYS.FIRST_MILE.list() });
+ toast({ title: "Record deleted", description: "First-mile record removed successfully." });
+ },
+ onError: () => {
+ toast({ title: "Delete failed", variant: "destructive" });
+ },
+ });
+
const acceptMutation = useMutation({
mutationFn: async ({ reference, vehicleId }: { reference: string; vehicleId: string | null }) => {
const res = await firstMileService.accept(reference);
@@ -837,6 +849,7 @@ const FirstMilePage = () => {
const assigned = isAssigned(row.original);
const nextStatus = NEXT_STATUS[row.original.status];
const canPrint = row.original.status !== "PAYMENT_PENDING";
+ const isPaid = (row.original as any).paid;
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 9d54326e1..eb00c0904 100644
--- a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx
+++ b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx
@@ -6,6 +6,7 @@ import {
Printer,
RefreshCw,
Ruler,
+ Trash,
Truck,
} from "lucide-react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
@@ -397,6 +398,17 @@ const LastMilePage = () => {
},
});
+ const deleteMutation = useMutation({
+ mutationFn: (id: string) => lastMileService.remove(id),
+ onSuccess: () => {
+ void qc.invalidateQueries({ queryKey: QUERY_KEYS.LAST_MILE.list() });
+ toast({ title: "Record deleted", description: "Last-mile record removed successfully." });
+ },
+ onError: () => {
+ toast({ title: "Delete failed", variant: "destructive" });
+ },
+ });
+
const allocateMutation = useMutation({
mutationFn: (data: Array<{ containerId: string; vehicleId: string }>) =>
api.post(`/last-mile/${activeId}/allocate-containers`, data),
@@ -817,6 +829,7 @@ const LastMilePage = () => {
const assigned = isAssigned(row.original);
const nextStatus = NEXT_STATUS[row.original.status];
const canPrint = row.original.status !== "PAYMENT_PENDING";
+ const isPaid = (row.original as any).paid;
return (
From bab329e8e5f14bf20ce6771169f4be4708ea26b1 Mon Sep 17 00:00:00 2001
From: natib21
Date: Thu, 2 Jul 2026 10:57:35 +0000
Subject: [PATCH 07/86] fix
---
.../backoffice/src/services/first-mile.service.ts | 4 +++-
.../backoffice/src/services/last-mile.service.ts | 4 +++-
2 files changed, 6 insertions(+), 2 deletions(-)
diff --git a/apps/edr-freight-web/backoffice/src/services/first-mile.service.ts b/apps/edr-freight-web/backoffice/src/services/first-mile.service.ts
index b182a16d0..1c81f5dc5 100644
--- a/apps/edr-freight-web/backoffice/src/services/first-mile.service.ts
+++ b/apps/edr-freight-web/backoffice/src/services/first-mile.service.ts
@@ -60,8 +60,10 @@ export const firstMileService = {
list: (pageSize = 1000) =>
api.get(`${FM.BASE}?pageSize=${pageSize}`),
getById: (id: string) => api.get(FM.BY_ID(id)),
- update: (id: string, data: { status?: FirstMileApiStatus; vehicleId?: string | null; estimatedKm?: number | null; exactKm?: number | null }) =>
+ update: (id: string, data: { status?: FirstMileApiStatus; vehicleId?: string | null; estimatedKm?: number | null; exactKm?: number | null; paid?: boolean }) =>
api.patch(FM.BY_ID(id), data),
accept: (bookingReference: string) =>
api.post(FM.ACCEPT(bookingReference)),
+ remove: (id: string) =>
+ api.delete(FM.BY_ID(id)),
};
diff --git a/apps/edr-freight-web/backoffice/src/services/last-mile.service.ts b/apps/edr-freight-web/backoffice/src/services/last-mile.service.ts
index cb9e61b52..158056e46 100644
--- a/apps/edr-freight-web/backoffice/src/services/last-mile.service.ts
+++ b/apps/edr-freight-web/backoffice/src/services/last-mile.service.ts
@@ -60,8 +60,10 @@ export const lastMileService = {
list: (pageSize = 1000) =>
api.get(`${LM.BASE}?pageSize=${pageSize}`),
getById: (id: string) => api.get(LM.BY_ID(id)),
- update: (id: string, data: { status?: LastMileApiStatus; vehicleId?: string | null; estimatedKm?: number | null; exactKm?: number | null }) =>
+ update: (id: string, data: { status?: LastMileApiStatus; vehicleId?: string | null; estimatedKm?: number | null; exactKm?: number | null; paid?: boolean }) =>
api.patch(LM.BY_ID(id), data),
accept: (bookingReference: string) =>
api.post(LM.ACCEPT(encodeURIComponent(bookingReference))),
+ remove: (id: string) =>
+ api.delete(LM.BY_ID(id)),
};
From ff7e20d2ee3a1cf25c038ae60d5d829104fbae69 Mon Sep 17 00:00:00 2001
From: natib21
Date: Thu, 2 Jul 2026 11:22:35 +0000
Subject: [PATCH 08/86] fix
---
.../1870000000000-AddLocationToVehicles.ts | 22 +++++++++++++++++++
.../vehicles/entities/vehicle.entity.ts | 3 +++
.../src/pages/fleet/config/vehicles.ts | 2 ++
.../src/services/vehicles.service.ts | 3 ++-
4 files changed, 29 insertions(+), 1 deletion(-)
create mode 100644 apps/edr-freight-api/src/migrations/1870000000000-AddLocationToVehicles.ts
diff --git a/apps/edr-freight-api/src/migrations/1870000000000-AddLocationToVehicles.ts b/apps/edr-freight-api/src/migrations/1870000000000-AddLocationToVehicles.ts
new file mode 100644
index 000000000..3434631a2
--- /dev/null
+++ b/apps/edr-freight-api/src/migrations/1870000000000-AddLocationToVehicles.ts
@@ -0,0 +1,22 @@
+import { MigrationInterface, QueryRunner } from "typeorm";
+
+/**
+ * Add location_id column to vehicles table to track vehicle base location.
+ */
+export class AddLocationToVehicles1870000000000 implements MigrationInterface {
+ name = "AddLocationToVehicles1870000000000";
+
+ public async up(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(`
+ ALTER TABLE freight.vehicles
+ ADD COLUMN IF NOT EXISTS location_id uuid;
+ `);
+ }
+
+ public async down(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(`
+ ALTER TABLE freight.vehicles
+ DROP COLUMN IF EXISTS location_id;
+ `);
+ }
+}
diff --git a/apps/edr-freight-api/src/modules/vehicles/entities/vehicle.entity.ts b/apps/edr-freight-api/src/modules/vehicles/entities/vehicle.entity.ts
index fef078ef8..1e4121802 100644
--- a/apps/edr-freight-api/src/modules/vehicles/entities/vehicle.entity.ts
+++ b/apps/edr-freight-api/src/modules/vehicles/entities/vehicle.entity.ts
@@ -68,4 +68,7 @@ export class Vehicle extends BaseEntity {
@Column({ name: 'actual_distance_km', type: 'numeric', nullable: true })
actualDistanceKm?: number;
+
+ @Column({ name: 'location_id', type: 'uuid', nullable: true })
+ locationId?: string;
}
diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/config/vehicles.ts b/apps/edr-freight-web/backoffice/src/pages/fleet/config/vehicles.ts
index 92da9b9dc..648c0579d 100644
--- a/apps/edr-freight-web/backoffice/src/pages/fleet/config/vehicles.ts
+++ b/apps/edr-freight-web/backoffice/src/pages/fleet/config/vehicles.ts
@@ -74,6 +74,7 @@ export const vehiclesConfig: FleetResourceConfig = {
{ name: "year", label: "Year", type: "number", required: true },
{ name: "fuelType", label: "Fuel Type", type: "select", required: true, options: FUEL_TYPE_OPTIONS },
{ name: "capacity", label: "Capacity", type: "number", required: true },
+ { name: "locationId", label: "Location", type: "select", dataSource: "yards" },
{ name: "estimatedDistanceKm", label: "Estimated Distance (KM)", type: "number" },
{ name: "actualDistanceKm", label: "Actual Distance (KM)", type: "number" },
{ name: "status", label: "Status", type: "select", required: true, options: VEHICLE_STATUS_OPTIONS },
@@ -90,6 +91,7 @@ export const vehiclesConfig: FleetResourceConfig = {
year: new Date().getFullYear(),
fuelType: "DIESEL",
capacity: 0,
+ locationId: null,
estimatedDistanceKm: "",
actualDistanceKm: "",
status: "ACTIVE",
diff --git a/apps/edr-freight-web/backoffice/src/services/vehicles.service.ts b/apps/edr-freight-web/backoffice/src/services/vehicles.service.ts
index b9d5129e3..3cb2ad358 100644
--- a/apps/edr-freight-web/backoffice/src/services/vehicles.service.ts
+++ b/apps/edr-freight-web/backoffice/src/services/vehicles.service.ts
@@ -29,6 +29,7 @@ export interface Vehicle {
code?: string | null;
powerPlateNo?: string | null;
trailerPlateNo?: string | null;
+ locationId?: string | null;
createdAt: string;
updatedAt: string;
}
@@ -55,7 +56,7 @@ export const vehiclesService = {
getById: (id: string) => apiClient.get(URL_CONSTANTS.VEHICLES.BY_ID(id)),
create: (data: Partial) =>
apiClient.post(URL_CONSTANTS.VEHICLES.BASE, data),
- update: (id: string, data: Partial) =>
+ update: (id: string, data: Partial) =>
apiClient.patch(URL_CONSTANTS.VEHICLES.BY_ID(id), data),
delete: (id: string) => apiClient.delete(URL_CONSTANTS.VEHICLES.BY_ID(id)),
};
From adde5962895699d5ef23e986adb83873c88a04b7 Mon Sep 17 00:00:00 2001
From: natib21
Date: Thu, 2 Jul 2026 11:32:16 +0000
Subject: [PATCH 09/86] fix
---
.../backoffice/src/pages/fleet/config/vehicles.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/config/vehicles.ts b/apps/edr-freight-web/backoffice/src/pages/fleet/config/vehicles.ts
index 648c0579d..6ac04e7aa 100644
--- a/apps/edr-freight-web/backoffice/src/pages/fleet/config/vehicles.ts
+++ b/apps/edr-freight-web/backoffice/src/pages/fleet/config/vehicles.ts
@@ -74,7 +74,7 @@ export const vehiclesConfig: FleetResourceConfig = {
{ name: "year", label: "Year", type: "number", required: true },
{ name: "fuelType", label: "Fuel Type", type: "select", required: true, options: FUEL_TYPE_OPTIONS },
{ name: "capacity", label: "Capacity", type: "number", required: true },
- { name: "locationId", label: "Location", type: "select", dataSource: "yards" },
+ { name: "locationId", label: "Location", type: "select", dynamicOptions: "yards" },
{ name: "estimatedDistanceKm", label: "Estimated Distance (KM)", type: "number" },
{ name: "actualDistanceKm", label: "Actual Distance (KM)", type: "number" },
{ name: "status", label: "Status", type: "select", required: true, options: VEHICLE_STATUS_OPTIONS },
From 787da1ccc08d009fba9fa79bbf7736d161c7e6f6 Mon Sep 17 00:00:00 2001
From: Nathnael
Date: Thu, 2 Jul 2026 12:35:43 +0000
Subject: [PATCH 10/86] chore: fix the invoice event
---
.../src/modules/billing/billing.service.ts | 8 +++++++-
.../src/modules/bookings/booking-invoice.service.ts | 9 ++++++++-
2 files changed, 15 insertions(+), 2 deletions(-)
diff --git a/apps/edr-freight-api/src/modules/billing/billing.service.ts b/apps/edr-freight-api/src/modules/billing/billing.service.ts
index 278d4cca9..b68db5974 100644
--- a/apps/edr-freight-api/src/modules/billing/billing.service.ts
+++ b/apps/edr-freight-api/src/modules/billing/billing.service.ts
@@ -691,7 +691,13 @@ export class BillingService {
status: invoice.status,
paymentId: invoice.paymentId ?? null,
};
- this.events.emit(`${invoice.source}.invoice.${event}`, payload);
+ this.events
+ .emitAsync(`${invoice.source}.invoice.${event}`, payload)
+ .catch((err) =>
+ this.logger.error(
+ `Listener for ${invoice.source}.invoice.${event} (invoice ${invoice.id}) failed: ${err instanceof Error ? err.message : String(err)}`,
+ ),
+ );
}
// ── Payment reconciliation (by source) ───────────────────────────────────────
diff --git a/apps/edr-freight-api/src/modules/bookings/booking-invoice.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-invoice.service.ts
index 3bfb838b4..30f41813b 100644
--- a/apps/edr-freight-api/src/modules/bookings/booking-invoice.service.ts
+++ b/apps/edr-freight-api/src/modules/bookings/booking-invoice.service.ts
@@ -143,9 +143,16 @@ export class BookingInvoiceService {
{ id: bookingId },
{ paymentStatus: "PAID", status: "PAID" },
);
- await this.firstMile.acceptBooking(bookingId);
});
+ try {
+ await this.firstMile.acceptBooking(bookingId);
+ } catch (err) {
+ this.logger.error(
+ `Error accepting first-mile after payment: ${err instanceof Error ? err.message : String(err)}`,
+ );
+ }
+
try {
await this.bookingBatch.ensurePaidBookingAllocated(bookingId);
} catch (err) {
From a0f4de05f859113cb26699b7d4ca7fb8d06afaa0 Mon Sep 17 00:00:00 2001
From: natib21
Date: Thu, 2 Jul 2026 12:37:02 +0000
Subject: [PATCH 11/86] log full error
---
.../src/modules/vehicles/dto/create-vehicle.dto.ts | 4 ++++
1 file changed, 4 insertions(+)
diff --git a/apps/edr-freight-api/src/modules/vehicles/dto/create-vehicle.dto.ts b/apps/edr-freight-api/src/modules/vehicles/dto/create-vehicle.dto.ts
index b5f9abcb8..5651a3906 100644
--- a/apps/edr-freight-api/src/modules/vehicles/dto/create-vehicle.dto.ts
+++ b/apps/edr-freight-api/src/modules/vehicles/dto/create-vehicle.dto.ts
@@ -57,4 +57,8 @@ export class CreateVehicleDto {
@IsOptional()
@IsNumber()
actualDistanceKm?: number;
+
+ @IsOptional()
+ @IsUUID()
+ locationId?: string;
}
From 4098b5476fd39652c479fbfb569662ae2415fb0e Mon Sep 17 00:00:00 2001
From: ghost2023
Date: Thu, 2 Jul 2026 15:37:49 +0300
Subject: [PATCH 12/86] fix(temp): invoice no for the telebirr
---
.../edr-freight-api/src/modules/billing/billing.service.ts | 7 +++++--
.../src/modules/bookings/booking-invoice.service.ts | 5 +++--
2 files changed, 8 insertions(+), 4 deletions(-)
diff --git a/apps/edr-freight-api/src/modules/billing/billing.service.ts b/apps/edr-freight-api/src/modules/billing/billing.service.ts
index b68db5974..baf3fa3f3 100644
--- a/apps/edr-freight-api/src/modules/billing/billing.service.ts
+++ b/apps/edr-freight-api/src/modules/billing/billing.service.ts
@@ -829,7 +829,10 @@ export class BillingService {
): Promise {
const mg = manager ?? this.dataSource.manager;
const invoice = await mg.findOne(Invoice, {
- where: { id: invoiceId, status: In([Freight.InvoiceStatus.Draft, ...OPEN_STATUSES]) },
+ where: {
+ id: invoiceId,
+ status: In([Freight.InvoiceStatus.Draft, ...OPEN_STATUSES]),
+ },
});
if (!invoice) return;
await mg.update(
@@ -882,7 +885,7 @@ export class BillingService {
// in the domain via `${source}.invoice.paid`. Neither billing nor the payment
// service branches on a domain-specific reference type.
referenceType: PaymentReferenceType.SHIPMENT,
- orderRef: invoice.invoiceNumber,
+ orderRef: invoice.invoiceNumber.replace("-", "_"),
amountMinor: Math.round(Number(invoice.balanceAmount)),
currency: invoice.currency,
reason: `Payment for invoice ${invoice.invoiceNumber}`,
diff --git a/apps/edr-freight-api/src/modules/bookings/booking-invoice.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-invoice.service.ts
index 30f41813b..1b8eddeca 100644
--- a/apps/edr-freight-api/src/modules/bookings/booking-invoice.service.ts
+++ b/apps/edr-freight-api/src/modules/bookings/booking-invoice.service.ts
@@ -5,7 +5,6 @@ import {
Injectable,
Logger,
} from "@nestjs/common";
-import { OnEvent } from "@nestjs/event-emitter";
import { Freight } from "@edr/types";
import { DataSource, EntityManager } from "typeorm";
@@ -95,8 +94,10 @@ export class BookingInvoiceService {
* reactions live here (not in the payment process): each invoice type advances
* the booking its own way. Only PREPAID exists today.
*/
- @OnEvent("booking.invoice.paid")
async onBookingInvoicePaid(payload: InvoiceEventPayload): Promise {
+ this.logger.log(
+ `onBookingInvoicePaid trigger for ${payload.sourceId} from ${payload.invoiceId}`,
+ );
switch (payload.type) {
case "PREPAID":
await this.advanceBookingOnPayment(payload.sourceId);
From 657f3bd2ab962b1b0ffb8210e641dcf474889fe6 Mon Sep 17 00:00:00 2001
From: natib21
Date: Thu, 2 Jul 2026 12:39:47 +0000
Subject: [PATCH 13/86] fix
---
.../src/pages/fleet/config/vehicles.ts | 17 +++++------------
1 file changed, 5 insertions(+), 12 deletions(-)
diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/config/vehicles.ts b/apps/edr-freight-web/backoffice/src/pages/fleet/config/vehicles.ts
index 6ac04e7aa..6f65224bc 100644
--- a/apps/edr-freight-web/backoffice/src/pages/fleet/config/vehicles.ts
+++ b/apps/edr-freight-web/backoffice/src/pages/fleet/config/vehicles.ts
@@ -49,18 +49,11 @@ export const vehiclesConfig: FleetResourceConfig = {
columns: [
{ id: "code", header: "Code", accessorKey: "code", format: "code", size: 110 },
{ id: "plateNumber", header: "Plate Number", accessorKey: "plateNumber", format: "code", size: 130 },
- { id: "powerPlateNo", header: "Power Plate No", accessorKey: "powerPlateNo", format: "code", size: 140 },
- { id: "trailerPlateNo", header: "Trailer Plate No", accessorKey: "trailerPlateNo", format: "code", size: 140 },
- { id: "registrationNumber", header: "Registration", accessorKey: "registrationNumber", format: "code", size: 140 },
- { id: "manufacturer", header: "Manufacturer", accessorKey: "manufacturer", format: "code", size: 140 },
- { id: "model", header: "Model", accessorKey: "model", format: "code", size: 120 },
- { id: "vehicleType", header: "Type", accessorKey: "vehicleType", format: "code", size: 100 },
- { id: "year", header: "Year", accessorKey: "year", format: "number", size: 80 },
- { id: "fuelType", header: "Fuel Type", accessorKey: "fuelType", format: "code", size: 110 },
- { id: "capacity", header: "Capacity (tons)", accessorKey: "capacity", format: "number", size: 130 },
- { id: "assignedDriverName", header: "Assigned Driver", accessorKey: "assignedDriverName", format: "code", size: 140 },
- { id: "estimatedDistanceKm", header: "Est. Distance (KM)", accessorKey: "estimatedDistanceKm", format: "number", size: 150 },
- { id: "actualDistanceKm", header: "Actual Distance (KM)", accessorKey: "actualDistanceKm", format: "number", size: 150 },
+ { id: "manufacturer", header: "Manufacturer", accessorKey: "manufacturer", format: "code", size: 120 },
+ { id: "model", header: "Model", accessorKey: "model", format: "code", size: 100 },
+ { id: "vehicleType", header: "Type", accessorKey: "vehicleType", format: "code", size: 80 },
+ { id: "capacity", header: "Capacity (tons)", accessorKey: "capacity", format: "number", size: 100 },
+ { id: "locationId", header: "Location", accessorKey: "locationId", format: "code", size: 130 },
{ id: "status", header: "Status", accessorKey: "status", format: "statusBadge", size: 100 },
],
formFields: [
From 58b3805d0bd4d60eb3f6e362adf2d92910a354eb Mon Sep 17 00:00:00 2001
From: natib21
Date: Thu, 2 Jul 2026 12:43:03 +0000
Subject: [PATCH 14/86] fix
---
.../1880000000000-AddVehicleStatuses.ts | 22 +++++++++++++++++++
.../vehicles/entities/vehicle.entity.ts | 2 ++
.../src/components/fleet/fleetFormat.tsx | 2 ++
.../src/pages/fleet/FleetResourcePage.tsx | 1 +
.../src/pages/fleet/config/vehicles.ts | 14 +++++++-----
.../src/services/vehicles.service.ts | 2 +-
6 files changed, 36 insertions(+), 7 deletions(-)
create mode 100644 apps/edr-freight-api/src/migrations/1880000000000-AddVehicleStatuses.ts
diff --git a/apps/edr-freight-api/src/migrations/1880000000000-AddVehicleStatuses.ts b/apps/edr-freight-api/src/migrations/1880000000000-AddVehicleStatuses.ts
new file mode 100644
index 000000000..29da135c3
--- /dev/null
+++ b/apps/edr-freight-api/src/migrations/1880000000000-AddVehicleStatuses.ts
@@ -0,0 +1,22 @@
+import { MigrationInterface, QueryRunner } from "typeorm";
+
+/**
+ * Add FREE and BUSY statuses to vehicle status enum.
+ */
+export class AddVehicleStatuses1880000000000 implements MigrationInterface {
+ name = "AddVehicleStatuses1880000000000";
+
+ public async up(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(`
+ ALTER TYPE freight.vehicles_status_enum ADD VALUE IF NOT EXISTS 'FREE' BEFORE 'MAINTENANCE';
+ `);
+ await queryRunner.query(`
+ ALTER TYPE freight.vehicles_status_enum ADD VALUE IF NOT EXISTS 'BUSY' AFTER 'FREE';
+ `);
+ }
+
+ public async down(queryRunner: QueryRunner): Promise {
+ // Note: Postgres cannot drop individual enum values, so the down migration is a no-op
+ // The enum values FREE and BUSY will remain but will be unused after downgrade
+ }
+}
diff --git a/apps/edr-freight-api/src/modules/vehicles/entities/vehicle.entity.ts b/apps/edr-freight-api/src/modules/vehicles/entities/vehicle.entity.ts
index 1e4121802..a94210e65 100644
--- a/apps/edr-freight-api/src/modules/vehicles/entities/vehicle.entity.ts
+++ b/apps/edr-freight-api/src/modules/vehicles/entities/vehicle.entity.ts
@@ -20,6 +20,8 @@ export enum FuelType {
export enum VehicleStatus {
ACTIVE = 'ACTIVE',
+ FREE = 'FREE',
+ BUSY = 'BUSY',
MAINTENANCE = 'MAINTENANCE',
RETIRED = 'RETIRED',
OUT_OF_SERVICE = 'OUT_OF_SERVICE',
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 5c997af9f..709f288ce 100644
--- a/apps/edr-freight-web/backoffice/src/components/fleet/fleetFormat.tsx
+++ b/apps/edr-freight-web/backoffice/src/components/fleet/fleetFormat.tsx
@@ -25,6 +25,8 @@ export const formatFleetCell = (
const getStatusColor = (st: string): string => {
const s = st.toUpperCase();
if (s === "ACTIVE" || s === "AVAILABLE") return "green";
+ if (s === "FREE") return "teal";
+ if (s === "BUSY") return "blue";
if (s === "INACTIVE") return "gray";
if (s === "SUSPENDED" || s === "OUT_OF_SERVICE") return "red";
if (s === "MAINTENANCE" || s === "ON_LEAVE") return "orange";
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 615e706f5..5310a6f12 100644
--- a/apps/edr-freight-web/backoffice/src/pages/fleet/FleetResourcePage.tsx
+++ b/apps/edr-freight-web/backoffice/src/pages/fleet/FleetResourcePage.tsx
@@ -189,6 +189,7 @@ const FleetResourcePage = () => {
registerFleetOptionLabels("wagonId", dynamicOptions.wagons);
registerFleetOptionLabels("containerId", dynamicOptions.containers);
registerFleetOptionLabels("currentYardId", dynamicOptions.yards);
+ registerFleetOptionLabels("locationId", dynamicOptions.yards);
}, [dynamicOptions]);
const formFields = useMemo((): FleetFormFieldDef[] => {
diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/config/vehicles.ts b/apps/edr-freight-web/backoffice/src/pages/fleet/config/vehicles.ts
index 6f65224bc..3643e390f 100644
--- a/apps/edr-freight-web/backoffice/src/pages/fleet/config/vehicles.ts
+++ b/apps/edr-freight-web/backoffice/src/pages/fleet/config/vehicles.ts
@@ -19,6 +19,8 @@ const FUEL_TYPE_OPTIONS = [
const VEHICLE_STATUS_OPTIONS = [
{ label: "Active", value: "ACTIVE" },
+ { label: "Free", value: "FREE" },
+ { label: "Busy", value: "BUSY" },
{ label: "Maintenance", value: "MAINTENANCE" },
{ label: "Retired", value: "RETIRED" },
{ label: "Out of service", value: "OUT_OF_SERVICE" },
@@ -47,13 +49,13 @@ export const vehiclesConfig: FleetResourceConfig = {
],
searchKeys: ["plateNumber", "registrationNumber", "manufacturer", "model", "vehicleType", "status"],
columns: [
- { id: "code", header: "Code", accessorKey: "code", format: "code", size: 110 },
- { id: "plateNumber", header: "Plate Number", accessorKey: "plateNumber", format: "code", size: 130 },
- { id: "manufacturer", header: "Manufacturer", accessorKey: "manufacturer", format: "code", size: 120 },
- { id: "model", header: "Model", accessorKey: "model", format: "code", size: 100 },
- { id: "vehicleType", header: "Type", accessorKey: "vehicleType", format: "code", size: 80 },
+ { id: "code", header: "Code", accessorKey: "code", size: 90 },
+ { id: "plateNumber", header: "Plate Number", accessorKey: "plateNumber", size: 120 },
+ { id: "manufacturer", header: "Manufacturer", accessorKey: "manufacturer", size: 120 },
+ { id: "model", header: "Model", accessorKey: "model", size: 100 },
+ { id: "vehicleType", header: "Type", accessorKey: "vehicleType", size: 75 },
{ id: "capacity", header: "Capacity (tons)", accessorKey: "capacity", format: "number", size: 100 },
- { id: "locationId", header: "Location", accessorKey: "locationId", format: "code", size: 130 },
+ { id: "locationId", header: "Location", accessorKey: "locationId", size: 140 },
{ id: "status", header: "Status", accessorKey: "status", format: "statusBadge", size: 100 },
],
formFields: [
diff --git a/apps/edr-freight-web/backoffice/src/services/vehicles.service.ts b/apps/edr-freight-web/backoffice/src/services/vehicles.service.ts
index 3cb2ad358..d5e9693d0 100644
--- a/apps/edr-freight-web/backoffice/src/services/vehicles.service.ts
+++ b/apps/edr-freight-web/backoffice/src/services/vehicles.service.ts
@@ -3,7 +3,7 @@ import { URL_CONSTANTS } from '@/constants/URLS';
export type VehicleType = 'TRUCK' | 'VAN' | 'CAR' | 'BUS' | 'TRAILER' | 'TANKER' | 'FLATBED';
export type FuelType = 'PETROL' | 'DIESEL' | 'ELECTRIC' | 'HYBRID';
-export type VehicleStatus = 'ACTIVE' | 'MAINTENANCE' | 'RETIRED' | 'OUT_OF_SERVICE';
+export type VehicleStatus = 'ACTIVE' | 'FREE' | 'BUSY' | 'MAINTENANCE' | 'RETIRED' | 'OUT_OF_SERVICE';
export interface VehicleListFilters {
status?: VehicleStatus;
From 43a32f192fe4cb9c70d53066246637747866a313 Mon Sep 17 00:00:00 2001
From: natib21
Date: Thu, 2 Jul 2026 12:47:10 +0000
Subject: [PATCH 15/86] fix
---
.../backoffice/src/pages/fleet/config/vehicles.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/config/vehicles.ts b/apps/edr-freight-web/backoffice/src/pages/fleet/config/vehicles.ts
index 3643e390f..1f1ae1412 100644
--- a/apps/edr-freight-web/backoffice/src/pages/fleet/config/vehicles.ts
+++ b/apps/edr-freight-web/backoffice/src/pages/fleet/config/vehicles.ts
@@ -49,8 +49,8 @@ export const vehiclesConfig: FleetResourceConfig = {
],
searchKeys: ["plateNumber", "registrationNumber", "manufacturer", "model", "vehicleType", "status"],
columns: [
- { id: "code", header: "Code", accessorKey: "code", size: 90 },
{ id: "plateNumber", header: "Plate Number", accessorKey: "plateNumber", size: 120 },
+ { id: "trailerPlateNo", header: "Trailer Plate No", accessorKey: "trailerPlateNo", size: 130 },
{ id: "manufacturer", header: "Manufacturer", accessorKey: "manufacturer", size: 120 },
{ id: "model", header: "Model", accessorKey: "model", size: 100 },
{ id: "vehicleType", header: "Type", accessorKey: "vehicleType", size: 75 },
From c8b7d76073526ce1b65048ed8652b3100fd49b89 Mon Sep 17 00:00:00 2001
From: natib21
Date: Thu, 2 Jul 2026 12:50:49 +0000
Subject: [PATCH 16/86] fix
---
.../backoffice/src/pages/fleet/config/vehicles.ts | 1 +
1 file changed, 1 insertion(+)
diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/config/vehicles.ts b/apps/edr-freight-web/backoffice/src/pages/fleet/config/vehicles.ts
index 1f1ae1412..c6923d9fa 100644
--- a/apps/edr-freight-web/backoffice/src/pages/fleet/config/vehicles.ts
+++ b/apps/edr-freight-web/backoffice/src/pages/fleet/config/vehicles.ts
@@ -49,6 +49,7 @@ export const vehiclesConfig: FleetResourceConfig = {
],
searchKeys: ["plateNumber", "registrationNumber", "manufacturer", "model", "vehicleType", "status"],
columns: [
+ { id: "code", header: "Code", accessorKey: "code", size: 90 },
{ id: "plateNumber", header: "Plate Number", accessorKey: "plateNumber", size: 120 },
{ id: "trailerPlateNo", header: "Trailer Plate No", accessorKey: "trailerPlateNo", size: 130 },
{ id: "manufacturer", header: "Manufacturer", accessorKey: "manufacturer", size: 120 },
From 64e346324b81c0380061c73d004d27cbe393e0cd Mon Sep 17 00:00:00 2001
From: natib21
Date: Thu, 2 Jul 2026 13:11:12 +0000
Subject: [PATCH 17/86] fix issue
---
.../src/migrations/1880000000000-AddVehicleStatuses.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/apps/edr-freight-api/src/migrations/1880000000000-AddVehicleStatuses.ts b/apps/edr-freight-api/src/migrations/1880000000000-AddVehicleStatuses.ts
index 29da135c3..ce9af151b 100644
--- a/apps/edr-freight-api/src/migrations/1880000000000-AddVehicleStatuses.ts
+++ b/apps/edr-freight-api/src/migrations/1880000000000-AddVehicleStatuses.ts
@@ -15,7 +15,7 @@ export class AddVehicleStatuses1880000000000 implements MigrationInterface {
`);
}
- public async down(queryRunner: QueryRunner): Promise {
+ public async down(_queryRunner: QueryRunner): Promise {
// Note: Postgres cannot drop individual enum values, so the down migration is a no-op
// The enum values FREE and BUSY will remain but will be unused after downgrade
}
From e429a2cebff69900473b6bd96f0e74e472fc59e7 Mon Sep 17 00:00:00 2001
From: natib21
Date: Thu, 2 Jul 2026 13:15:44 +0000
Subject: [PATCH 18/86] fix
---
...8427600000-AddServiceTypesAndCargoTypes.ts | 33 +++++++++++--------
.../1880000000000-AddVehicleStatuses.ts | 15 ++++++---
2 files changed, 30 insertions(+), 18 deletions(-)
diff --git a/apps/edr-freight-api/src/migrations/1748427600000-AddServiceTypesAndCargoTypes.ts b/apps/edr-freight-api/src/migrations/1748427600000-AddServiceTypesAndCargoTypes.ts
index 65052bad4..c3698aed6 100644
--- a/apps/edr-freight-api/src/migrations/1748427600000-AddServiceTypesAndCargoTypes.ts
+++ b/apps/edr-freight-api/src/migrations/1748427600000-AddServiceTypesAndCargoTypes.ts
@@ -93,20 +93,25 @@ export class AddServiceTypesAndCargoTypes1748427600000 implements MigrationInter
);
// Create indexes for service_types
- await queryRunner.createIndex(
- "freight.service_types",
- new TableIndex({
- name: "IDX_SERVICE_TYPES_IS_ACTIVE",
- columnNames: ["is_active"],
- }),
- );
- await queryRunner.createIndex(
- "freight.service_types",
- new TableIndex({
- name: "IDX_SERVICE_TYPES_DISPLAY_ORDER",
- columnNames: ["display_order"],
- }),
- );
+ const table = await queryRunner.getTable("freight.service_types");
+ if (table && !(await queryRunner.hasIndex("freight.service_types", "IDX_SERVICE_TYPES_IS_ACTIVE"))) {
+ await queryRunner.createIndex(
+ "freight.service_types",
+ new TableIndex({
+ name: "IDX_SERVICE_TYPES_IS_ACTIVE",
+ columnNames: ["is_active"],
+ }),
+ );
+ }
+ if (table && !(await queryRunner.hasIndex("freight.service_types", "IDX_SERVICE_TYPES_DISPLAY_ORDER"))) {
+ await queryRunner.createIndex(
+ "freight.service_types",
+ new TableIndex({
+ name: "IDX_SERVICE_TYPES_DISPLAY_ORDER",
+ columnNames: ["display_order"],
+ }),
+ );
+ }
// Create cargo_types table
if (!(await queryRunner.hasTable("freight.cargo_types"))) await queryRunner.createTable(
diff --git a/apps/edr-freight-api/src/migrations/1880000000000-AddVehicleStatuses.ts b/apps/edr-freight-api/src/migrations/1880000000000-AddVehicleStatuses.ts
index ce9af151b..484a2686b 100644
--- a/apps/edr-freight-api/src/migrations/1880000000000-AddVehicleStatuses.ts
+++ b/apps/edr-freight-api/src/migrations/1880000000000-AddVehicleStatuses.ts
@@ -7,11 +7,18 @@ export class AddVehicleStatuses1880000000000 implements MigrationInterface {
name = "AddVehicleStatuses1880000000000";
public async up(queryRunner: QueryRunner): Promise {
+ // Create enum type if it doesn't exist
await queryRunner.query(`
- ALTER TYPE freight.vehicles_status_enum ADD VALUE IF NOT EXISTS 'FREE' BEFORE 'MAINTENANCE';
- `);
- await queryRunner.query(`
- ALTER TYPE freight.vehicles_status_enum ADD VALUE IF NOT EXISTS 'BUSY' AFTER 'FREE';
+ DO $$
+ BEGIN
+ IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'vehicles_status_enum' AND typnamespace = (SELECT oid FROM pg_namespace WHERE nspname = 'freight')) THEN
+ CREATE TYPE freight.vehicles_status_enum AS ENUM ('ACTIVE', 'FREE', 'BUSY', 'MAINTENANCE', 'RETIRED', 'OUT_OF_SERVICE');
+ ELSE
+ -- Add values if enum already exists but doesn't have them
+ ALTER TYPE freight.vehicles_status_enum ADD VALUE IF NOT EXISTS 'FREE' BEFORE 'MAINTENANCE';
+ ALTER TYPE freight.vehicles_status_enum ADD VALUE IF NOT EXISTS 'BUSY' AFTER 'FREE';
+ END IF;
+ END $$;
`);
}
From 1869790eab76c84ff3ccfa3b660ca02c0e633b1c Mon Sep 17 00:00:00 2001
From: natib21
Date: Thu, 2 Jul 2026 13:18:34 +0000
Subject: [PATCH 19/86] fix
---
.../migrations/1748427600000-AddServiceTypesAndCargoTypes.ts | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/apps/edr-freight-api/src/migrations/1748427600000-AddServiceTypesAndCargoTypes.ts b/apps/edr-freight-api/src/migrations/1748427600000-AddServiceTypesAndCargoTypes.ts
index c3698aed6..08ce634eb 100644
--- a/apps/edr-freight-api/src/migrations/1748427600000-AddServiceTypesAndCargoTypes.ts
+++ b/apps/edr-freight-api/src/migrations/1748427600000-AddServiceTypesAndCargoTypes.ts
@@ -94,7 +94,7 @@ export class AddServiceTypesAndCargoTypes1748427600000 implements MigrationInter
// Create indexes for service_types
const table = await queryRunner.getTable("freight.service_types");
- if (table && !(await queryRunner.hasIndex("freight.service_types", "IDX_SERVICE_TYPES_IS_ACTIVE"))) {
+ if (table && !table.indices.some((idx) => idx.name === "IDX_SERVICE_TYPES_IS_ACTIVE")) {
await queryRunner.createIndex(
"freight.service_types",
new TableIndex({
@@ -103,7 +103,7 @@ export class AddServiceTypesAndCargoTypes1748427600000 implements MigrationInter
}),
);
}
- if (table && !(await queryRunner.hasIndex("freight.service_types", "IDX_SERVICE_TYPES_DISPLAY_ORDER"))) {
+ if (table && !table.indices.some((idx) => idx.name === "IDX_SERVICE_TYPES_DISPLAY_ORDER")) {
await queryRunner.createIndex(
"freight.service_types",
new TableIndex({
From 79c3293a72b2a97f4ea351fc866ed39ae4829d49 Mon Sep 17 00:00:00 2001
From: ghost2023
Date: Thu, 2 Jul 2026 16:18:46 +0300
Subject: [PATCH 20/86] fix: booking paid trigger
---
.../bookings/booking-invoice.service.ts | 2 +
.../src/modules/payment/payment.service.ts | 77 ++++++++++---------
2 files changed, 43 insertions(+), 36 deletions(-)
diff --git a/apps/edr-freight-api/src/modules/bookings/booking-invoice.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-invoice.service.ts
index 1b8eddeca..21fef08ea 100644
--- a/apps/edr-freight-api/src/modules/bookings/booking-invoice.service.ts
+++ b/apps/edr-freight-api/src/modules/bookings/booking-invoice.service.ts
@@ -5,6 +5,7 @@ import {
Injectable,
Logger,
} from "@nestjs/common";
+import { OnEvent } from "@nestjs/event-emitter";
import { Freight } from "@edr/types";
import { DataSource, EntityManager } from "typeorm";
@@ -94,6 +95,7 @@ export class BookingInvoiceService {
* reactions live here (not in the payment process): each invoice type advances
* the booking its own way. Only PREPAID exists today.
*/
+ @OnEvent("booking.invoice.paid")
async onBookingInvoicePaid(payload: InvoiceEventPayload): Promise {
this.logger.log(
`onBookingInvoicePaid trigger for ${payload.sourceId} from ${payload.invoiceId}`,
diff --git a/apps/edr-freight-api/src/modules/payment/payment.service.ts b/apps/edr-freight-api/src/modules/payment/payment.service.ts
index d92af7a3e..d773ebe1f 100644
--- a/apps/edr-freight-api/src/modules/payment/payment.service.ts
+++ b/apps/edr-freight-api/src/modules/payment/payment.service.ts
@@ -189,48 +189,53 @@ export class PaymentService {
* has stored the intent id, avoiding a settle-before-correlation race.
*/
async initiate(input: InitiateIntentInput): Promise {
- const snapshot = await this.paymentClient.initiate({
- service: PaymentServiceEnum.FREIGHT,
- referenceType: PaymentReferenceType.SHIPMENT,
- referenceId: input.referenceId,
- orderRef: input.orderRef,
- amountMinor: input.amountMinor,
- currency: input.currency,
- provider: input.method as ProviderMethod,
- platform: input.platform,
- payerAccount: input.payerAccount,
- returnUrl:
- input.returnUrl ?? "https://edrfreight.triaplc.com/payment/success",
- failureUrl:
- input.failureUrl ?? "https://edrfreight.triaplc.com/payment/failure",
- });
+ try {
+ const snapshot = await this.paymentClient.initiate({
+ service: PaymentServiceEnum.FREIGHT,
+ referenceType: PaymentReferenceType.SHIPMENT,
+ referenceId: input.referenceId,
+ orderRef: input.orderRef,
+ amountMinor: input.amountMinor,
+ currency: input.currency,
+ provider: input.method as ProviderMethod,
+ platform: input.platform,
+ payerAccount: input.payerAccount,
+ returnUrl:
+ input.returnUrl ?? "https://edrfreight.triaplc.com/payment/success",
+ failureUrl:
+ input.failureUrl ?? "https://edrfreight.triaplc.com/payment/failure",
+ });
- const immediateSuccess =
- snapshot.status === ProviderPaymentStatus.SUCCEEDED;
- const paidAt = snapshot.paidAt ? new Date(snapshot.paidAt) : undefined;
+ const immediateSuccess =
+ snapshot.status === ProviderPaymentStatus.SUCCEEDED;
+ const paidAt = snapshot.paidAt ? new Date(snapshot.paidAt) : undefined;
- const intent = await this.upsertIntent(input, snapshot);
+ const intent = await this.upsertIntent(input, snapshot);
- if (immediateSuccess) {
- // Settle the projection but DO NOT notify billing — billing settles
- // inline once it has stored intentId on the invoice (see payInvoice),
- // avoiding a settle-before-correlation race.
- await this.markIntentSucceeded(intent.id, {
+ if (immediateSuccess) {
+ // Settle the projection but DO NOT notify billing — billing settles
+ // inline once it has stored intentId on the invoice (see payInvoice),
+ // avoiding a settle-before-correlation race.
+ await this.markIntentSucceeded(intent.id, {
+ providerTxnId: snapshot.providerTxnId,
+ paidAt,
+ notify: false,
+ });
+ }
+
+ return {
+ intentId: intent.id,
+ // `intent` still reflects the projection status ("processing" on immediate
+ // success — settlement is applied by the caller, not shown synchronously).
+ response: this.formatIntentResponse(intent),
+ immediateSuccess,
providerTxnId: snapshot.providerTxnId,
paidAt,
- notify: false,
- });
+ };
+ } catch (err) {
+ console.log(err);
+ throw err;
}
-
- return {
- intentId: intent.id,
- // `intent` still reflects the projection status ("processing" on immediate
- // success — settlement is applied by the caller, not shown synchronously).
- response: this.formatIntentResponse(intent),
- immediateSuccess,
- providerTxnId: snapshot.providerTxnId,
- paidAt,
- };
}
/** Create or update the local intent projection from a provider snapshot. */
From fe40d9f4be5b25ea1658f90f9f753f9b8e06959c Mon Sep 17 00:00:00 2001
From: Marshal
Date: Thu, 2 Jul 2026 13:24:39 +0000
Subject: [PATCH 21/86] update import gl flow
---
.../bookings/booking-invoice.service.ts | 2 +-
.../bookings/entities/booking.entity.ts | 2 +-
.../booking-clearance.service.spec.ts | 11 ++
.../contracts/booking-clearance.service.ts | 39 ++++--
.../contracts/contract-clearance.service.ts | 42 +++++--
.../modules/contracts/contracts.controller.ts | 27 ++++
.../contracts/gl-operations.service.ts | 118 +++++++++++++++++-
.../contracts/phased-clearance.util.ts | 68 +++++++++-
8 files changed, 280 insertions(+), 29 deletions(-)
diff --git a/apps/edr-freight-api/src/modules/bookings/booking-invoice.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-invoice.service.ts
index 3bfb838b4..e01e6992a 100644
--- a/apps/edr-freight-api/src/modules/bookings/booking-invoice.service.ts
+++ b/apps/edr-freight-api/src/modules/bookings/booking-invoice.service.ts
@@ -135,7 +135,7 @@ export class BookingInvoiceService {
);
return;
}
- if (booking.paymentStatus === "PAID") return;
+ // if (booking.paymentStatus === "PAID") return;
await this.dataSource.transaction(async (mg) => {
await mg.update(
diff --git a/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts
index 3ae0fb64f..484b368fb 100644
--- a/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts
+++ b/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts
@@ -272,7 +272,7 @@ export class Booking extends BaseEntity {
@Column({ name: 'origin_yard_id', type: 'uuid' })
originYardId!: string;
- @ManyToOne(() => Yard)
+ @ManyToOne(() => Yard)
@JoinColumn({ name: 'origin_yard_id' })
originYard?: Yard;
diff --git a/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.spec.ts b/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.spec.ts
index 7ea818e34..2b0126003 100644
--- a/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.spec.ts
+++ b/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.spec.ts
@@ -65,6 +65,16 @@ function makeService(overrides?: {
children: [{ value: '2' }],
}),
};
+ const glOperationsService = {
+ t1State: jest.fn().mockResolvedValue({
+ bookingId: 'b-general',
+ wagonAllocated: false,
+ trainDepartedAt: null,
+ trainArrivedAt: null,
+ closed: false,
+ closedAt: null,
+ }),
+ };
const service = new BookingClearanceService(
bookingsRepository as never,
@@ -74,6 +84,7 @@ function makeService(overrides?: {
workflowService as never,
milestoneService as never,
dropdownSettingsService as never,
+ glOperationsService as never,
);
return {
diff --git a/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.ts b/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.ts
index 7bb835df2..4e9a7b69d 100644
--- a/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.ts
+++ b/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.ts
@@ -1,5 +1,5 @@
import { BadRequestException, Injectable } from '@nestjs/common';
-import { ContractDocPhase } from '@edr/types';
+import { ContractDocPhase, type ClearanceT1State } from '@edr/types';
import { DropdownSettingsService } from '../dropdown-settings/dropdown-settings.service';
import { FileUploadSettingsService } from '../file-upload-settings/file-upload-settings.service';
@@ -11,6 +11,7 @@ import { Booking } from '../bookings/entities/booking.entity';
import { clearanceCodesForBooking } from '../bookings/clearance.util';
import { ClearanceWorkflowService } from './clearance-workflow.service';
import { ClearanceMilestoneService } from './clearance-milestone.service';
+import { GlOperationsService } from './gl-operations.service';
import { AdviseContractDutyDto } from './dto/phased-clearance.dto';
import { buildWorkflowFiles, belongsOnDjClearanceQueue, belongsOnEtClearanceQueue, DJ_BOOKING_QUEUE_STATUSES, persistDeclarationUploads, persistTransitPermitUploads, PHASED_CUSTOMS_BOOKING_QUEUE_STATUSES } from './phased-clearance.util';
@@ -63,6 +64,8 @@ export interface BookingClearanceView {
noticeFile?: { id: string; name: string; url: string } | null;
} | null;
workflowFiles?: ReturnType;
+ /** Import post-allocation T1 transit document state (null until wagon allocation). */
+ t1?: ClearanceT1State | null;
}
@Injectable()
@@ -75,6 +78,7 @@ export class BookingClearanceService {
private readonly workflowService: ClearanceWorkflowService,
private readonly milestoneService: ClearanceMilestoneService,
private readonly dropdownSettingsService: DropdownSettingsService,
+ private readonly glOperationsService: GlOperationsService,
) {}
private async assertPhasedGeneralCustoms(booking: Booking): Promise {
@@ -162,6 +166,15 @@ export class BookingClearanceService {
booking.tradeDirection ?? 'IMPORT',
);
+ let t1: ClearanceT1State | null = null;
+ if ((booking.tradeDirection ?? 'IMPORT') === 'IMPORT') {
+ try {
+ t1 = await this.glOperationsService.t1State(bookingId);
+ } catch {
+ t1 = null;
+ }
+ }
+
return {
bookingId,
status: booking.status,
@@ -192,6 +205,7 @@ export class BookingClearanceService {
preClearanceFinalized: Boolean(booking.preClearanceFinalizedAt),
dutyAdvice,
workflowFiles,
+ t1,
};
}
@@ -421,6 +435,13 @@ export class BookingClearanceService {
clearanceCurrentPhase: ContractDocPhase.GlDjCollection,
} as never);
+ // GL Djibouti may have uploaded the DO early (un-gated) — count it now.
+ const files = await this.filesService.findByResource(bookingId, 'bookings');
+ if (files.some((f) => f.code === 'delivery_order')) {
+ await this.workflowService.completeMilestoneForBooking(bookingId, 'DO_COLLECTED');
+ await this.workflowService.markReadyForOperation(bookingId);
+ }
+
return this.bookingsService.findById(bookingId);
}
@@ -434,15 +455,11 @@ export class BookingClearanceService {
throw new BadRequestException('Delivery Order applies only to import bookings.');
}
- if (!booking.preClearanceFinalizedAt) {
- throw new BadRequestException(
- 'GL Ethiopia must finalize pre-clearance before the Delivery Order can be uploaded.',
- );
- }
-
- await this.workflowService.assertPriorCompleteForBooking(bookingId, 'IMPORT', 'DO_COLLECTED');
if (!file) throw new BadRequestException('No Delivery Order uploaded');
+ // DO upload is deliberately un-gated: GL Djibouti may attach it at any point,
+ // any file type. The DO_COLLECTED milestone (and operation readiness) still
+ // waits for GL Ethiopia to finalize pre-clearance so the workflow order holds.
await this.filesService.upsertByCode({
resourceId: bookingId,
resource: 'bookings',
@@ -450,8 +467,10 @@ export class BookingClearanceService {
file,
});
- await this.workflowService.completeMilestoneForBooking(bookingId, 'DO_COLLECTED', userId);
- await this.workflowService.markReadyForOperation(bookingId);
+ if (booking.preClearanceFinalizedAt) {
+ await this.workflowService.completeMilestoneForBooking(bookingId, 'DO_COLLECTED', userId);
+ await this.workflowService.markReadyForOperation(bookingId);
+ }
return this.bookingsService.findById(bookingId);
}
diff --git a/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts
index a09de407c..34e3d3d94 100644
--- a/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts
+++ b/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts
@@ -1,5 +1,5 @@
import { BadRequestException, ConflictException, Injectable } from '@nestjs/common';
-import { ContractDocPhase } from '@edr/types';
+import { ContractDocPhase, type ClearanceT1State } from '@edr/types';
import { DropdownSettingsService } from '../dropdown-settings/dropdown-settings.service';
import { FileUploadSettingsService } from '../file-upload-settings/file-upload-settings.service';
@@ -10,6 +10,7 @@ import { BookingsService } from '../bookings/bookings.service';
import { contractClearanceCodes } from './contract-clearance.util';
import { ClearanceWorkflowService } from './clearance-workflow.service';
import { ClearanceMilestoneService } from './clearance-milestone.service';
+import { GlOperationsService } from './gl-operations.service';
import { ClearanceMilestone } from './entities/clearance-milestone.entity';
import { Contract } from './entities/contract.entity';
import { ContractDocReviewStatus } from './entities/contract-document-review.entity';
@@ -77,6 +78,8 @@ export interface ContractClearanceView {
noticeFile?: { id: string; name: string; url: string } | null;
} | null;
workflowFiles?: ReturnType;
+ /** Import post-allocation T1 transit document state (null until a booking is linked). */
+ t1?: ClearanceT1State | null;
}
@Injectable()
@@ -90,6 +93,7 @@ export class ContractClearanceService {
private readonly workflowService: ClearanceWorkflowService,
private readonly milestoneService: ClearanceMilestoneService,
private readonly dropdownSettingsService: DropdownSettingsService,
+ private readonly glOperationsService: GlOperationsService,
) {}
private isPhasedCustoms(contract: Contract): boolean {
@@ -224,6 +228,15 @@ export class ContractClearanceService {
workflowFiles = [...byCode.values()];
}
+ let t1: ClearanceT1State | null = null;
+ if (cycle?.bookingId && contract.tradeDirection === 'IMPORT') {
+ try {
+ t1 = await this.glOperationsService.t1State(cycle.bookingId);
+ } catch {
+ t1 = null; // linked booking missing — view stays usable
+ }
+ }
+
let nextAction = this.workflowService.computeNextAction(contract, cycle, milestones);
if (cycle?.bookingId && contract.tradeDirection === 'EXPORT') {
const bookingMilestones = await this.workflowService.listMilestonesForBooking(
@@ -272,6 +285,7 @@ export class ContractClearanceService {
linkedBookingId: cycle?.bookingId ?? null,
dutyAdvice,
workflowFiles,
+ t1,
};
}
@@ -1011,6 +1025,13 @@ export class ContractClearanceService {
currentPhase: ContractDocPhase.GlDjCollection,
});
+ // GL Djibouti may have uploaded the DO early (un-gated) — count it now.
+ const files = await this.filesService.findByResource(contractId, 'contracts');
+ if (files.some((f) => f.code === 'delivery_order')) {
+ await this.workflowService.completeMilestone(contractId, 'DO_COLLECTED');
+ await this.workflowService.markReadyForBooking(contractId);
+ }
+
return this.contractsService.findById(contractId);
}
@@ -1025,17 +1046,11 @@ export class ContractClearanceService {
throw new BadRequestException('Delivery Order applies only to import contracts.');
}
- const cycle = await this.contractsRepository.currentCycle(contractId);
- if (!cycle?.preClearanceFinalizedAt) {
- throw new BadRequestException(
- 'GL Ethiopia must finalize pre-clearance before the Delivery Order can be uploaded.',
- );
- }
-
- await this.workflowService.assertPriorComplete(contractId, 'IMPORT', 'DO_COLLECTED');
-
if (!file) throw new BadRequestException('No Delivery Order uploaded');
+ // DO upload is deliberately un-gated: GL Djibouti may attach it at any point,
+ // any file type. The DO_COLLECTED milestone (and booking readiness) still waits
+ // for GL Ethiopia to finalize pre-clearance so the workflow order holds.
await this.filesService.upsertByCode({
resourceId: contractId,
resource: 'contracts',
@@ -1043,8 +1058,11 @@ export class ContractClearanceService {
file,
});
- await this.workflowService.completeMilestone(contractId, 'DO_COLLECTED', userId);
- await this.workflowService.markReadyForBooking(contractId);
+ const cycle = await this.contractsRepository.currentCycle(contractId);
+ if (cycle?.preClearanceFinalizedAt) {
+ await this.workflowService.completeMilestone(contractId, 'DO_COLLECTED', userId);
+ await this.workflowService.markReadyForBooking(contractId);
+ }
return this.contractsService.findById(contractId);
}
diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts
index 5e712a177..872d13a0c 100644
--- a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts
+++ b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts
@@ -873,6 +873,33 @@ export class ContractsController {
return this.glOperationsService.uploadTransportDocument(bookingId, files ?? []);
}
+ @Post('bookings/:bookingId/t1-documents')
+ @BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions)
+ @UseInterceptors(AnyFilesInterceptor())
+ @ApiConsumes('multipart/form-data')
+ @ApiOperation({
+ summary:
+ 'GL Djibouti uploads T1 transit documents (multi-file) after wagon allocation; locked once the train departs',
+ })
+ uploadT1Documents(
+ @Param('bookingId', ParseUUIDPipe) bookingId: string,
+ @UploadedFiles() files: Express.Multer.File[],
+ ) {
+ return this.glOperationsService.uploadT1Documents(bookingId, files ?? []);
+ }
+
+ @Post('bookings/:bookingId/t1-close')
+ @BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
+ @ApiOperation({
+ summary: 'GL Ethiopia closes (accepts) the T1 document set after the train arrives',
+ })
+ closeT1(
+ @Param('bookingId', ParseUUIDPipe) bookingId: string,
+ @CurrentUser() user: AuthUserPayload,
+ ) {
+ return this.glOperationsService.closeT1(bookingId, resolveAuthUserId(user));
+ }
+
@Post('bookings/:bookingId/documents')
@BookingStaff(FREIGHT_PERMS.bookings.uploadClearanceOutput)
@UseInterceptors(AnyFilesInterceptor())
diff --git a/apps/edr-freight-api/src/modules/contracts/gl-operations.service.ts b/apps/edr-freight-api/src/modules/contracts/gl-operations.service.ts
index 73ca3a65d..f900c564f 100644
--- a/apps/edr-freight-api/src/modules/contracts/gl-operations.service.ts
+++ b/apps/edr-freight-api/src/modules/contracts/gl-operations.service.ts
@@ -1,14 +1,19 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { DataSource } from 'typeorm';
+import { isT1TransportFileCode, type Freight } from '@edr/types';
import { FilesService } from '../files/files.service';
import { Booking } from '../bookings/entities/booking.entity';
+import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
import {
ClearanceIncident,
IncidentType,
} from './entities/clearance-incident.entity';
import { ClearanceMilestoneService } from './clearance-milestone.service';
-import { persistExportTransportUploads } from './phased-clearance.util';
+import {
+ persistExportTransportUploads,
+ persistT1TransportUploads,
+} from './phased-clearance.util';
/**
* Maps a GL post-booking document `code` to the milestone it auto-completes when
@@ -18,7 +23,8 @@ import { persistExportTransportUploads } from './phased-clearance.util';
const DOC_CODE_TO_MILESTONE: Record = {
release_order: 'RELEASE_ORDER_SECURED', // export — GL DJ
delivery_order: 'DO_COLLECTED', // import — GL DJ
- t1_transport_document: 'T1_CLOSED', // import — GL ET
+ // t1_transport_document intentionally NOT doc-triggered: T1_CLOSED completes only
+ // when GL Ethiopia accepts the T1 set after the train arrives (closeT1).
import_release: 'IMPORT_RELEASE_GRANTED', // import — GL ET
full_in_interchange: 'OFFLOADED', // export — GL DJ
final_declaration: 'IMPORT_PROCESS_COMPLETED', // import — GL ET
@@ -161,6 +167,114 @@ export class GlOperationsService {
return { uploaded: files.length, completedMilestones };
}
+ /**
+ * T1 transit-document lifecycle state for an import shipment booking. Wagon
+ * allocation opens the upload window; train departure locks it; train arrival
+ * lets GL Ethiopia close (accept) the T1 set.
+ */
+ async t1State(bookingId: string): Promise {
+ const booking = await this.getBooking(bookingId);
+ const milestones = await this.milestoneService.listForBooking(bookingId);
+
+ const wagonMilestone = milestones.find((m) => m.milestoneCode === 'WAGON_ALLOCATED');
+ const wagonAllocated =
+ wagonMilestone?.status === 'COMPLETED' ||
+ booking.schedulingStatus === 'SCHEDULED' ||
+ booking.schedulingStatus === 'DISPATCHED' ||
+ Boolean(booking.trainScheduleId);
+
+ let schedule: TrainSchedule | null = null;
+ if (booking.trainScheduleId) {
+ schedule = await this.dataSource
+ .getRepository(TrainSchedule)
+ .findOne({ where: { id: booking.trainScheduleId } });
+ }
+
+ const closedMilestone = milestones.find(
+ (m) => m.milestoneCode === 'T1_CLOSED' && m.status === 'COMPLETED',
+ );
+
+ return {
+ bookingId,
+ wagonAllocated,
+ trainDepartedAt: schedule?.actualDepartureAt
+ ? new Date(schedule.actualDepartureAt).toISOString()
+ : null,
+ trainArrivedAt: schedule?.actualArrivalAt
+ ? new Date(schedule.actualArrivalAt).toISOString()
+ : null,
+ closed: Boolean(closedMilestone),
+ closedAt: closedMilestone?.triggeredAt
+ ? new Date(closedMilestone.triggeredAt).toISOString()
+ : null,
+ };
+ }
+
+ /**
+ * GL Djibouti uploads T1 transport documents (multi-file) after wagon allocation.
+ * Replaces the previous batch; locked once the train departs or T1 is closed.
+ */
+ async uploadT1Documents(
+ bookingId: string,
+ files: Express.Multer.File[],
+ ): Promise<{ uploaded: number }> {
+ const booking = await this.getBooking(bookingId);
+ if (booking.tradeDirection !== 'IMPORT') {
+ throw new BadRequestException('T1 transport documents apply to import shipments only.');
+ }
+
+ const state = await this.t1State(bookingId);
+ if (!state.wagonAllocated) {
+ throw new BadRequestException(
+ 'Wagons must be allocated before T1 transport documents can be uploaded.',
+ );
+ }
+ if (state.closed) {
+ throw new BadRequestException('T1 has been closed by GL Ethiopia — documents are final.');
+ }
+ if (state.trainDepartedAt) {
+ throw new BadRequestException(
+ 'The train has departed — T1 transport documents can no longer be changed.',
+ );
+ }
+
+ await persistT1TransportUploads(this.filesService, bookingId, files);
+ return { uploaded: files.length };
+ }
+
+ /**
+ * GL Ethiopia closes (accepts) the T1 document set once the train has arrived.
+ * Completes the T1_CLOSED milestone; the document set becomes final.
+ */
+ async closeT1(
+ bookingId: string,
+ userId?: string,
+ ): Promise {
+ const booking = await this.getBooking(bookingId);
+ if (booking.tradeDirection !== 'IMPORT') {
+ throw new BadRequestException('T1 closure applies to import shipments only.');
+ }
+
+ const state = await this.t1State(bookingId);
+ if (state.closed) return state;
+ if (!state.trainArrivedAt) {
+ throw new BadRequestException(
+ 'The train has not arrived yet — T1 can be closed only after arrival.',
+ );
+ }
+
+ const files = await this.filesService.findByResource(bookingId, 'bookings');
+ const hasT1 = files.some((f) => isT1TransportFileCode(f.code));
+ if (!hasT1) {
+ throw new BadRequestException(
+ 'No T1 transport documents on file — GL Djibouti must upload them first.',
+ );
+ }
+
+ await this.milestoneService.completeForBooking(bookingId, 'T1_CLOSED', userId);
+ return this.t1State(bookingId);
+ }
+
/**
* GL ET uploads export transport document after wagon allocation (export ONE_TIME).
*/
diff --git a/apps/edr-freight-api/src/modules/contracts/phased-clearance.util.ts b/apps/edr-freight-api/src/modules/contracts/phased-clearance.util.ts
index 89fbf797e..2e8682951 100644
--- a/apps/edr-freight-api/src/modules/contracts/phased-clearance.util.ts
+++ b/apps/edr-freight-api/src/modules/contracts/phased-clearance.util.ts
@@ -5,7 +5,9 @@ import {
isDeclarationFileCode,
isImportTransitPermitFileCode,
isExportTransportFileCode,
+ isT1TransportFileCode,
exportTransportFileLabel,
+ t1TransportFileLabel,
transitPermitFileLabel,
type ClearanceWorkflowFile,
} from '@edr/types';
@@ -160,6 +162,50 @@ export async function persistExportTransportUploads(
);
}
+/** Require at least one T1 transport document in the upload batch. */
+export function assertT1TransportFiles(files: Express.Multer.File[]): void {
+ if (files.length === 0) {
+ throw new BadRequestException('No T1 transport documents uploaded');
+ }
+}
+
+export function normalizeT1TransportFieldNames(
+ files: Express.Multer.File[],
+): Express.Multer.File[] {
+ return files.map((file, index) => ({
+ ...file,
+ fieldname: `t1_transport_document_${index}`,
+ }));
+}
+
+/** Replace all T1 transport documents on a booking with a new multi-file batch. */
+export async function persistT1TransportUploads(
+ store: DeclarationFileStore,
+ bookingId: string,
+ files: Express.Multer.File[],
+): Promise {
+ const normalized = normalizeT1TransportFieldNames(files);
+ assertT1TransportFiles(normalized);
+
+ const existing = await store.findByResource(bookingId, 'bookings');
+ await Promise.all(
+ existing
+ .filter((f) => f.code && isT1TransportFileCode(f.code))
+ .map((f) => store.deleteByCode(bookingId, 'bookings', f.code!)),
+ );
+
+ await Promise.all(
+ normalized.map((file, index) =>
+ store.upload({
+ resourceId: bookingId,
+ resource: 'bookings',
+ code: `t1_transport_document_${index}`,
+ file,
+ }),
+ ),
+ );
+}
+
export function parseDutyRequiredForm(value: string | boolean | undefined): boolean {
if (typeof value === 'boolean') return value;
if (value === undefined || value === '') return false;
@@ -194,9 +240,9 @@ export function belongsOnDjClearanceQueue(
);
if (hasDjActivity) return true;
- const preFinalized =
- cycle?.preClearanceFinalizedAt ?? extras?.preClearanceFinalizedAt ?? null;
- if (tradeDirection === 'IMPORT' && preFinalized) return true;
+ // Import DO upload is un-gated — Djibouti GL must see import customs items from
+ // the start, not only after Ethiopia finalizes pre-clearance.
+ if (tradeDirection === 'IMPORT') return true;
return false;
}
@@ -295,6 +341,22 @@ export function buildWorkflowFiles(
file: { id: file.id, name: file.name, url: file.url },
});
});
+
+ const extraT1 = files
+ .filter((f) => f.code && isT1TransportFileCode(f.code) && !included.has(f.code))
+ .sort((a, b) => (a.code ?? '').localeCompare(b.code ?? ''));
+
+ extraT1.forEach((file, index) => {
+ if (!file.code) return;
+ included.add(file.code);
+ out.push({
+ code: file.code,
+ label: t1TransportFileLabel(file.code, index),
+ uploadedBy: 'gl_dj',
+ category: 'djibouti',
+ file: { id: file.id, name: file.name, url: file.url },
+ });
+ });
}
if (tradeDirection === 'EXPORT') {
From b75a3ab54b24e4c94726d0e71c998768fe811b0a Mon Sep 17 00:00:00 2001
From: ghost2023
Date: Thu, 2 Jul 2026 16:25:45 +0300
Subject: [PATCH 22/86] fix: first-mile error
---
.../modules/first-mile/first-mile.service.ts | 169 ++++++++++++------
1 file changed, 116 insertions(+), 53 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 ae0ada831..3ba1e4e75 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,19 +1,25 @@
-import { BadRequestException, ConflictException, Injectable, Logger, NotFoundException } from '@nestjs/common';
-import { FindOptionsWhere } from 'typeorm';
-import { InjectDataSource } from '@nestjs/typeorm';
-import { DataSource } from 'typeorm';
+import {
+ BadRequestException,
+ ConflictException,
+ Injectable,
+ Logger,
+ NotFoundException,
+} from "@nestjs/common";
+import { FindOptionsWhere } from "typeorm";
+import { InjectDataSource } from "@nestjs/typeorm";
+import { DataSource } from "typeorm";
-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 { CreateFirstMileDto } from './dto/create-first-mile.dto';
-import { UpdateFirstMileDto } from './dto/update-first-mile.dto';
-import { FirstMile, FirstMileStatus } from './entities/first-mile.entity';
-import { FirstMileContainerAllocation } from './entities/first-mile-container-allocation.entity';
-import { FirstMileRepository } from './first-mile.repository';
-import { OnEvent } from '@nestjs/event-emitter';
-import { InvoiceEventPayload } from '../billing/billing.service';
+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 { CreateFirstMileDto } from "./dto/create-first-mile.dto";
+import { UpdateFirstMileDto } from "./dto/update-first-mile.dto";
+import { FirstMile, FirstMileStatus } from "./entities/first-mile.entity";
+import { FirstMileContainerAllocation } from "./entities/first-mile-container-allocation.entity";
+import { FirstMileRepository } from "./first-mile.repository";
+import { OnEvent } from "@nestjs/event-emitter";
+import { InvoiceEventPayload } from "../billing/billing.service";
type FirstMileListFilter = {
status?: FirstMileStatus;
@@ -26,10 +32,10 @@ type FirstMileListFilter = {
};
const SORTABLE_FIELDS: (keyof FirstMile)[] = [
- 'status',
- 'advancedPayment',
- 'remainingPayment',
- 'createdAt',
+ "status",
+ "advancedPayment",
+ "remainingPayment",
+ "createdAt",
];
@Injectable()
@@ -43,26 +49,28 @@ export class FirstMileService {
private readonly vehiclesService: VehiclesService,
private readonly driversService: DriversService,
private readonly smsClient: SmsClientService,
- ) {}
+ ) { }
/**
* 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
* unknown or the booking has not reached PAID status.
*/
- async acceptBooking(bookingId: string): Promise {
+ async acceptBooking(bookingId: string): Promise {
const booking = await this.bookingsRepository.findById(bookingId, {
relations: { serviceType: true },
});
if (!booking) {
- throw new NotFoundException(`Booking ${bookingId} not found`);
+ return null;
}
return this.acceptEligibleBooking(booking);
}
- async acceptBookingByReference(bookingReference: string): Promise {
+ async acceptBookingByReference(
+ bookingReference: string,
+ ): Promise {
const [booking] = await this.bookingsRepository.findAll({
where: { reference: bookingReference },
relations: { serviceType: true },
@@ -89,20 +97,20 @@ export class FirstMileService {
tradeDirection?: string | null;
firstMilePickupAddress?: string | null;
serviceType?: { includesFirstMile?: boolean | null } | null;
- }): Promise {
+ }): Promise {
const label = booking.reference ?? booking.id;
- if (booking.paymentStatus !== 'PAID') {
- throw new BadRequestException(`Booking ${label} is not paid`);
+ if (booking.paymentStatus !== "PAID") {
+ return null;
}
if (!this.bookingRequestsFirstMile(booking)) {
- throw new BadRequestException(`Booking ${label} does not require a first mile`);
+ return null;
}
const existing = await this.findByBookingId(booking.id);
if (existing) {
- throw new ConflictException(`Booking ${label} already has a first-mile assignment`);
+ return null;
}
return this.create({
@@ -118,8 +126,9 @@ export class FirstMileService {
const pageSize = filter.pageSize ?? 50;
const sortBy = SORTABLE_FIELDS.includes(filter.sortBy as keyof FirstMile)
? (filter.sortBy as keyof FirstMile)
- : 'createdAt';
- const sortOrder = filter.sortOrder?.toUpperCase() === 'ASC' ? 'ASC' : 'DESC';
+ : "createdAt";
+ const sortOrder =
+ filter.sortOrder?.toUpperCase() === "ASC" ? "ASC" : "DESC";
const where: FindOptionsWhere = {};
if (filter.status) where.status = filter.status;
@@ -129,7 +138,13 @@ export class FirstMileService {
const [data, total] = await this.firstMileRepository.findAndCount({
where,
relations: {
- booking: { company: true, serviceType: true, originYard: true, destinationYard: true, cargoType: true },
+ booking: {
+ company: true,
+ serviceType: true,
+ originYard: true,
+ destinationYard: true,
+ cargoType: true,
+ },
vehicle: true,
},
order: { [sortBy]: sortOrder },
@@ -151,8 +166,12 @@ export class FirstMileService {
@OnEvent("firstmile.invoice.paid")
async onBookingInvoicePaid(payload: InvoiceEventPayload): Promise {
try {
- await this.firstMileRepository.update(payload.sourceId, { paid: true } as any);
- this.logger.log(`Marked first-mile record ${payload.sourceId} as paid (invoice ${payload.invoiceId})`);
+ await this.firstMileRepository.update(payload.sourceId, {
+ paid: true,
+ } as any);
+ this.logger.log(
+ `Marked first-mile record ${payload.sourceId} as paid (invoice ${payload.invoiceId})`,
+ );
} catch (err) {
this.logger.error(
`Failed to update first-mile payment status for record ${payload.sourceId}: ${String(err)}`,
@@ -163,7 +182,13 @@ export class FirstMileService {
async findById(id: string): Promise {
const record = await this.firstMileRepository.findById(id, {
relations: {
- booking: { company: true, serviceType: true, originYard: true, destinationYard: true, cargoType: true },
+ booking: {
+ company: true,
+ serviceType: true,
+ originYard: true,
+ destinationYard: true,
+ cargoType: true,
+ },
vehicle: true,
},
});
@@ -183,7 +208,7 @@ export class FirstMileService {
return this.firstMileRepository.create({
bookingId: dto.bookingId,
- status: dto.status ?? 'READY_TO_TRANSIT',
+ status: dto.status ?? "READY_TO_TRANSIT",
advancedPayment: dto.advancedPayment ?? 0,
remainingPayment: dto.remainingPayment ?? 0,
estimatedKm: dto.estimatedKm ?? null,
@@ -197,7 +222,13 @@ export class FirstMileService {
const [records] = await this.firstMileRepository.findAndCount({
where: { bookingId },
relations: {
- booking: { company: true, serviceType: true, originYard: true, destinationYard: true, cargoType: true },
+ booking: {
+ company: true,
+ serviceType: true,
+ originYard: true,
+ destinationYard: true,
+ cargoType: true,
+ },
vehicle: true,
},
take: 1,
@@ -213,9 +244,9 @@ export class FirstMileService {
// Export bookings always need a first mile (pickup → origin yard); the
// pickup address is captured at assignment time, not required upfront.
return Boolean(
- booking.tradeDirection === 'EXPORT' ||
- booking.firstMilePickupAddress?.trim() ||
- booking.serviceType?.includesFirstMile,
+ booking.tradeDirection === "EXPORT" ||
+ booking.firstMilePickupAddress?.trim() ||
+ booking.serviceType?.includesFirstMile,
);
}
@@ -226,9 +257,15 @@ export class FirstMileService {
const updated = await this.firstMileRepository.update(id, {
...(dto.bookingId !== undefined ? { bookingId: dto.bookingId } : {}),
...(dto.status !== undefined ? { status: dto.status } : {}),
- ...(dto.advancedPayment !== undefined ? { advancedPayment: dto.advancedPayment } : {}),
- ...(dto.remainingPayment !== undefined ? { remainingPayment: dto.remainingPayment } : {}),
- ...(dto.estimatedKm !== undefined ? { estimatedKm: dto.estimatedKm } : {}),
+ ...(dto.advancedPayment !== undefined
+ ? { advancedPayment: dto.advancedPayment }
+ : {}),
+ ...(dto.remainingPayment !== undefined
+ ? { remainingPayment: dto.remainingPayment }
+ : {}),
+ ...(dto.estimatedKm !== undefined
+ ? { estimatedKm: dto.estimatedKm }
+ : {}),
...(dto.exactKm !== undefined ? { exactKm: dto.exactKm } : {}),
...(dto.vehicleId !== undefined ? { vehicleId: dto.vehicleId } : {}),
...(dtoAny.paid !== undefined ? { paid: dtoAny.paid } : {}),
@@ -256,37 +293,63 @@ export class FirstMileService {
return updated;
}
- private async notifyDriverAssignment(vehicleId: string, record: FirstMile): Promise {
+ private async notifyDriverAssignment(
+ vehicleId: string,
+ record: FirstMile,
+ ): Promise {
try {
const vehicle = await this.vehiclesService.findById(vehicleId);
if (!vehicle.assignedDriverId) {
- this.logger.warn(`Vehicle ${vehicleId} has no assigned driver — skipping SMS`);
+ this.logger.warn(
+ `Vehicle ${vehicleId} has no assigned driver — skipping SMS`,
+ );
return;
}
- const driver = await this.driversService.findById(vehicle.assignedDriverId);
+ const driver = await this.driversService.findById(
+ vehicle.assignedDriverId,
+ );
if (!driver.phoneNumber) {
- this.logger.warn(`Driver ${vehicle.assignedDriverId} has no phone number — skipping SMS`);
+ this.logger.warn(
+ `Driver ${vehicle.assignedDriverId} has no phone number — skipping SMS`,
+ );
return;
}
- const booking = (record as FirstMile & { booking?: { reference?: string; firstMilePickupAddress?: string | null; originYard?: { label?: string } | null } }).booking;
+ const booking = (
+ record as FirstMile & {
+ booking?: {
+ reference?: string;
+ firstMilePickupAddress?: string | null;
+ originYard?: { label?: string } | null;
+ };
+ }
+ ).booking;
- const driverName = `${driver.firstName ?? ''} ${driver.lastName ?? ''}`.trim();
+ const driverName =
+ `${driver.firstName ?? ""} ${driver.lastName ?? ""}`.trim();
const message =
`Dear ${driverName}, you have been assigned to a first-mile pickup. ` +
`Booking: ${booking?.reference ?? record.bookingId}. Vehicle: ${vehicle.plateNumber ?? vehicleId}. ` +
- (booking?.firstMilePickupAddress ? `Pickup: ${booking.firstMilePickupAddress}. ` : '') +
- (booking?.originYard?.label ? `Destination: ${booking.originYard.label}.` : '');
+ (booking?.firstMilePickupAddress
+ ? `Pickup: ${booking.firstMilePickupAddress}. `
+ : "") +
+ (booking?.originYard?.label
+ ? `Destination: ${booking.originYard.label}.`
+ : "");
void this.smsClient.sendSms({
to: driver.phoneNumber,
message,
});
- this.logger.log(`SMS queued to driver ${driver.phoneNumber} for vehicle ${vehicleId} assignment`);
+ this.logger.log(
+ `SMS queued to driver ${driver.phoneNumber} for vehicle ${vehicleId} assignment`,
+ );
} catch (err) {
- this.logger.error(`Failed to notify driver for vehicle ${vehicleId}: ${String(err)}`);
+ this.logger.error(
+ `Failed to notify driver for vehicle ${vehicleId}: ${String(err)}`,
+ );
}
}
@@ -314,7 +377,7 @@ export class FirstMileService {
firstMileId,
containerId: allocation.containerId,
vehicleId: allocation.vehicleId,
- containerType: 'CONTAINER',
+ containerType: "CONTAINER",
quantity: 1,
});
}
From fec2a5d3208e1250418a3900243f17229e434cb0 Mon Sep 17 00:00:00 2001
From: Marshal
Date: Thu, 2 Jul 2026 13:27:59 +0000
Subject: [PATCH 23/86] update import gl flow
---
.../contracts/GlClearanceUploadModal.tsx | 3 +-
.../contracts/PhasedClearanceActionPanel.tsx | 232 ++++++++++++-
.../backoffice/src/constants/URLS.ts | 4 +
.../pages/contracts/GlClearanceDetailPage.tsx | 3 +-
.../src/services/contracts.service.ts | 21 ++
apps/edr-freight-web/portal/package.json | 5 +-
.../new-booking-form/LocationPicker.tsx | 308 ++++++++----------
apps/edr-freight-web/portal/src/vite-env.d.ts | 1 +
.../src/freight/clearance-files.catalog.ts | 28 ++
packages/types/src/freight/contracts.ts | 16 +
packages/types/src/freight/index.ts | 2 +
pnpm-lock.yaml | 81 ++---
12 files changed, 452 insertions(+), 252 deletions(-)
diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/GlClearanceUploadModal.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/GlClearanceUploadModal.tsx
index b33729bf1..ba54e3b8b 100644
--- a/apps/edr-freight-web/backoffice/src/components/contracts/GlClearanceUploadModal.tsx
+++ b/apps/edr-freight-web/backoffice/src/components/contracts/GlClearanceUploadModal.tsx
@@ -121,7 +121,8 @@ export function GlClearanceUploadModal({
& { operationReady?: boolean };
type MilestoneRow = NonNullable[number];
@@ -79,7 +80,10 @@ function isBookingMilestoneDone(
return m?.status === "COMPLETED" || m?.status === "SKIPPED";
}
-function computeImportActiveStep(clearance: ClearanceViewLike): number {
+function computeImportActiveStep(
+ clearance: ClearanceViewLike,
+ bookingCreated: boolean,
+): number {
if (!isMilestoneDone(clearance.milestones, "DOCUMENTS_APPROVED")) return 0;
if (!isMilestoneDone(clearance.milestones, "DECLARED")) return 1;
if (
@@ -98,7 +102,23 @@ function computeImportActiveStep(clearance: ClearanceViewLike): number {
if (!isMilestoneDone(clearance.milestones, "TRANSIT_PERMIT_UPLOADED")) return 4;
if (!clearance.preClearanceFinalized) return 5;
if (!isMilestoneDone(clearance.milestones, "DO_COLLECTED")) return 6;
- return 7;
+ if (!bookingCreated) return 7;
+ if (!clearance.t1?.closed) return 8;
+ return 9;
+}
+
+function t1FilesFromWorkflow(
+ workflowFiles: Freight.ClearanceWorkflowFile[],
+): Array<{ code: string; label: string; file: { id: string; name: string } }> {
+ return workflowFiles
+ .filter(
+ (f) => f.code.toLowerCase().startsWith("t1_transport_document") && f.file,
+ )
+ .map((f) => ({
+ code: f.code,
+ label: f.label,
+ file: f.file!,
+ }));
}
function declarationFilesFromWorkflow(
@@ -174,9 +194,12 @@ export function PhasedClearanceActionPanel({
const showEt = roleMode === "ET" || roleMode === "ALL";
const showDj = roleMode === "DJ" || roleMode === "ALL";
const isImport = tradeDirection === "IMPORT";
+ // The server only builds the t1 block once a booking is linked — use it as the
+ // booking-created signal on pages that don't pass bookingCreated (GL DJ detail).
+ const effectiveBookingCreated = bookingCreated || Boolean(clearance.t1);
const activeStep = useMemo(
- () => (isImport ? computeImportActiveStep(clearance) : 0),
- [clearance, isImport],
+ () => (isImport ? computeImportActiveStep(clearance, effectiveBookingCreated) : 0),
+ [clearance, isImport, effectiveBookingCreated],
);
if (isImport) {
@@ -401,11 +424,7 @@ export function PhasedClearanceActionPanel({
description="GL Djibouti uploads DO"
icon={}
>
- {showDj &&
- canDj &&
- !useUploadModals &&
- (activeStep >= 6 ||
- isMilestoneDone(clearance.milestones, "DO_COLLECTED")) ? (
+ {showDj && canDj && !useUploadModals ? (
{useUploadModals && showDj && canDj && onUploadDoRequest ? (
@@ -439,7 +454,6 @@ export function PhasedClearanceActionPanel({
color="edr-green"
leftSection={}
onClick={onUploadDoRequest}
- disabled={!clearance.preClearanceFinalized && !findWorkflowFile(workflowFiles, "delivery_order")}
>
{findWorkflowFile(workflowFiles, "delivery_order")
? "Replace DO"
@@ -472,17 +486,37 @@ export function PhasedClearanceActionPanel({
) : (
)}
+
+ :
+ }
+ >
+
+
@@ -578,6 +612,170 @@ export function PhasedClearanceActionPanel({
);
}
+function ImportT1Section({
+ t1,
+ workflowFiles = [],
+ canDjAct,
+ canEtAct,
+ onChanged,
+ onViewFile,
+ onDownloadFile,
+}: {
+ t1: Freight.ClearanceT1State | null;
+ workflowFiles?: Freight.ClearanceWorkflowFile[];
+ canDjAct: boolean;
+ canEtAct: boolean;
+ onChanged?: () => void;
+ onViewFile?: (file: { name: string; url: string }) => void;
+ onDownloadFile?: (file: { id: string; name: string }) => void;
+}) {
+ const [files, setFiles] = useState([]);
+ const [uploading, setUploading] = useState(false);
+ const [closing, setClosing] = useState(false);
+
+ const uploaded = t1FilesFromWorkflow(workflowFiles);
+ const replaceMode = uploaded.length > 0;
+
+ if (!t1) {
+ return (
+
+ );
+ }
+
+ const departed = Boolean(t1.trainDepartedAt);
+ const arrived = Boolean(t1.trainArrivedAt);
+ const canUpload = canDjAct && t1.wagonAllocated && !departed && !t1.closed;
+
+ return (
+
+ {uploaded.length > 0 ? (
+
+
+ T1 document{uploaded.length > 1 ? "s" : ""}
+
+ {uploaded.map((row) => (
+
+ ))}
+
+ ) : null}
+
+ {t1.closed ? (
+
+ ) : !t1.wagonAllocated ? (
+
+ ) : departed ? (
+ }>
+ The train has departed — T1 documents are locked and can no longer be changed.
+
+ ) : uploaded.length === 0 && !canUpload ? (
+
+ ) : null}
+
+ {canUpload ? (
+ <>
+
+
+
+ }
+ fullWidth
+ onClick={async () => {
+ setUploading(true);
+ try {
+ const payload = Object.fromEntries(
+ files.map((file, index) => [`t1_transport_document_${index}`, file]),
+ ) as Record;
+ await contractsService.uploadT1Documents(t1.bookingId, payload);
+ setFiles([]);
+ toast.success(replaceMode ? "T1 documents updated" : "T1 documents uploaded");
+ onChanged?.();
+ } catch (e) {
+ toast.error(e instanceof Error ? e.message : "Upload failed");
+ } finally {
+ setUploading(false);
+ }
+ }}
+ >
+ {replaceMode ? "Replace T1 documents" : "Upload T1 documents"}
+
+ >
+ ) : null}
+
+ {canEtAct && !t1.closed ? (
+ arrived ? (
+
+
+ The train has arrived — review the T1 documents and close (accept) them.
+
+ }
+ onClick={async () => {
+ setClosing(true);
+ try {
+ await contractsService.closeT1(t1.bookingId);
+ toast.success("T1 closed");
+ onChanged?.();
+ } catch (e) {
+ toast.error(e instanceof Error ? e.message : "Failed");
+ } finally {
+ setClosing(false);
+ }
+ }}
+ >
+ Accept & close T1
+
+
+ ) : departed ? (
+ }>
+ Train en route — T1 can be closed once it arrives in Ethiopia.
+
+ ) : null
+ ) : null}
+
+ );
+}
+
function StepStatus({
done,
pendingLabel,
diff --git a/apps/edr-freight-web/backoffice/src/constants/URLS.ts b/apps/edr-freight-web/backoffice/src/constants/URLS.ts
index 753dcb2a5..eb48a89b1 100644
--- a/apps/edr-freight-web/backoffice/src/constants/URLS.ts
+++ b/apps/edr-freight-web/backoffice/src/constants/URLS.ts
@@ -211,6 +211,10 @@ export const URL_CONSTANTS = {
`/contracts/bookings/${bookingId}/documents`,
BOOKING_TRANSPORT_DOCUMENT: (bookingId: string) =>
`/contracts/bookings/${bookingId}/transport-document`,
+ BOOKING_T1_DOCUMENTS: (bookingId: string) =>
+ `/contracts/bookings/${bookingId}/t1-documents`,
+ BOOKING_T1_CLOSE: (bookingId: string) =>
+ `/contracts/bookings/${bookingId}/t1-close`,
BOOKING_INCIDENTS: (bookingId: string) =>
`/contracts/bookings/${bookingId}/incidents`,
},
diff --git a/apps/edr-freight-web/backoffice/src/pages/contracts/GlClearanceDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/contracts/GlClearanceDetailPage.tsx
index ecf95fe89..6756ecc3e 100644
--- a/apps/edr-freight-web/backoffice/src/pages/contracts/GlClearanceDetailPage.tsx
+++ b/apps/edr-freight-web/backoffice/src/pages/contracts/GlClearanceDetailPage.tsx
@@ -112,7 +112,8 @@ export default function GlClearanceDetailPage() {
const isImport = data.tradeDirection === "IMPORT";
const hasDo = Boolean(findWorkflowFile(workflowFiles, "delivery_order"));
const hasRo = Boolean(findWorkflowFile(workflowFiles, "release_order"));
- const canUploadDo = isImport && Boolean(data.clearance.preClearanceFinalized || hasDo);
+ // DO upload is un-gated — Djibouti GL may attach it at any point, any file type.
+ const canUploadDo = isImport;
const vesselDepartureDate =
"vesselDepartureDate" in data.clearance
? (data.clearance.vesselDepartureDate ?? null)
diff --git a/apps/edr-freight-web/backoffice/src/services/contracts.service.ts b/apps/edr-freight-web/backoffice/src/services/contracts.service.ts
index 465cc302d..fe923d016 100644
--- a/apps/edr-freight-web/backoffice/src/services/contracts.service.ts
+++ b/apps/edr-freight-web/backoffice/src/services/contracts.service.ts
@@ -333,6 +333,27 @@ export const contractsService = {
return unwrap(response.data);
},
+ /** GL Djibouti uploads T1 transit documents (multi-file, post wagon allocation). */
+ uploadT1Documents: async (
+ bookingId: string,
+ files: Record,
+ ) => {
+ const form = new FormData();
+ for (const [key, file] of Object.entries(files)) {
+ if (file) form.append(key, file);
+ }
+ const response = await client.post(C.BOOKING_T1_DOCUMENTS(bookingId), form, {
+ headers: { "Content-Type": "multipart/form-data" },
+ });
+ return unwrap(response.data);
+ },
+
+ /** GL Ethiopia closes (accepts) the T1 document set after the train arrives. */
+ closeT1: async (bookingId: string): Promise => {
+ const response = await client.post(C.BOOKING_T1_CLOSE(bookingId));
+ return unwrap(response.data) as Freight.ClearanceT1State;
+ },
+
// ── Path A self-clearance (Operations review) ──
getOpsClearanceQueue: async (): Promise => {
const response = await client.get(
diff --git a/apps/edr-freight-web/portal/package.json b/apps/edr-freight-web/portal/package.json
index c445d67e9..a9bcf4ecc 100644
--- a/apps/edr-freight-web/portal/package.json
+++ b/apps/edr-freight-web/portal/package.json
@@ -20,18 +20,17 @@
"@mantine/hooks": "^9.3.0",
"@tanstack/react-query": "^5.59.0",
"@tria-plc/iamui": "file:../../../local-packages/tria-plc-iamui-0.1.1.tgz",
+ "@vis.gl/react-google-maps": "^1.8.3",
"axios": "^1.7.7",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^3.6.0",
- "leaflet": "^1.9.4",
"lucide-react": "^1.14.0",
"radix-ui": "^1.4.3",
"react": "19.2.6",
"react-dom": "19.2.6",
"react-hook-form": "^7.76.0",
"react-hot-toast": "^2.6.0",
- "react-leaflet": "^5.0.0",
"react-phone-number-input": "^3.4.17",
"react-router-dom": "^6.27.0",
"recharts": "^3.8.1",
@@ -44,7 +43,7 @@
"@edr/tsconfig": "workspace:*",
"@hookform/devtools": "^4.4.0",
"@tailwindcss/vite": "^4.3.0",
- "@types/leaflet": "^1.9.21",
+ "@types/google.maps": "^3.65.2",
"@types/react": "^18.3.11",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.2",
diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/LocationPicker.tsx b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/LocationPicker.tsx
index 4aad2be68..176cd0aa9 100644
--- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/LocationPicker.tsx
+++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/LocationPicker.tsx
@@ -1,5 +1,3 @@
-import "leaflet/dist/leaflet.css";
-
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
Box,
@@ -13,8 +11,14 @@ import {
useCombobox,
} from "@mantine/core";
import { Check, MapPin, Search } from "lucide-react";
-import L from "leaflet";
-import { MapContainer, Marker, TileLayer, useMap, useMapEvents } from "react-leaflet";
+import {
+ APIProvider,
+ Map as GoogleMap,
+ type MapMouseEvent,
+ Marker,
+ useMap,
+ useMapsLibrary,
+} from "@vis.gl/react-google-maps";
import { fieldStyles } from "./shared";
@@ -25,129 +29,93 @@ export interface LocationValue {
lng: number | null;
}
-/** A single Nominatim search result, normalised to what the UI needs. */
+/** A single geocoding result, normalised to what the UI needs. */
interface GeocodeResult {
displayName: string;
lat: number;
lng: number;
}
-// Leaflet's default marker icon URLs break under bundlers; point them at the
-// CDN-hosted assets once so every map instance renders a visible pin.
-const markerIcon = L.icon({
- iconUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png",
- iconRetinaUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon-2x.png",
- shadowUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png",
- iconSize: [25, 41],
- iconAnchor: [12, 41],
- popupAnchor: [1, -34],
- shadowSize: [41, 41],
-});
+// Maps JavaScript API keys are public client-side keys (lock them down by
+// HTTP-referrer in the Google Cloud console). The env var lets deployments
+// override the default key without a code change.
+const GOOGLE_MAPS_API_KEY =
+ import.meta.env.VITE_GOOGLE_MAPS_API_KEY ||
+ "AIzaSyBg4tN31-fgvH_2Ix_TPo6VSfOA2uA5CCI";
// Centre of the EDR corridor (Addis Ababa) — a sensible default view.
-const DEFAULT_CENTER: [number, number] = [9.03, 38.74];
+const DEFAULT_CENTER = { lat: 9.03, lng: 38.74 };
const DEFAULT_ZOOM = 6;
const PINNED_ZOOM = 14;
-const NOMINATIM_URL = "https://nominatim.openstreetmap.org/search";
-const NOMINATIM_REVERSE_URL = "https://nominatim.openstreetmap.org/reverse";
// Search only fires once the user pauses typing for this long. Slightly longer
// than a keystroke burst so we make one request per pause, not per character.
const SEARCH_DEBOUNCE_MS = 550;
const MIN_QUERY_LEN = 2;
// Bias geocoding toward the EDR corridor countries so local addresses surface
-// first (Nominatim still returns global matches if nothing local fits).
-const SEARCH_COUNTRYCODES = "et,dj";
-// Nominatim's fair-use policy allows at most 1 request/second. We keep a hard
-// floor a touch above 1s so a flurry of map clicks / searches can never trip
-// the 429 ("Too Many Requests") wall.
-const MIN_REQUEST_INTERVAL_MS = 1100;
+// first (we retry globally if nothing local matches).
+const SEARCH_COUNTRIES = ["ET", "DJ"];
+const MAX_RESULTS = 8;
// Reverse-geocode precision: coordinates are rounded to ~11m before caching so
// near-identical pin drags resolve from cache instead of re-hitting the API.
const REVERSE_COORD_PRECISION = 4;
-// ── Module-level rate-limited request queue ─────────────────────────────────
-// Every Nominatim call (forward + reverse, across ALL picker instances on the
-// page) funnels through one promise chain that spaces requests ≥1.1s apart.
-let lastRequestAt = 0;
-let queueTail: Promise = Promise.resolve();
-
-function scheduleRequest(run: () => Promise): Promise {
- const result = queueTail.then(async () => {
- const now = Date.now();
- const wait = Math.max(0, lastRequestAt + MIN_REQUEST_INTERVAL_MS - now);
- if (wait > 0) await new Promise((r) => setTimeout(r, wait));
- lastRequestAt = Date.now();
- return run();
- });
- // Keep the chain alive even if this request rejects, so one failure doesn't
- // stall every queued request behind it.
- queueTail = result.catch(() => undefined);
- return result;
-}
-
// Simple in-memory caches keyed by the normalized query / rounded coordinate.
const searchCache = new Map();
const reverseCache = new Map();
-/** One Nominatim forward-geocode request. `countryCodes` biases to a region. */
-async function nominatimSearch(
- query: string,
- signal: AbortSignal,
- countryCodes?: string,
+/**
+ * One Geocoder request, normalised. The promise-based `geocode` rejects on
+ * ZERO_RESULTS (and any other non-OK status), so failures collapse to "no
+ * matches" rather than surfacing as an error state.
+ */
+async function geocode(
+ geocoder: google.maps.Geocoder,
+ request: google.maps.GeocoderRequest,
): Promise {
- const params = new URLSearchParams({
- q: query,
- format: "jsonv2",
- addressdetails: "0",
- limit: "8",
- });
- if (countryCodes) params.set("countrycodes", countryCodes);
- const res = await fetch(`${NOMINATIM_URL}?${params}`, {
- signal,
- headers: { Accept: "application/json", "Accept-Language": "en" },
- });
- if (!res.ok) return [];
- const data = (await res.json()) as Array<{
- display_name: string;
- lat: string;
- lon: string;
- }>;
- return data.map((d) => ({
- displayName: d.display_name,
- lat: Number(d.lat),
- lng: Number(d.lon),
- }));
+ try {
+ const { results } = await geocoder.geocode(request);
+ return results.slice(0, MAX_RESULTS).map((r) => ({
+ displayName: r.formatted_address,
+ lat: r.geometry.location.lat(),
+ lng: r.geometry.location.lng(),
+ }));
+ } catch {
+ return [];
+ }
}
/**
- * Forward-geocode a free-text query. Served from cache when possible; otherwise
- * queued (rate-limited) and tried EDR-corridor-first, then global, so local
- * addresses rank highest without the field ever looking "broken".
+ * Forward-geocode a free-text query. Served from cache when possible;
+ * otherwise tried EDR-corridor-first, then global, so local addresses rank
+ * highest without the field ever looking "broken".
*/
async function searchPlaces(
+ geocoder: google.maps.Geocoder,
query: string,
- signal: AbortSignal,
): Promise {
const key = query.trim().toLowerCase();
const cached = searchCache.get(key);
if (cached) return cached;
- const found = await scheduleRequest(async () => {
- if (signal.aborted) return [];
- const local = await nominatimSearch(query, signal, SEARCH_COUNTRYCODES);
- if (local.length > 0) return local;
- return nominatimSearch(query, signal);
- });
+ // The Geocoder only accepts one country restriction per request, so the
+ // corridor pass fans out to one request per country and merges in order.
+ const perCountry = await Promise.all(
+ SEARCH_COUNTRIES.map((country) =>
+ geocode(geocoder, { address: query, componentRestrictions: { country } }),
+ ),
+ );
+ const local = perCountry.flat().slice(0, MAX_RESULTS);
+ const found = local.length > 0 ? local : await geocode(geocoder, { address: query });
if (found.length > 0) searchCache.set(key, found);
return found;
}
-/** Reverse-geocode a dropped pin to its nearest address (cached + queued). */
+/** Reverse-geocode a dropped pin to its nearest address (cached). */
async function reverseGeocode(
+ geocoder: google.maps.Geocoder,
lat: number,
lng: number,
- signal?: AbortSignal,
): Promise {
const key = `${lat.toFixed(REVERSE_COORD_PRECISION)},${lng.toFixed(
REVERSE_COORD_PRECISION,
@@ -155,63 +123,33 @@ async function reverseGeocode(
const cached = reverseCache.get(key);
if (cached != null) return cached;
- const params = new URLSearchParams({
- lat: String(lat),
- lon: String(lng),
- format: "json",
- });
- try {
- const address = await scheduleRequest(async () => {
- if (signal?.aborted) return "";
- const res = await fetch(`${NOMINATIM_REVERSE_URL}?${params}`, {
- signal,
- headers: { Accept: "application/json", "Accept-Language": "en" },
- });
- if (!res.ok) return "";
- const data = (await res.json()) as { display_name?: string };
- return data.display_name ?? "";
- });
- reverseCache.set(key, address);
- return address;
- } catch {
- return "";
- }
+ const [best] = await geocode(geocoder, { location: { lat, lng } });
+ const address = best?.displayName ?? "";
+ reverseCache.set(key, address);
+ return address;
}
-/**
- * Leaflet computes its tile layout from the container size at mount. When the
- * map is revealed inside a just-toggled section it can mount before layout
- * settles and render grey tiles — invalidating the size on the next frame
- * forces a correct redraw.
- */
-function InvalidateSizeOnMount() {
- const map = useMap();
- useEffect(() => {
- const id = setTimeout(() => map.invalidateSize(), 0);
- return () => clearTimeout(id);
- }, [map]);
- return null;
+/** Lazily constructs a Geocoder once the geocoding library has loaded. */
+function useGeocoder(): google.maps.Geocoder | null {
+ const geocodingLib = useMapsLibrary("geocoding");
+ return useMemo(
+ () => (geocodingLib ? new geocodingLib.Geocoder() : null),
+ [geocodingLib],
+ );
}
/** Recenters the map imperatively when the pinned coordinate changes. */
function MapRecenter({ lat, lng }: { lat: number | null; lng: number | null }) {
const map = useMap();
useEffect(() => {
- if (lat != null && lng != null) {
- map.setView([lat, lng], PINNED_ZOOM, { animate: true });
+ if (map && lat != null && lng != null) {
+ map.panTo({ lat, lng });
+ map.setZoom(PINNED_ZOOM);
}
}, [lat, lng, map]);
return null;
}
-/** Captures map clicks and forwards the dropped coordinate. */
-function ClickToPin({ onPick }: { onPick: (lat: number, lng: number) => void }) {
- useMapEvents({
- click: (e) => onPick(e.latlng.lat, e.latlng.lng),
- });
- return null;
-}
-
export interface LocationPickerProps {
value: LocationValue;
onChange: (value: LocationValue) => void;
@@ -227,14 +165,21 @@ export interface LocationPickerProps {
}
/**
- * Address + map location picker backed by free OpenStreetMap services:
- * - type to search (Nominatim forward geocoding),
- * - or click anywhere on the map to drop a pin (Nominatim reverse geocoding).
+ * Address + map location picker backed by Google Maps:
+ * - type to search (Geocoding API forward geocoding, debounced),
+ * - or click anywhere on the map to drop a pin (reverse geocoding).
* Reports the resolved address and coordinates up via `onChange`.
*/
export function LocationPicker(props: LocationPickerProps) {
- if (props.variant === "modal") return ;
- return ;
+ return (
+
+ {props.variant === "modal" ? (
+
+ ) : (
+
+ )}
+
+ );
}
/** Compact trigger + modal wrapper around the inline picker. */
@@ -343,12 +288,13 @@ function LocationPickerInline({
withinPortal = true,
}: LocationPickerProps & { mapHeight?: number; withinPortal?: boolean }) {
const combobox = useCombobox();
+ const geocoder = useGeocoder();
const [query, setQuery] = useState("");
const [results, setResults] = useState([]);
const [searching, setSearching] = useState(false);
const [resolving, setResolving] = useState(false);
- const abortRef = useRef(null);
- const reverseAbortRef = useRef(null);
+ const searchStaleRef = useRef<{ stale: boolean } | null>(null);
+ const reverseStaleRef = useRef<{ stale: boolean } | null>(null);
const hasPin = value.lat != null && value.lng != null;
@@ -366,32 +312,31 @@ function LocationPickerInline({
}
setSearching(true);
combobox.openDropdown();
- abortRef.current?.abort();
- const controller = new AbortController();
- abortRef.current = controller;
+ if (!geocoder) return; // re-runs once the geocoding library loads
+ // The Geocoder has no abort support, so a token marks superseded requests
+ // and their responses are dropped instead of overwriting newer results.
+ const token = { stale: false };
+ searchStaleRef.current = token;
const handle = setTimeout(async () => {
- try {
- const found = await searchPlaces(q, controller.signal);
- if (controller.signal.aborted) return;
- setResults(found);
- combobox.openDropdown();
- } catch (err) {
- // Ignore aborts (a newer keystroke superseded this request).
- if ((err as Error)?.name !== "AbortError") setResults([]);
- } finally {
- if (!controller.signal.aborted) setSearching(false);
- }
+ const found = await searchPlaces(geocoder, q);
+ if (token.stale) return;
+ setResults(found);
+ setSearching(false);
+ combobox.openDropdown();
}, SEARCH_DEBOUNCE_MS);
- // Cancel both the pending debounce AND any in-flight request when the query
- // changes, so a stale response can't overwrite newer results.
return () => {
clearTimeout(handle);
- controller.abort();
+ token.stale = true;
};
- }, [query, combobox]);
+ }, [query, geocoder, combobox]);
- // Abort any in-flight reverse lookup when the picker unmounts.
- useEffect(() => () => reverseAbortRef.current?.abort(), []);
+ // Drop any in-flight reverse lookup when the picker unmounts.
+ useEffect(
+ () => () => {
+ if (reverseStaleRef.current) reverseStaleRef.current.stale = true;
+ },
+ [],
+ );
const selectResult = useCallback(
(r: GeocodeResult) => {
@@ -407,13 +352,14 @@ function LocationPickerInline({
async (lat: number, lng: number) => {
// Show the pin immediately; fill the address once reverse geocoding lands.
onChange({ address: value.address, lat, lng });
- // Cancel any in-flight reverse lookup — only the latest dropped pin counts.
- reverseAbortRef.current?.abort();
- const controller = new AbortController();
- reverseAbortRef.current = controller;
+ if (!geocoder) return;
+ // Mark any in-flight reverse lookup stale — only the latest pin counts.
+ if (reverseStaleRef.current) reverseStaleRef.current.stale = true;
+ const token = { stale: false };
+ reverseStaleRef.current = token;
setResolving(true);
- const address = await reverseGeocode(lat, lng, controller.signal);
- if (controller.signal.aborted) return; // a newer pin superseded this one
+ const address = await reverseGeocode(geocoder, lat, lng);
+ if (token.stale) return; // a newer pin superseded this one
setResolving(false);
onChange({
address: address || `${lat.toFixed(5)}, ${lng.toFixed(5)}`,
@@ -421,14 +367,21 @@ function LocationPickerInline({
lng,
});
},
- [onChange, value.address],
+ [onChange, value.address, geocoder],
+ );
+
+ const handleMapClick = useCallback(
+ (e: MapMouseEvent) => {
+ const latLng = e.detail.latLng;
+ if (latLng) void handlePin(latLng.lat, latLng.lng);
+ },
+ [handlePin],
);
const inputValue = query || value.address;
- const center = useMemo<[number, number]>(
- () => (hasPin ? [value.lat as number, value.lng as number] : DEFAULT_CENTER),
- [hasPin, value.lat, value.lng],
- );
+ const center = hasPin
+ ? { lat: value.lat as number, lng: value.lng as number }
+ : DEFAULT_CENTER;
return (
@@ -494,26 +447,23 @@ function LocationPickerInline({
border: "1px solid #E6ECF2",
}}
>
-
-
-
-
{hasPin && (
)}
-
+
diff --git a/apps/edr-freight-web/portal/src/vite-env.d.ts b/apps/edr-freight-web/portal/src/vite-env.d.ts
index d755e510b..b24245689 100644
--- a/apps/edr-freight-web/portal/src/vite-env.d.ts
+++ b/apps/edr-freight-web/portal/src/vite-env.d.ts
@@ -16,6 +16,7 @@ interface Window {
interface ImportMetaEnv {
readonly VITE_API_URL: string;
+ readonly VITE_GOOGLE_MAPS_API_KEY?: string;
}
interface ImportMeta {
diff --git a/packages/types/src/freight/clearance-files.catalog.ts b/packages/types/src/freight/clearance-files.catalog.ts
index fe6e9ebbf..307965dd8 100644
--- a/packages/types/src/freight/clearance-files.catalog.ts
+++ b/packages/types/src/freight/clearance-files.catalog.ts
@@ -33,6 +33,13 @@ export const CLEARANCE_WORKFLOW_FILE_CATALOG: ClearanceWorkflowFileCatalogEntry[
},
{ code: "delivery_order", label: "Delivery Order", uploadedBy: "gl_dj", category: "djibouti", tradeDirection: "IMPORT" },
{ code: "release_order", label: "Release Order", uploadedBy: "gl_dj", category: "djibouti", tradeDirection: "EXPORT" },
+ {
+ code: "t1_transport_document",
+ label: "T1 Transport Document",
+ uploadedBy: "gl_dj",
+ category: "djibouti",
+ tradeDirection: "IMPORT",
+ },
];
/** Legacy single-type declaration codes (still shown when already uploaded). */
@@ -101,6 +108,27 @@ export function transitPermitFileLabel(code: string, index?: number): string {
return code;
}
+/** Legacy single T1 code (GL post-booking uploader). */
+export const LEGACY_T1_TRANSPORT_CODE = "t1_transport_document";
+
+/** Multi-file T1 transport uploads use `t1_transport_document_0`, `_1`, … */
+export const T1_TRANSPORT_FILE_PREFIX = "t1_transport_document_";
+
+export function isT1TransportFileCode(code: string | null | undefined): boolean {
+ if (!code) return false;
+ const lower = code.toLowerCase();
+ return lower === LEGACY_T1_TRANSPORT_CODE || lower.startsWith(T1_TRANSPORT_FILE_PREFIX);
+}
+
+export function t1TransportFileLabel(code: string, index?: number): string {
+ const lower = code.toLowerCase();
+ if (lower === LEGACY_T1_TRANSPORT_CODE) return "T1 Transport Document";
+ if (lower.startsWith(T1_TRANSPORT_FILE_PREFIX)) {
+ return index != null ? `T1 transport document ${index + 1}` : "T1 Transport Document";
+ }
+ return code;
+}
+
export const LEGACY_EXPORT_TRANSPORT_CODE = "export_transport_document";
export function isExportTransportFileCode(code: string | null | undefined): boolean {
diff --git a/packages/types/src/freight/contracts.ts b/packages/types/src/freight/contracts.ts
index f4c0b3380..c9d698f86 100644
--- a/packages/types/src/freight/contracts.ts
+++ b/packages/types/src/freight/contracts.ts
@@ -242,6 +242,20 @@ export interface ContractClearanceDocument {
reviewedByStaffId?: string | null;
}
+/**
+ * Post-allocation T1 transit document state for the booking linked to an import
+ * customs flow. GL Djibouti uploads after wagon allocation; uploads lock once the
+ * train departs; GL Ethiopia closes (accepts) T1 when the train arrives.
+ */
+export interface ClearanceT1State {
+ bookingId: string;
+ wagonAllocated: boolean;
+ trainDepartedAt: string | null;
+ trainArrivedAt: string | null;
+ closed: boolean;
+ closedAt?: string | null;
+}
+
export interface ContractClearanceView {
contractId: string;
/** Overall contract status (e.g. CLEARANCE_UNDER_REVIEW). */
@@ -283,6 +297,8 @@ export interface ContractClearanceView {
} | null;
/** Phased customs uploads (IM4, DO, transit permit, etc.) with friendly labels. */
workflowFiles?: import("./clearance-files.catalog").ClearanceWorkflowFile[];
+ /** Import post-allocation T1 transit document state (null until a booking is linked). */
+ t1?: ClearanceT1State | null;
}
export type ClearanceActorRole = "CUSTOMER" | "GL_ET" | "GL_DJ" | "OPERATIONS";
diff --git a/packages/types/src/freight/index.ts b/packages/types/src/freight/index.ts
index 2def8fa75..b05c9d8d1 100644
--- a/packages/types/src/freight/index.ts
+++ b/packages/types/src/freight/index.ts
@@ -548,6 +548,8 @@ export interface ClearanceView {
} | null;
/** Phased customs uploads (IM4, DO, transit permit, etc.) with friendly labels. */
workflowFiles?: import("./clearance-files.catalog").ClearanceWorkflowFile[];
+ /** Import post-allocation T1 transit document state (null until wagon allocation). */
+ t1?: import("./contracts").ClearanceT1State | null;
}
/** Company an invoice is billed to (minimal projection). */
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 8051dab4a..abd86df8f 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -344,6 +344,9 @@ importers:
'@tria-plc/iamui':
specifier: file:../../../local-packages/tria-plc-iamui-0.1.1.tgz
version: file:local-packages/tria-plc-iamui-0.1.1.tgz(0ce39b7e349029277dcd938d06eeb0f7)
+ '@vis.gl/react-google-maps':
+ specifier: ^1.8.3
+ version: 1.8.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
axios:
specifier: ^1.7.7
version: 1.17.0
@@ -356,9 +359,6 @@ importers:
date-fns:
specifier: ^3.6.0
version: 3.6.0
- leaflet:
- specifier: ^1.9.4
- version: 1.9.4
lucide-react:
specifier: ^1.14.0
version: 1.17.0(react@19.2.6)
@@ -377,9 +377,6 @@ importers:
react-hot-toast:
specifier: ^2.6.0
version: 2.6.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
- react-leaflet:
- specifier: ^5.0.0
- version: 5.0.0(leaflet@1.9.4)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
react-phone-number-input:
specifier: ^3.4.17
version: 3.4.17(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
@@ -411,9 +408,9 @@ importers:
'@tailwindcss/vite':
specifier: ^4.3.0
version: 4.3.0(vite@5.4.21(@types/node@24.13.1)(lightningcss@1.32.0)(terser@5.48.0))
- '@types/leaflet':
- specifier: ^1.9.21
- version: 1.9.21
+ '@types/google.maps':
+ specifier: ^3.65.2
+ version: 3.65.2
'@types/react':
specifier: ^18.3.11
version: 18.3.31
@@ -1701,6 +1698,9 @@ packages:
reflect-metadata: ^0.2.2
rxjs: ^7.x
+ '@googlemaps/js-api-loader@2.1.1':
+ resolution: {integrity: sha512-yUpAwksbHrlZIWD49JmveNSfBG4oAK0AwMknfSaPMnP5N7UT8oFRVCqwjGb1XQovi//7KLbPQKZpbofiLGzpDw==}
+
'@hello-pangea/dnd@18.0.1':
resolution: {integrity: sha512-xojVWG8s/TGrKT1fC8K2tIWeejJYTAeJuj36zM//yEm/ZrnZUSFGS15BpO+jGZT1ybWvyXmeDJwPYb4dhWlbZQ==}
peerDependencies:
@@ -3515,13 +3515,6 @@ packages:
'@radix-ui/rect@1.1.2':
resolution: {integrity: sha512-xnXE7wG13PI+cxieVssYXlQJuYVRhH9NBoxt3KNwzghDIA69GMm7d4wXRouHIYjE+KvS6U/MsMO73NdS2MH9ZA==}
- '@react-leaflet/core@3.0.0':
- resolution: {integrity: sha512-3EWmekh4Nz+pGcr+xjf0KNyYfC3U2JjnkWsh0zcqaexYqmmB5ZhH37kz41JXGmKzpaMZCnPofBBm64i+YrEvGQ==}
- peerDependencies:
- leaflet: ^1.9.0
- react: ^19.0.0
- react-dom: ^19.0.0
-
'@react-pdf-viewer/attachment@3.12.0':
resolution: {integrity: sha512-mhwrYJSIpCvHdERpLUotqhMgSjhtF+BTY1Yb9Fnzpcq3gLZP+Twp5Rynq21tCrVdDizPaVY7SKu400GkgdMfZw==}
peerDependencies:
@@ -4265,8 +4258,8 @@ packages:
'@types/express@5.0.6':
resolution: {integrity: sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==}
- '@types/geojson@7946.0.16':
- resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==}
+ '@types/google.maps@3.65.2':
+ resolution: {integrity: sha512-e52bmOhGCQSNabFpL48iQlwJybq6rfns8NUVJ20MR7CdPlHQ2RmSCnPbJfrUYJfogrE4OiHQTZ4LXpop+eer1w==}
'@types/graceful-fs@4.1.9':
resolution: {integrity: sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ==}
@@ -4303,9 +4296,6 @@ packages:
'@types/jsonwebtoken@9.0.5':
resolution: {integrity: sha512-VRLSGzik+Unrup6BsouBeHsf4d1hOEgYWTm/7Nmw1sXoN1+tRly/Gy/po3yeahnP4jfnQWWAhQAqcNfH7ngOkA==}
- '@types/leaflet@1.9.21':
- resolution: {integrity: sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==}
-
'@types/lodash@4.17.24':
resolution: {integrity: sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==}
@@ -4609,6 +4599,12 @@ packages:
cpu: [x64]
os: [win32]
+ '@vis.gl/react-google-maps@1.8.3':
+ resolution: {integrity: sha512-DW7nEuvOJ299DmdBnvGiUARrgS/+sTEO1iJgG9J8YaErZqLoq7S4TJ22f3EjJvR4dti4L4gft43JEK77nnKXDw==}
+ peerDependencies:
+ react: '>=16.8.0 || ^19.0 || ^19.0.0-rc'
+ react-dom: '>=16.8.0 || ^19.0 || ^19.0.0-rc'
+
'@vitejs/plugin-react@4.7.0':
resolution: {integrity: sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==}
engines: {node: ^14.18.0 || >=16.0.0}
@@ -7978,9 +7974,6 @@ packages:
resolution: {integrity: sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==}
engines: {node: '>= 0.6.3'}
- leaflet@1.9.4:
- resolution: {integrity: sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==}
-
leven@3.1.0:
resolution: {integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==}
engines: {node: '>=6'}
@@ -9368,13 +9361,6 @@ packages:
react-is@19.2.7:
resolution: {integrity: sha512-kZFnouyVv7eP/Phmrlo9FK+zcAdriZJvzxXHF1Sl1P377WSGe2G/JxVolhTrB/jeV47lKImhNUsijjHAAbcl/A==}
- react-leaflet@5.0.0:
- resolution: {integrity: sha512-CWbTpr5vcHw5bt9i4zSlPEVQdTVcML390TjeDG0cK59z1ylexpqC6M1PJFjV8jD7CF+ACBFsLIDs6DRMoLEofw==}
- peerDependencies:
- leaflet: ^1.9.0
- react: ^19.0.0
- react-dom: ^19.0.0
-
react-number-format@5.4.5:
resolution: {integrity: sha512-y8O2yHHj3w0aE9XO8d2BCcUOOdQTRSVq+WIuMlLVucAm5XNjJAy+BoOJiuQMldVYVOKTMyvVNfnbl2Oqp+YxGw==}
peerDependencies:
@@ -12134,6 +12120,10 @@ snapshots:
reflect-metadata: 0.2.2
rxjs: 7.8.2
+ '@googlemaps/js-api-loader@2.1.1':
+ dependencies:
+ '@types/google.maps': 3.65.2
+
'@hello-pangea/dnd@18.0.1(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
dependencies:
'@babel/runtime': 7.29.7
@@ -14803,12 +14793,6 @@ snapshots:
'@radix-ui/rect@1.1.2': {}
- '@react-leaflet/core@3.0.0(leaflet@1.9.4)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
- dependencies:
- leaflet: 1.9.4
- react: 19.2.6
- react-dom: 19.2.6(react@19.2.6)
-
'@react-pdf-viewer/attachment@3.12.0(pdfjs-dist@5.4.296)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
dependencies:
'@react-pdf-viewer/core': 3.12.0(pdfjs-dist@5.4.296)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
@@ -15809,7 +15793,7 @@ snapshots:
'@types/express-serve-static-core': 5.1.1
'@types/serve-static': 2.2.0
- '@types/geojson@7946.0.16': {}
+ '@types/google.maps@3.65.2': {}
'@types/graceful-fs@4.1.9':
dependencies:
@@ -15847,10 +15831,6 @@ snapshots:
dependencies:
'@types/node': 20.19.42
- '@types/leaflet@1.9.21':
- dependencies:
- '@types/geojson': 7946.0.16
-
'@types/lodash@4.17.24': {}
'@types/luxon@3.7.1': {}
@@ -16142,6 +16122,14 @@ snapshots:
'@unrs/resolver-binding-win32-x64-msvc@1.12.2':
optional: true
+ '@vis.gl/react-google-maps@1.8.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
+ dependencies:
+ '@googlemaps/js-api-loader': 2.1.1
+ '@types/google.maps': 3.65.2
+ fast-deep-equal: 3.1.3
+ react: 19.2.6
+ react-dom: 19.2.6(react@19.2.6)
+
'@vitejs/plugin-react@4.7.0(vite@5.4.21(@types/node@24.13.1)(lightningcss@1.32.0)(terser@5.48.0))':
dependencies:
'@babel/core': 7.29.7
@@ -20147,8 +20135,6 @@ snapshots:
dependencies:
readable-stream: 2.3.8
- leaflet@1.9.4: {}
-
leven@3.1.0: {}
levn@0.4.1:
@@ -21629,13 +21615,6 @@ snapshots:
react-is@19.2.7: {}
- react-leaflet@5.0.0(leaflet@1.9.4)(react-dom@19.2.6(react@19.2.6))(react@19.2.6):
- dependencies:
- '@react-leaflet/core': 3.0.0(leaflet@1.9.4)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
- leaflet: 1.9.4
- react: 19.2.6
- react-dom: 19.2.6(react@19.2.6)
-
react-number-format@5.4.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
dependencies:
react: 18.3.1
From df60c4750e93247f016502f780eeb32479810fd5 Mon Sep 17 00:00:00 2001
From: ghost2023
Date: Thu, 2 Jul 2026 16:32:35 +0300
Subject: [PATCH 24/86] fix: warehouse query
---
.../modules/first-mile/first-mile.service.ts | 10 +-
.../warehouses/warehouse-invoice.service.ts | 370 ++++++++++++------
2 files changed, 250 insertions(+), 130 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 3ba1e4e75..d05964878 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,10 +1,4 @@
-import {
- BadRequestException,
- ConflictException,
- Injectable,
- Logger,
- NotFoundException,
-} from "@nestjs/common";
+import { Injectable, Logger, NotFoundException } from "@nestjs/common";
import { FindOptionsWhere } from "typeorm";
import { InjectDataSource } from "@nestjs/typeorm";
import { DataSource } from "typeorm";
@@ -98,8 +92,6 @@ export class FirstMileService {
firstMilePickupAddress?: string | null;
serviceType?: { includesFirstMile?: boolean | null } | null;
}): Promise {
- const label = booking.reference ?? booking.id;
-
if (booking.paymentStatus !== "PAID") {
return null;
}
diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts
index 6f7219781..c5a75a8c5 100644
--- a/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts
+++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts
@@ -1,29 +1,39 @@
-import { BadRequestException, ConflictException, Injectable, Logger, NotFoundException } from '@nestjs/common';
-import { OnEvent } from '@nestjs/event-emitter';
-import { Freight } from '@edr/types';
-import { DataSource } from 'typeorm';
+import {
+ BadRequestException,
+ ConflictException,
+ Injectable,
+ Logger,
+ NotFoundException,
+} from "@nestjs/common";
+import { OnEvent } from "@nestjs/event-emitter";
+import { Freight } from "@edr/types";
+import { DataSource } from "typeorm";
-import { BillingService, InvoiceEventPayload, InvoiceLineInput } from '../billing/billing.service';
-import { Invoice } from '../billing/entities/invoice.entity';
-import { InvoiceLine } from '../billing/entities/invoice-line.entity';
+import {
+ BillingService,
+ InvoiceEventPayload,
+ InvoiceLineInput,
+} from "../billing/billing.service";
+import { Invoice } from "../billing/entities/invoice.entity";
+import { InvoiceLine } from "../billing/entities/invoice-line.entity";
import {
InvoiceDocumentModel,
InvoiceDocumentService,
-} from '../billing/documents/invoice-document.service';
-import { NotificationsService } from '../notifications/notifications.service';
-import { WarehouseFeeService } from './warehouse-fee.service';
+} from "../billing/documents/invoice-document.service";
+import { NotificationsService } from "../notifications/notifications.service";
+import { WarehouseFeeService } from "./warehouse-fee.service";
import {
WarehouseFeeInvoiceView,
WarehouseFeeType,
WarehouseInvoiceItemView,
WarehouseInvoiceStatus,
WarehouseInvoiceType,
-} from './warehouse-invoice.types';
+} from "./warehouse-invoice.types";
interface GenerateOptions {
confirmZero?: boolean;
performedBy?: string;
- billingCurrency?: 'ETB' | 'USD';
+ billingCurrency?: "ETB" | "USD";
}
export interface PayInvoiceDto {
@@ -45,7 +55,10 @@ const BLOCKING_STATUSES: Freight.InvoiceStatus[] = [
Freight.InvoiceStatus.Overdue,
];
/** Global statuses considered an "active" invoice for per-inventory dedup. */
-const ACTIVE_STATUSES: Freight.InvoiceStatus[] = [...BLOCKING_STATUSES, Freight.InvoiceStatus.Paid];
+const ACTIVE_STATUSES: Freight.InvoiceStatus[] = [
+ ...BLOCKING_STATUSES,
+ Freight.InvoiceStatus.Paid,
+];
export interface InvoiceDocumentDetails {
bookingReference: string | null;
@@ -120,10 +133,13 @@ export class WarehouseInvoiceService {
private readonly invoiceDocuments: InvoiceDocumentService,
private readonly feeService: WarehouseFeeService,
private readonly notifications: NotificationsService,
- ) {}
+ ) { }
// ── Generation ───────────────────────────────────────────────────────────
- async generateForInventory(inventoryId: string, opts: GenerateOptions = {}): Promise {
+ async generateForInventory(
+ inventoryId: string,
+ opts: GenerateOptions = {},
+ ): Promise {
const [item] = await this.dataSource.query(
`SELECT inv.id, inv.booking_id AS "bookingId", inv.warehouse_id AS "warehouseId",
inv.yard_id AS "yardId", inv.zone_id AS "zoneId", inv.arrived_at AS "arrivedAt",
@@ -136,43 +152,47 @@ export class WarehouseInvoiceService {
WHERE inv.id = $1 AND inv.deleted_at IS NULL`,
[inventoryId],
);
- if (!item) throw new NotFoundException(`Inventory item ${inventoryId} not found`);
+ if (!item)
+ throw new NotFoundException(`Inventory item ${inventoryId} not found`);
// Routing through the global invoice requires a billable company + profile,
// both of which come from the inventory's booking.
if (!item.companyId || !item.companyProfileId) {
throw new BadRequestException(
- 'Cannot generate a warehouse fee invoice: the inventory item has no billable company (no associated booking).',
+ "Cannot generate a warehouse fee invoice: the inventory item has no billable company (no associated booking).",
);
}
// Dedup: only one active (non-cancelled) invoice per inventory item.
if (await this.hasActiveInvoice(inventoryId)) {
throw new ConflictException(
- 'An active warehouse fee invoice already exists for this item. Cancel it before generating a new one.',
+ "An active warehouse fee invoice already exists for this item. Cancel it before generating a new one.",
);
}
- const billingCurrency = opts.billingCurrency === 'ETB' ? 'ETB' : 'USD';
- const previews = await this.feeService.previewForInventory(inventoryId, billingCurrency);
- const isContainer = (item.freightType ?? '').toUpperCase() === 'CONTAINER';
+ const billingCurrency = opts.billingCurrency === "ETB" ? "ETB" : "USD";
+ const previews = await this.feeService.previewForInventory(
+ inventoryId,
+ billingCurrency,
+ );
+ const isContainer = (item.freightType ?? "").toUpperCase() === "CONTAINER";
const items = previews
.filter((p) => p.amount > 0)
.map((p) => {
const feeType: WarehouseFeeType =
- p.ruleType === 'STORAGE_FEE'
- ? 'STORAGE_FEE'
+ p.ruleType === "STORAGE_FEE"
+ ? "STORAGE_FEE"
: isContainer
- ? 'CONTAINER_DEMURRAGE'
- : 'BULK_DEMURRAGE';
+ ? "CONTAINER_DEMURRAGE"
+ : "BULK_DEMURRAGE";
return {
feeRuleId: p.ruleId,
feeType,
description:
- p.ruleType === 'STORAGE_FEE'
+ p.ruleType === "STORAGE_FEE"
? `Storage fee - ${p.chargeableDays} chargeable day(s) x ${p.containerCount} container(s) after ${p.freeDays} free`
- : `${isContainer ? 'Container' : 'Bulk'} demurrage - ${p.chargeableDays} chargeable day(s) x ${p.containerCount} container(s) after ${p.freeDays} free`,
+ : `${isContainer ? "Container" : "Bulk"} demurrage - ${p.chargeableDays} chargeable day(s) x ${p.containerCount} container(s) after ${p.freeDays} free`,
quantity: p.billableUnits,
unitRate: p.ratePerDay,
amount: p.amount,
@@ -184,13 +204,19 @@ export class WarehouseInvoiceService {
const total = items.reduce((s, i) => s + i.amount, 0);
if (total <= 0 && !opts.confirmZero) {
- throw new BadRequestException('No payable warehouse fee found for this item.');
+ throw new BadRequestException(
+ "No payable warehouse fee found for this item.",
+ );
}
- const hasDemurrage = items.some((i) => i.feeType !== 'STORAGE_FEE');
- const hasStorage = items.some((i) => i.feeType === 'STORAGE_FEE');
+ const hasDemurrage = items.some((i) => i.feeType !== "STORAGE_FEE");
+ const hasStorage = items.some((i) => i.feeType === "STORAGE_FEE");
const invoiceType: WarehouseInvoiceType =
- hasDemurrage && hasStorage ? 'MIXED_WAREHOUSE_FEES' : hasStorage ? 'STORAGE_FEE' : 'DEMURRAGE';
+ hasDemurrage && hasStorage
+ ? "MIXED_WAREHOUSE_FEES"
+ : hasStorage
+ ? "STORAGE_FEE"
+ : "DEMURRAGE";
const lines: InvoiceLineInput[] = items.map((it) => ({
chargeType: it.feeType,
@@ -232,18 +258,23 @@ export class WarehouseInvoiceService {
}
listForInventory(inventoryId: string): Promise {
- return this.queryViews('AND i.source_id = $1', [inventoryId]);
+ return this.queryViews("AND i.source_id = $1", [inventoryId]);
}
listForBooking(bookingId: string): Promise {
- return this.queryViews('AND inv.booking_id = $1', [bookingId]);
+ return this.queryViews("AND inv.booking_id = $1", [bookingId]);
}
async findAll(
filter: Partial<
Pick<
WarehouseFeeInvoiceView,
- 'status' | 'invoiceType' | 'warehouseId' | 'facilityId' | 'customerId' | 'bookingId'
+ | "status"
+ | "invoiceType"
+ | "warehouseId"
+ | "facilityId"
+ | "customerId"
+ | "bookingId"
>
>,
): Promise {
@@ -254,41 +285,56 @@ export class WarehouseInvoiceService {
conditions.push(sql(`$${params.length}`));
};
- if (filter.status) add((p) => `i.status::text = ${p}`, this.toGlobalStatus(filter.status as WarehouseInvoiceStatus));
+ if (filter.status)
+ add(
+ (p) => `i.status::text = ${p}`,
+ this.toGlobalStatus(filter.status as WarehouseInvoiceStatus),
+ );
if (filter.invoiceType) add((p) => `i.type = ${p}`, filter.invoiceType);
if (filter.customerId) add((p) => `i.company_id = ${p}`, filter.customerId);
- if (filter.warehouseId) add((p) => `inv.warehouse_id = ${p}`, filter.warehouseId);
- if (filter.facilityId) add((p) => `w.facility_id = ${p}`, filter.facilityId);
+ if (filter.warehouseId)
+ add((p) => `inv.warehouse_id = ${p}`, filter.warehouseId);
+ if (filter.facilityId)
+ add((p) => `w.facility_id = ${p}`, filter.facilityId);
if (filter.bookingId) add((p) => `inv.booking_id = ${p}`, filter.bookingId);
- return this.queryViews(conditions.map((c) => `AND ${c}`).join(' '), params);
+ return this.queryViews(conditions.map((c) => `AND ${c}`).join(" "), params);
}
async document(id: string): Promise<{ filename: string; buffer: Buffer }> {
const invoice = await this.findById(id);
- return this.invoiceDocuments.render(this.toDocumentModel(invoice, 'INVOICE'));
+ return this.invoiceDocuments.render(
+ this.toDocumentModel(invoice, "INVOICE"),
+ );
}
async receipt(id: string): Promise<{ filename: string; buffer: Buffer }> {
const invoice = await this.findById(id);
if (Number(invoice.paidAmount) <= 0) {
- throw new BadRequestException('A receipt is available only after payment is recorded.');
+ throw new BadRequestException(
+ "A receipt is available only after payment is recorded.",
+ );
}
- return this.invoiceDocuments.render(this.toDocumentModel(invoice, 'RECEIPT'));
+ return this.invoiceDocuments.render(
+ this.toDocumentModel(invoice, "RECEIPT"),
+ );
}
// ── State changes ────────────────────────────────────────────────────────
async cancel(id: string): Promise {
const invoice = await this.loadWarehouseInvoice(id);
if (invoice.status === Freight.InvoiceStatus.Paid) {
- throw new BadRequestException('A paid invoice cannot be cancelled.');
+ throw new BadRequestException("A paid invoice cannot be cancelled.");
}
await this.billing.cancelInvoice(id);
return this.findById(id);
}
/** Record a payment against the invoice (delegates settlement to billing). */
- async pay(id: string, dto: PayInvoiceDto): Promise {
+ async pay(
+ id: string,
+ dto: PayInvoiceDto,
+ ): Promise {
// Guard that this is a warehouse invoice before recording (404 otherwise).
await this.loadWarehouseInvoice(id);
await this.billing.recordPayment(id, {
@@ -297,7 +343,10 @@ export class WarehouseInvoiceService {
reference: dto.reference ?? null,
metadata:
dto.driverName || dto.driverPhone
- ? { driverName: dto.driverName ?? null, driverPhone: dto.driverPhone ?? null }
+ ? {
+ driverName: dto.driverName ?? null,
+ driverPhone: dto.driverPhone ?? null,
+ }
: null,
});
const detail = await this.findById(id);
@@ -313,16 +362,20 @@ export class WarehouseInvoiceService {
* counter settlement leaves it null. Skipping null-`paymentId` events avoids
* double-notifying a counter payment that already sent its SMS.
*/
- @OnEvent('warehouse.invoice.paid')
+ @OnEvent("warehouse.invoice.paid")
async onWarehouseInvoicePaid(payload: InvoiceEventPayload): Promise {
if (!payload.paymentId) return;
const detail = await this.findById(payload.invoiceId);
- await this.notifyWarehouseFeePayment(detail, { amount: Number(detail.totalAmount) });
+ await this.notifyWarehouseFeePayment(detail, {
+ amount: Number(detail.totalAmount),
+ });
}
// ── Release blocking ──────────────────────────────────────────────────────
/** Returns the first unpaid invoice that blocks terminal release, or null. */
- async findBlockingInvoice(inventoryId: string): Promise {
+ async findBlockingInvoice(
+ inventoryId: string,
+ ): Promise {
const blocking = await this.queryViews(
`AND i.source_id = $1 AND i.status::text = ANY($2::text[])`,
[inventoryId, BLOCKING_STATUSES],
@@ -331,21 +384,31 @@ export class WarehouseInvoiceService {
}
async assertClearanceAllowed(inventoryId: string): Promise {
- const invoices = await this.queryViews('AND i.source_id = $1', [inventoryId]);
- const blocking = invoices.find((inv) => inv.status === 'ISSUED' || inv.status === 'PARTIALLY_PAID');
+ const invoices = await this.queryViews("AND i.source_id = $1", [
+ inventoryId,
+ ]);
+ const blocking = invoices.find(
+ (inv) => inv.status === "ISSUED" || inv.status === "PARTIALLY_PAID",
+ );
if (blocking) {
throw new BadRequestException(
`Warehouse demurrage/storage invoice ${blocking.invoiceNumber} must be fully paid before terminal release.`,
);
}
- if (invoices.some((inv) => inv.status === 'PAID')) return;
+ if (invoices.some((inv) => inv.status === "PAID")) return;
- const previews = await this.feeService.previewForInventory(inventoryId, 'USD');
- const payableAmount = previews.reduce((sum, fee) => sum + Number(fee.amount || 0), 0);
+ const previews = await this.feeService.previewForInventory(
+ inventoryId,
+ "USD",
+ );
+ const payableAmount = previews.reduce(
+ (sum, fee) => sum + Number(fee.amount || 0),
+ 0,
+ );
if (payableAmount > 0) {
throw new BadRequestException(
- 'Generate and fully pay the warehouse demurrage/storage invoice before terminal release.',
+ "Generate and fully pay the warehouse demurrage/storage invoice before terminal release.",
);
}
}
@@ -353,7 +416,9 @@ export class WarehouseInvoiceService {
// ── Internal: loading & projection ─────────────────────────────────────────
/** Load a global invoice (+lines) and assert it is a warehouse fee invoice. */
- private async loadWarehouseInvoice(id: string): Promise {
+ private async loadWarehouseInvoice(
+ id: string,
+ ): Promise {
const invoice = await this.billing.findById(id);
if (invoice.source !== SOURCE) {
throw new NotFoundException(`Invoice ${id} not found`);
@@ -376,7 +441,10 @@ export class WarehouseInvoiceService {
* Project warehouse-source global invoices into the historical view, joined to
* their inventory item for the typed FKs. Powers every list/filter read.
*/
- private async queryViews(extraWhere: string, params: unknown[]): Promise {
+ private async queryViews(
+ extraWhere: string,
+ params: unknown[],
+ ): Promise {
const rows = await this.dataSource.query(
`SELECT i.id, i.invoice_number AS "invoiceNumber", i.company_id AS "companyId",
i.source_id AS "sourceId", i.type, i.status,
@@ -389,7 +457,7 @@ export class WarehouseInvoiceService {
inv.yard_id AS "yardId", inv.zone_id AS "zoneId", inv.arrived_at AS "periodStart",
w.facility_id AS "facilityId"
FROM freight.invoices i
- LEFT JOIN freight.warehouse_inventory inv ON inv.id = i.source_id AND inv.deleted_at IS NULL
+ LEFT JOIN freight.warehouse_inventory inv ON inv.id::text = i.source_id AND inv.deleted_at IS NULL
LEFT JOIN freight.warehouses w ON w.id = inv.warehouse_id
WHERE i.source = $${params.length + 1} AND i.deleted_at IS NULL ${extraWhere}
ORDER BY i.created_at DESC`,
@@ -409,7 +477,10 @@ export class WarehouseInvoiceService {
}
/** Reshape a global invoice (+ derived inventory context) into the warehouse view. */
- private buildView(inv: ViewSource, ctx: InventoryContext): WarehouseFeeInvoiceView {
+ private buildView(
+ inv: ViewSource,
+ ctx: InventoryContext,
+ ): WarehouseFeeInvoiceView {
const status = this.toWarehouseStatus(inv.status);
return {
id: inv.id,
@@ -436,7 +507,7 @@ export class WarehouseInvoiceService {
issuedAt: inv.issuedAt ?? null,
dueDate: inv.dueAt ?? null,
paidAt: inv.paidAt ?? null,
- cancelledAt: status === 'CANCELLED' ? inv.updatedAt : null,
+ cancelledAt: status === "CANCELLED" ? inv.updatedAt : null,
payments: (inv.payments ?? []).map((p) => ({
amount: Number(p.amount),
method: p.method ?? null,
@@ -458,7 +529,7 @@ export class WarehouseInvoiceService {
return {
feeRuleId: meta.feeRuleId ?? null,
feeType: line.chargeType as WarehouseFeeType,
- description: line.description ?? '',
+ description: line.description ?? "",
quantity: Number(line.quantity),
unitRate: Number(line.unitRate),
amount: Number(line.amount),
@@ -468,32 +539,36 @@ export class WarehouseInvoiceService {
};
}
- private toWarehouseStatus(status: Freight.InvoiceStatus | string): WarehouseInvoiceStatus {
+ private toWarehouseStatus(
+ status: Freight.InvoiceStatus | string,
+ ): WarehouseInvoiceStatus {
switch (status) {
case Freight.InvoiceStatus.Draft:
- return 'DRAFT';
+ return "DRAFT";
case Freight.InvoiceStatus.PartiallyPaid:
- return 'PARTIALLY_PAID';
+ return "PARTIALLY_PAID";
case Freight.InvoiceStatus.Paid:
- return 'PAID';
+ return "PAID";
case Freight.InvoiceStatus.Cancelled:
case Freight.InvoiceStatus.Refunded:
- return 'CANCELLED';
+ return "CANCELLED";
default:
// Issued / Pending / Overdue → an issued, still-owed invoice.
- return 'ISSUED';
+ return "ISSUED";
}
}
- private toGlobalStatus(status: WarehouseInvoiceStatus): Freight.InvoiceStatus {
+ private toGlobalStatus(
+ status: WarehouseInvoiceStatus,
+ ): Freight.InvoiceStatus {
switch (status) {
- case 'DRAFT':
+ case "DRAFT":
return Freight.InvoiceStatus.Draft;
- case 'PARTIALLY_PAID':
+ case "PARTIALLY_PAID":
return Freight.InvoiceStatus.PartiallyPaid;
- case 'PAID':
+ case "PAID":
return Freight.InvoiceStatus.Paid;
- case 'CANCELLED':
+ case "CANCELLED":
return Freight.InvoiceStatus.Cancelled;
default:
return Freight.InvoiceStatus.Issued;
@@ -503,39 +578,54 @@ export class WarehouseInvoiceService {
/** Map a warehouse fee invoice view onto the shared document model. */
private toDocumentModel(
invoice: WarehouseFeeInvoiceDetail,
- kind: 'INVOICE' | 'RECEIPT',
+ kind: "INVOICE" | "RECEIPT",
): InvoiceDocumentModel {
const lastPayment = [...(invoice.payments ?? [])].pop();
const date = (value: unknown) =>
- value ? new Date(value as string | Date).toLocaleDateString('en-GB') : null;
+ value
+ ? new Date(value as string | Date).toLocaleDateString("en-GB")
+ : null;
return {
kind,
- title: 'Warehouse Fee',
+ title: "Warehouse Fee",
documentNumber: invoice.invoiceNumber,
issuedAt: invoice.issuedAt ?? invoice.createdAt,
status: invoice.status,
currency: invoice.currency,
summary: [
- { label: 'Status', value: invoice.status.replace(/_/g, ' ') },
- { label: 'Invoice type', value: invoice.invoiceType.replace(/_/g, ' ') },
- { label: 'Booking reference', value: invoice.bookingReference ?? null },
- { label: 'Customer', value: invoice.customerName ?? null },
- { label: 'Inventory reference', value: invoice.inventoryReference ?? null },
- { label: 'Inventory info', value: invoice.inventoryInfo ?? null },
- { label: 'Clearance', value: invoice.clearanceStatus ?? null },
- { label: 'Warehouse', value: invoice.warehouseName ?? null },
+ { label: "Status", value: invoice.status.replace(/_/g, " ") },
{
- label: 'Yard / Zone',
- value: [invoice.yardName, invoice.zoneName].filter(Boolean).join(' / ') || null,
+ label: "Invoice type",
+ value: invoice.invoiceType.replace(/_/g, " "),
},
- { label: 'Period', value: `${date(invoice.periodStart) ?? '-'} - ${date(invoice.periodEnd) ?? '-'}` },
+ { label: "Booking reference", value: invoice.bookingReference ?? null },
+ { label: "Customer", value: invoice.customerName ?? null },
{
- label: 'Payment',
- value: lastPayment ? `${lastPayment.method ?? 'MANUAL'} / ${date(lastPayment.paidAt) ?? '-'}` : null,
+ label: "Inventory reference",
+ value: invoice.inventoryReference ?? null,
+ },
+ { label: "Inventory info", value: invoice.inventoryInfo ?? null },
+ { label: "Clearance", value: invoice.clearanceStatus ?? null },
+ { label: "Warehouse", value: invoice.warehouseName ?? null },
+ {
+ label: "Yard / Zone",
+ value:
+ [invoice.yardName, invoice.zoneName].filter(Boolean).join(" / ") ||
+ null,
+ },
+ {
+ label: "Period",
+ value: `${date(invoice.periodStart) ?? "-"} - ${date(invoice.periodEnd) ?? "-"}`,
+ },
+ {
+ label: "Payment",
+ value: lastPayment
+ ? `${lastPayment.method ?? "MANUAL"} / ${date(lastPayment.paidAt) ?? "-"}`
+ : null,
},
],
- categoryHeader: 'Fee type',
+ categoryHeader: "Fee type",
lines: invoice.items.map((item) => ({
description: item.description ?? null,
category: item.feeType ?? null,
@@ -545,17 +635,19 @@ export class WarehouseInvoiceService {
currency: item.currency ?? invoice.currency,
})),
totals: [
- { label: 'Subtotal', amount: Number(invoice.subtotalAmount) },
- { label: 'Tax', amount: Number(invoice.taxAmount) },
- { label: 'Total', amount: Number(invoice.totalAmount), grand: true },
- { label: 'Paid', amount: Number(invoice.paidAmount) },
- { label: 'Balance', amount: Number(invoice.balanceAmount) },
+ { label: "Subtotal", amount: Number(invoice.subtotalAmount) },
+ { label: "Tax", amount: Number(invoice.taxAmount) },
+ { label: "Total", amount: Number(invoice.totalAmount), grand: true },
+ { label: "Paid", amount: Number(invoice.paidAmount) },
+ { label: "Balance", amount: Number(invoice.balanceAmount) },
],
};
}
/** Warehouse-specific display details, derived from the linked inventory item. */
- private async getInvoiceDocumentDetails(invoice: ViewSource): Promise {
+ private async getInvoiceDocumentDetails(
+ invoice: ViewSource,
+ ): Promise {
const [row] = await this.dataSource.query(
`SELECT b.reference AS "bookingReference",
company.name AS "customerName",
@@ -591,12 +683,12 @@ export class WarehouseInvoiceService {
[invoice.sourceId],
);
- const fullyPaid = this.toWarehouseStatus(invoice.status) === 'PAID';
+ const fullyPaid = this.toWarehouseStatus(invoice.status) === "PAID";
const clearanceStatus = row?.releaseDate
- ? 'RELEASE ISSUED'
+ ? "RELEASE ISSUED"
: fullyPaid
- ? 'FEE PAID - READY FOR RELEASE'
- : 'PENDING PAYMENT';
+ ? "FEE PAID - READY FOR RELEASE"
+ : "PENDING PAYMENT";
return {
bookingReference: row?.bookingReference ?? null,
@@ -613,7 +705,9 @@ export class WarehouseInvoiceService {
};
}
- private async getInventoryContext(inventoryId: string): Promise {
+ private async getInventoryContext(
+ inventoryId: string,
+ ): Promise {
const [row] = await this.dataSource.query(
`SELECT inv.booking_id AS "bookingId", inv.warehouse_id AS "warehouseId",
inv.yard_id AS "yardId", inv.zone_id AS "zoneId", inv.arrived_at AS "periodStart",
@@ -701,55 +795,89 @@ export class WarehouseInvoiceService {
};
}
- private async sendSms(recipient: string | null | undefined, message: string, context: string): Promise {
+ private async sendSms(
+ recipient: string | null | undefined,
+ message: string,
+ context: string,
+ ): Promise {
const phone = recipient?.trim();
if (!phone) return;
try {
- await this.notifications.directSend('sms', phone, message);
+ await this.notifications.directSend("sms", phone, message);
} catch (error) {
- this.logger.error(`Failed to send ${context} SMS to ${phone}: ${String(error)}`);
+ this.logger.error(
+ `Failed to send ${context} SMS to ${phone}: ${String(error)}`,
+ );
}
}
- private async notifyWarehouseFeeIssued(invoice: WarehouseFeeInvoiceView): Promise {
- const contacts = await this.getInvoiceNotificationContacts(invoice.inventoryId);
- const customerName = contacts.customerName?.trim() || 'Customer';
- const bookingReference = contacts.bookingReference ? ` Booking: ${contacts.bookingReference}.` : '';
+ private async notifyWarehouseFeeIssued(
+ invoice: WarehouseFeeInvoiceView,
+ ): Promise {
+ const contacts = await this.getInvoiceNotificationContacts(
+ invoice.inventoryId,
+ );
+ const customerName = contacts.customerName?.trim() || "Customer";
+ const bookingReference = contacts.bookingReference
+ ? ` Booking: ${contacts.bookingReference}.`
+ : "";
const cargo = contacts.containerNumber || contacts.cargoDescription;
- const cargoText = cargo ? ` Cargo: ${cargo}.` : '';
+ const cargoText = cargo ? ` Cargo: ${cargo}.` : "";
const message =
- `Dear ${customerName}, warehouse ${invoice.invoiceType.replace(/_/g, ' ').toLowerCase()} fee ` +
+ `Dear ${customerName}, warehouse ${invoice.invoiceType.replace(/_/g, " ").toLowerCase()} fee ` +
`${invoice.invoiceNumber} is due.${bookingReference}${cargoText} Amount: ` +
`${Number(invoice.totalAmount).toLocaleString()} ${invoice.currency}. Please pay before cargo pickup.`;
- await this.sendSms(contacts.customerPhone, message, `warehouse fee invoice ${invoice.invoiceNumber}`);
+ await this.sendSms(
+ contacts.customerPhone,
+ message,
+ `warehouse fee invoice ${invoice.invoiceNumber}`,
+ );
}
- private async notifyWarehouseFeePayment(invoice: WarehouseFeeInvoiceView, dto: PayInvoiceDto): Promise {
- const contacts = await this.getInvoiceNotificationContacts(invoice.inventoryId);
- const customerName = contacts.customerName?.trim() || 'Customer';
- const bookingReference = contacts.bookingReference ? ` Booking: ${contacts.bookingReference}.` : '';
+ private async notifyWarehouseFeePayment(
+ invoice: WarehouseFeeInvoiceView,
+ dto: PayInvoiceDto,
+ ): Promise {
+ const contacts = await this.getInvoiceNotificationContacts(
+ invoice.inventoryId,
+ );
+ const customerName = contacts.customerName?.trim() || "Customer";
+ const bookingReference = contacts.bookingReference
+ ? ` Booking: ${contacts.bookingReference}.`
+ : "";
const statusText =
- invoice.status === 'PAID'
- ? 'fully paid and ready for pickup release'
+ invoice.status === "PAID"
+ ? "fully paid and ready for pickup release"
: `partially paid. Balance: ${Number(invoice.balanceAmount).toLocaleString()} ${invoice.currency}`;
const customerMessage =
`Dear ${customerName}, payment of ${Number(dto.amount).toLocaleString()} ${invoice.currency} ` +
`was recorded for warehouse fee ${invoice.invoiceNumber}.${bookingReference} Status: ${statusText}.`;
- await this.sendSms(contacts.customerPhone, customerMessage, `warehouse fee payment ${invoice.invoiceNumber}`);
+ await this.sendSms(
+ contacts.customerPhone,
+ customerMessage,
+ `warehouse fee payment ${invoice.invoiceNumber}`,
+ );
- if (invoice.status !== 'PAID') return;
+ if (invoice.status !== "PAID") return;
const driverPhone = dto.driverPhone?.trim() || contacts.driverPhone;
- const driverName = dto.driverName?.trim() || contacts.driverName || 'Driver';
+ const driverName =
+ dto.driverName?.trim() || contacts.driverName || "Driver";
const cargo = contacts.containerNumber || contacts.cargoDescription;
const driverMessage =
`Dear ${driverName}, warehouse demurrage/storage fee ${invoice.invoiceNumber} is paid.` +
- (contacts.bookingReference ? ` Booking: ${contacts.bookingReference}.` : '') +
- (cargo ? ` Cargo: ${cargo}.` : '') +
- ' Proceed with pickup after gate verification.';
+ (contacts.bookingReference
+ ? ` Booking: ${contacts.bookingReference}.`
+ : "") +
+ (cargo ? ` Cargo: ${cargo}.` : "") +
+ " Proceed with pickup after gate verification.";
- await this.sendSms(driverPhone, driverMessage, `warehouse pickup driver ${invoice.invoiceNumber}`);
+ await this.sendSms(
+ driverPhone,
+ driverMessage,
+ `warehouse pickup driver ${invoice.invoiceNumber}`,
+ );
}
}
From 71894ae2d3442baa7c0984ea4cbd38b5a53104ef Mon Sep 17 00:00:00 2001
From: Marshal
Date: Thu, 2 Jul 2026 13:43:07 +0000
Subject: [PATCH 25/86] merge conflict
---
apps/edr-freight-api/src/app.module.ts | 4 -
.../seed/paid-indode-demo-bookings.seeder.ts | 1131 -----------------
.../src/pages/billing/InvoiceDetailPage.tsx | 30 -
3 files changed, 1165 deletions(-)
delete mode 100644 apps/edr-freight-api/src/seed/paid-indode-demo-bookings.seeder.ts
diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts
index a81a539ba..f40e4f40f 100644
--- a/apps/edr-freight-api/src/app.module.ts
+++ b/apps/edr-freight-api/src/app.module.ts
@@ -59,7 +59,6 @@ import { FreightPermissionKeyMigrationSeeder } from "./seed/freight-permission-k
import { DemoFreightDataSeeder } from "./seed/demo-freight-data.seeder";
import { GovCompaniesSeeder } from "./seed/gov-companies.seeder";
import { ApprovedFirstLastMileDemoBookingsSeeder } from "./seed/approved-first-lastmile-demo-bookings.seeder";
-import { PaidIndodeDemoBookingsSeeder } from "./seed/paid-indode-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';
@@ -161,7 +160,6 @@ import { ImportOperationsModule } from './modules/import-operations/import-opera
ExportDjiboutiInterchangeDemoSeeder,
MarshallingDemoTrainsSeeder,
ApprovedFirstLastMileDemoBookingsSeeder,
- PaidIndodeDemoBookingsSeeder,
],
})
export class AppModule implements OnApplicationBootstrap {
@@ -180,7 +178,6 @@ export class AppModule implements OnApplicationBootstrap {
private readonly warehouseDemoSeeder: WarehouseDemoSeeder,
private readonly exportDjiboutiInterchangeDemoSeeder: ExportDjiboutiInterchangeDemoSeeder,
private readonly marshallingDemoTrainsSeeder: MarshallingDemoTrainsSeeder,
- private readonly paidIndodeDemoBookingsSeeder: PaidIndodeDemoBookingsSeeder,
private readonly freightPermissionKeyMigrationSeeder: FreightPermissionKeyMigrationSeeder,
private readonly demoFreightDataSeeder: DemoFreightDataSeeder,
private readonly govCompaniesSeeder: GovCompaniesSeeder,
@@ -202,7 +199,6 @@ export class AppModule implements OnApplicationBootstrap {
await this.warehouseDemoSeeder.run();
await this.exportDjiboutiInterchangeDemoSeeder.run();
await this.marshallingDemoTrainsSeeder.run();
- await this.paidIndodeDemoBookingsSeeder.run();
// Idempotent demo data: ≥100 wagons/type, approval chains, 4 staff users.
// Each block self-guards on an empty-table check, so this is safe every boot.
// Demo data seeds (DemoBookingsSeeder, PricingDataSeeder,
diff --git a/apps/edr-freight-api/src/seed/paid-indode-demo-bookings.seeder.ts b/apps/edr-freight-api/src/seed/paid-indode-demo-bookings.seeder.ts
deleted file mode 100644
index e33baa7fd..000000000
--- a/apps/edr-freight-api/src/seed/paid-indode-demo-bookings.seeder.ts
+++ /dev/null
@@ -1,1131 +0,0 @@
-import { Injectable, Logger } from '@nestjs/common';
-import { CargoUnitOfMeasure, TrainScheduleStatus, WagonStatus } from '@edr/types';
-import { randomUUID } from 'crypto';
-import { DataSource, EntityManager, In } from 'typeorm';
-
-import { BookingContainer } from '../modules/bookings/entities/booking-container.entity';
-import { Booking } from '../modules/bookings/entities/booking.entity';
-import {
- Company,
- CompanyKind,
- CompanyNationality,
- CompanyStatus,
- CompanyType,
-} from '../modules/companies/entities/company.entity';
-import {
- CompanyProfile,
- ProfileStatus,
- ProfileType,
-} from '../modules/companies/entities/company-profile.entity';
-import { FirstMile } from '../modules/first-mile/entities/first-mile.entity';
-import { LastMile } from '../modules/last-mile/entities/last-mile.entity';
-import { Locomotive } from '../modules/locomotives/entities/locomotive.entity';
-import { CargoType } from '../modules/rule-engine/entities/cargo-type.entity';
-import { ContainerType } from '../modules/rule-engine/entities/container-type.entity';
-import { ServiceType } from '../modules/rule-engine/entities/service-type.entity';
-import { Yard } from '../modules/rule-engine/entities/yard.entity';
-import { WagonAllocationContainerItem } from '../modules/train-schedules/entities/wagon-allocation-container-item.entity';
-import { WagonBookingAllocation } from '../modules/train-schedules/entities/wagon-booking-allocation.entity';
-import { TrainSchedule } from '../modules/train-schedules/entities/train-schedule.entity';
-import { TrainScheduleBooking } from '../modules/train-schedules/entities/train-schedule-booking.entity';
-import { TrainSetWagon } from '../modules/train-sets/entities/train-set-wagon.entity';
-import { TrainSet } from '../modules/train-sets/entities/train-set.entity';
-import { ImportDjiboutiOperation } from '../modules/train-scheduling/entities/import-djibouti-operation.entity';
-import { WagonType } from '../modules/wagon-types/entities/wagon-type.entity';
-import { Wagon } from '../modules/wagons/entities/wagon.entity';
-import { WarehouseInventory } from '../modules/warehouses/entities/warehouse-inventory.entity';
-import { WarehouseActivityLog } from '../modules/warehouses/entities/warehouse-activity-log.entity';
-import { Warehouse } from '../modules/warehouses/entities/warehouse.entity';
-import { WarehouseYard } from '../modules/warehouses/entities/warehouse-yard.entity';
-import { WarehouseZone } from '../modules/warehouses/entities/warehouse-zone.entity';
-import { Driver, DriverStatus } from '../modules/drivers/entities/driver.entity';
-import { FuelType, Vehicle, VehicleStatus, VehicleType } from '../modules/vehicles/entities/vehicle.entity';
-
-const CUSTOMER_TIN = 'US12DEMO01';
-
-const DEMO_TRAINS = [
- {
- trainNumber: 'US12-DJI-IND-01',
- direction: 'IMPORT',
- originCode: 'NAGAD',
- destinationCode: 'INDODE',
- departureHoursAgo: 30,
- arrivalHoursAgo: 14,
- },
- {
- trainNumber: 'US12-IND-DJI-01',
- direction: 'EXPORT',
- originCode: 'INDODE',
- destinationCode: 'NAGAD',
- departureHoursAgo: 28,
- arrivalHoursAgo: 12,
- },
- {
- trainNumber: 'US12-DJI-IND-LM-02',
- direction: 'IMPORT',
- originCode: 'NAGAD',
- destinationCode: 'INDODE',
- departureHoursAgo: 24,
- arrivalHoursAgo: 8,
- },
- {
- trainNumber: 'US12-IND-DJI-LM-02',
- direction: 'EXPORT',
- originCode: 'INDODE',
- destinationCode: 'NAGAD',
- departureHoursAgo: 22,
- arrivalHoursAgo: 6,
- },
-] as const;
-
-const TRAIN_DEMO_BOOKINGS = [
- {
- reference: 'US12-IMP-FM-001',
- trainNumber: 'US12-DJI-IND-01',
- tradeDirection: 'IMPORT',
- freightType: 'CONTAINER',
- withFirstMile: true,
- withLastMile: true,
- containerCode: '40FT',
- cargoCode: 'GENERAL_CARGO',
- weightTons: 27,
- totalAmount: 18450,
- pickupAddress: 'Doraleh Container Terminal, Djibouti',
- pickupLat: 11.5881,
- pickupLng: 43.1372,
- deliveryAddress: 'Indode bonded warehouse gate, Ethiopia',
- deliveryLat: 8.7566,
- deliveryLng: 38.9846,
- },
- {
- reference: 'US12-IMP-NOFM-001',
- trainNumber: 'US12-DJI-IND-01',
- tradeDirection: 'IMPORT',
- freightType: 'BULK',
- withFirstMile: false,
- withLastMile: false,
- containerCode: null,
- cargoCode: 'BULK',
- weightTons: 42,
- totalAmount: 22100,
- pickupAddress: null,
- pickupLat: null,
- pickupLng: null,
- deliveryAddress: null,
- deliveryLat: null,
- deliveryLng: null,
- },
- {
- reference: 'US12-EXP-FM-001',
- trainNumber: 'US12-IND-DJI-01',
- tradeDirection: 'EXPORT',
- freightType: 'CONTAINER',
- withFirstMile: true,
- withLastMile: true,
- containerCode: '20FT',
- cargoCode: 'GENERAL_CARGO',
- weightTons: 19,
- totalAmount: 15680,
- pickupAddress: 'Indode export truck gate, Ethiopia',
- pickupLat: 8.7566,
- pickupLng: 38.9846,
- deliveryAddress: 'Nagad Terminal customer handover yard, Djibouti',
- deliveryLat: 11.5536,
- deliveryLng: 43.1103,
- },
- {
- reference: 'US12-EXP-NOFM-001',
- trainNumber: 'US12-IND-DJI-01',
- tradeDirection: 'EXPORT',
- freightType: 'BULK',
- withFirstMile: false,
- withLastMile: false,
- containerCode: null,
- cargoCode: 'BULK',
- weightTons: 55,
- totalAmount: 29800,
- pickupAddress: null,
- pickupLat: null,
- pickupLng: null,
- deliveryAddress: null,
- deliveryLat: null,
- deliveryLng: null,
- },
- {
- reference: 'US12-IMP-LM-TRAIN-001',
- trainNumber: 'US12-DJI-IND-LM-02',
- tradeDirection: 'IMPORT',
- freightType: 'CONTAINER',
- withFirstMile: false,
- withLastMile: true,
- containerCode: '40FT',
- cargoCode: 'GENERAL_CARGO',
- weightTons: 31,
- totalAmount: 20300,
- pickupAddress: null,
- pickupLat: null,
- pickupLng: null,
- deliveryAddress: 'Indode last-mile customer delivery bay, Ethiopia',
- deliveryLat: 8.7581,
- deliveryLng: 38.9834,
- },
- {
- reference: 'US12-EXP-LM-TRAIN-001',
- trainNumber: 'US12-IND-DJI-LM-02',
- tradeDirection: 'EXPORT',
- freightType: 'CONTAINER',
- withFirstMile: false,
- withLastMile: true,
- containerCode: '20FT',
- cargoCode: 'GENERAL_CARGO',
- weightTons: 21,
- totalAmount: 17600,
- pickupAddress: null,
- pickupLat: null,
- pickupLng: null,
- deliveryAddress: 'Nagad last-mile consignee handover yard, Djibouti',
- deliveryLat: 11.5549,
- deliveryLng: 43.1121,
- },
-] as const;
-
-const CUSTOMER_TRUCK_DEMO_BOOKINGS = [
- {
- reference: 'US12-EXP-FM-TRUCK-001',
- trainNumber: null,
- originCode: 'INDODE',
- destinationCode: 'NAGAD',
- tradeDirection: 'EXPORT',
- freightType: 'CONTAINER',
- withFirstMile: true,
- withLastMile: false,
- containerCode: '40FT',
- cargoCode: 'GENERAL_CARGO',
- weightTons: 24,
- totalAmount: 14800,
- pickupAddress: 'Customer factory gate, Addis Ababa',
- pickupLat: 8.9806,
- pickupLng: 38.8736,
- deliveryAddress: null,
- deliveryLat: null,
- deliveryLng: null,
- },
- {
- reference: 'US12-EXP-NOFM-TRUCK-001',
- trainNumber: null,
- originCode: 'INDODE',
- destinationCode: 'NAGAD',
- tradeDirection: 'EXPORT',
- freightType: 'CONTAINER',
- withFirstMile: false,
- withLastMile: false,
- containerCode: '20FT',
- cargoCode: 'GENERAL_CARGO',
- weightTons: 18,
- totalAmount: 11200,
- pickupAddress: null,
- pickupLat: null,
- pickupLng: null,
- deliveryAddress: null,
- deliveryLat: null,
- deliveryLng: null,
- customerTruckPlateNumber: 'ET-CUS-2046',
- customerTruckDriverName: 'Dawit Customer Carrier',
- customerTruckType: 'Container Chassis',
- customerTruckContainerNumber: 'USDU1234567',
- },
-] as const;
-
-const DEMO_BOOKINGS = [...TRAIN_DEMO_BOOKINGS, ...CUSTOMER_TRUCK_DEMO_BOOKINGS] as const;
-
-@Injectable()
-export class PaidIndodeDemoBookingsSeeder {
- private readonly logger = new Logger(PaidIndodeDemoBookingsSeeder.name);
-
- constructor(private readonly dataSource: DataSource) {}
-
- async run(): Promise {
- try {
- await this.dataSource.transaction(async (manager) => {
- const refs = await this.ensureReferenceData(manager);
- const schedules = await this.ensureArrivedTrains(manager, refs);
- const bookings = await this.ensureBookings(manager, refs, schedules);
- await this.ensureTrainLinks(manager, refs, schedules, bookings);
- await this.ensureGatepasses(manager, schedules);
- await this.ensureImportWarehouseInventory(manager, bookings);
- });
-
- this.logger.log(
- `US12 paid Indode demo bookings ready: ${DEMO_BOOKINGS.length} booking(s), ${DEMO_TRAINS.length} arrived train(s)`,
- );
- } catch (error) {
- this.logger.error(
- `PaidIndodeDemoBookingsSeeder failed: ${error instanceof Error ? error.message : String(error)}`,
- );
- }
- }
-
- private async ensureReferenceData(manager: EntityManager) {
- await manager.getRepository(Yard).upsert(
- [
- {
- code: 'INDODE',
- label: 'Indode Terminal',
- country: 'Ethiopia',
- isActive: true,
- displayOrder: 1,
- },
- {
- code: 'NAGAD',
- label: 'Nagad Terminal, Djibouti',
- country: 'Djibouti',
- isActive: true,
- displayOrder: 2,
- },
- ],
- { conflictPaths: { code: true } },
- );
-
- await manager.getRepository(ServiceType).upsert(
- [
- {
- code: 'RAIL_CONTAINER_FIRST_LAST',
- serviceName: 'Rail Freight with First and Last Mile',
- description: 'Rail movement with first-mile pickup and last-mile delivery',
- canBeBookedAlone: true,
- includesFirstMile: true,
- includesLastMile: true,
- includesCustoms: false,
- priorityBonusPoints: 15,
- isActive: true,
- displayOrder: 3,
- },
- {
- code: 'RAIL_CONTAINER_LAST_MILE',
- serviceName: 'Rail Freight with Last Mile',
- description: 'Rail movement with last-mile delivery from terminal',
- canBeBookedAlone: true,
- includesFirstMile: false,
- includesLastMile: true,
- includesCustoms: false,
- priorityBonusPoints: 8,
- isActive: true,
- displayOrder: 4,
- },
- {
- code: 'RAIL_CONTAINER',
- serviceName: 'Rail Freight',
- description: 'Rail movement without first-mile pickup',
- canBeBookedAlone: true,
- includesFirstMile: false,
- includesLastMile: false,
- includesCustoms: false,
- priorityBonusPoints: 0,
- isActive: true,
- displayOrder: 1,
- },
- {
- code: 'RAIL_CONTAINER_FIRST_MILE',
- serviceName: 'Rail Freight with First Mile',
- description: 'Rail movement with first-mile pickup to terminal',
- canBeBookedAlone: true,
- includesFirstMile: true,
- includesLastMile: false,
- includesCustoms: false,
- priorityBonusPoints: 10,
- isActive: true,
- displayOrder: 2,
- },
- ],
- { conflictPaths: { code: true } },
- );
-
- await manager.getRepository(ContainerType).upsert(
- [
- {
- code: '20FT',
- label: '20FT Standard',
- sizeFt: 20,
- wagonsPerUnit: 1,
- isReefer: false,
- isOpenTop: false,
- isActive: true,
- displayOrder: 1,
- },
- {
- code: '40FT',
- label: '40FT Standard',
- sizeFt: 40,
- wagonsPerUnit: 1,
- isReefer: false,
- isOpenTop: false,
- isActive: true,
- displayOrder: 2,
- },
- ],
- { conflictPaths: { code: true } },
- );
-
- await manager.getRepository(CargoType).upsert(
- [
- {
- code: 'GENERAL_CARGO',
- cargoTypeName: 'General Cargo',
- showFreeTextBox: true,
- unitOfMeasure: null,
- requiresDirectorApproval: false,
- isActive: true,
- displayOrder: 1,
- },
- {
- code: 'BULK',
- cargoTypeName: 'Bulk Cargo',
- showFreeTextBox: true,
- unitOfMeasure: CargoUnitOfMeasure.PerTon,
- requiresDirectorApproval: false,
- isActive: true,
- displayOrder: 2,
- },
- ],
- { conflictPaths: { code: true } },
- );
-
- await manager.getRepository(WagonType).upsert(
- {
- code: 'US12-DEMO',
- name: 'US12 Demo Flat/Bulk Wagon',
- capacityTons: 70,
- lengthMeters: 14,
- maxWagonsPerTrain: 53,
- supportedLoadTypes: ['CONTAINER', 'BULK'],
- isActive: true,
- equatedLengthM: 14,
- tareWeightTons: 14,
- supportsContainer: true,
- maxContainerGrossT: 40,
- },
- { conflictPaths: { code: true } },
- );
-
- await manager.getRepository(Company).upsert(
- {
- name: 'US12 Indode Demo Customer PLC',
- type: CompanyType.Customer,
- kind: CompanyKind.Commercial,
- status: CompanyStatus.Active,
- tin: CUSTOMER_TIN,
- vatNumber: 'VAT-US12-001',
- fanNumber: 'US12000000000001',
- country: 'Ethiopia',
- nationality: CompanyNationality.Ethiopian,
- address: 'Bole Road, Addis Ababa, Ethiopia',
- phone: '251911120012',
- email: 'us12.indode.demo@edr.local',
- website: 'https://edr.local/us12-demo',
- contactPersonName: 'Aster Bekele',
- contactPersonPhone: '251911120013',
- generalManagerName: 'Mekonnen Desta',
- generalManagerEmail: 'manager.us12.demo@edr.local',
- generalManagerPhone: '251911120014',
- licenceNumber: 'LIC-US12-2026',
- region: 'Addis Ababa',
- zone: 'Bole',
- woreda: '03',
- kebele: '12',
- houseNo: 'US12-01',
- attributes: {
- seededBy: 'PaidIndodeDemoBookingsSeeder',
- note: 'Paid customer with import/export demo bookings for US12.',
- } as any,
- },
- { conflictPaths: { tin: true } },
- );
-
- const company = await manager.getRepository(Company).findOneByOrFail({ tin: CUSTOMER_TIN });
- await manager.getRepository(CompanyProfile).upsert(
- [
- {
- companyId: company.id,
- type: ProfileType.importer,
- reference: 'US12-IMP',
- status: ProfileStatus.Active,
- businessLicense: 'BL-US12-IMP-2026',
- attributes: { seededBy: 'PaidIndodeDemoBookingsSeeder' } as any,
- },
- {
- companyId: company.id,
- type: ProfileType.exporter,
- reference: 'US12-EXP',
- status: ProfileStatus.Active,
- businessLicense: 'BL-US12-EXP-2026',
- attributes: { seededBy: 'PaidIndodeDemoBookingsSeeder' } as any,
- },
- ],
- { conflictPaths: { reference: true } },
- );
-
- const [yards, serviceTypes, containerTypes, cargoTypes, wagonType, importerProfile, exporterProfile] =
- await Promise.all([
- manager.getRepository(Yard).find({ where: { code: In(['INDODE', 'NAGAD']) } }),
- manager
- .getRepository(ServiceType)
- .find({
- where: {
- code: In([
- 'RAIL_CONTAINER',
- 'RAIL_CONTAINER_FIRST_MILE',
- 'RAIL_CONTAINER_LAST_MILE',
- 'RAIL_CONTAINER_FIRST_LAST',
- ]),
- },
- }),
- manager.getRepository(ContainerType).find({ where: { code: In(['20FT', '40FT']) } }),
- manager.getRepository(CargoType).find({ where: { code: In(['GENERAL_CARGO', 'BULK']) } }),
- manager.getRepository(WagonType).findOneByOrFail({ code: 'US12-DEMO' }),
- manager.getRepository(CompanyProfile).findOneByOrFail({ reference: 'US12-IMP' }),
- manager.getRepository(CompanyProfile).findOneByOrFail({ reference: 'US12-EXP' }),
- ]);
-
- return {
- company,
- importerProfile,
- exporterProfile,
- yards: new Map(yards.map((yard) => [yard.code, yard])),
- serviceTypes: new Map(serviceTypes.map((serviceType) => [serviceType.code, serviceType])),
- containerTypes: new Map(containerTypes.map((containerType) => [containerType.code, containerType])),
- cargoTypes: new Map(cargoTypes.map((cargoType) => [cargoType.code, cargoType])),
- wagonType,
- };
- }
-
- private async ensureArrivedTrains(
- manager: EntityManager,
- refs: Awaited>,
- ): Promise
-
- {isDragOver
- ? "Release to replace the document on file."
- : "Saved to your application. Drag a new file here or click to replace it."}
-
+ {existingForField.length > 0 ? (
+
+ ) : (
+
+ {isDragOver
+ ? "Release to replace the document on file."
+ : "Saved to your application. Drag a new file here or click to replace it."}
+
+ )}
@@ -480,9 +528,9 @@ export function SmartFileInput({
? "border-primary bg-primary/5 dark:bg-primary/10"
: "border-border hover:border-primary/50 hover:bg-muted/10",
fieldError &&
- "border-destructive hover:border-destructive/80",
+ "border-destructive hover:border-destructive/80",
disabled &&
- "opacity-50 pointer-events-none cursor-not-allowed",
+ "opacity-50 pointer-events-none cursor-not-allowed",
)}
>
Date: Fri, 3 Jul 2026 06:57:22 +0000
Subject: [PATCH 51/86] feat: extended the smartfileinput and add a global
useFileViewer
---
.../src/components/SmartFileInput/index.tsx | 105 ++++++++++++++----
.../ui-common/src/hooks/useFileViewer.tsx | 27 +++++
packages/ui-common/src/index.ts | 2 +
3 files changed, 113 insertions(+), 21 deletions(-)
create mode 100644 packages/ui-common/src/hooks/useFileViewer.tsx
diff --git a/packages/ui-common/src/components/SmartFileInput/index.tsx b/packages/ui-common/src/components/SmartFileInput/index.tsx
index d39e431c7..0da3e2f37 100644
--- a/packages/ui-common/src/components/SmartFileInput/index.tsx
+++ b/packages/ui-common/src/components/SmartFileInput/index.tsx
@@ -11,6 +11,7 @@ import {
} from "lucide-react";
import { cn } from "../../lib/utils";
import { Button } from "../button";
+import type { ViewableFile } from "../FileViewer";
export interface SmartFileInputProps {
/** The settings object containing features and their upload fields config. */
@@ -35,8 +36,18 @@ export interface SmartFileInputProps {
*/
existingFiles?: Record<
string,
- { name: string; url: string; size?: number }[]
+ { name: string; url: string; size?: number; mimeType?: string | null }[]
>;
+ /**
+ * When provided, already-uploaded files render as buttons that call this with
+ * the file instead of opening a new browser tab. Wire it to `useFileViewer`'s
+ * `view` to preview documents inline:
+ *
+ * const { view, viewer } = useFileViewer();
+ *
+ * {viewer}
+ */
+ onViewFile?: (file: ViewableFile) => void;
/** Disabled state for the entire file input group. */
disabled?: boolean;
/** Display variant style. Default is "default" (large dropzone). Minimal renders a compact upload button. */
@@ -80,6 +91,68 @@ function FileIcon({ name, className }: { name: string; className?: string }) {
return ;
}
+type ExistingFile = {
+ name: string;
+ url: string;
+ size?: number;
+ mimeType?: string | null;
+};
+
+/**
+ * A single already-uploaded file. Renders a click-to-view button when
+ * `onViewFile` is set (inline preview via the FileViewer), otherwise a plain
+ * new-tab anchor.
+ */
+function ExistingFileLink({
+ file: f,
+ onViewFile,
+ className,
+ showSize = false,
+}: {
+ file: ExistingFile;
+ onViewFile?: (file: ViewableFile) => void;
+ className?: string;
+ showSize?: boolean;
+}) {
+ const label =
+ showSize && typeof f.size === "number"
+ ? `${f.name} (${formatBytes(f.size)})`
+ : f.name;
+
+ if (onViewFile) {
+ return (
+
+ );
+ }
+
+ return (
+ e.stopPropagation()}
+ className={cn(
+ "relative z-10 text-xs text-primary hover:underline truncate max-w-xs",
+ className,
+ )}
+ >
+ {label}
+
+ );
+}
+
export function SmartFileInput({
file,
value,
@@ -87,6 +160,7 @@ export function SmartFileInput({
errors,
uploadedKeys,
existingFiles,
+ onViewFile,
disabled = false,
variant = "default",
className,
@@ -433,15 +507,11 @@ export function SmartFileInput({
{existingForField.length > 0 && (
{existingForField.map((f, idx) => (
-
- {f.name}
-
+ file={f}
+ onViewFile={onViewFile}
+ />
))}
)}
@@ -488,19 +558,12 @@ export function SmartFileInput({
{existingForField.length > 0 ? (
) : (
diff --git a/packages/ui-common/src/hooks/useFileViewer.tsx b/packages/ui-common/src/hooks/useFileViewer.tsx
new file mode 100644
index 000000000..68269f7cf
--- /dev/null
+++ b/packages/ui-common/src/hooks/useFileViewer.tsx
@@ -0,0 +1,27 @@
+import { useCallback, useState } from "react";
+import { FileViewerModal, type ViewableFile } from "../components/FileViewer";
+
+/**
+ * Drives a single shared {@link FileViewerModal} for a page. Call `view(file)`
+ * from any file row to open the document inline (pdf / image / video / office /
+ * text); render `viewer` once near the page root.
+ *
+ * const { view, viewer } = useFileViewer();
+ *
+ * {viewer}
+ *
+ * Pass `view` straight into `SmartFileInput`'s `onViewFile` prop to make its
+ * already-uploaded files open in the viewer instead of a new tab.
+ */
+export function useFileViewer() {
+ const [file, setFile] = useState(null);
+
+ const view = useCallback((f: ViewableFile) => setFile(f), []);
+ const close = useCallback(() => setFile(null), []);
+
+ const viewer = (
+
+ );
+
+ return { view, close, viewer };
+}
diff --git a/packages/ui-common/src/index.ts b/packages/ui-common/src/index.ts
index 2e8779076..43c39d962 100644
--- a/packages/ui-common/src/index.ts
+++ b/packages/ui-common/src/index.ts
@@ -20,6 +20,8 @@ export type {
ViewableFile,
} from "./components/FileViewer";
+export { useFileViewer } from "./hooks/useFileViewer";
+
export { OperationDatePicker } from "./components/OperationDatePicker";
export type { OperationDatePickerProps } from "./components/OperationDatePicker";
From 300afb4c777fb0e43498e58e43ce2ccd2c57cb3f Mon Sep 17 00:00:00 2001
From: Nathnael
Date: Fri, 3 Jul 2026 06:57:56 +0000
Subject: [PATCH 52/86] feat: add existing files to customer documet tab on
portal
---
.../portal/src/hooks/useFileViewer.tsx | 27 +-
.../src/pages/settings/TabDocuments.tsx | 273 ++++++++++++------
.../portal/src/services/api.ts | 32 +-
.../portal/src/services/companies.service.ts | 40 ++-
4 files changed, 245 insertions(+), 127 deletions(-)
diff --git a/apps/edr-freight-web/portal/src/hooks/useFileViewer.tsx b/apps/edr-freight-web/portal/src/hooks/useFileViewer.tsx
index 4ca24476c..c00777ef7 100644
--- a/apps/edr-freight-web/portal/src/hooks/useFileViewer.tsx
+++ b/apps/edr-freight-web/portal/src/hooks/useFileViewer.tsx
@@ -1,24 +1,3 @@
-import { useCallback, useState } from "react";
-import { FileViewerModal, type ViewableFile } from "@edr/ui-common";
-
-/**
- * Drives a single shared {@link FileViewerModal} for a page. Call `view(file)`
- * from any file row to open the document inline (pdf / image / video / office /
- * text); render `viewer` once near the page root.
- *
- * const { view, viewer } = useFileViewer();
- *
- * {viewer}
- */
-export function useFileViewer() {
- const [file, setFile] = useState(null);
-
- const view = useCallback((f: ViewableFile) => setFile(f), []);
- const close = useCallback(() => setFile(null), []);
-
- const viewer = (
-
- );
-
- return { view, close, viewer };
-}
+// Re-export of the shared hook, now living in @edr/ui-common. Kept so existing
+// `@/hooks/useFileViewer` imports keep working.
+export { useFileViewer } from "@edr/ui-common";
diff --git a/apps/edr-freight-web/portal/src/pages/settings/TabDocuments.tsx b/apps/edr-freight-web/portal/src/pages/settings/TabDocuments.tsx
index 4d5c4b759..6fbcbe911 100644
--- a/apps/edr-freight-web/portal/src/pages/settings/TabDocuments.tsx
+++ b/apps/edr-freight-web/portal/src/pages/settings/TabDocuments.tsx
@@ -4,10 +4,12 @@ import { getMinFiles } from "@/types/fileUploadSettings";
import type { ProfileResponse } from "@/types/profile";
import { SmartFileInput } from "@edr/ui-common";
import {
+ Anchor,
Button,
Card,
Center,
Group,
+ Stack,
Text,
Title,
} from "@mantine/core";
@@ -17,10 +19,19 @@ import {
CheckCircle2,
FileCheck,
Loader2,
+ Paperclip,
UploadCloud,
XCircle,
} from "lucide-react";
-import { useState } from "react";
+import { useMemo, useState } from "react";
+
+const ROLE_LABELS: Record = {
+ importer: "Importer",
+ exporter: "Exporter",
+ freight_forwarder: "Freight Forwarder",
+ dj_freight_forwarder: "DJ Freight Forwarder",
+ transporter: "Transporter",
+};
interface TabDocumentsProps {
profile: ProfileResponse;
@@ -28,21 +39,59 @@ interface TabDocumentsProps {
onContinue?: () => void;
}
-export default function TabDocuments({ profile, mode = "edit", onContinue }: TabDocumentsProps) {
+function documentSettingCode(nationality: string | null | undefined): string {
+ return nationality === "foreign"
+ ? "company_onboarding_documents_foreign"
+ : "company_onboarding_documents_ethiopian";
+}
+
+export default function TabDocuments({
+ profile,
+ mode = "edit",
+ onContinue,
+}: TabDocumentsProps) {
const queryClient = useQueryClient();
- const [documentFiles, setDocumentFiles] = useState>({});
+ const [documentFiles, setDocumentFiles] = useState<
+ Record
+ >({});
const docSettingQuery = useQuery(
api.fileUploadSettings.getByCode.queryOptions({
- input: { code: "customer_file_documents" },
+ input: { code: documentSettingCode(profile.nationality) },
}),
);
+ const docsQuery = useQuery(
+ api.companies.documents.queryOptions({
+ input: { companyId: profile.companyId },
+ }),
+ );
+
+ const uploadedKeys = useMemo(
+ () => (docsQuery.data ?? []).map((d) => d.code),
+ [docsQuery.data],
+ );
+
+ const existingFilesByKey = useMemo(() => {
+ const map: Record =
+ {};
+ for (const doc of docsQuery.data ?? []) {
+ (map[doc.code] ??= []).push({
+ name: doc.name,
+ url: doc.url,
+ size: doc.size,
+ });
+ }
+ return map;
+ }, [docsQuery.data]);
+
const docUploadMutation = useMutation({
mutationFn: (files: Record) =>
companiesService.uploadDocuments(profile.companyId, files),
onSuccess: () => {
- queryClient.invalidateQueries({ queryKey: api.companies.getProfile.queryKey() });
+ queryClient.invalidateQueries({
+ queryKey: api.companies.getProfile.queryKey(),
+ });
},
});
@@ -73,6 +122,7 @@ export default function TabDocuments({ profile, mode = "edit", onContinue }: Tab
for (const field of docSettingQuery.data?.fields ?? []) {
const min = getMinFiles(field);
if (min <= 0) continue;
+ if (uploadedKeys.includes(field.fileKey)) continue;
const v = documentFiles[field.fileKey];
const count = Array.isArray(v) ? v.length : v ? 1 : 0;
if (count < min) {
@@ -82,94 +132,141 @@ export default function TabDocuments({ profile, mode = "edit", onContinue }: Tab
return errs;
};
+ const licenseProfiles = profile.companyProfiles.filter(
+ (p) => p.licenseFiles && p.licenseFiles.length > 0,
+ );
+
return (
-
-
-
- Documents
-
-
- Upload and manage required business documents
-
-
- {docSettingQuery.isLoading ? (
-
-
-
- ) : !docSettingQuery.data ? (
-
- No document requirements configured for your account.
+ <>
+
+
+
+ Documents
+
+
+ Upload and manage required business documents
- ) : (
-
- )}
- {docSettingQuery.data && (
-
-
- {docUploadMutation.isSuccess && (
-
-
-
- {mode === "onboarding" ? "Saved successfully" : "Documents uploaded successfully"}
-
-
- )}
- {docUploadMutation.isError && (
-
-
- Upload failed
-
+ {docSettingQuery.isLoading ? (
+
+
+
+ ) : !docSettingQuery.data ? (
+
+ No document requirements configured for your account.
+
+ ) : (
+
+ )}
+
+ {docSettingQuery.data && (
+
+
+ {docUploadMutation.isSuccess && (
+
+
+
+ {mode === "onboarding"
+ ? "Saved successfully"
+ : "Documents uploaded successfully"}
+
+
+ )}
+ {docUploadMutation.isError && (
+
+
+
+ Upload failed
+
+
+ )}
+
+ {mode === "onboarding" ? (
+ }
+ loading={docUploadMutation.isPending}
+ onClick={() => {
+ const validationErrors = validateRequired();
+ if (Object.keys(validationErrors).length > 0) {
+ setFieldErrors(validationErrors);
+ return;
+ }
+ if (hasFiles) {
+ docUploadMutation.mutate(documentFiles, {
+ onSuccess: () => onContinue?.(),
+ });
+ } else {
+ onContinue?.();
+ }
+ }}
+ >
+ Continue
+
+ ) : (
+ }
+ loading={docUploadMutation.isPending}
+ disabled={!hasFiles}
+ onClick={() => {
+ if (!hasFiles) return;
+ docUploadMutation.mutate(documentFiles);
+ }}
+ >
+ Upload Documents
+
)}
- {mode === "onboarding" ? (
- }
- loading={docUploadMutation.isPending}
- onClick={() => {
- const validationErrors = validateRequired();
- if (Object.keys(validationErrors).length > 0) {
- setFieldErrors(validationErrors);
- return;
- }
- if (hasFiles) {
- docUploadMutation.mutate(documentFiles, {
- onSuccess: () => onContinue?.(),
- });
- } else {
- onContinue?.();
- }
- }}
- >
- Continue
-
- ) : (
- }
- loading={docUploadMutation.isPending}
- disabled={!hasFiles}
- onClick={() => {
- if (!hasFiles) return;
- docUploadMutation.mutate(documentFiles);
- }}
- >
- Upload Documents
-
- )}
-
+ )}
+
+
+ {licenseProfiles.length > 0 && (
+
+
+
+ Business licenses
+
+
+ License documents uploaded per operational profile
+
+
+
+ {licenseProfiles.map((p) => (
+
+
+ {ROLE_LABELS[p.type] ?? p.type} · {p.reference}
+
+ {p.licenseFiles.map((f) => (
+
+
+
+ {f.name}
+
+
+ ))}
+
+ ))}
+
+
)}
-
+ >
);
}
diff --git a/apps/edr-freight-web/portal/src/services/api.ts b/apps/edr-freight-web/portal/src/services/api.ts
index a09bd4b4e..1ba83ec30 100644
--- a/apps/edr-freight-web/portal/src/services/api.ts
+++ b/apps/edr-freight-web/portal/src/services/api.ts
@@ -53,6 +53,7 @@ import {
UpdateDropdownSettingDto,
} from "@/types/dropdownSettings";
import type {
+ CompanyDocument,
CompanyInfoResponse,
CompanyNationality,
CompanyProfileResponse,
@@ -158,7 +159,11 @@ export const api = {
createCompanyProfile: endpoint<
{ type: ProfileTypeValue; businessLicense?: string },
CompanyProfileResponse
- >("companies", "createCompanyProfile", companiesService.createCompanyProfile),
+ >(
+ "companies",
+ "createCompanyProfile",
+ companiesService.createCompanyProfile,
+ ),
startOnboarding: endpoint<
{
@@ -192,6 +197,12 @@ export const api = {
"onboardingRequirements",
companiesService.getOnboardingRequirements,
),
+
+ documents: endpoint<{ companyId: string }, CompanyDocument[]>(
+ "companies",
+ "documents",
+ ({ companyId }) => companiesService.getDocuments(companyId),
+ ),
},
bookings: {
@@ -228,7 +239,8 @@ export const api = {
downloadHandoverDocument: endpoint<{ inventoryId: string }, Blob>(
"bookings",
"downloadHandoverDocument",
- ({ inventoryId }) => bookingsService.downloadHandoverDocument(inventoryId),
+ ({ inventoryId }) =>
+ bookingsService.downloadHandoverDocument(inventoryId),
),
create: endpoint<
@@ -312,11 +324,8 @@ export const api = {
proceedToOperation: endpoint<
{ id: string; scheduledDate: string },
Freight.IBooking
- >(
- "bookings",
- "proceedToOperation",
- ({ id, scheduledDate }) =>
- bookingsService.proceedToOperation(id, scheduledDate),
+ >("bookings", "proceedToOperation", ({ id, scheduledDate }) =>
+ bookingsService.proceedToOperation(id, scheduledDate),
),
checkPayment: endpoint<{ orderId: string }, { status: string }>(
@@ -354,10 +363,11 @@ export const api = {
bookingsService.getAvailableDays({ originYardId, destinationYardId }),
),
- getAvailableDaysForCargo: endpoint(
- "train-scheduling",
- "availableDaysForCargo",
- (input) => bookingsService.getAvailableDaysForCargo(input),
+ getAvailableDaysForCargo: endpoint<
+ Freight.AvailableDaysForCargoQuery,
+ string[]
+ >("train-scheduling", "availableDaysForCargo", (input) =>
+ bookingsService.getAvailableDaysForCargo(input),
),
getMyBookingWindows: endpoint(
diff --git a/apps/edr-freight-web/portal/src/services/companies.service.ts b/apps/edr-freight-web/portal/src/services/companies.service.ts
index d326811b8..d3f584170 100644
--- a/apps/edr-freight-web/portal/src/services/companies.service.ts
+++ b/apps/edr-freight-web/portal/src/services/companies.service.ts
@@ -82,6 +82,18 @@ export interface CompanyInfoResponse {
company: CompanyResponse;
}
+/** A single company-level document uploaded against a `file_upload_settings` field. */
+export interface CompanyDocument {
+ id: string;
+ name: string;
+ /** The `fileKey` of the setting field it was uploaded against. */
+ code: string;
+ mimeType: string;
+ size: number;
+ uploadedAt: string;
+ url: string;
+}
+
/** A single onboarding document field, as resolved and described by the backend. */
export interface OnboardingDocumentField {
fileKey: string;
@@ -124,7 +136,12 @@ export interface OnboardingRequirements {
}
export interface CompanyProfileInput {
- type: "importer" | "exporter" | "freight_forwarder" | "dj_freight_forwarder" | "transporter";
+ type:
+ | "importer"
+ | "exporter"
+ | "freight_forwarder"
+ | "dj_freight_forwarder"
+ | "transporter";
businessLicense?: string;
}
@@ -180,7 +197,9 @@ export const companiesService = {
}
},
- create: async (payload: CreateCompanyPayload): Promise => {
+ create: async (
+ payload: CreateCompanyPayload,
+ ): Promise => {
const response = await client.post>(
URL_CONSTANTS.COMPANIES_API.CREATE,
payload,
@@ -195,7 +214,9 @@ export const companiesService = {
return unwrap(response.data);
},
- updateProfile: async (payload: UpdateProfilePayload): Promise => {
+ updateProfile: async (
+ payload: UpdateProfilePayload,
+ ): Promise => {
const response = await client.patch>(
URL_CONSTANTS.COMPANIES_API.PROFILE,
payload,
@@ -293,7 +314,18 @@ export const companiesService = {
formData.append(fieldName, fileOrFiles);
}
}
- await client.post(URL_CONSTANTS.COMPANIES_API.DOCUMENTS(companyId), formData);
+ await client.post(
+ URL_CONSTANTS.COMPANIES_API.DOCUMENTS(companyId),
+ formData,
+ );
+ },
+
+ /** List documents already uploaded for a company (settings-driven, by fileKey). */
+ getDocuments: async (companyId: string): Promise => {
+ const response = await client.get>(
+ URL_CONSTANTS.COMPANIES_API.DOCUMENTS(companyId),
+ );
+ return unwrap(response.data);
},
/** Upload business-license document(s) for a company profile (multi-file). */
From 43f7a039ad9e204dc9e7081c66b8e07c5d4e4b22 Mon Sep 17 00:00:00 2001
From: Nathnael
Date: Fri, 3 Jul 2026 07:20:31 +0000
Subject: [PATCH 53/86] fix: payment api crash
---
apps/edr-freight-api/src/modules/payment/payment.repository.ts | 3 +--
1 file changed, 1 insertion(+), 2 deletions(-)
diff --git a/apps/edr-freight-api/src/modules/payment/payment.repository.ts b/apps/edr-freight-api/src/modules/payment/payment.repository.ts
index 25c3bdd6b..96a4994ea 100644
--- a/apps/edr-freight-api/src/modules/payment/payment.repository.ts
+++ b/apps/edr-freight-api/src/modules/payment/payment.repository.ts
@@ -93,9 +93,8 @@ export class PaymentRepository {
p.paid_at,
p.created_at
FROM freight.payments p
- JOIN freight.bookings b ON b.id = p.ref_id
+ JOIN freight.bookings b ON b.id = p.ref_id::uuid
WHERE b.company_id = $1
- AND p.deleted_at IS NULL
AND b.deleted_at IS NULL
ORDER BY p.created_at DESC`,
[companyId],
From 3925a11dd4ccdd1548cac480136d91721fd7441c Mon Sep 17 00:00:00 2001
From: Nathnael
Date: Fri, 3 Jul 2026 07:21:19 +0000
Subject: [PATCH 54/86] feat: add invoice to customer detail on backoffice
---
.../src/components/customers/badges.tsx | 64 +++++-
.../src/components/customers/index.ts | 1 +
.../backoffice/src/hooks/useFileViewer.tsx | 27 +--
.../pages/customers/CustomerDetailPage.tsx | 209 +++++++++++++++---
.../backoffice/src/types/customer.ts | 11 +
5 files changed, 256 insertions(+), 56 deletions(-)
diff --git a/apps/edr-freight-web/backoffice/src/components/customers/badges.tsx b/apps/edr-freight-web/backoffice/src/components/customers/badges.tsx
index e108d1b1f..31c7efc52 100644
--- a/apps/edr-freight-web/backoffice/src/components/customers/badges.tsx
+++ b/apps/edr-freight-web/backoffice/src/components/customers/badges.tsx
@@ -1,3 +1,4 @@
+import type { Freight } from "@edr/types";
import { Badge, Button, Group, Tooltip } from "@mantine/core";
import { useMutation } from "@tanstack/react-query";
import { api } from "@/services/api";
@@ -88,7 +89,13 @@ export function ProfileChips({
}) {
if (!profiles.length) {
return (
-
+
No profiles
);
@@ -118,7 +125,13 @@ export function ProfileChips({
))}
{extra > 0 ? (
-
+
+{extra}
) : null}
@@ -169,7 +182,11 @@ const BOOKING_STATUS_COLOR: Record = {
CANCELLED: "red",
};
-export function BookingStatusBadge({ status }: { status: CustomerBookingStatus }) {
+export function BookingStatusBadge({
+ status,
+}: {
+ status: CustomerBookingStatus;
+}) {
return (
= {
refunded: "grape",
};
-export function PaymentStatusBadge({ status }: { status: CustomerPaymentStatus }) {
+export function PaymentStatusBadge({
+ status,
+}: {
+ status: CustomerPaymentStatus;
+}) {
return (
= {
+ DRAFT: "gray",
+ ISSUED: "cyan",
+ PENDING: "yellow",
+ PARTIALLY_PAID: "orange",
+ PAID: "edr-green",
+ OVERDUE: "red",
+ CANCELLED: "gray",
+ REFUNDED: "grape",
+ EXPIRED: "red",
+};
+
+export function InvoiceStatusBadge({
+ status,
+}: {
+ status: Freight.InvoiceStatus;
+}) {
+ return (
+
+ {humanize(status)}
+
+ );
+}
+
/**
* Inline approval action buttons for a profile row.
* Transitions: pending → approve/reject | active → suspend | suspended → reactivate/blacklist | blacklisted → reinstate
@@ -225,8 +278,7 @@ export function ProfileApprovalActions({
api.customers.setProfileStatus.mutationOptions(),
);
- const act = (next: ProfileStatus) =>
- mutate({ profileId, status: next });
+ const act = (next: ProfileStatus) => mutate({ profileId, status: next });
if (status === "pending") {
return (
diff --git a/apps/edr-freight-web/backoffice/src/components/customers/index.ts b/apps/edr-freight-web/backoffice/src/components/customers/index.ts
index 61b75767a..620b5ec35 100644
--- a/apps/edr-freight-web/backoffice/src/components/customers/index.ts
+++ b/apps/edr-freight-web/backoffice/src/components/customers/index.ts
@@ -2,6 +2,7 @@ export {
BookingStatusBadge,
CompanyStatusBadge,
CompanyTypeBadge,
+ InvoiceStatusBadge,
PaymentStatusBadge,
ProfileApprovalActions,
ProfileChips,
diff --git a/apps/edr-freight-web/backoffice/src/hooks/useFileViewer.tsx b/apps/edr-freight-web/backoffice/src/hooks/useFileViewer.tsx
index 4ca24476c..c00777ef7 100644
--- a/apps/edr-freight-web/backoffice/src/hooks/useFileViewer.tsx
+++ b/apps/edr-freight-web/backoffice/src/hooks/useFileViewer.tsx
@@ -1,24 +1,3 @@
-import { useCallback, useState } from "react";
-import { FileViewerModal, type ViewableFile } from "@edr/ui-common";
-
-/**
- * Drives a single shared {@link FileViewerModal} for a page. Call `view(file)`
- * from any file row to open the document inline (pdf / image / video / office /
- * text); render `viewer` once near the page root.
- *
- * const { view, viewer } = useFileViewer();
- *
- * {viewer}
- */
-export function useFileViewer() {
- const [file, setFile] = useState(null);
-
- const view = useCallback((f: ViewableFile) => setFile(f), []);
- const close = useCallback(() => setFile(null), []);
-
- const viewer = (
-
- );
-
- return { view, close, viewer };
-}
+// Re-export of the shared hook, now living in @edr/ui-common. Kept so existing
+// `@/hooks/useFileViewer` imports keep working.
+export { useFileViewer } from "@edr/ui-common";
diff --git a/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx
index 5689186cd..f94f97c71 100644
--- a/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx
+++ b/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx
@@ -1,5 +1,6 @@
import {
ActionIcon,
+ Anchor,
Box,
Button,
Card,
@@ -21,6 +22,8 @@ import {
IdCard,
LayoutGrid,
Package,
+ Paperclip,
+ Receipt,
} from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { useMemo } from "react";
@@ -30,6 +33,7 @@ import {
BookingStatusBadge,
CompanyStatusBadge,
CompanyTypeBadge,
+ InvoiceStatusBadge,
PaymentStatusBadge,
ProfileApprovalActions,
ProfileChips,
@@ -50,7 +54,8 @@ import type {
CustomerDocument,
CustomerPayment,
} from "@/types/customer";
-import { DataTable, type ColumnDef } from "@edr/ui-common";
+import type { Invoice } from "@/types/invoice";
+import { DataTable, usePagination, type ColumnDef } from "@edr/ui-common";
function InfoField({ label, value }: { label: string; value?: string | null }) {
return (
@@ -104,9 +109,34 @@ export default function CustomerDetailPage() {
}),
);
+ const { pagination: invoicePagination, setPagination: setInvoicePagination } =
+ usePagination({
+ pageSize: 10,
+ });
+ const invoiceFilter = useMemo(
+ () => ({
+ companyId: id ?? "",
+ page: invoicePagination.pageIndex + 1,
+ pageSize: invoicePagination.pageSize,
+ }),
+ [id, invoicePagination.pageIndex, invoicePagination.pageSize],
+ );
+ const invoicesQuery = useQuery(
+ api.invoices.list.queryOptions({
+ input: { filter: invoiceFilter },
+ enabled: Boolean(id),
+ }),
+ );
+
const bookings = bookingsQuery.data ?? [];
const documents = documentsQuery.data ?? [];
const payments = paymentsQuery.data ?? [];
+ const invoices = invoicesQuery.data?.items ?? [];
+ const invoiceTotal = invoicesQuery.data?.total ?? 0;
+ const invoicePageCount = Math.max(
+ 1,
+ Math.ceil(invoiceTotal / invoicePagination.pageSize),
+ );
const totalPaid = useMemo(
() =>
@@ -358,6 +388,59 @@ export default function CustomerDetailPage() {
[],
);
+ const invoiceColumns: ColumnDef[] = useMemo(
+ () => [
+ {
+ id: "invoiceNumber",
+ header: "Invoice",
+ cell: ({ row }) => (
+
+ {row.original.invoiceNumber}
+
+ ),
+ },
+ {
+ id: "source",
+ header: "Source",
+ cell: ({ row }) => (
+
+ {humanize(row.original.source)}
+
+ ),
+ },
+ {
+ id: "status",
+ header: "Status",
+ cell: ({ row }) => ,
+ },
+ {
+ id: "amount",
+ header: "Amount",
+ meta: { headerClassName: "text-right", cellClassName: "text-right" },
+ cell: ({ row }) => (
+
+ {formatMoney(row.original.totalAmount, row.original.currency)}
+
+ ),
+ },
+ {
+ id: "dueAt",
+ header: "Due",
+ meta: { headerClassName: "text-right", cellClassName: "text-right" },
+ cell: ({ row }) => (
+
+ {formatDate(row.original.dueAt)}
+
+ ),
+ },
+ ],
+ [],
+ );
+
+ const licenseProfiles = (company?.companyProfiles ?? []).filter(
+ (p) => p.licenseFiles && p.licenseFiles.length > 0,
+ );
+
if (isLoading) {
return (
@@ -392,8 +475,9 @@ export default function CustomerDetailPage() {
]}
backTo="/dashboard/customers"
title={company.name}
- subtitle={`TIN ${company.tin}${company.country ? ` · ${company.country}` : ""
- }`}
+ subtitle={`TIN ${company.tin}${
+ company.country ? ` · ${company.country}` : ""
+ }`}
meta={
@@ -416,6 +500,9 @@ export default function CustomerDetailPage() {
}>
Payments
+ }>
+ Invoices
+
{/* OVERVIEW */}
@@ -528,9 +615,9 @@ export default function CustomerDetailPage() {
error={
bookingsQuery.isError
? {
- message: "Failed to load bookings.",
- onRetry: () => void bookingsQuery.refetch(),
- }
+ message: "Failed to load bookings.",
+ onRetry: () => void bookingsQuery.refetch(),
+ }
: undefined
}
/>
@@ -539,23 +626,57 @@ export default function CustomerDetailPage() {
{/* DOCUMENTS */}
-
- void documentsQuery.refetch(),
- }
- : undefined
- }
- />
-
+
+
+ void documentsQuery.refetch(),
+ }
+ : undefined
+ }
+ />
+
+
+ {licenseProfiles.length > 0 && (
+
+
+
+ Business licenses
+
+
+ {licenseProfiles.map((p) => (
+
+
+ {humanize(p.type)} · {p.reference}
+
+ {(p.licenseFiles ?? []).map((f) => (
+
+
+
+ {f.name}
+
+
+ ))}
+
+ ))}
+
+
+
+ )}
+
{/* PAYMENTS */}
@@ -570,14 +691,50 @@ export default function CustomerDetailPage() {
error={
paymentsQuery.isError
? {
- message: "Failed to load payments.",
- onRetry: () => void paymentsQuery.refetch(),
- }
+ message: "Failed to load payments.",
+ onRetry: () => void paymentsQuery.refetch(),
+ }
: undefined
}
/>
+
+ {/* INVOICES */}
+
+
+
+ navigate(`/dashboard/invoices/${row.id}`)}
+ emptyMessage="No invoices for this customer."
+ containerClassName="border-0 shadow-none bg-transparent"
+ error={
+ invoicesQuery.isError
+ ? {
+ message: "Failed to load invoices.",
+ onRetry: () => void invoicesQuery.refetch(),
+ }
+ : undefined
+ }
+ pagination={{
+ pageIndex: invoicePagination.pageIndex,
+ pageSize: invoicePagination.pageSize,
+ pageCount: invoicePageCount,
+ totalCount: invoiceTotal,
+ }}
+ tableOptions={{
+ state: { pagination: invoicePagination },
+ onPaginationChange: setInvoicePagination,
+ manualPagination: true,
+ pageCount: invoicePageCount,
+ }}
+ />
+
+
+
);
diff --git a/apps/edr-freight-web/backoffice/src/types/customer.ts b/apps/edr-freight-web/backoffice/src/types/customer.ts
index 638ab98cd..a67f1732f 100644
--- a/apps/edr-freight-web/backoffice/src/types/customer.ts
+++ b/apps/edr-freight-web/backoffice/src/types/customer.ts
@@ -32,6 +32,14 @@ export type ProfileType =
/** Mirrors backend `ProfileStatus`. */
export type ProfileStatus = "active" | "pending" | "suspended" | "blacklisted";
+/** A business-license document uploaded for a company profile. */
+export interface LicenseFile {
+ name: string;
+ url: string;
+ size: number;
+ mimeType?: string;
+}
+
/** A single role a company is registered for, with its reference code. */
export interface CompanyProfile {
id: string;
@@ -39,7 +47,10 @@ export interface CompanyProfile {
type: ProfileType;
reference: string;
status: ProfileStatus;
+ /** @deprecated Superseded by licenseFiles (file model). */
businessLicense?: string | null;
+ /** Business-license documents uploaded for this profile. */
+ licenseFiles?: LicenseFile[];
attributes?: Record | null;
createdAt: string;
updatedAt: string;
From 46971fd5c57f07fec7b3b49f46fb934f6c425c9f Mon Sep 17 00:00:00 2001
From: Hagernesh
Date: Fri, 3 Jul 2026 07:30:12 +0000
Subject: [PATCH 55/86] 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
+ }
+ loading={busyScheduleId === train.scheduleId && secureGatePass.isPending}
+ onClick={() => secureGate(train)}
+ >
+ Secure Gate Pass
+
)
}
- loading={busyScheduleId === train.scheduleId}
+ loading={busyScheduleId === train.scheduleId && autoUnload.isPending}
onClick={() => unloadTrain(train)}
>
Auto Unload Export Items
From d832e83b4a15a2dfcd3b29be3402247b7ca7a443 Mon Sep 17 00:00:00 2001
From: Marshal
Date: Fri, 3 Jul 2026 08:13:51 +0000
Subject: [PATCH 56/86] update
---
.../train-schedules/entities/train-schedule-booking.entity.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
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..951cdaa80 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
@@ -11,7 +11,7 @@ export class TrainScheduleBooking extends BaseEntity {
@Column({ name: 'train_schedule_id', type: 'uuid' })
trainScheduleId!: string;
- @ManyToOne(() => TrainSchedule, (trainSchedule) => trainSchedule.scheduleBookings, {
+ @ManyToOne(() => TrainSchedule, (trainSchedule) => trainScmahedule.scheduleBookings, {
onDelete: 'CASCADE',
})
@JoinColumn({ name: 'train_schedule_id' })
From 515d59fb532abc7df52a1b471659f1f749e8dde0 Mon Sep 17 00:00:00 2001
From: Nathnael
Date: Fri, 3 Jul 2026 08:35:57 +0000
Subject: [PATCH 57/86] fix: file preview
---
.../pages/customers/CustomerDetailPage.tsx | 62 ++++++++++++++-----
.../src/pages/settings/TabDocuments.tsx | 25 ++++----
2 files changed, 61 insertions(+), 26 deletions(-)
diff --git a/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx
index f94f97c71..7f62ea3f5 100644
--- a/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx
+++ b/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx
@@ -18,6 +18,7 @@ import {
ArrowRight,
Banknote,
Download,
+ Eye,
FileText,
IdCard,
LayoutGrid,
@@ -55,7 +56,12 @@ import type {
CustomerPayment,
} from "@/types/customer";
import type { Invoice } from "@/types/invoice";
-import { DataTable, usePagination, type ColumnDef } from "@edr/ui-common";
+import {
+ DataTable,
+ useFileViewer,
+ usePagination,
+ type ColumnDef,
+} from "@edr/ui-common";
function InfoField({ label, value }: { label: string; value?: string | null }) {
return (
@@ -83,6 +89,7 @@ function tableStatus(query: { isLoading: boolean; isError: boolean }) {
export default function CustomerDetailPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
+ const { view, viewer } = useFileViewer();
const { data: company, isLoading } = useQuery(
api.customers.getById.queryOptions({
@@ -315,20 +322,37 @@ export default function CustomerDetailPage() {
header: "",
meta: { headerClassName: "text-right", cellClassName: "text-right" },
cell: ({ row }) => (
-
-
-
+
+
+ view({
+ name: row.original.name,
+ url: fileViewUrl(row.original.id),
+ mimeType: row.original.mimeType,
+ })
+ }
+ >
+
+
+
+
+
+
),
},
],
- [],
+ [view],
);
const paymentColumns: ColumnDef[] = useMemo(
@@ -661,9 +685,15 @@ export default function CustomerDetailPage() {
+ view({
+ name: f.name,
+ url: f.url,
+ mimeType: f.mimeType,
+ })
+ }
size="xs"
>
{f.name}
@@ -736,6 +766,8 @@ export default function CustomerDetailPage() {
+
+ {viewer}
);
}
diff --git a/apps/edr-freight-web/portal/src/pages/settings/TabDocuments.tsx b/apps/edr-freight-web/portal/src/pages/settings/TabDocuments.tsx
index 6fbcbe911..b904d7eba 100644
--- a/apps/edr-freight-web/portal/src/pages/settings/TabDocuments.tsx
+++ b/apps/edr-freight-web/portal/src/pages/settings/TabDocuments.tsx
@@ -1,8 +1,9 @@
+import { fileViewUrl } from "@/constants/apiConfig";
import { api } from "@/services/api";
import { companiesService } from "@/services/companies.service";
import { getMinFiles } from "@/types/fileUploadSettings";
import type { ProfileResponse } from "@/types/profile";
-import { SmartFileInput } from "@edr/ui-common";
+import { SmartFileInput, useFileViewer } from "@edr/ui-common";
import {
Anchor,
Button,
@@ -51,6 +52,7 @@ export default function TabDocuments({
onContinue,
}: TabDocumentsProps) {
const queryClient = useQueryClient();
+ const { view, viewer } = useFileViewer();
const [documentFiles, setDocumentFiles] = useState<
Record
>({});
@@ -73,13 +75,16 @@ export default function TabDocuments({
);
const existingFilesByKey = useMemo(() => {
- const map: Record =
- {};
+ const map: Record<
+ string,
+ { name: string; url: string; size?: number; mimeType?: string | null }[]
+ > = {};
for (const doc of docsQuery.data ?? []) {
(map[doc.code] ??= []).push({
name: doc.name,
- url: doc.url,
+ url: fileViewUrl(doc.id),
size: doc.size,
+ mimeType: doc.mimeType,
});
}
return map;
@@ -163,6 +168,7 @@ export default function TabDocuments({
errors={fieldErrors}
uploadedKeys={uploadedKeys}
existingFiles={existingFilesByKey}
+ onViewFile={view}
/>
)}
@@ -252,14 +258,9 @@ export default function TabDocuments({
{p.licenseFiles.map((f) => (
-
+
{f.name}
-
+
))}
@@ -267,6 +268,8 @@ export default function TabDocuments({
)}
+
+ {viewer}
>
);
}
From e4b0c73c635e2acdad964fdb7f4d26671a910c8a Mon Sep 17 00:00:00 2001
From: natib21
Date: Fri, 3 Jul 2026 09:10:05 +0000
Subject: [PATCH 58/86] fix fayda
---
apps/edr-freight-api/.env.example | 18 +
apps/edr-freight-api/package.json | 1 +
apps/edr-freight-api/src/app.module.ts | 5 +-
.../src/config/fayda.config.ts | 126 +++
...0000000002-AddFaydaVerificationSessions.ts | 44 ++
...890000000003-AddDriverFaydaVerification.ts | 26 +
.../modules/drivers/dto/create-driver.dto.ts | 10 +-
.../modules/drivers/entities/driver.entity.ts | 7 +
.../fayda-verification-session.entity.ts | 49 ++
.../modules/verifayda/optional-jwt.guard.ts | 30 +
.../utils/client-assertion.util.spec.ts | 71 ++
.../verifayda/utils/client-assertion.util.ts | 22 +
.../modules/verifayda/utils/pkce.util.spec.ts | 65 ++
.../src/modules/verifayda/utils/pkce.util.ts | 21 +
.../modules/verifayda/verifayda.controller.ts | 107 +++
.../src/modules/verifayda/verifayda.dto.ts | 105 +++
.../src/modules/verifayda/verifayda.errors.ts | 19 +
.../src/modules/verifayda/verifayda.module.ts | 13 +
.../modules/verifayda/verifayda.service.ts | 597 ++++++++++++++
.../src/modules/verifayda/verifayda.types.ts | 45 ++
apps/edr-freight-web/backoffice/src/App.tsx | 3 +
.../src/components/fleet/FleetFormDialog.tsx | 125 ++-
.../src/components/fleet/fleetFormat.tsx | 12 +-
.../src/pages/FaydaCallbackPage.tsx | 56 ++
.../src/pages/fleet/FleetResourcePage.tsx | 1 +
.../src/pages/fleet/config/drivers.ts | 2 +
.../src/pages/fleet/config/resources.ts | 4 +-
.../src/services/drivers.service.ts | 2 +
.../src/services/verifayda.service.ts | 46 ++
pnpm-lock.yaml | 740 +++---------------
30 files changed, 1720 insertions(+), 652 deletions(-)
create mode 100644 apps/edr-freight-api/src/config/fayda.config.ts
create mode 100644 apps/edr-freight-api/src/migrations/1890000000002-AddFaydaVerificationSessions.ts
create mode 100644 apps/edr-freight-api/src/migrations/1890000000003-AddDriverFaydaVerification.ts
create mode 100644 apps/edr-freight-api/src/modules/verifayda/entities/fayda-verification-session.entity.ts
create mode 100644 apps/edr-freight-api/src/modules/verifayda/optional-jwt.guard.ts
create mode 100644 apps/edr-freight-api/src/modules/verifayda/utils/client-assertion.util.spec.ts
create mode 100644 apps/edr-freight-api/src/modules/verifayda/utils/client-assertion.util.ts
create mode 100644 apps/edr-freight-api/src/modules/verifayda/utils/pkce.util.spec.ts
create mode 100644 apps/edr-freight-api/src/modules/verifayda/utils/pkce.util.ts
create mode 100644 apps/edr-freight-api/src/modules/verifayda/verifayda.controller.ts
create mode 100644 apps/edr-freight-api/src/modules/verifayda/verifayda.dto.ts
create mode 100644 apps/edr-freight-api/src/modules/verifayda/verifayda.errors.ts
create mode 100644 apps/edr-freight-api/src/modules/verifayda/verifayda.module.ts
create mode 100644 apps/edr-freight-api/src/modules/verifayda/verifayda.service.ts
create mode 100644 apps/edr-freight-api/src/modules/verifayda/verifayda.types.ts
create mode 100644 apps/edr-freight-web/backoffice/src/pages/FaydaCallbackPage.tsx
create mode 100644 apps/edr-freight-web/backoffice/src/services/verifayda.service.ts
diff --git a/apps/edr-freight-api/.env.example b/apps/edr-freight-api/.env.example
index e02391ccd..79fea03e9 100644
--- a/apps/edr-freight-api/.env.example
+++ b/apps/edr-freight-api/.env.example
@@ -60,3 +60,21 @@ REDIS_PORT=6379
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
+FAYDA_CLIENT_ID=
+FAYDA_AUTHORIZATION_ENDPOINT=
+FAYDA_TOKEN_ENDPOINT=
+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=
+# OAuth redirect_uri for WEB clients. Defaults to FAYDA_REDIRECT_URI when unset.
+FAYDA_WEB_REDIRECT_URI=
+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
diff --git a/apps/edr-freight-api/package.json b/apps/edr-freight-api/package.json
index b24cf6c83..e4f741f2d 100644
--- a/apps/edr-freight-api/package.json
+++ b/apps/edr-freight-api/package.json
@@ -62,6 +62,7 @@
"dotenv": "^17.4.2",
"dotenv-cli": "^11.0.0",
"handlebars": "^4.7.9",
+ "jose": "^5.10.0",
"libphonenumber-js": "^1.13.6",
"minio": "7.1.3",
"pg": "^8.13.0",
diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts
index f40e4f40f..d72c8b720 100644
--- a/apps/edr-freight-api/src/app.module.ts
+++ b/apps/edr-freight-api/src/app.module.ts
@@ -12,6 +12,7 @@ import appConfig from "./config/app.config";
import databaseConfig from "./config/database.config";
import telebirrConfig from "./config/telebirr.config";
import rabbitmqConfig from "./config/rabbitmq.config";
+import faydaConfig from "./config/fayda.config";
import { BookingsModule } from "./modules/bookings/bookings.module";
import { ContractsModule } from "./modules/contracts/contracts.module";
@@ -75,12 +76,13 @@ 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 { VerifaydaModule } from './modules/verifayda/verifayda.module';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
- load: [appConfig, databaseConfig, telebirrConfig, rabbitmqConfig],
+ load: [appConfig, databaseConfig, telebirrConfig, rabbitmqConfig, faydaConfig],
}),
ScheduleModule.forRoot(),
EventEmitterModule.forRoot(),
@@ -141,6 +143,7 @@ import { ImportOperationsModule } from './modules/import-operations/import-opera
LastMileModule,
InterchangeDocumentsModule,
ImportOperationsModule,
+ VerifaydaModule,
],
providers: [
EdrOrgSeeder,
diff --git a/apps/edr-freight-api/src/config/fayda.config.ts b/apps/edr-freight-api/src/config/fayda.config.ts
new file mode 100644
index 000000000..a25289159
--- /dev/null
+++ b/apps/edr-freight-api/src/config/fayda.config.ts
@@ -0,0 +1,126 @@
+import { registerAs } from '@nestjs/config';
+
+export interface FaydaJwk {
+ kty: 'RSA';
+ use?: string;
+ kid?: string;
+ alg?: string;
+ n: string;
+ e: string;
+ d: string;
+ p?: string;
+ q?: string;
+ dp?: string;
+ dq?: string;
+ qi?: string;
+}
+
+export type FaydaPlatform = 'WEB' | 'MOBILE';
+
+export interface FaydaConfig {
+ enabled: boolean;
+ clientId: string;
+ authorizationEndpoint: string;
+ tokenEndpoint: string;
+ userInfoEndpoint: string;
+ /** OAuth redirect_uri sent to eSignet for MOBILE clients. */
+ redirectUri: string;
+ /** OAuth redirect_uri sent to eSignet for WEB clients. Falls back to `redirectUri`. */
+ webRedirectUri: string;
+ privateJwk: FaydaJwk;
+ scope: string;
+ acrValues: string;
+ claimsLocales: string;
+ sessionTtlMinutes: number;
+}
+
+const REQUIRED_VARS = [
+ 'FAYDA_CLIENT_ID',
+ 'FAYDA_AUTHORIZATION_ENDPOINT',
+ 'FAYDA_TOKEN_ENDPOINT',
+ 'FAYDA_USERINFO_ENDPOINT',
+ 'FAYDA_PRIVATE_KEY_BASE64',
+] as const;
+
+function decodePrivateJwk(base64: string): FaydaJwk {
+ let jwk: unknown;
+ try {
+ const json = Buffer.from(base64, 'base64').toString('utf8');
+ jwk = JSON.parse(json);
+ } catch (err) {
+ throw new Error(
+ `FAYDA_PRIVATE_KEY_BASE64 is not valid Base64-encoded JSON: ${(err as Error).message}`,
+ );
+ }
+ if (!jwk || typeof jwk !== 'object') {
+ throw new Error('FAYDA_PRIVATE_KEY_BASE64 must decode to a JSON object');
+ }
+ const candidate = jwk as Partial;
+ if (candidate.kty !== 'RSA') {
+ throw new Error('FAYDA_PRIVATE_KEY_BASE64 JWK must have kty="RSA"');
+ }
+ if (!candidate.n || !candidate.e || !candidate.d) {
+ throw new Error(
+ 'FAYDA_PRIVATE_KEY_BASE64 JWK is missing required RSA private-key fields (n, e, d)',
+ );
+ }
+ return candidate as FaydaJwk;
+}
+
+export default registerAs('fayda', (): FaydaConfig => {
+ const enabled = (process.env.FAYDA_ENABLED ?? 'false').toLowerCase() === 'true';
+ // `profile` covers name/birthdate/gender/picture; `email`, `phone`, `address`
+ // are needed so the matching essential claims aren't rejected as out-of-scope.
+ const scope = process.env.FAYDA_SCOPE ?? 'openid profile email phone address';
+ const acrValues = process.env.FAYDA_ACR_VALUES ?? 'mosip:idp:acr:generated-code';
+ const claimsLocales = process.env.FAYDA_CLAIMS_LOCALES ?? 'en am';
+ const sessionTtl = Number.parseInt(process.env.FAYDA_SESSION_TTL_MINUTES ?? '10', 10);
+ const redirectUri = process.env.FAYDA_REDIRECT_URI ?? '';
+ const webRedirectUri = process.env.FAYDA_WEB_REDIRECT_URI || redirectUri;
+ if (!enabled) {
+ return {
+ enabled: false,
+ clientId: process.env.FAYDA_CLIENT_ID ?? '',
+ authorizationEndpoint: process.env.FAYDA_AUTHORIZATION_ENDPOINT ?? '',
+ tokenEndpoint: process.env.FAYDA_TOKEN_ENDPOINT ?? '',
+ userInfoEndpoint: process.env.FAYDA_USERINFO_ENDPOINT ?? '',
+ redirectUri,
+ webRedirectUri,
+ privateJwk: { kty: 'RSA', n: '', e: '', d: '' },
+ scope,
+ acrValues,
+ claimsLocales,
+ sessionTtlMinutes: Number.isNaN(sessionTtl) || sessionTtl <= 0 ? 10 : sessionTtl,
+ };
+ }
+
+ const missing = REQUIRED_VARS.filter((name) => !process.env[name]);
+ if (missing.length > 0) {
+ throw new Error(
+ `Fayda integration is enabled (FAYDA_ENABLED=true) but the following env vars are missing: ${missing.join(', ')}`,
+ );
+ }
+ if (!redirectUri) {
+ throw new Error(
+ 'Fayda integration is enabled but the redirect URI is missing: set FAYDA_REDIRECT_URI',
+ );
+ }
+ if (Number.isNaN(sessionTtl) || sessionTtl <= 0) {
+ throw new Error('FAYDA_SESSION_TTL_MINUTES must be a positive integer');
+ }
+
+ return {
+ enabled: true,
+ clientId: process.env.FAYDA_CLIENT_ID!,
+ authorizationEndpoint: process.env.FAYDA_AUTHORIZATION_ENDPOINT!,
+ tokenEndpoint: process.env.FAYDA_TOKEN_ENDPOINT!,
+ userInfoEndpoint: process.env.FAYDA_USERINFO_ENDPOINT!,
+ redirectUri,
+ webRedirectUri,
+ privateJwk: decodePrivateJwk(process.env.FAYDA_PRIVATE_KEY_BASE64!),
+ scope,
+ acrValues,
+ claimsLocales,
+ sessionTtlMinutes: sessionTtl,
+ };
+});
diff --git a/apps/edr-freight-api/src/migrations/1890000000002-AddFaydaVerificationSessions.ts b/apps/edr-freight-api/src/migrations/1890000000002-AddFaydaVerificationSessions.ts
new file mode 100644
index 000000000..10b358eea
--- /dev/null
+++ b/apps/edr-freight-api/src/migrations/1890000000002-AddFaydaVerificationSessions.ts
@@ -0,0 +1,44 @@
+import { MigrationInterface, QueryRunner } from "typeorm";
+
+/**
+ * Session store for the VeriFayda 2.0 OIDC verification flow (ported from
+ * passenger-api). One row per started verification; `state` is the
+ * single-use CSRF token linking the eSignet redirect back to the session.
+ */
+export class AddFaydaVerificationSessions1890000000002 implements MigrationInterface {
+ name = "AddFaydaVerificationSessions1890000000002";
+
+ public async up(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(`
+ CREATE TABLE IF NOT EXISTS freight.fayda_verification_sessions (
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+ state varchar NOT NULL UNIQUE,
+ code_verifier varchar NOT NULL,
+ purpose varchar NOT NULL DEFAULT 'VERIFY',
+ platform varchar NOT NULL DEFAULT 'WEB',
+ save_to_account boolean NOT NULL DEFAULT false,
+ status varchar NOT NULL DEFAULT 'PENDING',
+ error_code varchar,
+ error_description text,
+ iam_user_id uuid,
+ expires_at timestamptz NOT NULL,
+ completed_at timestamptz,
+ 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_FAYDA_SESSIONS_EXPIRES_AT"
+ ON freight.fayda_verification_sessions (expires_at)
+ `);
+ await queryRunner.query(`
+ CREATE INDEX IF NOT EXISTS "IDX_FAYDA_SESSIONS_IAM_USER_ID"
+ ON freight.fayda_verification_sessions (iam_user_id)
+ `);
+ }
+
+ public async down(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(`DROP TABLE IF EXISTS freight.fayda_verification_sessions`);
+ }
+}
diff --git a/apps/edr-freight-api/src/migrations/1890000000003-AddDriverFaydaVerification.ts b/apps/edr-freight-api/src/migrations/1890000000003-AddDriverFaydaVerification.ts
new file mode 100644
index 000000000..ba8003143
--- /dev/null
+++ b/apps/edr-freight-api/src/migrations/1890000000003-AddDriverFaydaVerification.ts
@@ -0,0 +1,26 @@
+import { MigrationInterface, QueryRunner } from "typeorm";
+
+/**
+ * Track Fayda identity verification on drivers: whether the driver's
+ * identity was verified through VeriFayda and the OIDC subject it was
+ * verified against.
+ */
+export class AddDriverFaydaVerification1890000000003 implements MigrationInterface {
+ name = "AddDriverFaydaVerification1890000000003";
+
+ public async up(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(`
+ ALTER TABLE freight.drivers
+ ADD COLUMN IF NOT EXISTS fayda_verified boolean DEFAULT false,
+ ADD COLUMN IF NOT EXISTS fayda_sub varchar
+ `);
+ }
+
+ public async down(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(`
+ ALTER TABLE freight.drivers
+ DROP COLUMN IF EXISTS fayda_verified,
+ DROP COLUMN IF EXISTS fayda_sub
+ `);
+ }
+}
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 d8e2aec4a..4bf1d640d 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,4 +1,4 @@
-import { IsString, IsEmail, IsDateString, IsEnum, IsOptional, IsArray } from 'class-validator';
+import { IsString, IsEmail, IsDateString, IsEnum, IsOptional, IsArray, IsBoolean } from 'class-validator';
import { DriverStatus } from '../entities/driver.entity';
export class CreateDriverDto {
@@ -42,4 +42,12 @@ export class CreateDriverDto {
@IsOptional()
@IsString()
notes?: string;
+
+ @IsOptional()
+ @IsBoolean()
+ faydaVerified?: boolean;
+
+ @IsOptional()
+ @IsString()
+ faydaSub?: 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 b3defe2db..c345938a4 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
@@ -51,4 +51,11 @@ export class Driver extends BaseEntity {
@Column({ type: 'numeric', precision: 3, scale: 2, nullable: true })
rating?: number | null;
+
+ @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 })
+ faydaSub?: string | null;
}
diff --git a/apps/edr-freight-api/src/modules/verifayda/entities/fayda-verification-session.entity.ts b/apps/edr-freight-api/src/modules/verifayda/entities/fayda-verification-session.entity.ts
new file mode 100644
index 000000000..3435acabc
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/verifayda/entities/fayda-verification-session.entity.ts
@@ -0,0 +1,49 @@
+import { Column, Entity, Index } from 'typeorm';
+import { BaseEntity } from '@edr/api-common';
+
+/**
+ * One row per started Fayda verification. Mirrors the passenger-api Prisma
+ * model `FaydaVerificationSession`, but stored in the freight schema via
+ * TypeORM. `state` is the single-use CSRF token that links the eSignet
+ * redirect back to this session.
+ */
+@Entity({ name: 'fayda_verification_sessions', schema: 'freight' })
+@Index(['expiresAt'])
+@Index(['iamUserId'])
+export class FaydaVerificationSession extends BaseEntity {
+ @Column({ name: 'state', unique: true })
+ state!: string;
+
+ @Column({ name: 'code_verifier' })
+ codeVerifier!: string;
+
+ /** VERIFY | LOGIN */
+ @Column({ name: 'purpose', default: 'VERIFY' })
+ purpose!: string;
+
+ /** WEB | MOBILE — recorded for audit */
+ @Column({ name: 'platform', default: 'WEB' })
+ platform!: string;
+
+ @Column({ name: 'save_to_account', type: 'boolean', default: false })
+ saveToAccount!: boolean;
+
+ /** PENDING | COMPLETED | FAILED */
+ @Column({ name: 'status', default: 'PENDING' })
+ status!: string;
+
+ @Column({ name: 'error_code', type: 'varchar', nullable: true })
+ errorCode?: string | null;
+
+ @Column({ name: 'error_description', type: 'text', nullable: true })
+ errorDescription?: string | null;
+
+ @Column({ name: 'iam_user_id', type: 'uuid', nullable: true })
+ iamUserId?: string | null;
+
+ @Column({ name: 'expires_at', type: 'timestamptz' })
+ expiresAt!: Date;
+
+ @Column({ name: 'completed_at', type: 'timestamptz', nullable: true })
+ completedAt?: Date | null;
+}
diff --git a/apps/edr-freight-api/src/modules/verifayda/optional-jwt.guard.ts b/apps/edr-freight-api/src/modules/verifayda/optional-jwt.guard.ts
new file mode 100644
index 000000000..8673aa60e
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/verifayda/optional-jwt.guard.ts
@@ -0,0 +1,30 @@
+import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
+import { Reflector } from '@nestjs/core';
+import { InjectDataSource } from '@nestjs/typeorm';
+import { JwtGuard as IamJwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard';
+import { DataSource } from 'typeorm';
+
+/**
+ * Like the IAM JwtGuard, but never rejects the request.
+ *
+ * When a valid IAM bearer token is present, `request.user` is populated with
+ * the package `TCurrentUser`. Missing or invalid tokens continue as guests.
+ */
+@Injectable()
+export class OptionalJwtGuard extends IamJwtGuard implements CanActivate {
+ constructor(
+ reflector: Reflector,
+ @InjectDataSource() dataSource: DataSource,
+ ) {
+ super(reflector, dataSource);
+ }
+
+ async canActivate(context: ExecutionContext): Promise {
+ try {
+ await super.canActivate(context);
+ } catch {
+ context.switchToHttp().getRequest().user = undefined;
+ }
+ return true;
+ }
+}
diff --git a/apps/edr-freight-api/src/modules/verifayda/utils/client-assertion.util.spec.ts b/apps/edr-freight-api/src/modules/verifayda/utils/client-assertion.util.spec.ts
new file mode 100644
index 000000000..9b4316fc7
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/verifayda/utils/client-assertion.util.spec.ts
@@ -0,0 +1,71 @@
+import { exportJWK, generateKeyPair, importJWK, jwtVerify, type JWK } from 'jose';
+import { generateClientAssertion } from './client-assertion.util';
+
+describe('generateClientAssertion', () => {
+ let privateJwk: JWK;
+ let publicJwk: JWK;
+
+ beforeAll(async () => {
+ const kp = await generateKeyPair('RS256', { extractable: true });
+ privateJwk = await exportJWK(kp.privateKey);
+ publicJwk = await exportJWK(kp.publicKey);
+ });
+
+ it('produces a JWT verifiable with the matching public key', async () => {
+ const jwt = await generateClientAssertion({
+ clientId: 'edr-passenger-test',
+ audience: 'https://esignet.example.com/token',
+ privateJwk,
+ });
+
+ const verifier = await importJWK(publicJwk, 'RS256');
+ const { payload, protectedHeader } = await jwtVerify(jwt, verifier, {
+ issuer: 'edr-passenger-test',
+ subject: 'edr-passenger-test',
+ audience: 'https://esignet.example.com/token',
+ });
+
+ expect(protectedHeader.alg).toBe('RS256');
+ expect(protectedHeader.typ).toBe('JWT');
+ expect(payload.iss).toBe('edr-passenger-test');
+ expect(payload.sub).toBe('edr-passenger-test');
+ expect(payload.aud).toBe('https://esignet.example.com/token');
+ expect(typeof payload.iat).toBe('number');
+ expect(typeof payload.exp).toBe('number');
+ });
+
+ it('defaults exp to 120 seconds after iat', async () => {
+ const jwt = await generateClientAssertion({
+ clientId: 'c',
+ audience: 'https://a/token',
+ privateJwk,
+ });
+ const verifier = await importJWK(publicJwk, 'RS256');
+ const { payload } = await jwtVerify(jwt, verifier);
+ expect(payload.exp! - payload.iat!).toBe(120);
+ });
+
+ it('honors a custom expiresIn', async () => {
+ const jwt = await generateClientAssertion({
+ clientId: 'c',
+ audience: 'https://a/token',
+ privateJwk,
+ expiresIn: '5m',
+ });
+ const verifier = await importJWK(publicJwk, 'RS256');
+ const { payload } = await jwtVerify(jwt, verifier);
+ expect(payload.exp! - payload.iat!).toBe(300);
+ });
+
+ it('fails verification against a wrong audience', async () => {
+ const jwt = await generateClientAssertion({
+ clientId: 'c',
+ audience: 'https://a/token',
+ privateJwk,
+ });
+ const verifier = await importJWK(publicJwk, 'RS256');
+ await expect(
+ jwtVerify(jwt, verifier, { audience: 'https://other/token' }),
+ ).rejects.toThrow();
+ });
+});
diff --git a/apps/edr-freight-api/src/modules/verifayda/utils/client-assertion.util.ts b/apps/edr-freight-api/src/modules/verifayda/utils/client-assertion.util.ts
new file mode 100644
index 000000000..dc3558ccc
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/verifayda/utils/client-assertion.util.ts
@@ -0,0 +1,22 @@
+import { SignJWT, importJWK, type JWK } from 'jose';
+
+export interface GenerateClientAssertionInput {
+ clientId: string;
+ audience: string;
+ privateJwk: JWK;
+ expiresIn?: string;
+}
+
+export async function generateClientAssertion(
+ input: GenerateClientAssertionInput,
+): Promise {
+ const privateKey = await importJWK(input.privateJwk, 'RS256');
+ return new SignJWT({})
+ .setProtectedHeader({ alg: 'RS256', typ: 'JWT' })
+ .setIssuer(input.clientId)
+ .setSubject(input.clientId)
+ .setAudience(input.audience)
+ .setIssuedAt()
+ .setExpirationTime(input.expiresIn ?? '2m')
+ .sign(privateKey);
+}
diff --git a/apps/edr-freight-api/src/modules/verifayda/utils/pkce.util.spec.ts b/apps/edr-freight-api/src/modules/verifayda/utils/pkce.util.spec.ts
new file mode 100644
index 000000000..d359a07f2
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/verifayda/utils/pkce.util.spec.ts
@@ -0,0 +1,65 @@
+import { createHash } from 'crypto';
+import {
+ base64Url,
+ generateCodeChallenge,
+ generateCodeVerifier,
+ generateState,
+} from './pkce.util';
+
+describe('pkce.util', () => {
+ describe('base64Url', () => {
+ it('strips padding and replaces + and / with - and _', () => {
+ const input = Buffer.from([0xfb, 0xff, 0xbf, 0xfe]);
+ const out = base64Url(input);
+ expect(out).not.toMatch(/[+/=]/);
+ });
+ });
+
+ describe('generateCodeVerifier', () => {
+ it('returns a base64url-safe string', () => {
+ expect(generateCodeVerifier()).toMatch(/^[A-Za-z0-9_-]+$/);
+ });
+
+ it('produces unique values across calls', () => {
+ const a = generateCodeVerifier();
+ const b = generateCodeVerifier();
+ expect(a).not.toEqual(b);
+ });
+
+ it('produces at least 43 characters (RFC 7636 minimum)', () => {
+ expect(generateCodeVerifier().length).toBeGreaterThanOrEqual(43);
+ });
+ });
+
+ describe('generateCodeChallenge', () => {
+ it('equals base64url(sha256(verifier))', () => {
+ const verifier = 'fixed-test-verifier';
+ const expected = createHash('sha256')
+ .update(verifier)
+ .digest('base64')
+ .replace(/\+/g, '-')
+ .replace(/\//g, '_')
+ .replace(/=/g, '');
+ expect(generateCodeChallenge(verifier)).toBe(expected);
+ });
+
+ it('is deterministic for the same verifier', () => {
+ const verifier = generateCodeVerifier();
+ expect(generateCodeChallenge(verifier)).toBe(generateCodeChallenge(verifier));
+ });
+
+ it('differs for different verifiers', () => {
+ expect(generateCodeChallenge('a')).not.toBe(generateCodeChallenge('b'));
+ });
+ });
+
+ describe('generateState', () => {
+ it('returns a base64url-safe string', () => {
+ expect(generateState()).toMatch(/^[A-Za-z0-9_-]+$/);
+ });
+
+ it('produces unique values across calls', () => {
+ expect(generateState()).not.toEqual(generateState());
+ });
+ });
+});
diff --git a/apps/edr-freight-api/src/modules/verifayda/utils/pkce.util.ts b/apps/edr-freight-api/src/modules/verifayda/utils/pkce.util.ts
new file mode 100644
index 000000000..89e9437d2
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/verifayda/utils/pkce.util.ts
@@ -0,0 +1,21 @@
+import { createHash, randomBytes } from 'crypto';
+
+export function base64Url(buffer: Buffer): string {
+ return buffer
+ .toString('base64')
+ .replace(/\+/g, '-')
+ .replace(/\//g, '_')
+ .replace(/=/g, '');
+}
+
+export function generateCodeVerifier(): string {
+ return base64Url(randomBytes(64));
+}
+
+export function generateCodeChallenge(codeVerifier: string): string {
+ return base64Url(createHash('sha256').update(codeVerifier).digest());
+}
+
+export function generateState(): string {
+ return base64Url(randomBytes(32));
+}
diff --git a/apps/edr-freight-api/src/modules/verifayda/verifayda.controller.ts b/apps/edr-freight-api/src/modules/verifayda/verifayda.controller.ts
new file mode 100644
index 000000000..977e677f8
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/verifayda/verifayda.controller.ts
@@ -0,0 +1,107 @@
+import {
+ Body,
+ Controller,
+ Get,
+ HttpCode,
+ HttpStatus,
+ Post,
+ Query,
+ Req,
+ UseGuards,
+} from '@nestjs/common';
+import {
+ ApiBearerAuth,
+ ApiOkResponse,
+ ApiOperation,
+ ApiTags,
+} from '@nestjs/swagger';
+import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
+import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator';
+import { JwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard';
+import { OptionalJwtGuard } from './optional-jwt.guard';
+import {
+ CompleteVerificationResultDto,
+ StartVerificationDto,
+ VerifaydaCallbackDto,
+ VerificationStatusDto,
+} from './verifayda.dto';
+import { VerifaydaService } from './verifayda.service';
+
+/** Minimal slices of the Express req we touch (avoids a hard dependency on
+ * `@types/express`, which isn't resolved in this package). */
+interface RequestWithOptionalUser {
+ user?: TCurrentUser;
+}
+interface RequestWithUser {
+ user: TCurrentUser;
+}
+
+@ApiTags('Fayda Verification')
+@Controller('fayda/verification')
+export class VerifaydaController {
+ constructor(private readonly service: VerifaydaService) {}
+
+ @Post('start')
+ @IsPublic()
+ @HttpCode(HttpStatus.OK)
+ @UseGuards(OptionalJwtGuard)
+ @ApiBearerAuth('JWT-auth')
+ @ApiOperation({
+ summary: 'Start a VeriFayda 2.0 verification session',
+ description: `Creates a verification session and returns the eSignet authorize URL the frontend should send the user to.
+
+- Works for **logged-in users** and **guests**. If a valid bearer token is present, the verification is tied to that user.
+- **VERIFY** (default): the user proves their identity and \`/complete\` returns the verified attributes (name, email, phone, dob, gender).
+- **LOGIN**: \`/complete\` resolves/creates the user and returns a JWT.
+- The returned \`authorizationUrl\` already carries the PKCE \`code_challenge\`, CSRF \`state\`, requested \`claims\`, and \`code_challenge_method=S256\`. The frontend simply navigates to it (full page or popup).`,
+ })
+ @ApiOkResponse({
+ description: 'Authorize URL the frontend should redirect the user to.',
+ schema: {
+ example: {
+ authorizationUrl:
+ 'https://esignet.example.com/authorize?client_id=...&state=...&code_challenge=...',
+ },
+ },
+ })
+ async start(
+ @Body() dto: StartVerificationDto,
+ @Req() req: RequestWithOptionalUser,
+ ): Promise<{ authorizationUrl: string }> {
+ const authorizationUrl = await this.service.startVerification({
+ purpose: dto.purpose ?? 'VERIFY',
+ platform: dto.platform ?? 'WEB',
+ userId: req.user?.id,
+ wantsPasswordSetup: dto.wantsPasswordSetup ?? false,
+ });
+ return { authorizationUrl };
+ }
+
+ @Get('complete')
+ @IsPublic()
+ @ApiOperation({
+ summary: 'Complete a verification (Fayda redirect / client callback lands here)',
+ description: `This is the registered Fayda \`redirect_uri\`. Fayda redirects the browser here with \`?code&state\``,
+ })
+ @ApiOkResponse({ type: CompleteVerificationResultDto })
+ async complete(
+ @Query() dto: VerifaydaCallbackDto,
+ ): Promise {
+ return this.service.completeVerification(dto);
+ }
+
+ @Get('status')
+ @UseGuards(JwtGuard)
+ @ApiBearerAuth('JWT-auth')
+ @ApiOperation({
+ summary: "Get the current user's Fayda verification status",
+ description:
+ 'Returns whether the authenticated user has linked a verified Fayda identity to their account, when, and the name on file.',
+ })
+ @ApiOkResponse({ type: VerificationStatusDto })
+ async status(
+ @Req() req: RequestWithUser,
+ ): Promise {
+ return this.service.getVerificationStatus(req.user.id);
+ }
+}
diff --git a/apps/edr-freight-api/src/modules/verifayda/verifayda.dto.ts b/apps/edr-freight-api/src/modules/verifayda/verifayda.dto.ts
new file mode 100644
index 000000000..1885b11e9
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/verifayda/verifayda.dto.ts
@@ -0,0 +1,105 @@
+import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
+import { IsIn, IsOptional, IsString } from 'class-validator';
+
+export class StartVerificationDto {
+ @ApiPropertyOptional({
+ enum: ['LOGIN', 'VERIFY'],
+ default: 'VERIFY',
+ description:
+ 'Reason for verification. VERIFY returns the verified identity attributes; LOGIN resolves/creates a user and returns a JWT.',
+ })
+ @IsOptional()
+ @IsIn(['LOGIN', 'VERIFY'])
+ purpose?: 'LOGIN' | 'VERIFY';
+
+ @ApiPropertyOptional({
+ enum: ['WEB', 'MOBILE'],
+ default: 'WEB',
+ description:
+ 'Client platform. Selects which OAuth redirect_uri is sent to eSignet: WEB uses FAYDA_WEB_REDIRECT_URI, MOBILE uses FAYDA_REDIRECT_URI. Both land on the same /complete endpoint with identical handling.',
+ })
+ @IsOptional()
+ @IsIn(['WEB', 'MOBILE'])
+ platform?: 'WEB' | 'MOBILE';
+
+ @ApiPropertyOptional({
+ type: Boolean,
+ default: false,
+ description:
+ 'Set to true when the user opts in to full account registration (checkbox). ' +
+ 'When true, the /complete response includes a short-lived token and promptPasswordSetup=true ' +
+ 'so the frontend can immediately prompt for a password via POST /v1/auth/set-fayda-password.',
+ })
+ @IsOptional()
+ wantsPasswordSetup?: boolean;
+}
+
+export class CompleteVerificationResultDto {
+ @ApiProperty({ enum: ['LOGIN', 'VERIFY'] })
+ purpose!: 'LOGIN' | 'VERIFY';
+
+ @ApiProperty() verified!: boolean;
+
+ @ApiPropertyOptional({ description: 'JWT. LOGIN: session token for the authenticated user. VERIFY: short-lived token for calling /v1/auth/set-fayda-password.' })
+ token?: string;
+
+ @ApiPropertyOptional()
+ refreshToken?: string;
+
+ @ApiPropertyOptional({
+ description: 'Authenticated user summary (LOGIN flow only; same shape as /auth/login).',
+ })
+ user?: {
+ id: string;
+ email: string;
+ role: string;
+ passengerId?: string;
+ agentId?: string;
+ };
+
+ @ApiPropertyOptional({ description: 'Verified full name from Fayda (VERIFY flow).' })
+ fullName?: string;
+
+ @ApiPropertyOptional({ description: 'Verified email from Fayda (VERIFY flow).' })
+ email?: string;
+
+ @ApiPropertyOptional({ description: 'Verified phone number from Fayda (VERIFY flow).' })
+ phoneNumber?: string;
+
+ @ApiPropertyOptional({
+ description: 'Verified date of birth from Fayda, ISO yyyy-MM-dd (VERIFY flow).',
+ })
+ birthdate?: string;
+
+ @ApiPropertyOptional({ description: 'Verified gender from Fayda (VERIFY flow).' })
+ gender?: string;
+
+ @ApiPropertyOptional({ description: 'Whether the verified identity was saved to IAM. False if the IAM write failed.' })
+ userDataSaved?: boolean;
+
+ @ApiPropertyOptional({ description: 'IAM user ID of the verified identity (VERIFY flow).' })
+ iamUserId?: string;
+
+ @ApiPropertyOptional({ description: 'True when the IAM account has not yet set a password (VERIFY flow).' })
+ requiresPassword?: boolean;
+
+ @ApiPropertyOptional({
+ description:
+ 'True when the user opted in to immediate password setup (wantsPasswordSetup=true at start) ' +
+ 'AND they have not yet set a password. Frontend should navigate to the set-password screen.',
+ })
+ promptPasswordSetup?: boolean;
+}
+
+export class VerifaydaCallbackDto {
+ @ApiPropertyOptional() @IsOptional() @IsString() code?: string;
+ @ApiPropertyOptional() @IsOptional() @IsString() state?: string;
+ @ApiPropertyOptional() @IsOptional() @IsString() error?: string;
+ @ApiPropertyOptional() @IsOptional() @IsString() error_description?: string;
+}
+
+export class VerificationStatusDto {
+ @ApiProperty() verified!: boolean;
+ @ApiPropertyOptional() verifiedAt?: Date;
+ @ApiPropertyOptional() fullName?: string;
+}
diff --git a/apps/edr-freight-api/src/modules/verifayda/verifayda.errors.ts b/apps/edr-freight-api/src/modules/verifayda/verifayda.errors.ts
new file mode 100644
index 000000000..a7d531102
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/verifayda/verifayda.errors.ts
@@ -0,0 +1,19 @@
+import { BadGatewayException, ConflictException } from '@nestjs/common';
+
+export class FaydaTokenExchangeException extends BadGatewayException {
+ constructor(message = 'Fayda token exchange failed') {
+ super({ code: 'FAYDA_TOKEN_EXCHANGE_FAILED', message });
+ }
+}
+
+export class FaydaUserInfoException extends BadGatewayException {
+ constructor(message = 'Fayda userinfo fetch failed') {
+ super({ code: 'FAYDA_USERINFO_FAILED', message });
+ }
+}
+
+export class FaydaIdentityConflictException extends ConflictException {
+ constructor(message = 'This Fayda identity is already linked to another account') {
+ super({ code: 'FAYDA_IDENTITY_CONFLICT', message });
+ }
+}
diff --git a/apps/edr-freight-api/src/modules/verifayda/verifayda.module.ts b/apps/edr-freight-api/src/modules/verifayda/verifayda.module.ts
new file mode 100644
index 000000000..b87b90e16
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/verifayda/verifayda.module.ts
@@ -0,0 +1,13 @@
+import { Module } from '@nestjs/common';
+import { TypeOrmModule } from '@nestjs/typeorm';
+import { VerifaydaController } from './verifayda.controller';
+import { VerifaydaService } from './verifayda.service';
+import { FaydaVerificationSession } from './entities/fayda-verification-session.entity';
+
+@Module({
+ imports: [TypeOrmModule.forFeature([FaydaVerificationSession])],
+ controllers: [VerifaydaController],
+ providers: [VerifaydaService],
+ exports: [VerifaydaService],
+})
+export class VerifaydaModule {}
diff --git a/apps/edr-freight-api/src/modules/verifayda/verifayda.service.ts b/apps/edr-freight-api/src/modules/verifayda/verifayda.service.ts
new file mode 100644
index 000000000..6e3e2e095
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/verifayda/verifayda.service.ts
@@ -0,0 +1,597 @@
+import {
+ BadRequestException,
+ Injectable,
+ Logger,
+ ServiceUnavailableException,
+ UnauthorizedException,
+} from '@nestjs/common';
+import { ConfigService } from '@nestjs/config';
+import { InjectDataSource, InjectRepository } from '@nestjs/typeorm';
+import { DataSource, Repository } from 'typeorm';
+import { generateToken, generateRefreshToken } from '@tria-plc/api-common/utils/token';
+import { FaydaConfig, FaydaPlatform } from '../../config/fayda.config';
+import { FaydaVerificationSession } from './entities/fayda-verification-session.entity';
+import {
+ generateCodeChallenge,
+ generateCodeVerifier,
+ generateState,
+} from './utils/pkce.util';
+import { generateClientAssertion } from './utils/client-assertion.util';
+import { VerifaydaCallbackDto, VerificationStatusDto } from './verifayda.dto';
+import {
+ FaydaTokenExchangeException,
+ FaydaUserInfoException,
+} from './verifayda.errors';
+import {
+ FaydaTokenResponse,
+ FaydaUserInfo,
+ NormalizedFaydaUserInfo,
+ VerifaydaPurpose,
+} from './verifayda.types';
+
+export interface StartVerificationInput {
+ purpose: VerifaydaPurpose;
+ platform?: FaydaPlatform;
+ userId?: string; // iamUserId of the authenticated user, if any
+ wantsPasswordSetup?: boolean;
+}
+
+export interface FaydaUserSummary {
+ id: string;
+ email: string;
+ role: string;
+}
+
+/**
+ * Result of completing a verification. `verified` is always true on success.
+ * LOGIN additionally returns a JWT + user; VERIFY returns the verified identity
+ * attributes (name, email, phone, dob, gender) for the caller to consume.
+ */
+export interface CompleteVerificationResult {
+ purpose: VerifaydaPurpose;
+ verified: boolean;
+ token?: string;
+ refreshToken?: string;
+ requiresPassword?: boolean;
+ promptPasswordSetup?: boolean;
+ iamUserId?: string;
+ user?: FaydaUserSummary;
+ fullName?: string;
+ email?: string;
+ phoneNumber?: string;
+ birthdate?: string;
+ gender?: string;
+ userDataSaved?: boolean;
+}
+
+@Injectable()
+export class VerifaydaService {
+ private readonly logger = new Logger(VerifaydaService.name);
+
+ private readonly faydaConfig: FaydaConfig;
+
+ constructor(
+ private readonly config: ConfigService,
+ @InjectRepository(FaydaVerificationSession)
+ private readonly sessionRepo: Repository,
+ @InjectDataSource() private readonly dataSource: DataSource,
+ ) {
+ const fayda = this.config.get('fayda');
+ if (!fayda) {
+ throw new Error('Fayda config namespace not registered');
+ }
+ this.faydaConfig = fayda;
+ }
+
+ // ==========================================================================
+ // OIDC flow
+ // ==========================================================================
+
+ async startVerification(input: StartVerificationInput): Promise {
+ if (!this.faydaConfig.enabled) {
+ throw new ServiceUnavailableException({
+ code: 'FAYDA_DISABLED',
+ message: 'Fayda integration is not enabled',
+ });
+ }
+
+ const state = generateState();
+ const codeVerifier = generateCodeVerifier();
+ const codeChallenge = generateCodeChallenge(codeVerifier);
+ const expiresAt = new Date(
+ Date.now() + this.faydaConfig.sessionTtlMinutes * 60_000,
+ );
+
+ await this.sessionRepo.save(
+ this.sessionRepo.create({
+ state,
+ codeVerifier,
+ purpose: input.purpose,
+ platform: input.platform ?? 'WEB',
+ saveToAccount: input.wantsPasswordSetup ?? false,
+ iamUserId: input.userId ?? null,
+ expiresAt,
+ }),
+ );
+
+ this.logger.log(
+ `Fayda verification started: purpose=${input.purpose} platform=${input.platform ?? 'WEB'} userId=${input.userId ?? 'none'}`,
+ );
+
+ return this.buildAuthorizationUrl({
+ state,
+ codeChallenge,
+ redirectUri: this.redirectUriForPlatform(input.platform ?? 'WEB'),
+ });
+ }
+
+ /** WEB clients use `webRedirectUri`; MOBILE uses the base `redirectUri`. */
+ private redirectUriForPlatform(platform?: FaydaPlatform): string {
+ return platform === 'MOBILE'
+ ? this.faydaConfig.redirectUri
+ : this.faydaConfig.webRedirectUri;
+ }
+
+ async completeVerification(
+ query: VerifaydaCallbackDto,
+ ): Promise {
+ if (query.error) {
+ this.logger.warn(`Fayda callback returned error: ${query.error}`);
+ if (query.state) {
+ await this.markSessionFailed(
+ query.state,
+ query.error,
+ query.error_description,
+ );
+ }
+ throw new BadRequestException({
+ code: 'FAYDA_AUTH_ERROR',
+ message: query.error,
+ description: query.error_description,
+ });
+ }
+
+ if (!query.code || !query.state) {
+ throw new BadRequestException({
+ code: 'FAYDA_MISSING_PARAMETERS',
+ message: 'code and state are required',
+ });
+ }
+
+ const session = await this.sessionRepo.findOne({
+ where: { state: query.state },
+ });
+ if (!session || session.status !== 'PENDING') {
+ this.logger.warn('Fayda complete with unknown or non-pending state');
+ throw new BadRequestException({
+ code: 'FAYDA_INVALID_STATE',
+ message: 'Verification session is invalid or already used',
+ });
+ }
+ if (session.expiresAt.getTime() < Date.now()) {
+ await this.markSessionFailed(query.state, 'session_expired');
+ throw new BadRequestException({
+ code: 'FAYDA_SESSION_EXPIRED',
+ message: 'Verification session has expired; start again',
+ });
+ }
+
+ try {
+ const tokens = await this.exchangeCodeForTokens(
+ query.code,
+ session.codeVerifier,
+ this.redirectUriForPlatform(session.platform as FaydaPlatform),
+ );
+ const userInfo = await this.fetchUserInfo(tokens.access_token);
+ const normalized = this.normalizeUserInfo(userInfo);
+
+ if (!normalized.sub) {
+ throw new FaydaUserInfoException('Fayda userinfo missing required sub');
+ }
+
+ let result: CompleteVerificationResult;
+ if (session.purpose === 'LOGIN') {
+ const { userId } = await this.handleLoginSuccess(normalized);
+ const login = await this.issueLoginToken(userId);
+ result = { purpose: 'LOGIN', verified: true, ...login };
+ } else {
+ // VERIFY — prove identity, save to IAM, return verified attributes + short-lived token.
+ const { iamUserId, userDataSaved } = await this.upsertIamUser(normalized);
+
+ let sessionToken: { token: string; refreshToken: string; requiresPassword: boolean } | undefined;
+ if (iamUserId) {
+ try {
+ sessionToken = await this.createFaydaSession(iamUserId);
+ } catch (err) {
+ this.logger.warn(`Fayda session creation failed: ${(err as Error).message}`);
+ }
+ }
+
+ result = {
+ purpose: 'VERIFY',
+ verified: true,
+ fullName: normalized.fullName,
+ email: normalized.email,
+ phoneNumber: normalized.phoneNumber,
+ birthdate: normalized.birthdate,
+ gender: normalized.gender,
+ userDataSaved,
+ iamUserId: iamUserId ?? undefined,
+ token: sessionToken?.token,
+ refreshToken: sessionToken?.refreshToken,
+ requiresPassword: sessionToken?.requiresPassword,
+ promptPasswordSetup: session.saveToAccount && (sessionToken?.requiresPassword ?? false),
+ };
+ }
+
+ await this.sessionRepo.update(session.id, {
+ status: 'COMPLETED',
+ completedAt: new Date(),
+ codeVerifier: '',
+ });
+
+ this.logger.log(
+ `Fayda verification completed: purpose=${session.purpose} platform=${session.platform}`,
+ );
+ return result;
+ } catch (err) {
+ const reason = this.classifyFailureReason(err);
+ this.logger.error(
+ `Fayda verification failed: reason=${reason} message=${(err as Error).message}`,
+ );
+ await this.markSessionFailed(
+ query.state,
+ reason,
+ (err as Error).message,
+ );
+ throw err;
+ }
+ }
+
+ private async issueLoginToken(
+ _userId: string,
+ ): Promise<{ token: string; user: FaydaUserSummary }> {
+ throw new UnauthorizedException({
+ code: 'FAYDA_LOGIN_MIGRATED_TO_IAM',
+ message: 'Fayda login tokens are issued by the IAM package auth endpoints.',
+ });
+ }
+
+ async getVerificationStatus(iamUserId: string): Promise {
+ const rows = await this.dataSource.query<{ verified_by: string | null; updated_at: Date | null; name: { en: string; am: string } | null }[]>(
+ `SELECT verified_by, updated_at, name FROM iam.users WHERE id = $1 LIMIT 1`,
+ [iamUserId],
+ );
+ const iam = rows[0] ?? null;
+ const faydaVerified = iam?.verified_by === 'fayda';
+ const faydaVerifiedAt = faydaVerified && iam?.updated_at ? new Date(iam.updated_at) : undefined;
+ const fullName = iam?.name?.en ?? iam?.name?.am ?? undefined;
+ return { verified: faydaVerified, verifiedAt: faydaVerifiedAt, fullName };
+ }
+
+ // ==========================================================================
+ // OIDC internals
+ // ==========================================================================
+
+ private buildAuthorizationUrl(args: {
+ state: string;
+ codeChallenge: string;
+ redirectUri: string;
+ }): string {
+ const params = new URLSearchParams({
+ client_id: this.faydaConfig.clientId,
+ response_type: 'code',
+ redirect_uri: args.redirectUri,
+ scope: this.faydaConfig.scope,
+ state: args.state,
+ code_challenge: args.codeChallenge,
+ code_challenge_method: 'S256',
+ acr_values: this.faydaConfig.acrValues,
+ claims_locales: this.faydaConfig.claimsLocales,
+ });
+
+ // Every claim is marked essential so eSignet shows them locked/pre-checked
+ // on the consent screen — the user cannot toggle any off; they either
+ // consent to all of them or the whole flow is cancelled (?error=...).
+ const claims = {
+ userinfo: {
+ name: { essential: true },
+ phone_number: { essential: true },
+ email: { essential: true },
+ birthdate: { essential: true },
+ gender: { essential: true },
+ address: { essential: true },
+ nationality: { essential: true },
+ picture: { essential: true },
+ },
+ id_token: {},
+ };
+ params.set('claims', JSON.stringify(claims));
+
+ return `${this.faydaConfig.authorizationEndpoint}?${params.toString()}`;
+ }
+
+ private async exchangeCodeForTokens(
+ code: string,
+ codeVerifier: string,
+ redirectUri: string,
+ ): Promise {
+ const clientAssertion = await generateClientAssertion({
+ clientId: this.faydaConfig.clientId,
+ audience: this.faydaConfig.tokenEndpoint,
+ privateJwk: this.faydaConfig.privateJwk,
+ });
+
+ const body = new URLSearchParams({
+ grant_type: 'authorization_code',
+ code,
+ redirect_uri: redirectUri,
+ client_id: this.faydaConfig.clientId,
+ client_assertion_type:
+ 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer',
+ client_assertion: clientAssertion,
+ code_verifier: codeVerifier,
+ });
+
+ const response = await fetch(this.faydaConfig.tokenEndpoint, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
+ body,
+ });
+
+ if (!response.ok) {
+ let detail = '';
+ try {
+ detail = await response.text();
+ } catch {
+ // ignore
+ }
+ throw new FaydaTokenExchangeException(
+ `Fayda token endpoint returned ${response.status}${detail ? `: ${detail}` : ''}`,
+ );
+ }
+
+ return (await response.json()) as FaydaTokenResponse;
+ }
+
+ private async fetchUserInfo(accessToken: string): Promise {
+ const response = await fetch(this.faydaConfig.userInfoEndpoint, {
+ method: 'GET',
+ headers: { Authorization: `Bearer ${accessToken}` },
+ });
+
+ if (!response.ok) {
+ throw new FaydaUserInfoException(
+ `Fayda userinfo endpoint returned ${response.status}`,
+ );
+ }
+
+ const contentType = response.headers.get('content-type') ?? '';
+ const raw = await response.text();
+
+ if (contentType.includes('application/json')) {
+ return JSON.parse(raw) as FaydaUserInfo;
+ }
+
+ // Signed JWT response — decode payload (signature verification = production TODO)
+ if (raw.split('.').length === 3) {
+ const payloadB64 = raw.split('.')[1];
+ const normalizedB64 = payloadB64.replace(/-/g, '+').replace(/_/g, '/');
+ const json = Buffer.from(normalizedB64, 'base64').toString('utf8');
+ return JSON.parse(json) as FaydaUserInfo;
+ }
+
+ throw new FaydaUserInfoException(
+ 'Unsupported Fayda userinfo response format',
+ );
+ }
+
+ private normalizeUserInfo(raw: FaydaUserInfo): NormalizedFaydaUserInfo {
+ const nameEn = raw['name#en'] as string | undefined;
+ const nameAm = raw['name#am'] as string | undefined;
+ const genderEn = raw['gender#en'] as string | undefined;
+ const genderAm = raw['gender#am'] as string | undefined;
+ const addressEn = raw['address#en'] as string | undefined;
+ const addressAm = raw['address#am'] as string | undefined;
+ const rawPhone = (raw.phone_number ?? raw['phone_number#en'] ?? raw['phone_number#am'] ?? raw.phone) as string | undefined;
+
+ return {
+ sub: raw.sub,
+ fullName: (raw.name as string | undefined) ?? nameEn ?? nameAm,
+ phoneNumber: rawPhone ? this.standardizePhoneNumber(rawPhone) : undefined,
+ rawPhoneNumber: rawPhone,
+ email: raw.email as string | undefined,
+ gender: genderEn ?? genderAm ?? (raw.gender as string | undefined),
+ birthdate: raw.birthdate as string | undefined,
+ picture: raw.picture as string | undefined,
+ nameEn,
+ nameAm,
+ genderEn,
+ genderAm,
+ addressEn,
+ addressAm,
+ };
+ }
+
+ private standardizePhoneNumber(phone: string): string {
+ const digits = phone.replace(/\D/g, '');
+ if (digits.startsWith('251')) return `+${digits}`;
+ if (digits.startsWith('0')) return `+251${digits.slice(1)}`;
+ return `+${digits}`;
+ }
+
+ // LOGIN via Fayda is handled entirely by the IAM package's own OIDC flow.
+ // This method is kept as a stub so completeVerification() still compiles;
+ // it throws immediately without touching the database.
+ private async handleLoginSuccess(
+ _normalized: NormalizedFaydaUserInfo,
+ ): Promise<{ userId: string }> {
+ throw new UnauthorizedException({
+ code: 'FAYDA_LOGIN_MIGRATED_TO_IAM',
+ message: 'Fayda login tokens are issued by the IAM package at /v1/auth/fayda endpoints.',
+ });
+ }
+
+ private async upsertIamUser(
+ normalized: NormalizedFaydaUserInfo,
+ ): Promise<{ iamUserId: string | null; userDataSaved: boolean }> {
+ try {
+ const iamMetadata = {
+ sub: normalized.sub,
+ address: { am: normalized.addressAm ?? '', en: normalized.addressEn ?? '' },
+ email: normalized.email ?? '',
+ gender: { am: normalized.genderAm ?? '', en: normalized.genderEn ?? '' },
+ name: { am: normalized.nameAm ?? '', en: normalized.nameEn ?? '' },
+ phoneNumber: normalized.rawPhoneNumber ?? '',
+ };
+
+ // Step 1 — already linked to this Fayda sub; ensure verified_by is set
+ const bySub = await this.dataSource.query<{ id: string }[]>(
+ `SELECT id FROM iam.users WHERE metadata->>'sub' = $1 LIMIT 1`,
+ [normalized.sub],
+ );
+ if (bySub.length > 0) {
+ await this.dataSource.query(
+ `UPDATE iam.users SET verified_by = 'fayda', updated_at = NOW() WHERE id = $1`,
+ [bySub[0].id],
+ );
+ return { iamUserId: bySub[0].id, userDataSaved: true };
+ }
+
+ // Step 2 — existing user by phone or email, not yet Fayda-verified
+ const conditions: string[] = [];
+ const params: unknown[] = [];
+ if (normalized.phoneNumber) {
+ params.push(normalized.phoneNumber);
+ conditions.push(`phone_number = $${params.length}`);
+ }
+ if (normalized.email) {
+ params.push(normalized.email);
+ conditions.push(`email = $${params.length}`);
+ }
+ if (conditions.length > 0) {
+ const byContact = await this.dataSource.query<{ id: string }[]>(
+ `SELECT id FROM iam.users WHERE ${conditions.join(' OR ')} LIMIT 1`,
+ params,
+ );
+ if (byContact.length > 0) {
+ const existingId = byContact[0].id;
+ await this.dataSource.query(
+ `UPDATE iam.users
+ SET metadata = COALESCE(metadata, '{}'::jsonb) || $1::jsonb,
+ verified_by = 'fayda',
+ updated_at = NOW()
+ WHERE id = $2`,
+ [JSON.stringify(iamMetadata), existingId],
+ );
+ return { iamUserId: existingId, userDataSaved: true };
+ }
+ }
+
+ // Step 3 — new user
+ const name = { am: normalized.nameAm ?? '', en: normalized.nameEn ?? '' };
+ const username = normalized.phoneNumber ?? normalized.email ?? normalized.sub;
+ const inserted = await this.dataSource.query<{ id: string }[]>(
+ `INSERT INTO iam.users (
+ id, name, username, email, phone_number, metadata,
+ user_type, status, is_active, has_set_password,
+ is_phone_number_verified, verified_by,
+ created_at, updated_at
+ ) VALUES (
+ gen_random_uuid(), $1::jsonb, $2, $3, $4, $5::jsonb,
+ 'individual', 'submitted', true, false,
+ false, 'fayda',
+ NOW(), NOW()
+ ) RETURNING id`,
+ [
+ JSON.stringify(name),
+ username,
+ normalized.email ?? null,
+ normalized.phoneNumber ?? null,
+ JSON.stringify(iamMetadata),
+ ],
+ );
+ return { iamUserId: inserted[0].id, userDataSaved: true };
+ } catch (err) {
+ this.logger.error(`Fayda IAM upsert failed: ${(err as Error).message}`);
+ return { iamUserId: null, userDataSaved: false };
+ }
+ }
+
+ private async createFaydaSession(
+ iamUserId: string,
+ ): Promise<{ token: string; refreshToken: string; requiresPassword: boolean }> {
+ const rows = await this.dataSource.query<{
+ id: string;
+ email: string;
+ name: { en: string; am: string } | null;
+ username: string;
+ phone_number: string | null;
+ has_set_password: boolean;
+ status: string;
+ }[]>(
+ `SELECT id, email, name, username, phone_number, has_set_password, status
+ FROM iam.users WHERE id = $1 LIMIT 1`,
+ [iamUserId],
+ );
+ if (!rows.length) throw new Error(`IAM user ${iamUserId} not found`);
+ const u = rows[0];
+
+ const userInfo = {
+ id: u.id,
+ email: u.email ?? '',
+ name: u.name ?? { en: '', am: '' },
+ userType: 'individual',
+ status: u.status,
+ hasSetPassword: u.has_set_password,
+ isPhoneNumberVerified: false,
+ hasFinishedRegistration: false,
+ hasFinishedDMSOnboarding: false,
+ username: u.username,
+ phoneNumber: u.phone_number ?? '',
+ roles: [],
+ permissions: [],
+ employee: [],
+ };
+
+ const sessions = await this.dataSource.query<{ id: string }[]>(
+ `INSERT INTO iam.sessions
+ (id, email, device, "userInfo", expiry_time, refresh_count, status, user_id)
+ VALUES (gen_random_uuid(), $1, 'fayda-verify', $2::jsonb, NOW() + INTERVAL '1 day', 0, 'ACTIVE', $3)
+ ON CONFLICT (user_id, device) DO UPDATE
+ SET status = 'ACTIVE', "userInfo" = EXCLUDED."userInfo",
+ expiry_time = NOW() + INTERVAL '1 day', updated_at = NOW()
+ RETURNING id`,
+ [u.email ?? '', JSON.stringify(userInfo), iamUserId],
+ );
+
+ const sessionId = sessions[0].id;
+ const token = generateToken({ id: sessionId });
+ const refreshToken = generateRefreshToken({ id: sessionId });
+
+ return { token, refreshToken, requiresPassword: !u.has_set_password };
+ }
+
+ private async markSessionFailed(
+ state: string,
+ errorCode: string,
+ errorDescription?: string,
+ ): Promise {
+ await this.sessionRepo.update(
+ { state, status: 'PENDING' },
+ {
+ status: 'FAILED',
+ errorCode,
+ errorDescription: errorDescription ?? null,
+ completedAt: new Date(),
+ codeVerifier: '',
+ },
+ );
+ }
+
+ private classifyFailureReason(err: unknown): string {
+ if (err instanceof FaydaTokenExchangeException) return 'token_exchange_failed';
+ if (err instanceof FaydaUserInfoException) return 'userinfo_failed';
+ return 'verification_failed';
+ }
+}
diff --git a/apps/edr-freight-api/src/modules/verifayda/verifayda.types.ts b/apps/edr-freight-api/src/modules/verifayda/verifayda.types.ts
new file mode 100644
index 000000000..442a22d2f
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/verifayda/verifayda.types.ts
@@ -0,0 +1,45 @@
+export type VerifaydaPurpose = 'LOGIN' | 'VERIFY';
+
+export interface FaydaTokenResponse {
+ access_token: string;
+ id_token?: string;
+ token_type: string;
+ expires_in?: number;
+ scope?: string;
+}
+
+export interface FaydaUserInfo {
+ sub: string;
+ name?: string;
+ 'name#en'?: string;
+ 'name#am'?: string;
+ phone_number?: string;
+ 'phone_number#en'?: string;
+ 'phone_number#am'?: string;
+ phone?: string;
+ email?: string;
+ gender?: string;
+ birthdate?: string;
+ picture?: string;
+ address?: Record;
+ [key: string]: unknown;
+}
+
+export interface NormalizedFaydaUserInfo {
+ sub: string;
+ // Convenience / display fields
+ fullName?: string;
+ phoneNumber?: string; // standardized e.g. +251911234567
+ email?: string;
+ gender?: string;
+ birthdate?: string;
+ picture?: string;
+ // Raw localized fields — preserved for IAM-identical writes
+ nameEn?: string;
+ nameAm?: string;
+ genderEn?: string;
+ genderAm?: string;
+ addressEn?: string;
+ addressAm?: string;
+ rawPhoneNumber?: string; // unstandardized, stored in IAM metadata
+}
diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx
index 8c1562bc4..742431c9a 100644
--- a/apps/edr-freight-web/backoffice/src/App.tsx
+++ b/apps/edr-freight-web/backoffice/src/App.tsx
@@ -93,6 +93,7 @@ import WarehouseInvoicesPage from "./pages/warehouses/WarehouseInvoicesPage";
import WarehouseListPage from "./pages/warehouses/WarehouseListPage";
import WarehouseRulesPage from "./pages/warehouses/WarehouseRulesPage";
import { HealthCheck } from "./features/health/HealthCheck";
+import FaydaCallbackPage from "./pages/FaydaCallbackPage";
const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
{
@@ -463,6 +464,7 @@ const App = () => {
} />
} />
+ } />
} />
);
@@ -472,6 +474,7 @@ const App = () => {
} />
} />
+ } />
} />
} />
}>
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 e1d1d4977..c75e8c7b3 100644
--- a/apps/edr-freight-web/backoffice/src/components/fleet/FleetFormDialog.tsx
+++ b/apps/edr-freight-web/backoffice/src/components/fleet/FleetFormDialog.tsx
@@ -1,6 +1,8 @@
import { useEffect, useMemo, useState } from "react";
-import { Loader2, Calendar } from "lucide-react";
+import { Loader2, Calendar, ShieldCheck } from "lucide-react";
import {
+ Alert,
+ Badge,
Button,
Group,
Modal,
@@ -20,6 +22,10 @@ import {
type FleetFormFieldDef,
} from "@/pages/fleet/config/resources";
import type { FleetRecord } from "@/services/fleet/fleet.service";
+import {
+ verifaydaService,
+ type FaydaCallbackMessage,
+} from "@/services/verifayda.service";
export interface FleetFormDialogProps {
open: boolean;
@@ -31,6 +37,12 @@ export interface FleetFormDialogProps {
isSubmitting: boolean;
selectOptionsLoading?: boolean;
onSubmit: (values: Record) => void;
+ /**
+ * Show a "Verify with Fayda" step: opens the eSignet popup and prefills
+ * firstName/lastName/email/phoneNumber/dateOfBirth from the verified
+ * identity, stamping faydaVerified + faydaSub on the payload.
+ */
+ verifyWithFayda?: boolean;
}
const buildInitialValues = (
@@ -72,9 +84,12 @@ const FleetFormDialog = ({
isSubmitting,
selectOptionsLoading,
onSubmit,
+ verifyWithFayda,
}: FleetFormDialogProps) => {
const [values, setValues] = useState>({});
const [errors, setErrors] = useState>({});
+ const [faydaLoading, setFaydaLoading] = useState(false);
+ const [faydaError, setFaydaError] = useState(null);
// Seed the form ONLY when the dialog opens or the edited record changes — NOT
// when `fields`/`emptyValues` get new object refs (they're rebuilt whenever the
@@ -87,10 +102,85 @@ const FleetFormDialog = ({
if (open) {
setValues(buildInitialValues(fields, emptyValues, initialRecord));
setErrors({});
+ setFaydaError(null);
+ setFaydaLoading(false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, recordId]);
+ // Receive the ?code&state relayed by the /callback popup, exchange it for
+ // the verified identity, and prefill the matching form fields.
+ useEffect(() => {
+ if (!open || !verifyWithFayda) return;
+ const onMessage = async (event: MessageEvent) => {
+ if (event.origin !== window.location.origin) return;
+ if (event.data?.type !== "fayda-callback") return;
+
+ if (event.data.error) {
+ setFaydaLoading(false);
+ setFaydaError(event.data.errorDescription ?? event.data.error);
+ return;
+ }
+ if (!event.data.code || !event.data.state) return;
+
+ try {
+ const result = await verifaydaService.complete(event.data.code, event.data.state);
+ if (!result.verified) {
+ setFaydaError("Identity could not be verified");
+ return;
+ }
+ const nameParts = (result.fullName ?? "").trim().split(/\s+/).filter(Boolean);
+ const [firstName, ...rest] = nameParts;
+ 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 } : {}),
+ faydaVerified: true,
+ ...(result.iamUserId ? { faydaSub: result.iamUserId } : {}),
+ }));
+ setFaydaError(null);
+ } catch (err) {
+ const message =
+ (err as { response?: { data?: { message?: string } } })?.response?.data?.message ??
+ (err instanceof Error ? err.message : "Verification failed");
+ setFaydaError(message);
+ } finally {
+ setFaydaLoading(false);
+ }
+ };
+ window.addEventListener("message", onMessage);
+ return () => window.removeEventListener("message", onMessage);
+ }, [open, verifyWithFayda]);
+
+ const handleFaydaVerify = async () => {
+ setFaydaError(null);
+ setFaydaLoading(true);
+ try {
+ const { authorizationUrl } = await verifaydaService.start();
+ const popup = window.open(
+ authorizationUrl,
+ "fayda-verify",
+ "width=480,height=760,noopener=no",
+ );
+ if (!popup) {
+ setFaydaLoading(false);
+ setFaydaError("Pop-up blocked — allow pop-ups for this site and retry.");
+ }
+ // Loading stays on until the popup posts back; reopening the dialog resets it.
+ } catch (err) {
+ setFaydaLoading(false);
+ const message =
+ (err as { response?: { data?: { message?: string } } })?.response?.data?.message ??
+ (err instanceof Error ? err.message : "Could not start verification");
+ setFaydaError(message);
+ }
+ };
+
+ const faydaVerified = values.faydaVerified === true;
+
const shortFields = useMemo(
() => fields.filter((f) => f.type !== "textarea"),
[fields],
@@ -333,6 +423,39 @@ const FleetFormDialog = ({
centered
>
+ {verifyWithFayda && (
+
+ {faydaVerified ? (
+ }
+ >
+ Identity verified with Fayda
+
+ ) : (
+
+ Verify the driver's identity with Fayda to prefill their details.
+
+ )}
+ }
+ loading={faydaLoading}
+ onClick={handleFaydaVerify}
+ >
+ {faydaVerified ? "Re-verify" : "Verify with Fayda"}
+
+
+ )}
+ {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 91e59abf0e9afe14231e62779511a611aa9844ae Mon Sep 17 00:00:00 2001
From: Abubeker Yasin
Date: Fri, 3 Jul 2026 12:21:41 +0300
Subject: [PATCH 59/86] fix: passenger id
---
.../src/modules/auth/passenger-auth.service.ts | 3 +++
.../portal/src/app/booking/review/page.tsx | 18 +++++++++++++++---
2 files changed, 18 insertions(+), 3 deletions(-)
diff --git a/apps/edr-passenger-api/src/modules/auth/passenger-auth.service.ts b/apps/edr-passenger-api/src/modules/auth/passenger-auth.service.ts
index 4dd131f3e..0213bb8f6 100644
--- a/apps/edr-passenger-api/src/modules/auth/passenger-auth.service.ts
+++ b/apps/edr-passenger-api/src/modules/auth/passenger-auth.service.ts
@@ -180,6 +180,9 @@ export class PassengerAuthService {
return {
iamUserId,
+ // Top-level passengerId keeps the profile shape consistent with the login
+ // response so the web User object always carries it (the JWT does not).
+ passengerId: passenger.id,
email: iam?.email ?? null,
phone: iam?.phone_number ?? null,
fullName: iam?.name?.en ?? iam?.name?.am ?? null,
diff --git a/apps/edr-passenger-web/portal/src/app/booking/review/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/review/page.tsx
index 3e7abd2d3..89bc115d4 100644
--- a/apps/edr-passenger-web/portal/src/app/booking/review/page.tsx
+++ b/apps/edr-passenger-web/portal/src/app/booking/review/page.tsx
@@ -260,11 +260,23 @@ export default function ReviewPage() {
if (localStoragePassengerId) passengerId = localStoragePassengerId;
}
- // Fallback 3: Use passengerId from user object
+ // Fallback 3: Use passengerId from user object. The profile response nests
+ // it under `passenger.id`, while the login response exposes it top-level as
+ // `passengerId` — accept either shape so a cached profile user still resolves.
if (!passengerId && user) {
- passengerId = (user as any).passengerId;
+ passengerId = (user as any).passengerId || (user as any).passenger?.id;
}
-
+
+ // Fallback 4: last resort — fetch the passenger profile directly.
+ if (!passengerId) {
+ try {
+ const me: any = await apiClient.get('/passengers/me');
+ passengerId = me?.id || me?.passengerId || '';
+ } catch (err) {
+ console.error('Failed to resolve passengerId from /passengers/me:', err);
+ }
+ }
+
if (!passengerId) {
throw new Error('Passenger ID not found in authentication token. Please log in again.');
}
From 35cb20da0bd2d0c558564ac5f6025d12f28b34fb Mon Sep 17 00:00:00 2001
From: Marshal
Date: Fri, 3 Jul 2026 09:26:27 +0000
Subject: [PATCH 60/86] feat: implement 20ft container weight-pairing
validation
- Added ContainerValidationService to handle 20ft weight-pairing logic.
- Introduced validate20ftWeightPairing utility function to check weight differences.
- Updated BookingPricingService to include overweight line details and pairing errors in price response.
- Enhanced BookingTransitionService to reject submissions with unpairable 20ft containers.
- Created ShipmentValidation interface for pre-submit validation of container contracts.
- Integrated shipment validation into the contract booking process, providing warnings for overweight containers and hard blocks for pairing errors.
- Updated front-end components to display validation results and prevent submission when errors are present.
---
.../bookings/booking-pricing.service.spec.ts | 1 +
.../bookings/booking-pricing.service.ts | 47 ++++++++
.../booking-transition.accept.spec.ts | 1 +
.../booking-transition.clearance.spec.ts | 3 +
.../booking-transition.operation.spec.ts | 1 +
.../bookings/booking-transition.service.ts | 21 ++++
.../src/modules/bookings/bookings.module.ts | 2 +
.../bookings/container-pairing.util.spec.ts | 55 +++++++++
.../bookings/container-pairing.util.ts | 64 ++++++++++
.../bookings/container-validation.service.ts | 76 ++++++++++++
.../dto/generate-price-response.dto.ts | 26 ++++
.../contracts/contract-booking.service.ts | 111 ++++++++++++++++++
.../modules/contracts/contracts.controller.ts | 12 ++
.../entities/train-schedule-booking.entity.ts | 2 +-
apps/edr-freight-web/backoffice/src/App.tsx | 64 +++++++++-
.../backoffice/src/lib/permissions.ts | 32 +++++
.../portal/src/constants/URLS.ts | 2 +
.../src/pages/contracts/NewShipmentPage.tsx | 90 +++++++++++++-
.../portal/src/services/api.ts | 8 ++
.../portal/src/services/contracts.service.ts | 33 ++++++
20 files changed, 646 insertions(+), 5 deletions(-)
create mode 100644 apps/edr-freight-api/src/modules/bookings/container-pairing.util.spec.ts
create mode 100644 apps/edr-freight-api/src/modules/bookings/container-pairing.util.ts
create mode 100644 apps/edr-freight-api/src/modules/bookings/container-validation.service.ts
diff --git a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.spec.ts b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.spec.ts
index 1c1b490dd..db6b70eae 100644
--- a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.spec.ts
+++ b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.spec.ts
@@ -45,6 +45,7 @@ describe('BookingPricingService — domestic corridor', () => {
{} as never,
ratesService as never,
exchangeService as never,
+ { validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
);
});
diff --git a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts
index c5e5b710e..e8469e627 100644
--- a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts
+++ b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts
@@ -17,6 +17,14 @@ import {
import { GeneratePriceResponseDto, PriceLineItemDto } from './dto/generate-price-response.dto';
import { Booking } from './entities/booking.entity';
import { assertBookingStatus } from './booking-status.util';
+import { ContainerValidationService } from './container-validation.service';
+
+export interface OverweightLine {
+ containerTypeCode: string;
+ totalVgmTons: number;
+ maxAllowedTons: number;
+ excessTons: number;
+}
export interface ComputedPriceResult {
lineItems: PriceLineItemDto[];
@@ -27,6 +35,7 @@ export interface ComputedPriceResult {
priorityScore: number;
warnings: string[];
hardBlocked: string[];
+ overweightLines: OverweightLine[];
}
type StoredPricingBreakdown = {
@@ -67,6 +76,7 @@ export class BookingPricingService {
private readonly containerTypesService: ContainerTypesService,
private readonly ratesService: RatesService,
private readonly exchangeService: ExchangeService,
+ private readonly containerValidationService: ContainerValidationService,
) {}
async generatePrice(bookingId: string): Promise {
@@ -94,12 +104,19 @@ export class BookingPricingService {
},
} as never);
+ // 20ft weight-pairing preview: surfaced now so the customer sees the problem
+ // (and the overweight warning + surcharge) at the confirm step, before submit.
+ // Submit re-runs this and HARD-BLOCKS on a non-empty result.
+ const pairing = await this.containerValidationService.validate20ftPairing(booking);
+
return {
bookingId,
totalAmount: computed.totalAmount,
currency: computed.currency,
lineItems: computed.lineItems,
warnings: computed.warnings,
+ overweightLines: computed.overweightLines,
+ pairingErrors: pairing.map((p) => p.message),
};
}
@@ -169,6 +186,35 @@ export class BookingPricingService {
if (rate) usedRatesMap.set(rate.id, rate);
}
+ // Overweight detail for the customer: map the engine's per-line results back
+ // to the booking's container lines (same order) for code + weights. maxAllowed
+ // is derived from the line total minus the excess the engine computed.
+ const overweightLines: OverweightLine[] = [];
+ const containerLines = (booking.bookingContainers ?? []).filter(
+ (bc) => bc.containerTypeId != null,
+ );
+ for (let i = 0; i < ruleResult.containerWeightResults.length; i++) {
+ const wr = ruleResult.containerWeightResults[i];
+ if (!wr?.isOverweight) continue;
+ const line = containerLines[i];
+ const totalVgmTons = Number(line?.totalVgmTons ?? 0);
+ const excessTons = Number(wr.overweightExcessTons ?? 0);
+ let code = line?.containerSize ?? '';
+ if (line?.containerTypeId) {
+ try {
+ code = (await this.containerTypesService.findById(line.containerTypeId)).code;
+ } catch {
+ // fall back to the container size label
+ }
+ }
+ overweightLines.push({
+ containerTypeCode: code,
+ totalVgmTons,
+ maxAllowedTons: Math.max(0, totalVgmTons - excessTons),
+ excessTons,
+ });
+ }
+
return {
lineItems,
totalAmount: total,
@@ -178,6 +224,7 @@ export class BookingPricingService {
priorityScore: ruleResult.priorityScore,
warnings: ruleResult.warnings,
hardBlocked: ruleResult.hardBlocked,
+ overweightLines,
};
}
diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.accept.spec.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.accept.spec.ts
index f9b160672..3c535f450 100644
--- a/apps/edr-freight-api/src/modules/bookings/booking-transition.accept.spec.ts
+++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.accept.spec.ts
@@ -37,6 +37,7 @@ describe('BookingTransitionService — acceptIntake validity window', () => {
bookingsService as never,
{ isPhasedGeneralCustomsBooking: () => false } as never,
{} as never,
+ { validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
);
return { service, bookingsRepository, ruleEngineService };
}
diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.clearance.spec.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.clearance.spec.ts
index d62883d53..9f9aa5713 100644
--- a/apps/edr-freight-api/src/modules/bookings/booking-transition.clearance.spec.ts
+++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.clearance.spec.ts
@@ -48,6 +48,7 @@ describe('BookingTransitionService — finalizeClearance gate', () => {
bookingsService as never,
{ isPhasedGeneralCustomsBooking: () => false } as never,
{} as never,
+ { validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
);
return { service, bookingsRepository };
}
@@ -132,6 +133,7 @@ describe('BookingTransitionService — finalizeClearance customs output gate', (
bookingsService as never,
{ isPhasedGeneralCustomsBooking: () => false } as never,
{} as never,
+ { validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
);
return { service, bookingsRepository };
}
@@ -202,6 +204,7 @@ describe('BookingTransitionService — submitClearanceDocuments required-fields
bookingsService as never,
{ isPhasedGeneralCustomsBooking: () => false } as never,
{} as never,
+ { validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
);
return { service, bookingsRepository, filesService };
}
diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.operation.spec.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.operation.spec.ts
index 66df02ac4..ea3618a08 100644
--- a/apps/edr-freight-api/src/modules/bookings/booking-transition.operation.spec.ts
+++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.operation.spec.ts
@@ -40,6 +40,7 @@ describe('BookingTransitionService — operation review', () => {
bookingsService as never,
{ isPhasedGeneralCustomsBooking: () => false } as never,
{} as never,
+ { validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
);
return { service, bookingsRepository, bookingBatchService };
}
diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts
index 227de08fe..3e1ea3cd4 100644
--- a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts
+++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts
@@ -16,6 +16,7 @@ import { FilesService } from '../files/files.service';
import { FileUploadSettingsService } from '../file-upload-settings/file-upload-settings.service';
import { BookingContractService } from './booking-contract.service';
import { BookingPricingService } from './booking-pricing.service';
+import { ContainerValidationService } from './container-validation.service';
import { BookingsRepository } from './bookings.repository';
import { assertBookingStatus } from './booking-status.util';
import { clearanceCodesForBooking } from './clearance.util';
@@ -51,6 +52,7 @@ export class BookingTransitionService {
@Inject(forwardRef(() => ClearanceWorkflowService))
private readonly workflowService: ClearanceWorkflowService,
private readonly invoiceService: BookingInvoiceService,
+ private readonly containerValidationService: ContainerValidationService,
) {}
@@ -58,6 +60,19 @@ export class BookingTransitionService {
return this.bookingClearanceService.isPhasedGeneralCustomsBooking(booking);
}
+ /** Reject submit when the booking's 20ft containers can't be balanced onto wagons. */
+ private async assert20ftPairable(booking: Booking): Promise {
+ const violations =
+ await this.containerValidationService.validate20ftPairing(booking);
+ if (violations.length) {
+ throw new BadRequestException(
+ `Cannot submit — 20ft containers cannot be paired on wagons: ${violations
+ .map((v) => v.message)
+ .join(' ')}`,
+ );
+ }
+ }
+
async submit(bookingId: string): Promise {
const booking = await this.bookingsService.findById(bookingId);
assertBookingStatus(booking, ["DRAFT", "CHANGES_REQUESTED"]);
@@ -78,6 +93,11 @@ export class BookingTransitionService {
requiresDirectorApproval: false,
});
+ // 20ft weight-pairing hard block: two 20ft on a wagon must differ ≤ the cap.
+ // If no balanced pairing exists the booking cannot proceed (overweight only
+ // warns; this rejects). An odd leftover 20ft is fine — it goes to consolidation.
+ await this.assert20ftPairable(booking);
+
const stored = booking.pricingBreakdown as {
lineItems?: PriceLineItemDto[];
totalAmount?: number;
@@ -158,6 +178,7 @@ export class BookingTransitionService {
hardBlocked: computed.hardBlocked,
requiresDirectorApproval: false,
});
+ await this.assert20ftPairable(booking);
await this.pricingService.createPricingSnapshots(
bookingId,
diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts
index 2cc23b3fa..50bd090b5 100644
--- a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts
+++ b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts
@@ -23,6 +23,7 @@ import { BookingsController } from './bookings.controller';
// import { PayController } from './pay.controller';
import { BookingsRepository } from './bookings.repository';
import { ConsolidationService } from './consolidation.service';
+import { ContainerValidationService } from './container-validation.service';
import { BookingsService } from './bookings.service';
import { BookingApprovalStep } from './entities/booking-approval-step.entity';
import { BookingCargoModifier } from './entities/booking-cargo-modifier.entity';
@@ -78,6 +79,7 @@ import { BookingContainerAllocation } from "./entities/booking-container-allocat
BookingsService,
BookingsRepository,
ConsolidationService,
+ ContainerValidationService,
BookingReferenceDataService,
BookingPricingService,
BookingTransitionService,
diff --git a/apps/edr-freight-api/src/modules/bookings/container-pairing.util.spec.ts b/apps/edr-freight-api/src/modules/bookings/container-pairing.util.spec.ts
new file mode 100644
index 000000000..6aed9bf1a
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/bookings/container-pairing.util.spec.ts
@@ -0,0 +1,55 @@
+import { validate20ftWeightPairing } from './container-pairing.util';
+
+describe('validate20ftWeightPairing', () => {
+ const MAX_DIFF = 10;
+
+ it('passes when a balanced pairing exists (adjacent diffs within cap)', () => {
+ // sorted: 8, 15, 18, 24 → pairs (8,15) diff 7, (18,24) diff 6 — both ≤ 10.
+ const units = [
+ { label: 'A', grossWeightTons: 24 },
+ { label: 'B', grossWeightTons: 8 },
+ { label: 'C', grossWeightTons: 18 },
+ { label: 'D', grossWeightTons: 15 },
+ ];
+ expect(validate20ftWeightPairing(units, MAX_DIFF)).toEqual([]);
+ });
+
+ it('flags a pair whose weight difference exceeds the cap', () => {
+ // sorted: 5, 25 → single pair diff 20 > 10.
+ const units = [
+ { label: 'HEAVY', grossWeightTons: 25 },
+ { label: 'LIGHT', grossWeightTons: 5 },
+ ];
+ const result = validate20ftWeightPairing(units, MAX_DIFF);
+ expect(result).toHaveLength(1);
+ expect(result[0].labels).toEqual(['LIGHT', 'HEAVY']);
+ expect(result[0].diffTons).toBe(20);
+ });
+
+ it('allows an odd leftover unit (goes to consolidation, not a violation)', () => {
+ // sorted: 10, 12, 30 → pair (10,12) diff 2 ok; 30 is the odd leftover.
+ const units = [
+ { label: 'A', grossWeightTons: 10 },
+ { label: 'B', grossWeightTons: 12 },
+ { label: 'C', grossWeightTons: 30 },
+ ];
+ expect(validate20ftWeightPairing(units, MAX_DIFF)).toEqual([]);
+ });
+
+ it('adjacent-by-weight pairing succeeds where a naive input order would fail', () => {
+ // Input order (20, 12, 22, 10) naively pairs (20,12)=8 and (22,10)=12 (fail),
+ // but sorted (10,12,20,22) pairs (10,12)=2 and (20,22)=2 — valid, so no violation.
+ const units = [
+ { label: 'A', grossWeightTons: 20 },
+ { label: 'B', grossWeightTons: 12 },
+ { label: 'C', grossWeightTons: 22 },
+ { label: 'D', grossWeightTons: 10 },
+ ];
+ expect(validate20ftWeightPairing(units, MAX_DIFF)).toEqual([]);
+ });
+
+ it('returns nothing for fewer than two units', () => {
+ expect(validate20ftWeightPairing([{ label: 'A', grossWeightTons: 30 }], MAX_DIFF)).toEqual([]);
+ expect(validate20ftWeightPairing([], MAX_DIFF)).toEqual([]);
+ });
+});
diff --git a/apps/edr-freight-api/src/modules/bookings/container-pairing.util.ts b/apps/edr-freight-api/src/modules/bookings/container-pairing.util.ts
new file mode 100644
index 000000000..cf1cfe944
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/bookings/container-pairing.util.ts
@@ -0,0 +1,64 @@
+/**
+ * Booking-time 20ft weight-pairing rule.
+ *
+ * A container wagon holds two 20ft containers (2 TEU). When two 20ft ride the
+ * same wagon their gross-weight difference must not exceed `maxPairDiffTons`
+ * (global rule `max20ftPairWeightDiffTons`, default 10t) so the wagon load stays
+ * balanced. 40ft containers occupy a whole wagon alone and never pair.
+ *
+ * At booking time the customer enters every 20ft container's weight but not its
+ * wagon slot, so we auto-pair: sort the 20ft weights ascending and pair adjacent
+ * (0-1, 2-3, …). Adjacent pairing minimises the diff of every pair, so if ANY
+ * valid pairing exists this one finds it — a violation here means no balanced
+ * pairing is possible and the booking must be blocked. An odd leftover 20ft is
+ * fine: it has no partner in this booking and flows to consolidation.
+ */
+
+export interface Container20ftUnit {
+ /** Human label for messages, e.g. the container number. */
+ label: string;
+ grossWeightTons: number;
+}
+
+export interface PairingViolation {
+ message: string;
+ /** The two container labels whose pairing exceeds the diff cap. */
+ labels: [string, string];
+ diffTons: number;
+}
+
+const round2 = (n: number): number => Math.round(n * 100) / 100;
+
+/**
+ * Validate that the given 20ft units can all be paired onto wagons within the
+ * weight-difference cap. Returns one violation per over-cap adjacent pair (empty
+ * when every wagon pair is balanced or there is nothing to pair). A single
+ * leftover unit (odd count) is not a violation.
+ */
+export function validate20ftWeightPairing(
+ units: Container20ftUnit[],
+ maxPairDiffTons: number,
+): PairingViolation[] {
+ if (units.length < 2 || maxPairDiffTons == null) return [];
+
+ // Ascending by weight: adjacent pairs have the smallest possible diffs.
+ const sorted = [...units].sort((a, b) => a.grossWeightTons - b.grossWeightTons);
+ const violations: PairingViolation[] = [];
+
+ for (let i = 0; i + 1 < sorted.length; i += 2) {
+ const a = sorted[i];
+ const b = sorted[i + 1];
+ const diff = Math.abs(a.grossWeightTons - b.grossWeightTons);
+ if (diff > maxPairDiffTons) {
+ violations.push({
+ message:
+ `20ft containers ${a.label} (${round2(a.grossWeightTons)}T) and ` +
+ `${b.label} (${round2(b.grossWeightTons)}T) cannot share a wagon: ` +
+ `weight difference ${round2(diff)}T exceeds the ${maxPairDiffTons}T limit.`,
+ labels: [a.label, b.label],
+ diffTons: round2(diff),
+ });
+ }
+ }
+ return violations;
+}
diff --git a/apps/edr-freight-api/src/modules/bookings/container-validation.service.ts b/apps/edr-freight-api/src/modules/bookings/container-validation.service.ts
new file mode 100644
index 000000000..de4dca661
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/bookings/container-validation.service.ts
@@ -0,0 +1,76 @@
+import { Injectable } from '@nestjs/common';
+import { InjectDataSource } from '@nestjs/typeorm';
+import { DataSource, In } from 'typeorm';
+
+import { TrainSchedulingGlobalRules } from '../train-scheduling/entities/train-scheduling-global-rules.entity';
+import { Booking } from './entities/booking.entity';
+import { BookingContainerUnit } from './entities/booking-container-unit.entity';
+import {
+ Container20ftUnit,
+ PairingViolation,
+ validate20ftWeightPairing,
+} from './container-pairing.util';
+
+/** Default 20ft pair weight-difference cap when no global rules row exists (matches the entity default). */
+const DEFAULT_MAX_20FT_PAIR_DIFF_TONS = 10;
+
+/**
+ * Booking-time container validations that need the customer-entered per-unit
+ * weights (`BookingContainerUnit`): the 20ft weight-pairing rule. Kept out of the
+ * rule engine (which works on line totals) because pairing is per physical unit.
+ */
+@Injectable()
+export class ContainerValidationService {
+ constructor(@InjectDataSource() private readonly dataSource: DataSource) {}
+
+ private async maxPairDiffTons(): Promise {
+ const row = await this.dataSource
+ .getRepository(TrainSchedulingGlobalRules)
+ .find({ order: { createdAt: 'ASC' }, take: 1 })
+ .then((rows) => rows[0] ?? null)
+ .catch(() => null);
+ const v = row?.max20ftPairWeightDiffTons;
+ const n = v == null ? NaN : Number(v);
+ return Number.isFinite(n) ? n : DEFAULT_MAX_20FT_PAIR_DIFF_TONS;
+ }
+
+ /** Load every 20ft container UNIT weight for a booking (customer-entered VGM). */
+ private async load20ftUnits(booking: Booking): Promise {
+ const lines = (booking.bookingContainers ?? []).filter(
+ (bc) => (bc.containerSize ?? '').includes('20'),
+ );
+ if (!lines.length) return [];
+
+ const units = await this.dataSource
+ .getRepository(BookingContainerUnit)
+ .find({
+ where: { bookingContainerId: In(lines.map((l) => l.id)) },
+ order: { sortOrder: 'ASC' },
+ });
+
+ return units.map((u) => ({
+ label: u.containerNumber || u.id.slice(0, 8),
+ grossWeightTons: Number(u.vgmTons ?? 0),
+ }));
+ }
+
+ /**
+ * Validate the 20ft weight-pairing rule for a booking. Returns one message per
+ * pair whose weight difference exceeds the cap; empty when all 20ft can be
+ * balanced onto wagons (or there is nothing to pair). A lone odd 20ft is fine —
+ * it flows to consolidation. Callers hard-block a non-empty result.
+ */
+ async validate20ftPairing(booking: Booking): Promise {
+ // Only bookings whose 20ft lines actually carry per-unit weights can be
+ // checked; contract-drawdown bookings do (units are required there).
+ const containerLines = booking.bookingContainers ?? [];
+ const has20ft = containerLines.some((bc) => (bc.containerSize ?? '').includes('20'));
+ if (!has20ft) return [];
+
+ const units = await this.load20ftUnits(booking);
+ if (units.length < 2) return [];
+
+ const maxDiff = await this.maxPairDiffTons();
+ return validate20ftWeightPairing(units, maxDiff);
+ }
+}
diff --git a/apps/edr-freight-api/src/modules/bookings/dto/generate-price-response.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/generate-price-response.dto.ts
index 532d6b1a7..0ee77aeed 100644
--- a/apps/edr-freight-api/src/modules/bookings/dto/generate-price-response.dto.ts
+++ b/apps/edr-freight-api/src/modules/bookings/dto/generate-price-response.dto.ts
@@ -27,6 +27,20 @@ export class PriceLineItemDto {
currency!: string;
}
+export class OverweightLineDto {
+ @ApiProperty()
+ containerTypeCode!: string;
+
+ @ApiProperty()
+ totalVgmTons!: number;
+
+ @ApiProperty()
+ maxAllowedTons!: number;
+
+ @ApiProperty()
+ excessTons!: number;
+}
+
export class GeneratePriceResponseDto {
@ApiProperty()
bookingId!: string;
@@ -42,4 +56,16 @@ export class GeneratePriceResponseDto {
@ApiProperty({ type: [String] })
warnings!: string[];
+
+ /** Overweight container lines (VGM over the weight-limit rule) — surcharge already in lineItems. */
+ @ApiProperty({ type: [OverweightLineDto] })
+ overweightLines!: OverweightLineDto[];
+
+ /**
+ * 20ft weight-pairing violations. Non-empty means the booking cannot be
+ * balanced onto wagons and submit is HARD-BLOCKED — the customer must fix
+ * container weights/quantities. (Overweight, by contrast, only warns.)
+ */
+ @ApiProperty({ type: [String] })
+ pairingErrors!: string[];
}
diff --git a/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts
index b0a5cc636..ef36ea5eb 100644
--- a/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts
+++ b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts
@@ -13,6 +13,8 @@ import { BookingContainerUnit } from '../bookings/entities/booking-container-uni
import { BookingsRepository } from '../bookings/bookings.repository';
import { BookingPricingService } from '../bookings/booking-pricing.service';
import { BookingInvoiceService } from '../bookings/booking-invoice.service';
+import { validate20ftWeightPairing } from '../bookings/container-pairing.util';
+import { TrainSchedulingGlobalRules } from '../train-scheduling/entities/train-scheduling-global-rules.entity';
import { ContainerTypesService } from '../rule-engine/services/container-types.service';
import { RuleEngineService } from '../rule-engine/rule-engine.service';
import { ContainerType } from '../rule-engine/entities/container-type.entity';
@@ -563,6 +565,115 @@ export class ContractBookingService {
}
}
+ /**
+ * Pre-create validation for the shipment form: run the overweight rule + the
+ * 20ft weight-pairing rule against the entered containers WITHOUT persisting a
+ * booking. The portal calls this from the price-confirm modal so the customer
+ * sees the overweight warning (+ surcharge basis) and is blocked on an
+ * un-pairable 20ft set before the booking is created.
+ */
+ async validateShipment(
+ contractId: string,
+ dto: CreateBookingUnderContractDto,
+ ): Promise<{
+ overweightLines: Array<{
+ containerTypeCode: string;
+ totalVgmTons: number;
+ maxAllowedTons: number;
+ excessTons: number;
+ }>;
+ pairingErrors: string[];
+ }> {
+ const contract = await this.contractsRepository.findByIdWithRelations(contractId);
+ if (!contract) throw new NotFoundException(`Contract ${contractId} not found`);
+
+ const lines = dto.containers ?? [];
+ if (!lines.length) return { overweightLines: [], pairingErrors: [] };
+
+ // Resolve each line's container type + total VGM (sum of unit weights) so the
+ // rule engine can flag overweight per line (maxVgmTons × quantity vs total).
+ const resolved = await Promise.all(
+ lines.map(async (line) => {
+ const ct = await this.resolveContainerTypeForSize(
+ line.containerSize,
+ contract.isReefer || (line.reeferQuantity ?? 0) > 0,
+ );
+ const totalVgmTons = (line.units ?? []).reduce(
+ (s, u) => s + Number(u.vgmTons ?? 0),
+ 0,
+ );
+ return { line, ct, totalVgmTons };
+ }),
+ );
+
+ const ruleResult = await this.ruleEngineService.evaluate({
+ freightType: 'CONTAINER',
+ cargoTypeId: null,
+ serviceTypeId: contract.serviceTypeId,
+ paymentCurrency: contract.paymentCurrency,
+ tradeDirection: contract.tradeDirection,
+ isHazardous: false,
+ isReefer: contract.isReefer ?? false,
+ isGovernment: false,
+ allowConsolidation: false,
+ shippingLineId: null,
+ totalWagons: 0,
+ bulkTons: 0,
+ containers: resolved.map((r) => ({
+ containerTypeId: r.ct.id,
+ quantity: r.line.quantity,
+ vgmPerUnitTons: r.line.quantity ? r.totalVgmTons / r.line.quantity : 0,
+ totalVgmTons: r.totalVgmTons,
+ isReefer: r.ct.isReefer,
+ })),
+ } as never);
+
+ const overweightLines: Array<{
+ containerTypeCode: string;
+ totalVgmTons: number;
+ maxAllowedTons: number;
+ excessTons: number;
+ }> = [];
+ for (let i = 0; i < ruleResult.containerWeightResults.length; i++) {
+ const wr = ruleResult.containerWeightResults[i];
+ if (!wr?.isOverweight) continue;
+ const r = resolved[i];
+ const excessTons = Number(wr.overweightExcessTons ?? 0);
+ overweightLines.push({
+ containerTypeCode: r?.ct.code ?? r?.line.containerSize ?? '',
+ totalVgmTons: r?.totalVgmTons ?? 0,
+ maxAllowedTons: Math.max(0, (r?.totalVgmTons ?? 0) - excessTons),
+ excessTons,
+ });
+ }
+
+ // 20ft weight-pairing: gather every 20ft unit weight and check the pair rule.
+ const twentyFtUnits = resolved
+ .filter((r) => (r.line.containerSize ?? '').includes('20'))
+ .flatMap((r) =>
+ (r.line.units ?? []).map((u, idx) => ({
+ label: u.containerNumber || `${r.line.containerSize}-${idx + 1}`,
+ grossWeightTons: Number(u.vgmTons ?? 0),
+ })),
+ );
+ const maxDiff = await this.max20ftPairDiffTons();
+ const pairingErrors = validate20ftWeightPairing(twentyFtUnits, maxDiff).map(
+ (v) => v.message,
+ );
+
+ return { overweightLines, pairingErrors };
+ }
+
+ private async max20ftPairDiffTons(): Promise {
+ const row = await this.dataSource
+ .getRepository(TrainSchedulingGlobalRules)
+ .find({ order: { createdAt: 'ASC' }, take: 1 })
+ .then((rows) => rows[0] ?? null)
+ .catch(() => null);
+ const n = row?.max20ftPairWeightDiffTons == null ? NaN : Number(row.max20ftPairWeightDiffTons);
+ return Number.isFinite(n) ? n : 10;
+ }
+
/** Pick the default container type for a size; prefer reefer when requested. */
private async resolveContainerTypeForSize(
size: string,
diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts
index 6ded65ae9..09e4a7ffd 100644
--- a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts
+++ b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts
@@ -788,6 +788,18 @@ export class ContractsController {
);
}
+ @Post(':id/validate-shipment')
+ @ApiOperation({
+ summary:
+ 'Pre-create validation: overweight lines + 20ft weight-pairing errors for a shipment payload (no booking created).',
+ })
+ validateShipment(
+ @Param('id', ParseUUIDPipe) id: string,
+ @Body() dto: CreateBookingUnderContractDto,
+ ) {
+ return this.contractBookingService.validateShipment(id, dto);
+ }
+
@Get(':id/capacity')
@ApiOperation({
summary: 'Remaining bookable quantity per cargo line (GENERAL draw-down cap)',
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 951cdaa80..4ffecea26 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
@@ -11,7 +11,7 @@ export class TrainScheduleBooking extends BaseEntity {
@Column({ name: 'train_schedule_id', type: 'uuid' })
trainScheduleId!: string;
- @ManyToOne(() => TrainSchedule, (trainSchedule) => trainScmahedule.scheduleBookings, {
+ @ManyToOne(() => TrainSchedule, (trainSchedule) => trainSchedule.scheduleBookings, {
onDelete: 'CASCADE',
})
@JoinColumn({ name: 'train_schedule_id' })
diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx
index 8c2453e4d..f9a273bfb 100644
--- a/apps/edr-freight-web/backoffice/src/App.tsx
+++ b/apps/edr-freight-web/backoffice/src/App.tsx
@@ -61,7 +61,13 @@ import UserManagementHostPage from "./pages/dashboard/user-management/UserManage
import PaymentsPage from "./pages/payments/PaymentsPage";
//import EmployeesPage from "./pages/dashboard/user-management/EmployeesPage";
import { RequirePermission } from "./components/auth/RequirePermission";
-import { FREIGHT_PERMS, hasPermission as hasFreightPermission } from "./lib/permissions";
+import {
+ FREIGHT_PERMS,
+ hasPermission as hasFreightPermission,
+ isDjiboutiGl,
+ isEthiopianGl,
+ isSuperAdmin,
+} from "./lib/permissions";
import PermissionsPage from "./pages/dashboard/user-management/PermissionsPage";
import PositionTypesPage from "./pages/dashboard/user-management/PositionTypesPage";
import RolesPage from "./pages/dashboard/user-management/RolesPage";
@@ -435,12 +441,35 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
},
];
-/** Keep only items the user is permitted to see; drop now-empty sections. */
+/** Hrefs of the two document-clearance menu items (stable identifiers). */
+const ET_CLEARANCE_HREF = "/dashboard/contracts/clearance";
+const DJ_CLEARANCE_HREF = "/dashboard/gl-djibouti/clearance";
+
+const isEtClearanceItem = (item: SidebarItem): boolean =>
+ item.href === ET_CLEARANCE_HREF;
+const isDjClearanceItem = (item: SidebarItem): boolean =>
+ item.href === DJ_CLEARANCE_HREF;
+const isClearanceItem = (item: SidebarItem): boolean =>
+ isEtClearanceItem(item) || isDjClearanceItem(item);
+
+/**
+ * Keep only items the user is permitted to see; drop now-empty sections.
+ *
+ * Position-scoped visibility (super_admin bypasses all of this):
+ * - Ethiopian GL → sees ONLY the ET document-clearance page.
+ * - Djibouti GL → sees ONLY the DJ clearance page.
+ * - Everyone else → sees everything they have permission for, EXCEPT the two
+ * clearance pages (those are GL-only).
+ */
const filterSidebarByPermission = (
sections: SidebarSection[],
user: ReturnType["user"],
): SidebarSection[] => {
- const itemAllowed = (item: SidebarItem): boolean => {
+ const superAdmin = isSuperAdmin(user);
+ const etGl = !superAdmin && isEthiopianGl(user);
+ const djGl = !superAdmin && isDjiboutiGl(user);
+
+ const permissionAllowed = (item: SidebarItem): boolean => {
if (!item.permission) return true;
const keys = Array.isArray(item.permission)
? item.permission
@@ -448,6 +477,19 @@ const filterSidebarByPermission = (
return keys.some((key) => hasFreightPermission(user, key));
};
+ const itemAllowed = (item: SidebarItem): boolean => {
+ if (superAdmin) return true;
+
+ // GL positions are locked to their single clearance page.
+ if (etGl) return isEtClearanceItem(item);
+ if (djGl) return isDjClearanceItem(item);
+
+ // Everyone else: hide the GL-only clearance pages entirely.
+ if (isClearanceItem(item)) return false;
+
+ return permissionAllowed(item);
+ };
+
return sections
.map((section) => ({
...section,
@@ -469,6 +511,22 @@ const DashboardShell = () => {
);
const displayName = user?.name?.en || user?.username || user?.email || "User";
+ // GL positions are locked to their single clearance page: if they navigate
+ // (or deep-link) anywhere else, send them back to their clearance hub.
+ // Super admin is exempt. Allow the clearance path + its detail sub-routes.
+ const superAdmin = isSuperAdmin(user);
+ const glClearanceHome = !superAdmin
+ ? isEthiopianGl(user)
+ ? ET_CLEARANCE_HREF
+ : isDjiboutiGl(user)
+ ? DJ_CLEARANCE_HREF
+ : null
+ : null;
+
+ if (glClearanceHome && !location.pathname.startsWith(glClearanceHome)) {
+ return ;
+ }
+
return (
();
+ for (const emp of user.employee ?? []) {
+ for (const pos of emp.positions ?? []) {
+ if (pos.key) keys.add(pos.key);
+ }
+ }
+ return [...keys];
+}
+
+export function hasPosition(
+ user: AuthUser | null | undefined,
+ positionKey: string,
+): boolean {
+ return getPositionKeys(user).includes(positionKey);
+}
+
+export const POSITION_KEYS = {
+ ethiopianGl: "ethiopian_gl",
+ djiboutiGl: "djibouti_gl",
+} as const;
+
+export function isEthiopianGl(user: AuthUser | null | undefined): boolean {
+ return hasPosition(user, POSITION_KEYS.ethiopianGl);
+}
+
+export function isDjiboutiGl(user: AuthUser | null | undefined): boolean {
+ return hasPosition(user, POSITION_KEYS.djiboutiGl);
+}
+
export function isSuperAdmin(user: AuthUser | null | undefined): boolean {
if (user?.isSuperAdmin) return true;
return Boolean(user?.roles?.some((r) => r.key === "super_admin"));
diff --git a/apps/edr-freight-web/portal/src/constants/URLS.ts b/apps/edr-freight-web/portal/src/constants/URLS.ts
index ff78046e1..ce2c35c1a 100644
--- a/apps/edr-freight-web/portal/src/constants/URLS.ts
+++ b/apps/edr-freight-web/portal/src/constants/URLS.ts
@@ -126,6 +126,8 @@ export const URL_CONSTANTS = {
`/api/contracts/${id}/clearance/documents`,
CLEARANCE_DUTY_SLIP: (id: string) => `/api/contracts/${id}/clearance/duty-slip`,
BOOKINGS: (id: string) => `/api/contracts/${id}/bookings`,
+ VALIDATE_SHIPMENT: (id: string) =>
+ `/api/contracts/${id}/validate-shipment`,
MILESTONES: (id: string) => `/api/contracts/${id}/milestones`,
BOOKING_MILESTONES: (bookingId: string) =>
`/api/contracts/bookings/${bookingId}/milestones`,
diff --git a/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx b/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx
index b72244368..d245cd20c 100644
--- a/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx
+++ b/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx
@@ -22,6 +22,7 @@ import {
} from "@mantine/core";
import {
AlertCircle,
+ AlertTriangle,
CalendarDays,
CheckCircle2,
ChevronLeft,
@@ -34,6 +35,7 @@ import {
import type { Freight } from "@edr/types";
import { OperationDatePicker } from "@edr/ui-common";
import { api } from "@/services/api";
+import type { ShipmentValidation } from "@/services/contracts.service";
import {
SelectField,
StepCard,
@@ -149,6 +151,8 @@ function NewShipmentBookingForm({
mode: "onChange",
});
+ const isContainerContract = contract.freightType === "CONTAINER";
+
const submitMutation = useMutation({
mutationFn: (dto: Freight.CreateBookingUnderContractDto) =>
api.contracts.createBookingUnderContract.call({ id: contractId, dto }),
@@ -161,6 +165,14 @@ function NewShipmentBookingForm({
},
});
+ // Pre-submit validation (container contracts only): warns on overweight
+ // containers and HARD-BLOCKS on 20ft wagon-pairing errors. Runs each time the
+ // price modal opens so re-reviewing after an edit re-checks.
+ const validateMutation = useMutation({
+ mutationFn: (dto: Freight.CreateBookingUnderContractDto) =>
+ api.contracts.validateShipment.call({ id: contractId, dto }),
+ });
+
function buildDto(
values: ShipmentFormValues,
): Freight.CreateBookingUnderContractDto {
@@ -207,13 +219,22 @@ function NewShipmentBookingForm({
};
}
- // Submit validates the whole form, then opens the price modal for confirmation.
+ // Submit validates the whole form, then opens the price modal for
+ // confirmation. For container contracts we also run the server-side shipment
+ // validation (overweight warnings + 20ft pairing hard-blocks) so the modal
+ // can surface them before the booking is created.
const handleReview = form.handleSubmit((values) => {
setPendingValues(values);
+ if (isContainerContract) {
+ validateMutation.reset();
+ validateMutation.mutate(buildDto(values));
+ }
});
const handleConfirm = () => {
if (!pendingValues) return;
+ // Guard: never let a booking with unresolved 20ft pairing errors submit.
+ if ((validateMutation.data?.pairingErrors.length ?? 0) > 0) return;
submitMutation.mutate(buildDto(pendingValues));
};
@@ -221,6 +242,7 @@ function NewShipmentBookingForm({
const handleReject = () => {
if (submitMutation.isPending) return;
setPendingValues(null);
+ validateMutation.reset();
};
const routes = contract.routes ?? [];
@@ -323,6 +345,8 @@ function NewShipmentBookingForm({
contract={contract}
values={pendingValues}
loading={submitMutation.isPending}
+ validation={validateMutation.data ?? null}
+ validationLoading={validateMutation.isPending}
onConfirm={handleConfirm}
onReject={handleReject}
/>
@@ -334,12 +358,16 @@ function PriceConfirmModal({
contract,
values,
loading,
+ validation,
+ validationLoading,
onConfirm,
onReject,
}: {
contract: Freight.IContract;
values: ShipmentFormValues | null;
loading: boolean;
+ validation: ShipmentValidation | null;
+ validationLoading: boolean;
onConfirm: () => void;
onReject: () => void;
}) {
@@ -348,6 +376,11 @@ function PriceConfirmModal({
[contract, values],
);
+ const overweightLines = validation?.overweightLines ?? [];
+ const pairingErrors = validation?.pairingErrors ?? [];
+ const hasPairingBlock = pairingErrors.length > 0;
+ const confirmDisabled = loading || validationLoading || hasPairingBlock;
+
return (
{total ? (
+ {validationLoading && (
+
+
+
+ Checking container weights and wagon pairing…
+
+
+ )}
+
+ {hasPairingBlock && (
+ }
+ title="Cannot create booking — 20ft wagon pairing"
+ >
+
+ {pairingErrors.map((msg, i) => (
+
+ {msg}
+
+ ))}
+
+ Adjust the 20ft container weights or quantities so pairs differ
+ by no more than 10 tons.
+
+
+
+ )}
+
+ {overweightLines.length > 0 && (
+ }
+ title="Overweight containers"
+ >
+
+ {overweightLines.map((line, i) => (
+
+ {line.containerTypeCode}: {line.totalVgmTons}t exceeds limit{" "}
+ {line.maxAllowedTons}t (+{line.excessTons}t overweight)
+
+ ))}
+
+ An overweight surcharge applies. You can still submit, or go
+ back and adjust weights.
+
+
+
+ )}
+
{total.lines.map((line, i) => (
@@ -442,6 +529,7 @@ function PriceConfirmModal({
leftSection={}
onClick={onConfirm}
loading={loading}
+ disabled={confirmDisabled}
>
Confirm & book
diff --git a/apps/edr-freight-web/portal/src/services/api.ts b/apps/edr-freight-web/portal/src/services/api.ts
index a09bd4b4e..92256b989 100644
--- a/apps/edr-freight-web/portal/src/services/api.ts
+++ b/apps/edr-freight-web/portal/src/services/api.ts
@@ -24,6 +24,7 @@ import {
ContractDocuments,
GenerateContractPriceResponse,
SubmitContractResponse,
+ ShipmentValidation,
} from "./contracts.service";
import type { BookingDocuments } from "@/pages/bookings/new-booking-form/schema";
import {
@@ -462,6 +463,13 @@ export const api = {
contractsService.createBookingUnderContract(id, dto),
),
+ validateShipment: endpoint<
+ { id: string; dto: Freight.CreateBookingUnderContractDto },
+ ShipmentValidation
+ >("contracts", "validateShipment", ({ id, dto }) =>
+ contractsService.validateShipment(id, dto),
+ ),
+
getContractMilestones: endpoint<
{ id: string },
Freight.IClearanceMilestone[]
diff --git a/apps/edr-freight-web/portal/src/services/contracts.service.ts b/apps/edr-freight-web/portal/src/services/contracts.service.ts
index 600c14860..3af87ee42 100644
--- a/apps/edr-freight-web/portal/src/services/contracts.service.ts
+++ b/apps/edr-freight-web/portal/src/services/contracts.service.ts
@@ -33,6 +33,25 @@ export interface SubmitContractResponse {
message?: string;
}
+/** A container line whose total VGM exceeds the weight-limit rule. */
+export interface OverweightLine {
+ containerTypeCode: string;
+ totalVgmTons: number;
+ maxAllowedTons: number;
+ excessTons: number;
+}
+
+/**
+ * Pre-submit validation for a shipment booking under a CONTAINER contract.
+ * `overweightLines` are WARNINGS only (an overweight surcharge applies — the
+ * customer may still submit); `pairingErrors` are HARD BLOCKS (20ft containers
+ * that cannot be balanced onto wagons) and must prevent booking.
+ */
+export interface ShipmentValidation {
+ overweightLines: OverweightLine[];
+ pairingErrors: string[];
+}
+
export interface ContractListFilter {
status?: string;
statuses?: string;
@@ -285,6 +304,20 @@ export const contractsService = {
return data.data.booking ?? data.data;
},
+ /**
+ * Pre-submit validation of a shipment booking (same DTO as
+ * `createBookingUnderContract`). Returns overweight warnings and hard-block
+ * 20ft wagon-pairing errors so the customer can be warned/blocked before the
+ * booking is created.
+ */
+ validateShipment: async (
+ id: string,
+ dto: Freight.CreateBookingUnderContractDto,
+ ): Promise => {
+ const { data } = await client.post(C.VALIDATE_SHIPMENT(id), dto);
+ return data.data ?? data;
+ },
+
// ── Milestones ──
getContractMilestones: async (
id: string,
From 48db0b240b7ab78ad997d2b8400c35782af0bd2d Mon Sep 17 00:00:00 2001
From: natib21
Date: Fri, 3 Jul 2026 09:32:41 +0000
Subject: [PATCH 61/86] 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 130625df54a5d05711360990fc8405ef714dd2b3 Mon Sep 17 00:00:00 2001
From: marshal
Date: Fri, 3 Jul 2026 13:54:12 +0300
Subject: [PATCH 62/86] user managemnt and wight limit rule
---
.../portal/src/pages/billing/InvoiceDetailPage.tsx | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/apps/edr-freight-web/portal/src/pages/billing/InvoiceDetailPage.tsx b/apps/edr-freight-web/portal/src/pages/billing/InvoiceDetailPage.tsx
index 5dca05b15..725445f57 100644
--- a/apps/edr-freight-web/portal/src/pages/billing/InvoiceDetailPage.tsx
+++ b/apps/edr-freight-web/portal/src/pages/billing/InvoiceDetailPage.tsx
@@ -68,6 +68,7 @@ export default function InvoiceDetailPage() {
const [paymentMethod, setPaymentMethod] = useState<"TELEBIRR" | "WAAFI">(
"TELEBIRR",
);
+ console.log(paymentMethod)
const {
data: invoice,
@@ -127,7 +128,7 @@ export default function InvoiceDetailPage() {
const payable = isPayable(invoice.status);
const lines = invoice.lines ?? [];
- const amountDue = Number(invoice.balanceAmount ?? invoice.totalAmount);
+ // const amountDue = Number(invoice.balanceAmount ?? invoice.totalAmount);
const handlePay = () => {
setPayModalOpen(true);
From 4e6e614b48b276b187c5c34001c1978836519ecd Mon Sep 17 00:00:00 2001
From: Nathnael
Date: Fri, 3 Jul 2026 10:59:06 +0000
Subject: [PATCH 63/86] 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 a4c6848233772299d407dd794eff5c450f33921f Mon Sep 17 00:00:00 2001
From: marshal
Date: Fri, 3 Jul 2026 14:06:18 +0300
Subject: [PATCH 64/86] user managemnt and wight limit rule
---
apps/edr-freight-web/portal/src/pages/settings/TabDocuments.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/apps/edr-freight-web/portal/src/pages/settings/TabDocuments.tsx b/apps/edr-freight-web/portal/src/pages/settings/TabDocuments.tsx
index b904d7eba..2b31559ce 100644
--- a/apps/edr-freight-web/portal/src/pages/settings/TabDocuments.tsx
+++ b/apps/edr-freight-web/portal/src/pages/settings/TabDocuments.tsx
@@ -5,7 +5,7 @@ import { getMinFiles } from "@/types/fileUploadSettings";
import type { ProfileResponse } from "@/types/profile";
import { SmartFileInput, useFileViewer } from "@edr/ui-common";
import {
- Anchor,
+ // Anchor,
Button,
Card,
Center,
From 2d71f24937af4880cdf174661c8ddb1d2aea5b69 Mon Sep 17 00:00:00 2001
From: Nathnael
Date: Fri, 3 Jul 2026 11:08:57 +0000
Subject: [PATCH 65/86] 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 ? (
- }
- style={{ alignSelf: "flex-start" }}
- >
- Send code via SMS
-
- ) : (
-
-
-
-
-
-
-
- )}
-
- {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 66/86] 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 67/86] 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 68/86] 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?
+
-
- }
- >
- Continue
-
-
-
- ) : phase === "role" ? (
-
+
+ What does your company do?(multiple)
+
)}
-
- }
- onClick={() => setPhase("nationality")}
- >
- Back
-
+
);
From 79731e58ec7ed5d1456266f119219b8ee4c205f2 Mon Sep 17 00:00:00 2001
From: Nathnael
Date: Fri, 3 Jul 2026 12:19:07 +0000
Subject: [PATCH 69/86] 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({