From 6101a377f1b9d9a273e3e0234327af103e3c2380 Mon Sep 17 00:00:00 2001 From: Abubeker Yasin Date: Mon, 3 Aug 2026 16:56:36 +0300 Subject: [PATCH 01/22] feat: ( bookings ) search backoffice bookings by provider transaction ID --- .../modules/bookings/bookings.controller.ts | 8 ++++ .../src/modules/bookings/bookings.service.ts | 30 ++++++++++--- .../backoffice/src/app/bookings/page.tsx | 44 ++++++++++++++----- .../backoffice/src/lib/api/bookings.ts | 1 + .../backoffice/src/types/index.ts | 2 + 5 files changed, 69 insertions(+), 16 deletions(-) diff --git a/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts b/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts index 2c9010270..482c34268 100644 --- a/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts @@ -152,6 +152,12 @@ export class BookingsController { @ApiQuery({ name: "returnLegStatus", required: false }) @ApiQuery({ name: "bookingType", required: false }) @ApiQuery({ name: "paymentStatus", required: false }) + @ApiQuery({ + name: "providerTxnId", + required: false, + description: + "Payment provider transaction / order / merchant reference (partial, case-insensitive)", + }) @ApiQuery({ name: "dateFrom", required: false }) @ApiQuery({ name: "dateTo", required: false }) @ApiQuery({ name: "page", required: false }) @@ -162,6 +168,7 @@ export class BookingsController { @Query("returnLegStatus") returnLegStatus?: string, @Query("bookingType") bookingType?: string, @Query("paymentStatus") paymentStatus?: string, + @Query("providerTxnId") providerTxnId?: string, @Query("dateFrom") dateFrom?: string, @Query("dateTo") dateTo?: string, @Query("page") page?: string, @@ -173,6 +180,7 @@ export class BookingsController { returnLegStatus, bookingType, paymentStatus, + providerTxnId, dateFrom, dateTo, page: page ? parseInt(page) : 1, diff --git a/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts b/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts index ce389ef3b..7f0a6eee5 100644 --- a/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts @@ -91,6 +91,7 @@ interface BookingFilters { returnLegStatus?: string; bookingType?: string; paymentStatus?: string; + providerTxnId?: string; dateFrom?: string; dateTo?: string; page?: number; @@ -453,8 +454,9 @@ export class BookingsService { } async findAll(filters: BookingFilters = {}) { - const { search, status, returnLegStatus, bookingType, paymentStatus, dateFrom, dateTo, page = 1, pageSize = 20 } = filters; + const { search, status, returnLegStatus, bookingType, paymentStatus, providerTxnId, dateFrom, dateTo, page = 1, pageSize = 20 } = filters; const skip = (page - 1) * pageSize; + const txn = providerTxnId?.trim() || undefined; const onlyPackages = bookingType === 'PACKAGE'; const includePackageBookings = !returnLegStatus && bookingType !== 'ONE_WAY' && bookingType !== 'ROUND_TRIP' && bookingType !== 'TRANSIT' && bookingType !== 'ROUND_TRIP_TRANSIT'; @@ -495,11 +497,24 @@ export class BookingsService { ...(dateTo ? { lte: new Date(new Date(dateTo).setHours(23, 59, 59, 999)) } : {}), }; } + // paymentStatus and providerTxnId both narrow the same relation — build one `is` filter + // so the second doesn't overwrite the first. + const paymentIntentIs: any = {}; if (paymentStatus) { const statusMap: Record = { PAID: 'SUCCEEDED', PENDING: 'REQUIRES_ACTION', FAILED: 'FAILED', REFUNDED: 'REFUNDED' }; - const mapped = statusMap[paymentStatus] ?? paymentStatus; - where.paymentIntent = { is: { status: mapped } }; + paymentIntentIs.status = statusMap[paymentStatus] ?? paymentStatus; } + if (txn) { + // Providers are inconsistent about which reference they hand back to the customer — + // match the transaction id, the provider/merchant order ids, and the generic ref. + paymentIntentIs.OR = [ + { providerTxnId: { contains: txn, mode: 'insensitive' } }, + { providerOrderId: { contains: txn, mode: 'insensitive' } }, + { merchantOrderId: { contains: txn, mode: 'insensitive' } }, + { providerRef: { contains: txn, mode: 'insensitive' } }, + ]; + } + if (Object.keys(paymentIntentIs).length) where.paymentIntent = { is: paymentIntentIs }; const pkgWhere: any = {}; if (search) { @@ -512,7 +527,12 @@ export class BookingsService { } if (status) pkgWhere.status = status; if (dateFrom || dateTo) pkgWhere.createdAt = where.createdAt; - if (paymentStatus) pkgWhere.paymentIntent = { is: { status: (where.paymentIntent as any)?.is?.status } }; + const pkgPaymentIntentIs: any = {}; + if (paymentStatus) pkgPaymentIntentIs.status = paymentIntentIs.status; + // PackagePaymentIntent has no providerTxnId/providerOrderId/merchantOrderId columns — + // providerRef is the only reference we can match a package booking on. + if (txn) pkgPaymentIntentIs.providerRef = { contains: txn, mode: 'insensitive' }; + if (Object.keys(pkgPaymentIntentIs).length) pkgWhere.paymentIntent = { is: pkgPaymentIntentIs }; if (onlyPackages) { // Package bookings live in two places: @@ -521,7 +541,7 @@ export class BookingsService { const bookingPkgWhere: any = { packageId: { not: null } }; if (status) bookingPkgWhere.status = status; if (dateFrom || dateTo) bookingPkgWhere.createdAt = where.createdAt; - if (paymentStatus) bookingPkgWhere.paymentIntent = where.paymentIntent; + if (where.paymentIntent) bookingPkgWhere.paymentIntent = where.paymentIntent; if (search) bookingPkgWhere.OR = where.OR; const [pkgItems, pkgTotal, regPkgItems, regPkgTotal] = await Promise.all([ diff --git a/apps/edr-passenger-web/backoffice/src/app/bookings/page.tsx b/apps/edr-passenger-web/backoffice/src/app/bookings/page.tsx index 66b5c6c5a..a1909d6e8 100644 --- a/apps/edr-passenger-web/backoffice/src/app/bookings/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/bookings/page.tsx @@ -32,7 +32,7 @@ const SectionHeader = ({ title }: { title: string }) => ( function BookingsPageContent() { const canManage = usePermission(PERMS.bookings.manage); const [filters, setFilters] = useState({ page: 1, pageSize: 20, search: '', status: '' }); - const [extraFilters, setExtraFilters] = useState({ bookingType: '', dateFrom: '', dateTo: '', paymentStatus: '' }); + const [extraFilters, setExtraFilters] = useState({ bookingType: '', dateFrom: '', dateTo: '', paymentStatus: '', providerTxnId: '' }); const [showExtraFilters, setShowExtraFilters] = useState(false); const [selectedBooking, setSelectedBooking] = useState(null); const [generateTicketBooking, setGenerateTicketBooking] = useState(null); @@ -50,20 +50,26 @@ function BookingsPageContent() { const [exportDateTo, setExportDateTo] = useState(''); const [exportColumns, setExportColumns] = useState>({ bookingRef: true, bookingType: true, passengerNames: true, contactPhone: true, - contactEmail: true, passengerCount: false, paymentStatus: true, totalMinor: true, status: true, createdAt: true, + contactEmail: true, passengerCount: false, paymentStatus: true, providerTxnId: false, totalMinor: true, status: true, createdAt: true, }); const queryClient = useQueryClient(); + // Single source of truth for the query params — the export path must send the same + // filters as the table, otherwise exporting while filtered dumps every booking. + const buildQueryFilters = (overrides: Partial = {}): BookingFilters => ({ + ...filters, + ...(extraFilters.bookingType && { bookingType: extraFilters.bookingType }), + ...(extraFilters.paymentStatus && { paymentStatus: extraFilters.paymentStatus }), + ...(extraFilters.providerTxnId && { providerTxnId: extraFilters.providerTxnId }), + ...(extraFilters.dateFrom && { dateFrom: extraFilters.dateFrom }), + ...(extraFilters.dateTo && { dateTo: extraFilters.dateTo }), + ...overrides, + }); + const { data, isLoading, error } = useQuery({ queryKey: ['bookings', filters, extraFilters], - queryFn: () => bookingsApi.getAll({ - ...filters, - ...(extraFilters.bookingType && { bookingType: extraFilters.bookingType }), - ...(extraFilters.paymentStatus && { paymentStatus: extraFilters.paymentStatus }), - ...(extraFilters.dateFrom && { dateFrom: extraFilters.dateFrom }), - ...(extraFilters.dateTo && { dateTo: extraFilters.dateTo }), - }), + queryFn: () => bookingsApi.getAll(buildQueryFilters()), }); const smartAssignMutation = useMutation({ @@ -112,7 +118,8 @@ function BookingsPageContent() { { key: 'bookingRef', label: 'Booking Reference' }, { key: 'journeyType', label: 'Journey Type' }, { key: 'passengerNames', label: 'Passenger Names' }, { key: 'contactPhone', label: 'Contact Phone' }, { key: 'contactEmail', label: 'Contact Email' }, { key: 'passengerCount', label: 'Passenger Count' }, - { key: 'paymentStatus', label: 'Payment Status' }, { key: 'totalMinor', label: 'Amount' }, + { key: 'paymentStatus', label: 'Payment Status' }, { key: 'providerTxnId', label: 'Provider Txn ID' }, + { key: 'totalMinor', label: 'Amount' }, { key: 'status', label: 'Status' }, { key: 'createdAt', label: 'Created At' }, ]; @@ -120,7 +127,7 @@ function BookingsPageContent() { const cols = Object.entries(exportColumns).filter(([, v]) => v).map(([k]) => k); if (!cols.length) { alert('Please select at least one column'); return; } // Fetch all records (not just current page) - const allData = await bookingsApi.getAll({ ...filters, page: 1, pageSize: 9999 }); + const allData = await bookingsApi.getAll(buildQueryFilters({ page: 1, pageSize: 9999 })); const exportItems = (allData?.items || []).filter((b: any) => { if (!exportDateFrom && !exportDateTo) return true; const d = b.createdAt ? new Date(b.createdAt).toISOString().split('T')[0] : null; @@ -138,6 +145,7 @@ function BookingsPageContent() { case 'contactEmail': return booking.contactEmail || 'N/A'; case 'passengerCount': return String((booking.adultCount ?? 0) + (booking.childCount ?? 0)); case 'paymentStatus': return booking.paymentIntent?.status || 'PENDING'; + case 'providerTxnId': return booking.paymentIntent?.providerTxnId || 'N/A'; case 'totalMinor': return formatCurrency(booking.totalMinor, booking.currency); case 'status': return booking.status; case 'createdAt': return booking.createdAt ? formatDateTime(booking.createdAt) : ''; @@ -274,6 +282,11 @@ function BookingsPageContent() {
{booking.paymentIntent?.status || 'PENDING'}
{formatCurrency(booking.displayTotalMinor ?? booking.totalMinor, booking.displayCurrency ?? booking.currency ?? 'ETB')}
+ {booking.paymentIntent?.providerTxnId && ( +
+ {booking.paymentIntent.providerTxnId} +
+ )}
), }, @@ -358,6 +371,12 @@ function BookingsPageContent() { setExtraFilters({ ...extraFilters, dateTo: e.target.value })} /> +
+ + setExtraFilters({ ...extraFilters, providerTxnId: e.target.value })} /> +
)} @@ -471,6 +490,9 @@ function BookingsPageContent() {

Payment Status

{b.paymentIntent?.status || 'PENDING'} + + + diff --git a/apps/edr-passenger-web/backoffice/src/lib/api/bookings.ts b/apps/edr-passenger-web/backoffice/src/lib/api/bookings.ts index fe2473d0a..41f2f9fda 100644 --- a/apps/edr-passenger-web/backoffice/src/lib/api/bookings.ts +++ b/apps/edr-passenger-web/backoffice/src/lib/api/bookings.ts @@ -8,6 +8,7 @@ export const bookingsApi = { if (filters?.status) params.append('status', filters.status); if (filters?.bookingType) params.append('bookingType', filters.bookingType); if (filters?.paymentStatus) params.append('paymentStatus', filters.paymentStatus); + if (filters?.providerTxnId) params.append('providerTxnId', filters.providerTxnId); if (filters?.dateFrom) params.append('dateFrom', filters.dateFrom); if (filters?.dateTo) params.append('dateTo', filters.dateTo); if (filters?.search) params.append('search', filters.search); diff --git a/apps/edr-passenger-web/backoffice/src/types/index.ts b/apps/edr-passenger-web/backoffice/src/types/index.ts index 88ffee43d..c688cc293 100644 --- a/apps/edr-passenger-web/backoffice/src/types/index.ts +++ b/apps/edr-passenger-web/backoffice/src/types/index.ts @@ -48,6 +48,8 @@ export interface BookingFilters { status?: string; bookingType?: string; paymentStatus?: string; + /** Payment provider transaction / order / merchant reference — partial, case-insensitive. */ + providerTxnId?: string; dateFrom?: string; dateTo?: string; search?: string; From 14af6374f877ba6d4cf605811aa925f594a84005 Mon Sep 17 00:00:00 2001 From: Abubeker Yasin Date: Tue, 4 Aug 2026 15:44:57 +0300 Subject: [PATCH 02/22] Update payment-deadline.utils.ts --- .../src/common/utils/payment-deadline.utils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/edr-passenger-api/src/common/utils/payment-deadline.utils.ts b/apps/edr-passenger-api/src/common/utils/payment-deadline.utils.ts index e125bb0bf..29ef49e59 100644 --- a/apps/edr-passenger-api/src/common/utils/payment-deadline.utils.ts +++ b/apps/edr-passenger-api/src/common/utils/payment-deadline.utils.ts @@ -8,7 +8,7 @@ */ /** Maximum time (hours) a passenger has to pay after booking. */ -export const MAX_PAYMENT_HOURS = 2; +export const MAX_PAYMENT_HOURS = 240; /** Minutes before departure: cutoff for new bookings and payment deadline. */ export const CUTOFF_MINUTES = 30; From d1a15a82e3cca580276d05a344baf0d3002bf2a4 Mon Sep 17 00:00:00 2001 From: Stephanos A Date: Wed, 5 Aug 2026 10:37:49 +0300 Subject: [PATCH 03/22] Excess baggage payment link changes --- .../excess-baggage/excess-baggage.dto.ts | 5 +- .../excess-baggage/excess-baggage.service.ts | 29 ++- .../test/money-integrity.e2e-spec.ts | 37 ++++ .../src/app/excess-baggage/page.tsx | 20 +- .../app/excess-baggage/pay/[token]/page.tsx | 183 ++++++++++++++++++ .../pay/[token]/result/page.tsx | 20 ++ 6 files changed, 275 insertions(+), 19 deletions(-) create mode 100644 apps/edr-passenger-web/portal/src/app/excess-baggage/pay/[token]/page.tsx create mode 100644 apps/edr-passenger-web/portal/src/app/excess-baggage/pay/[token]/result/page.tsx diff --git a/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.dto.ts b/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.dto.ts index 4379ae28d..df0abd8be 100644 --- a/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.dto.ts +++ b/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.dto.ts @@ -2,7 +2,10 @@ import { IsString, IsInt, IsOptional, IsPositive } from 'class-validator'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; export class LogExcessBaggageDto { - @ApiProperty({ example: 'booking-uuid' }) @IsString() bookingId: string; + @ApiPropertyOptional({ example: 'booking-uuid', description: 'Booking UUID for the passenger booking' }) + @IsOptional() @IsString() bookingId?: string; + @ApiPropertyOptional({ example: 'JS6MJ9', description: 'Booking reference for the passenger booking' }) + @IsOptional() @IsString() bookingReference?: string; @ApiPropertyOptional({ example: 'agent-uuid', description: 'Injected from IAM token; optional override' }) @IsOptional() @IsString() agentId?: string; @ApiProperty({ example: 7, description: 'Excess weight in kg above the free allowance' }) diff --git a/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.service.ts b/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.service.ts index eccda0208..32fc06c13 100644 --- a/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.service.ts +++ b/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.service.ts @@ -39,12 +39,25 @@ export class ExcessBaggageService { ) {} async logCharge(dto: LogExcessBaggageDto) { - const booking = await this.prisma.booking.findUnique({ - where: { id: dto.bookingId }, - include: { - passenger: { include: { user: true } }, - }, - }); + const bookingRef = dto.bookingReference?.trim(); + const bookingId = dto.bookingId?.trim(); + + const booking = bookingRef + ? await this.prisma.booking.findFirst({ + where: { bookingRef: { equals: bookingRef, mode: 'insensitive' } }, + include: { + passenger: { include: { user: true } }, + }, + }) + : bookingId + ? await this.prisma.booking.findUnique({ + where: { id: bookingId }, + include: { + passenger: { include: { user: true } }, + }, + }) + : null; + if (!booking) throw new NotFoundException('Booking not found'); if (!['CONFIRMED', 'BOARDED'].includes(booking.status)) { throw new BadRequestException('Booking must be CONFIRMED or BOARDED to log excess baggage'); @@ -64,7 +77,7 @@ export class ExcessBaggageService { const charge = await this.prisma.excessBaggageCharge.create({ data: { - bookingId: dto.bookingId, + bookingId: booking.id, agentId: dto.agentId ?? '', excessWeightKg: dto.excessWeightKg, feePerKgMinor, @@ -81,7 +94,7 @@ export class ExcessBaggageService { await this.sendPaymentLink(charge, booking, contactPhone, contactEmail); } - await this.auditService.log({ action: 'CREATE', entityType: 'ExcessBaggageCharge', entityId: charge.id, newData: { bookingId: dto.bookingId, excessWeightKg: dto.excessWeightKg, totalMinor, status } }); + await this.auditService.log({ action: 'CREATE', entityType: 'ExcessBaggageCharge', entityId: charge.id, newData: { bookingId: booking.id, excessWeightKg: dto.excessWeightKg, totalMinor, status } }); return charge; } diff --git a/apps/edr-passenger-api/test/money-integrity.e2e-spec.ts b/apps/edr-passenger-api/test/money-integrity.e2e-spec.ts index 02ca87863..66dfa9015 100644 --- a/apps/edr-passenger-api/test/money-integrity.e2e-spec.ts +++ b/apps/edr-passenger-api/test/money-integrity.e2e-spec.ts @@ -110,6 +110,43 @@ describe("Money integrity (Tier-2 direct instantiation)", () => { expect(walletAfter?.balanceMinor).toBe(0); }); + it("accepts a booking reference when logging an excess baggage charge", async () => { + const passenger = await prisma.passenger.create({ data: {} }); + const schedule = await makeSchedule(prisma, passenger.id); + const booking = await prisma.booking.create({ + data: { + bookingRef: "BAG-REF-001", + passengerId: passenger.id, + scheduleId: schedule.id, + totalMinor: 30_000, + status: "CONFIRMED", + }, + }); + + await prisma.baggageAllowance.create({ + data: { seatClassId: IDS.seatClassLocal, maxWeightKg: 20, maxPiecesCount: 2, excessFeePerKg: 80 }, + }); + + const service = new ExcessBaggageService( + prisma as any, + asyncStub(), + asyncStub(), + asyncStub(), + asyncStub(), + asyncStub(), + ); + + const charge: any = await service.logCharge({ + bookingReference: booking.bookingRef, + excessWeightKg: 2, + collectCash: true, + } as any); + + expect(charge.bookingId).toBe(booking.id); + expect(charge.feePerKgMinor).toBe(80); + expect(charge.totalMinor).toBe(160); + }); + // ── E1 / E2 ──────────────────────────────────────────────────────────────── it("E1/E2 🔴 excess-baggage uses the OLDEST allowance globally (ignores seat class); fee = rate×kg", async () => { const passenger = await prisma.passenger.create({ data: {} }); diff --git a/apps/edr-passenger-web/backoffice/src/app/excess-baggage/page.tsx b/apps/edr-passenger-web/backoffice/src/app/excess-baggage/page.tsx index e49c16523..f571499a4 100644 --- a/apps/edr-passenger-web/backoffice/src/app/excess-baggage/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/excess-baggage/page.tsx @@ -28,7 +28,7 @@ export default function ExcessBaggagePage() { const [waiveReason, setWaiveReason] = useState(''); const [waiveError, setWaiveError] = useState(null); const [logModal, setLogModal] = useState(false); - const [logForm, setLogForm] = useState({ bookingId: '', excessWeightKg: '', collectCash: false }); + const [logForm, setLogForm] = useState({ bookingReference: '', excessWeightKg: '', collectCash: false }); const [logError, setLogError] = useState(null); const [resendModal, setResendModal] = useState(null); const [resendSuccess, setResendSuccess] = useState(false); @@ -59,7 +59,7 @@ export default function ExcessBaggagePage() { onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['excess-baggage'] }); setLogModal(false); - setLogForm({ bookingId: '', excessWeightKg: '', collectCash: false }); + setLogForm({ bookingReference: '', excessWeightKg: '', collectCash: false }); setLogError(null); }, onError: (e: any) => setLogError(e?.response?.data?.message || e?.message || 'Failed to log charge'), @@ -178,7 +178,7 @@ export default function ExcessBaggagePage() {

Excess Lugagge

Track and manage excess luggage charges at boarding

- { setLogModal(true); setLogError(null); setLogForm({ bookingId: '', excessWeightKg: '', collectCash: false }); }}> + { setLogModal(true); setLogError(null); setLogForm({ bookingReference: '', excessWeightKg: '', collectCash: false }); }}> Log Excess Luggage @@ -248,12 +248,12 @@ export default function ExcessBaggagePage() { Rate: {(excessRate.excessFeePerKg / 100).toFixed(2)} ETB/kg
- + setLogForm({ ...logForm, bookingId: e.target.value })} + placeholder="e.g. JS6MJ9" + value={logForm.bookingReference} + onChange={(e) => setLogForm({ ...logForm, bookingReference: e.target.value })} />
@@ -294,12 +294,12 @@ export default function ExcessBaggagePage() { { - if (!logForm.bookingId.trim() || !logForm.excessWeightKg) { - setLogError('Booking ID and excess weight are required'); + if (!logForm.bookingReference.trim() || !logForm.excessWeightKg) { + setLogError('Booking reference and excess weight are required'); return; } logMutation.mutate({ - bookingId: logForm.bookingId.trim(), + bookingReference: logForm.bookingReference.trim(), excessWeightKg: parseInt(logForm.excessWeightKg), collectCash: logForm.collectCash, }); diff --git a/apps/edr-passenger-web/portal/src/app/excess-baggage/pay/[token]/page.tsx b/apps/edr-passenger-web/portal/src/app/excess-baggage/pay/[token]/page.tsx new file mode 100644 index 000000000..209651756 --- /dev/null +++ b/apps/edr-passenger-web/portal/src/app/excess-baggage/pay/[token]/page.tsx @@ -0,0 +1,183 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { useParams, useRouter } from "next/navigation"; +import { useQuery, useMutation } from "@tanstack/react-query"; +import { apiClient } from "@/lib/api-client"; +import { PaymentMethod } from "@/types"; +import { + AlertCircle, + CheckCircle, + CreditCard, + Landmark, + Loader2, + Smartphone, + Wallet, +} from "lucide-react"; + +const getIconForMethod = (methodId: string) => { + if (methodId.includes("CARD")) return CreditCard; + if (methodId.includes("WALLET")) return Wallet; + if (methodId.includes("CAC")) return Landmark; + return Smartphone; +}; + +export default function ExcessBaggagePayPage() { + const { token } = useParams<{ token: string }>(); + const router = useRouter(); + const [selectedMethod, setSelectedMethod] = useState(null); + const [isProcessing, setIsProcessing] = useState(false); + const [paymentError, setPaymentError] = useState(null); + + const { data: charge, isLoading: loadingCharge, error: chargeError } = useQuery({ + queryKey: ["excessBaggageCharge", token], + queryFn: () => apiClient.get(`/excess-baggage/pay/${token}`), + retry: false, + enabled: !!token, + }); + + const { data: paymentMethods = [], isLoading: loadingMethods } = useQuery({ + queryKey: ["paymentMethods"], + queryFn: async () => { + const res = await apiClient.get("/payments/methods"); + return Array.isArray(res) ? res : []; + }, + enabled: !!charge, + }); + + const amountDisplay = useMemo(() => { + const amountMinor = Number(charge?.totalMinor ?? charge?.amountMinor ?? 0); + return (amountMinor / 100).toFixed(2); + }, [charge]); + + const currency = charge?.currency ?? charge?.booking?.currency ?? "ETB"; + + const payMutation = useMutation({ + mutationFn: (method: string) => + apiClient.post(`/excess-baggage/pay/${token}/initiate`, { + method, + platform: "web", + }), + onSuccess: (data: any) => { + if (data?.clientAction?.type === "REDIRECT") { + window.location.href = data.clientAction.url; + return; + } + router.push(`/excess-baggage/pay/${token}/result`); + }, + onError: (err: any) => { + setPaymentError(err?.response?.data?.message ?? err?.message ?? "Payment failed. Please try again."); + setIsProcessing(false); + }, + }); + + const handlePay = () => { + if (!selectedMethod) return; + setIsProcessing(true); + setPaymentError(null); + payMutation.mutate(selectedMethod); + }; + + if (loadingCharge) { + return ( +
+ +
+ ); + } + + if (chargeError || !charge) { + const msg = (chargeError as any)?.response?.data?.message ?? "This payment link is invalid or has expired."; + return ( +
+
+ +

Link unavailable

+

{msg}

+
+
+ ); + } + + return ( +
+
+
+

Pay excess baggage

+

+ Booking {charge.booking?.bookingRef ?? "—"} +

+
+ +
+
+ Amount due + + {currency} {amountDisplay} + +
+
+ Weight + {charge.excessWeightKg ?? "—"} kg +
+
+ +
+

Select payment method

+ {loadingMethods ? ( +
+ + Loading... +
+ ) : ( +
+ {paymentMethods.filter((m) => m.enabled).map((method) => { + const Icon = getIconForMethod(method.type); + const isSelected = selectedMethod === method.type; + return ( + + ); + })} +
+ )} +
+ + {paymentError &&

⚠️ {paymentError}

} + + +
+
+ ); +} diff --git a/apps/edr-passenger-web/portal/src/app/excess-baggage/pay/[token]/result/page.tsx b/apps/edr-passenger-web/portal/src/app/excess-baggage/pay/[token]/result/page.tsx new file mode 100644 index 000000000..cc334c859 --- /dev/null +++ b/apps/edr-passenger-web/portal/src/app/excess-baggage/pay/[token]/result/page.tsx @@ -0,0 +1,20 @@ +"use client"; + +import { useParams } from "next/navigation"; +import { CheckCircle } from "lucide-react"; + +export default function ExcessBaggagePayResultPage() { + const { token } = useParams<{ token: string }>(); + + return ( +
+
+ +

Payment submitted

+

+ Your excess baggage payment request is being processed. Reference: {token} +

+
+
+ ); +} From 8476a6a00ef9a65dc0307eb2284f758b9bcd4063 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Wed, 5 Aug 2026 13:00:43 +0000 Subject: [PATCH 04/22] fix: the fayda and etrade syncing --- .../companies.fayda-identity.spec.ts | 95 +++++++++++++++- .../modules/companies/companies.service.ts | 103 ++++++++++++------ apps/edr-freight-web/backoffice/.env.example | 3 + apps/edr-freight-web/backoffice/package.json | 2 +- .../edr-freight-web/backoffice/vite.config.ts | 7 +- apps/edr-freight-web/portal/package.json | 2 +- apps/edr-freight-web/portal/vite.config.ts | 47 ++++---- 7 files changed, 197 insertions(+), 62 deletions(-) diff --git a/apps/edr-freight-api/src/modules/companies/companies.fayda-identity.spec.ts b/apps/edr-freight-api/src/modules/companies/companies.fayda-identity.spec.ts index 32513df4f..51dd1d61f 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.fayda-identity.spec.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.fayda-identity.spec.ts @@ -280,15 +280,104 @@ describe("Fayda identity verification binds a person to the company", () => { expect(ctx.attributes.poaFaydaSub).toBe("new-sub"); }); - it("refuses to rename a verified person by hand", async () => { - const { service } = makeService({ + it("stages nothing for a verified field an approved company resubmits", async () => { + // Approving it could not move the live row — the verified value is written + // back over it — so it must never reach a reviewer as a pending change. + const { service, deps } = makeService({ + status: CompanyStatus.Active, + attributes: { ...OWNER_VERIFIED, ownerEmail: "abebe@example.com" }, + }); + + await expect( + service.updateProfile("user-1", { + companyEmail: "someone-else@example.com", + } as never), + ).resolves.toBeDefined(); + expect(deps.changeRequestRepo.create).not.toHaveBeenCalled(); + expect(deps.changeRequestRepo.update).not.toHaveBeenCalled(); + expect(deps.companiesRepo.update).not.toHaveBeenCalled(); + }); + + // The verified value wins, and it wins by overwriting rather than by + // rejecting: nobody types these fields, so a submission that disagrees is a + // stale form echoing itself back, not an edit. Failing it would block a save + // the customer never made — and leave them no way through, since re-verifying + // returns the same value they are being 400'd for. + it("overwrites a hand-renamed verified person with the verified name", async () => { + const { service, ctx } = makeService({ attributes: { ...OWNER_VERIFIED, ...POA_VERIFIED }, files: [paper()], }); await expect( service.updateProfile("user-1", { poaName: "Someone Else" } as never), - ).rejects.toBeInstanceOf(BadRequestException); + ).resolves.toBeDefined(); + expect(ctx.attributes.poaName).toBe(POA_VERIFIED.poaName); + }); + + // Fayda's email and phone claims are optional — a verification can prove the + // person and return neither. Holding the company mirrors to "the owner is + // verified" rather than to "the verification supplied this value" would + // clobber the fallbacks the portal is built to send (account email, eTrade's + // registered phone) with nothing at all. OWNER_VERIFIED is exactly that + // shape: a sub, no contact details. + it("keeps company contact details a Fayda verification never supplied", async () => { + const { service, deps } = makeService({ + attributes: { ...OWNER_VERIFIED }, + }); + + await expect( + service.updateProfile("user-1", { + companyEmail: "account@example.com", + companyPhone: "+251911777777", + } as never), + ).resolves.toBeDefined(); + const [, patch] = deps.companiesRepo.update.mock.calls.at(-1)!; + expect(patch.email).toBe("account@example.com"); + expect(patch.phone).toBe("+251911777777"); + }); + + it("overwrites company contact details the verification did supply", async () => { + const { service, deps } = makeService({ + attributes: { + ...OWNER_VERIFIED, + ownerEmail: "abebe@example.com", + ownerPhone: "+251911000000", + }, + }); + + await expect( + service.updateProfile("user-1", { + companyEmail: "someone-else@example.com", + companyPhone: "+251911999999", + } as never), + ).resolves.toBeDefined(); + const [, patch] = deps.companiesRepo.update.mock.calls.at(-1)!; + expect(patch.email).toBe("abebe@example.com"); + expect(patch.phone).toBe("+251911000000"); + }); + + // "Same as owner" copies `ownerEmail ?? null` onto the GM while setting + // `gmFaydaSub`. Locking that null made generalManagerEmail required by + // onboarding, hidden by the portal's link card and unwritable at once. + it("lets the GM's details be typed when the copied owner identity carried none", async () => { + const { service } = makeService({ + attributes: { + ...OWNER_VERIFIED, + gmSameAsOwner: true, + gmFaydaSub: "owner-sub", + generalManagerName: "Abebe Bikila", + generalManagerEmail: null, + generalManagerPhone: null, + }, + }); + + await expect( + service.updateProfile("user-1", { + generalManagerEmail: "gm@example.com", + generalManagerPhone: "+251911888888", + } as never), + ).resolves.toBeDefined(); }); it("never locks or gates the general manager — it is not the verified subject", async () => { diff --git a/apps/edr-freight-api/src/modules/companies/companies.service.ts b/apps/edr-freight-api/src/modules/companies/companies.service.ts index f21263c7f..b5c953cac 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.service.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.service.ts @@ -739,6 +739,39 @@ export class CompaniesService { return out; } + /** + * `UpdateProfileDto` keys this company's completed verifications own — the + * ones `mapProfileDtoToCompanyUpdates` overwrites with the verified value + * whatever a request submits for them. + * + * A key only lands here once there is a verified value to hold it to: Fayda's + * email and phone claims are optional, and a verification that returned + * neither owns nothing to overwrite with. + * + * The map is the enforcement; this is the list used to keep those keys out of + * a change request in the first place. If the two ever drift the map still + * wins — the cost is a staged field that approving turns out not to move. + */ + private faydaOwnedKeys(company: Company): string[] { + const attrs = company.attributes ?? {}; + const held = (key: string) => { + const v = attrs[key]; + return v !== null && v !== undefined && v !== ""; + }; + + const keys: string[] = []; + if (attrs.ownerFaydaSub) { + // The Company-column mirrors of the owner's verified contact details. + if (held("ownerEmail")) keys.push("companyEmail"); + if (held("ownerPhone")) keys.push("companyPhone"); + } + for (const subject of IDENTITY_SUBJECTS) { + if (!attrs[`${IDENTITY_PREFIX[subject]}FaydaSub`]) continue; + keys.push(...IDENTITY_OWNED_FIELDS[subject].filter(held)); + } + return keys; + } + /** * Translate an UpdateProfileDto (or a staged change-request snapshot) into a * `Company` patch: scalar columns plus a merged `attributes` blob (contact/GM/ @@ -829,49 +862,46 @@ export class CompaniesService { // lets the customer type them once verified) — lock them the same way // ownerEmail/ownerPhone themselves are locked below, once there is a // verified owner to lock them to. + // + // Keyed on the verified VALUE, not on `ownerFaydaSub`: Fayda's email and + // phone claims are optional, so a verification can prove the person while + // supplying neither (see completeIdentityVerification's conditional + // spreads). The portal falls back to the account email / eTrade's + // registered phone in exactly that case and submits it on every save of + // the company step — locking against an absent value would 400 that + // forever, and re-verifying could never clear it because Fayda still has + // nothing to return. if (attrUpdates.ownerFaydaSub) { - if ( - dto.companyEmail !== undefined && - dto.companyEmail !== attrUpdates.ownerEmail - ) { - throw new BadRequestException( - "companyEmail is set by the owner's Fayda verification and cannot be edited. Re-verify to change it.", - ); - } - if ( - dto.companyPhone !== undefined && - normalizeE164(dto.companyPhone) !== - normalizeE164(String(attrUpdates.ownerPhone ?? "")) - ) { - throw new BadRequestException( - "companyPhone is set by the owner's Fayda verification and cannot be edited. Re-verify to change it.", - ); - } + if (attrUpdates.ownerEmail && dto.companyEmail !== undefined) + companyUpdates.email = attrUpdates.ownerEmail; + if (attrUpdates.ownerPhone && dto.companyPhone !== undefined) + companyUpdates.phone = normalizeE164(String(attrUpdates.ownerPhone)); } // Renaming a Fayda-verified person by hand would launder the guarantee - // away, so the fields the verification owns are refused once it exists. + // away, so the verification keeps these fields: a submission that disagrees + // is overwritten with the verified value rather than rejected — the same + // doctrine `applyEtradeSourcedFields` uses for eTrade's fields, and for the + // same reason. The customer never types these (the portal derives them, and + // a stale form or a re-render can echo back something else entirely), so a + // 400 punishes a save they never made while an overwrite lands the truth. for (const subject of IDENTITY_SUBJECTS) { if (!attrUpdates[`${IDENTITY_PREFIX[subject]}FaydaSub`]) continue; for (const field of IDENTITY_OWNED_FIELDS[subject]) { - const incoming = (dto as Record)[field]; - if (incoming === undefined) continue; - // The verification itself is allowed to write them; anything else is - // compared against what is already stored, not against the value this - // same call just copied into the patch. Phones are compared normalized: - // a form that re-renders +251911000000 as 0911000000 is echoing the - // stored value back, not trying to change it. + if ((dto as Record)[field] === undefined) continue; + // The verification itself is what writes them; it must not be undone by + // the value this same call just copied into the patch. if (dto.faydaIdentity && field in dto.faydaIdentity) continue; const stored = company.attributes?.[field]; - const same = field.endsWith("Phone") - ? normalizeE164(String(incoming)) === - normalizeE164(String(stored ?? "")) - : incoming === stored; - if (!same) { - throw new BadRequestException( - `${field} is set by the Fayda verification of this company's ${IDENTITY_LABEL[subject]} and cannot be edited. Re-verify to change it.`, - ); - } + // A verification that supplied nothing for this field left no guarantee + // to protect, so it stays typeable. Matters most for the GM — + // `setGmSameAsOwner` copies `ownerEmail ?? null` onto + // `generalManagerEmail` while setting `gmFaydaSub`, and + // REQUIRED_COMPANY_INFO still demands that email, so holding a null + // here makes it required, hidden by the portal's "same as owner" card, + // and unwritable all at once. + if (stored === null || stored === undefined || stored === "") continue; + attrUpdates[field] = stored; } } @@ -977,6 +1007,11 @@ export class CompaniesService { // for review with the live row left intact. await this.assertTinAvailable(company, dto.tin); const fields = this.pickDefined(dto); + // Drop what the verifications own before anything is staged. Approving one + // of these could not change the live row — mapProfileDtoToCompanyUpdates + // writes the verified value back over it — so showing it to a reviewer + // asks them to rule on a change that does not exist. + for (const key of this.faydaOwnedKeys(company)) delete fields[key]; const selfService: Record = {}; const staged: Record = {}; for (const [key, value] of Object.entries(fields)) { diff --git a/apps/edr-freight-web/backoffice/.env.example b/apps/edr-freight-web/backoffice/.env.example index c840d18a1..454817139 100644 --- a/apps/edr-freight-web/backoffice/.env.example +++ b/apps/edr-freight-web/backoffice/.env.example @@ -1,3 +1,6 @@ +# Dev server port. Default: 5283. +PORT=5283 + VITE_API_URL=http://localhost:3001 VITE_BASE_API_URL=http://localhost:3001 diff --git a/apps/edr-freight-web/backoffice/package.json b/apps/edr-freight-web/backoffice/package.json index 675eced1a..68fdb2a8f 100644 --- a/apps/edr-freight-web/backoffice/package.json +++ b/apps/edr-freight-web/backoffice/package.json @@ -4,7 +4,7 @@ "private": true, "type": "module", "scripts": { - "dev": "vite --port 5183 --clearScreen false", + "dev": "vite --clearScreen false", "prebuild": "node -e \"const fs=require('fs'); fs.rmSync('dist',{recursive:true,force:true});\"", "build": "vite build", "preview": "vite preview --port 5183", diff --git a/apps/edr-freight-web/backoffice/vite.config.ts b/apps/edr-freight-web/backoffice/vite.config.ts index 6e7326189..63735d72e 100644 --- a/apps/edr-freight-web/backoffice/vite.config.ts +++ b/apps/edr-freight-web/backoffice/vite.config.ts @@ -2,6 +2,7 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; import { createRequire } from "node:module"; +import { loadEnv } from "vite"; import { defineConfig } from "vitest/config"; import react from "@vitejs/plugin-react"; import tailwindcss from "@tailwindcss/vite"; @@ -10,7 +11,9 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url)); const require = createRequire(import.meta.url); const streamBrowserifyPath = require.resolve("stream-browserify"); -export default defineConfig(() => { +export default defineConfig(({ mode }) => { + const env = loadEnv(mode, __dirname, ""); + return { plugins: [react(), tailwindcss()], resolve: { @@ -31,7 +34,7 @@ export default defineConfig(() => { dedupe: ["react", "react-dom", "@mantine/core", "@mantine/hooks"], }, server: { - port: 5183, + port: Number(env.PORT) || 5283, host: "0.0.0.0", }, test: { diff --git a/apps/edr-freight-web/portal/package.json b/apps/edr-freight-web/portal/package.json index f3b493aca..4bb96ee4f 100644 --- a/apps/edr-freight-web/portal/package.json +++ b/apps/edr-freight-web/portal/package.json @@ -4,7 +4,7 @@ "private": true, "type": "module", "scripts": { - "dev": "vite --port 3000 --clearScreen false", + "dev": "vite --clearScreen false", "build": "tsc -b && vite build", "preview": "vite preview --port 5173", "lint": "eslint src", diff --git a/apps/edr-freight-web/portal/vite.config.ts b/apps/edr-freight-web/portal/vite.config.ts index 99e2a41d5..89483c7dd 100644 --- a/apps/edr-freight-web/portal/vite.config.ts +++ b/apps/edr-freight-web/portal/vite.config.ts @@ -1,6 +1,7 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; +import { loadEnv } from "vite"; import { defineConfig } from "vitest/config"; import react from "@vitejs/plugin-react"; import tailwindcss from "@tailwindcss/vite"; @@ -13,26 +14,30 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url)); const mantineCore = path.resolve(__dirname, "node_modules/@mantine/core"); const mantineHooks = path.resolve(__dirname, "node_modules/@mantine/hooks"); -export default defineConfig({ - plugins: [react(), tailwindcss()], - resolve: { - alias: { - "@": path.resolve(__dirname, "./src"), - // Resolve from TS source so Vite gets ESM named exports (dist is CommonJS). - "@edr/types": path.resolve(__dirname, "../../../packages/types/src/index.ts"), - "@mantine/core": mantineCore, - "@mantine/hooks": mantineHooks, +export default defineConfig(({ mode }) => { + const env = loadEnv(mode, __dirname, ""); + + return { + plugins: [react(), tailwindcss()], + resolve: { + alias: { + "@": path.resolve(__dirname, "./src"), + // Resolve from TS source so Vite gets ESM named exports (dist is CommonJS). + "@edr/types": path.resolve(__dirname, "../../../packages/types/src/index.ts"), + "@mantine/core": mantineCore, + "@mantine/hooks": mantineHooks, + }, + dedupe: ["react", "react-dom", "@mantine/core", "@mantine/hooks"], }, - dedupe: ["react", "react-dom", "@mantine/core", "@mantine/hooks"], - }, - optimizeDeps: { - include: ["@mantine/core", "@mantine/hooks", "@edr/ui-common"], - }, - server: { - port: 5173, - host: "0.0.0.0", - }, - test: { - environment: "node", - }, + optimizeDeps: { + include: ["@mantine/core", "@mantine/hooks", "@edr/ui-common"], + }, + server: { + port: Number(env.PORT) || 5273, + host: "0.0.0.0", + }, + test: { + environment: "node", + }, + }; }); From d5622ed36088c425293957c678e112090624ada9 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Wed, 5 Aug 2026 13:39:45 +0000 Subject: [PATCH 05/22] chore: install auditlog pkg --- apps/edr-freight-api/package.json | 1 + local-packages/tria-plc-auditlog-1.1.2.tgz | Bin 0 -> 25758 bytes pnpm-lock.yaml | 313 ++++++--------------- 3 files changed, 84 insertions(+), 230 deletions(-) create mode 100644 local-packages/tria-plc-auditlog-1.1.2.tgz diff --git a/apps/edr-freight-api/package.json b/apps/edr-freight-api/package.json index d10f19f10..c4837c8a7 100644 --- a/apps/edr-freight-api/package.json +++ b/apps/edr-freight-api/package.json @@ -59,6 +59,7 @@ "@nestjs/typeorm": "^11.0.1", "@nestjs/websockets": "^11.1.27", "@tria-plc/api-common": "file:../../local-packages/tria-plc-api-common-1.6.0.tgz", + "@tria-plc/auditlog": "file:../../local-packages/tria-plc-auditlog-1.1.2.tgz", "@tria-plc/iamapi-common": "file:../../local-packages/tria-plc-iamapi-common-1.0.0.tgz", "amqp-connection-manager": "^5.0.0", "amqplib": "^2.0.1", diff --git a/local-packages/tria-plc-auditlog-1.1.2.tgz b/local-packages/tria-plc-auditlog-1.1.2.tgz new file mode 100644 index 0000000000000000000000000000000000000000..3f59c4394bd25018dd39677e7cf87a0dba3a8dc4 GIT binary patch literal 25758 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'} + peerDependencies: + '@nestjs/common': ^10.0.0 || ^11.0.0 + '@nestjs/core': ^10.0.0 || ^11.0.0 + '@nestjs/microservices': ^10.0.0 || ^11.0.0 + '@nestjs/swagger': ^10.0.0 || ^11.0.0 + '@nestjs/typeorm': ^10.0.0 || ^11.0.0 + rxjs: ^7.0.0 + typeorm: ^0.3.0 + '@tria-plc/iamapi-common@file:local-packages/tria-plc-iamapi-common-1.0.0.tgz': resolution: {integrity: sha512-rfHSXOm/0VUMTj7HrvYysrEAqxItqfPs4Rd35qWJyaAk+snF1wTKFRVz6cRGPoQNj8fhplkVAefnvuKBuHT+xQ==, tarball: file:local-packages/tria-plc-iamapi-common-1.0.0.tgz} version: 1.0.0 @@ -12298,11 +12314,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(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) gensync: 1.0.0-beta.2 json5: 2.2.3 semver: 6.3.1 @@ -12337,7 +12353,7 @@ snapshots: '@babel/helper-optimise-call-expression': 7.29.7 '@babel/helper-replace-supers': 7.29.7(@babel/core@7.29.7) '@babel/helper-skip-transparent-expression-wrappers': 7.29.7 - '@babel/traverse': 7.29.7 + '@babel/traverse': 7.29.7(supports-color@5.5.0) semver: 6.3.1 transitivePeerDependencies: - supports-color @@ -12346,14 +12362,7 @@ snapshots: '@babel/helper-member-expression-to-functions@7.29.7': dependencies: - '@babel/traverse': 7.29.7 - '@babel/types': 7.29.7 - transitivePeerDependencies: - - supports-color - - '@babel/helper-module-imports@7.29.7': - dependencies: - '@babel/traverse': 7.29.7 + '@babel/traverse': 7.29.7(supports-color@5.5.0) '@babel/types': 7.29.7 transitivePeerDependencies: - supports-color @@ -12368,9 +12377,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 @@ -12385,13 +12394,13 @@ snapshots: '@babel/core': 7.29.7 '@babel/helper-member-expression-to-functions': 7.29.7 '@babel/helper-optimise-call-expression': 7.29.7 - '@babel/traverse': 7.29.7 + '@babel/traverse': 7.29.7(supports-color@5.5.0) transitivePeerDependencies: - supports-color '@babel/helper-skip-transparent-expression-wrappers@7.29.7': dependencies: - '@babel/traverse': 7.29.7 + '@babel/traverse': 7.29.7(supports-color@5.5.0) '@babel/types': 7.29.7 transitivePeerDependencies: - supports-color @@ -12544,18 +12553,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(supports-color@8.1.1) - transitivePeerDependencies: - - supports-color - '@babel/traverse@7.29.7(supports-color@5.5.0)': dependencies: '@babel/code-frame': 7.29.7 @@ -12781,7 +12778,7 @@ snapshots: '@emotion/babel-plugin@11.13.5': dependencies: - '@babel/helper-module-imports': 7.29.7 + '@babel/helper-module-imports': 7.29.7(supports-color@5.5.0) '@babel/runtime': 7.29.7 '@emotion/hash': 0.9.2 '@emotion/memoize': 0.9.0 @@ -12947,7 +12944,7 @@ snapshots: '@eslint/eslintrc@2.1.4': dependencies: ajv: 6.15.0 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) espree: 9.6.1 globals: 13.24.0 ignore: 5.3.2 @@ -13107,7 +13104,7 @@ snapshots: '@humanwhocodes/config-array@0.13.0': dependencies: '@humanwhocodes/object-schema': 2.0.3 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) minimatch: 3.1.5 transitivePeerDependencies: - supports-color @@ -14304,7 +14301,7 @@ snapshots: '@puppeteer/browsers@2.13.2': dependencies: - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) extract-zip: 2.0.1 progress: 2.0.3 proxy-agent: 6.5.0 @@ -16372,7 +16369,7 @@ snapshots: '@tokenizer/inflate@0.4.1': dependencies: - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) token-types: 6.1.2 transitivePeerDependencies: - supports-color @@ -16467,6 +16464,18 @@ snapshots: - debug - supports-color + '@tria-plc/auditlog@file:local-packages/tria-plc-auditlog-1.1.2.tgz(@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/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/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))))(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)))': + 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)(@nestjs/websockets@11.1.27)(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)(@nestjs/websockets@11.1.27)(amqp-connection-manager@5.0.0(amqplib@2.0.1))(amqplib@2.0.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@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/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))) + amqp-connection-manager: 5.0.0(amqplib@0.10.9) + amqplib: 0.10.9 + 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-1.0.0.tgz(cc085a020c559b355f168432c579a024)': 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) @@ -16659,130 +16668,6 @@ snapshots: - utf-8-validate - vite - '@tria-plc/iamui@file:local-packages/tria-plc-iamui-0.1.1.tgz(a5d0bdee55164ae56064fb273b530e00)': - dependencies: - '@emotion/react': 11.14.0(@types/react@18.3.31)(react@19.2.6) - '@emotion/styled': 11.14.1(@emotion/react@11.14.0(@types/react@18.3.31)(react@19.2.6))(@types/react@18.3.31)(react@19.2.6) - '@hookform/resolvers': 5.4.0(react-hook-form@7.77.0(react@19.2.6)) - '@lottiefiles/react-lottie-player': 3.6.0(react@19.2.6) - '@mantine/charts': 7.17.8(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(recharts@3.8.1(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6)(redux@5.0.1)) - '@mantine/core': 7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@mantine/dates': 7.17.8(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@mantine/hooks': 7.17.8(react@19.2.6) - '@mantine/notifications': 7.17.8(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-accordion': 1.2.13(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-alert-dialog': 1.1.16(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-avatar': 1.1.12(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-checkbox': 1.3.4(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-collapsible': 1.1.13(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-context-menu': 2.3.0(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-dialog': 1.1.16(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-dropdown-menu': 2.1.17(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-hover-card': 1.1.16(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-label': 2.1.9(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-navigation-menu': 1.2.15(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-popover': 1.1.16(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-progress': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-radio-group': 1.4.0(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-scroll-area': 1.2.11(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-select': 2.3.0(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-separator': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-slot': 1.2.5(@types/react@18.3.31)(react@19.2.6) - '@radix-ui/react-switch': 1.3.0(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-tabs': 1.1.14(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-toast': 1.2.16(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-tooltip': 1.2.9(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@react-pdf-viewer/core': 3.12.0(pdfjs-dist@5.4.296)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@react-pdf-viewer/default-layout': 3.12.0(pdfjs-dist@5.4.296)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@react-pdf/renderer': 4.5.1(react@19.2.6) - '@reduxjs/toolkit': 2.12.0(react-redux@9.3.0(@types/react@18.3.31)(react@19.2.6)(redux@5.0.1))(react@19.2.6) - '@tabler/icons-react': 3.44.0(react@19.2.6) - '@tailwindcss/vite': 4.3.0(vite@5.4.21(@types/node@24.13.1)(lightningcss@1.32.0)(terser@5.48.0)) - '@tanstack/react-query': 5.101.0(react@19.2.6) - '@tanstack/react-query-devtools': 5.101.0(@tanstack/react-query@5.101.0(react@19.2.6))(react@19.2.6) - '@tanstack/react-table': 8.21.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@tinymce/tinymce-react': 6.3.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(tinymce@7.9.3) - '@types/dompurify': 3.2.0 - '@types/node': 24.13.1 - '@types/tinymce': 4.6.9 - axios: 1.17.0 - class-variance-authority: 0.7.1 - clsx: 2.1.1 - cmdk: 1.1.1(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - date-fns: 3.6.0 - dayjs: 1.11.21 - dompurify: 3.4.8 - ethiopian-calendar-date-converter: 2.1.6 - ethiopian-calendar-new: 1.1.0 - file-type: 18.7.0 - framer-motion: 12.40.0(@emotion/is-prop-valid@1.4.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - html2canvas: 1.4.1 - i18next: 25.10.10(typescript@5.9.3) - i18next-browser-languagedetector: 8.2.1 - jquery: 3.7.1 - js-cookie: 3.0.8 - jspdf: 3.0.4 - lodash: 4.18.1 - lucide-react: 0.513.0(react@19.2.6) - mantine-react-table: 2.0.0-beta.9(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/dates@7.17.8(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(@tabler/icons-react@3.44.0(react@19.2.6))(clsx@2.1.1)(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - mui-ethiopian-datepicker: 0.3.2(4b3af212eafdf0059f009b005d7e343d) - next-themes: 0.4.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - path: 0.12.7 - pdf-lib: 1.17.1 - qs: 6.15.2 - react: 19.2.6 - react-cookie: 8.1.2(@types/react@18.3.31)(react@19.2.6) - react-css-nocode-editor: 1.0.13(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6) - react-day-picker: 8.10.2(date-fns@3.6.0)(react@19.2.6) - react-dom: 19.2.6(react@19.2.6) - react-dropzone: 14.4.1(react@19.2.6) - react-hook-form: 7.77.0(react@19.2.6) - react-i18next: 15.7.4(i18next@25.10.10(typescript@5.9.3))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.9.3) - react-icons: 5.6.0(react@19.2.6) - react-image-crop: 11.0.10(react@19.2.6) - react-intersection-observer: 9.16.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - react-pdf: 10.4.1(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - react-pdf-html: 2.1.5(@react-pdf/renderer@4.5.1(react@19.2.6))(react@19.2.6) - react-redux: 9.3.0(@types/react@18.3.31)(react@19.2.6)(redux@5.0.1) - react-resizable-panels: 3.0.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - react-router-dom: 7.17.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - react-signature-canvas: 1.1.0-alpha.2(@types/prop-types@15.7.15)(@types/react@18.3.31)(prop-types@15.8.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - recharts: 3.8.1(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6)(redux@5.0.1) - rollup-plugin-visualizer: 7.0.1(rollup@4.61.1) - socket.io-client: 4.8.3 - sonner: 2.0.7(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - tailwind-merge: 3.6.0 - tailwind-scrollbar-hide: 4.0.0(tailwindcss@4.3.0) - tailwindcss: 4.3.0 - tailwindcss-animate: 1.0.7(tailwindcss@4.3.0) - tinymce: 7.9.3 - url: 0.11.4 - vaul: 1.1.2(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - xlsx: 0.18.5 - zod: 3.25.76 - transitivePeerDependencies: - - '@babel/core' - - '@emotion/is-prop-valid' - - '@mui/icons-material' - - '@mui/material' - - '@mui/x-date-pickers' - - '@types/prop-types' - - '@types/react' - - '@types/react-dom' - - bufferutil - - debug - - pdfjs-dist - - prop-types - - react-is - - react-native - - redux - - rolldown - - rollup - - supports-color - - typescript - - utf-8-validate - - vite - '@ts-morph/common@0.27.0': dependencies: fast-glob: 3.3.3 @@ -17161,7 +17046,7 @@ snapshots: '@typescript-eslint/types': 8.60.1 '@typescript-eslint/typescript-estree': 8.60.1(typescript@5.9.3) '@typescript-eslint/visitor-keys': 8.60.1 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) eslint: 8.57.1 typescript: 5.9.3 transitivePeerDependencies: @@ -17171,7 +17056,7 @@ snapshots: dependencies: '@typescript-eslint/tsconfig-utils': 8.60.1(typescript@5.9.3) '@typescript-eslint/types': 8.60.1 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) typescript: 5.9.3 transitivePeerDependencies: - supports-color @@ -17190,7 +17075,7 @@ snapshots: '@typescript-eslint/types': 8.60.1 '@typescript-eslint/typescript-estree': 8.60.1(typescript@5.9.3) '@typescript-eslint/utils': 8.60.1(eslint@8.57.1)(typescript@5.9.3) - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) eslint: 8.57.1 ts-api-utils: 2.5.0(typescript@5.9.3) typescript: 5.9.3 @@ -17205,7 +17090,7 @@ snapshots: '@typescript-eslint/tsconfig-utils': 8.60.1(typescript@5.9.3) '@typescript-eslint/types': 8.60.1 '@typescript-eslint/visitor-keys': 8.60.1 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) minimatch: 10.2.5 semver: 7.8.2 tinyglobby: 0.2.17 @@ -17494,7 +17379,7 @@ snapshots: agent-base@6.0.2: dependencies: - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) transitivePeerDependencies: - supports-color @@ -17547,6 +17432,11 @@ snapshots: amqplib: 0.10.9 promise-breaker: 6.0.0 + amqp-connection-manager@5.0.0(amqplib@0.10.9): + dependencies: + amqplib: 0.10.9 + promise-breaker: 6.0.0 + amqp-connection-manager@5.0.0(amqplib@2.0.1): dependencies: amqplib: 2.0.1 @@ -18003,16 +17893,6 @@ snapshots: transitivePeerDependencies: - supports-color - babel-plugin-styled-components@2.3.0(styled-components@5.3.11(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6))(supports-color@5.5.0): - dependencies: - '@babel/helper-annotate-as-pure': 7.29.7 - '@babel/helper-module-imports': 7.29.7(supports-color@5.5.0) - '@babel/plugin-syntax-jsx': 7.29.7(@babel/core@7.29.7) - picomatch: 4.0.4 - styled-components: 5.3.11(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6) - transitivePeerDependencies: - - supports-color - babel-polyfill@6.26.0: dependencies: babel-runtime: 6.26.0 @@ -18166,7 +18046,7 @@ snapshots: dependencies: bytes: 3.1.2 content-type: 1.0.5 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) http-errors: 2.0.1 iconv-lite: 0.7.2 on-finished: 2.4.1 @@ -19163,7 +19043,7 @@ snapshots: engine.io-client@6.6.5: dependencies: '@socket.io/component-emitter': 3.1.2 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) engine.io-parser: 5.2.3 ws: 8.20.1 xmlhttprequest-ssl: 2.1.2 @@ -19183,7 +19063,7 @@ snapshots: base64id: 2.0.0 cookie: 0.7.2 cors: 2.8.6 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) engine.io-parser: 5.2.3 ws: 8.21.0 transitivePeerDependencies: @@ -19412,7 +19292,7 @@ snapshots: eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.60.1(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1): dependencies: '@nolyfill/is-core-module': 1.0.39 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) eslint: 8.57.1 get-tsconfig: 4.14.0 is-bun-module: 2.0.0 @@ -19540,7 +19420,7 @@ snapshots: ajv: 6.15.0 chalk: 4.1.2 cross-spawn: 7.0.6 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) doctrine: 3.0.0 escape-string-regexp: 4.0.0 eslint-scope: 7.2.2 @@ -19770,7 +19650,7 @@ snapshots: content-type: 1.0.5 cookie: 0.7.2 cookie-signature: 1.2.2 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) depd: 2.0.0 encodeurl: 2.0.0 escape-html: 1.0.3 @@ -19823,7 +19703,7 @@ snapshots: extract-zip@2.0.1: dependencies: - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) get-stream: 5.2.0 yauzl: 2.10.0 optionalDependencies: @@ -19974,7 +19854,7 @@ snapshots: finalhandler@2.1.1: dependencies: - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) encodeurl: 2.0.0 escape-html: 1.0.3 on-finished: 2.4.1 @@ -20220,7 +20100,7 @@ snapshots: dependencies: basic-ftp: 5.3.1 data-uri-to-buffer: 6.0.2 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) transitivePeerDependencies: - supports-color @@ -20501,7 +20381,7 @@ snapshots: http-proxy-agent@7.0.2: dependencies: agent-base: 7.1.4 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) transitivePeerDependencies: - supports-color @@ -20514,14 +20394,14 @@ snapshots: https-proxy-agent@5.0.1: dependencies: agent-base: 6.0.2 - debug: 4.4.3(supports-color@8.1.1) + 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(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) transitivePeerDependencies: - supports-color @@ -20946,7 +20826,7 @@ snapshots: istanbul-lib-source-maps@4.0.1: dependencies: - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) istanbul-lib-coverage: 3.2.2 source-map: 0.6.1 transitivePeerDependencies: @@ -21592,7 +21472,7 @@ snapshots: dependencies: chalk: 5.6.2 commander: 13.1.0 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) execa: 8.0.1 lilconfig: 3.1.3 listr2: 8.3.3 @@ -22389,7 +22269,7 @@ snapshots: dependencies: '@tootallnate/quickjs-emscripten': 0.23.0 agent-base: 7.1.4 - debug: 4.4.3(supports-color@8.1.1) + 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 @@ -22727,7 +22607,7 @@ snapshots: proxy-agent@6.5.0: dependencies: agent-base: 7.1.4 - debug: 4.4.3(supports-color@8.1.1) + 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 @@ -22756,7 +22636,7 @@ snapshots: dependencies: '@puppeteer/browsers': 2.13.2 chromium-bidi: 14.0.0(devtools-protocol@0.0.1608973) - debug: 4.4.3(supports-color@8.1.1) + 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 @@ -23009,15 +22889,6 @@ snapshots: - '@babel/core' - react-is - react-css-nocode-editor@1.0.13(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6): - dependencies: - react: 19.2.6 - react-dom: 19.2.6(react@19.2.6) - styled-components: 5.3.11(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6) - transitivePeerDependencies: - - '@babel/core' - - react-is - react-day-picker@8.10.2(date-fns@3.6.0)(react@19.2.6): dependencies: date-fns: 3.6.0 @@ -23591,7 +23462,7 @@ snapshots: router@2.2.0: dependencies: - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) depd: 2.0.0 is-promise: 4.0.0 parseurl: 1.3.3 @@ -23709,7 +23580,7 @@ snapshots: send@1.2.1: dependencies: - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) encodeurl: 2.0.0 escape-html: 1.0.3 etag: 1.8.1 @@ -23925,7 +23796,7 @@ snapshots: socket.io-adapter@2.5.8: dependencies: - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) ws: 8.21.0 transitivePeerDependencies: - bufferutil @@ -23935,7 +23806,7 @@ snapshots: socket.io-client@4.8.3: dependencies: '@socket.io/component-emitter': 3.1.2 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) engine.io-client: 6.6.5 socket.io-parser: 4.2.6 transitivePeerDependencies: @@ -23946,7 +23817,7 @@ snapshots: socket.io-parser@4.2.6: dependencies: '@socket.io/component-emitter': 3.1.2 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) transitivePeerDependencies: - supports-color @@ -23955,7 +23826,7 @@ snapshots: accepts: 1.3.8 base64id: 2.0.0 cors: 2.8.6 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) engine.io: 6.6.9 socket.io-adapter: 2.5.8 socket.io-parser: 4.2.6 @@ -23967,7 +23838,7 @@ snapshots: socks-proxy-agent@8.0.5: dependencies: agent-base: 7.1.4 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) socks: 2.8.9 transitivePeerDependencies: - supports-color @@ -24245,24 +24116,6 @@ snapshots: transitivePeerDependencies: - '@babel/core' - styled-components@5.3.11(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6): - dependencies: - '@babel/helper-module-imports': 7.29.7(supports-color@5.5.0) - '@babel/traverse': 7.29.7(supports-color@5.5.0) - '@emotion/is-prop-valid': 1.4.0 - '@emotion/stylis': 0.8.5 - '@emotion/unitless': 0.7.5 - babel-plugin-styled-components: 2.3.0(styled-components@5.3.11(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6))(supports-color@5.5.0) - css-to-react-native: 3.2.0 - hoist-non-react-statics: 3.3.2 - react: 19.2.6 - react-dom: 19.2.6(react@19.2.6) - react-is: 19.2.7 - shallowequal: 1.1.0 - supports-color: 5.5.0 - transitivePeerDependencies: - - '@babel/core' - styled-jsx@5.1.1(babel-plugin-macros@3.1.0)(react@18.3.1): dependencies: client-only: 0.0.1 @@ -24288,7 +24141,7 @@ snapshots: dependencies: component-emitter: 1.3.1 cookiejar: 2.1.4 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) fast-safe-stringify: 2.1.1 form-data: 4.0.5 formidable: 3.5.4 @@ -24797,7 +24650,7 @@ snapshots: app-root-path: 3.1.0 buffer: 6.0.3 dayjs: 1.11.21 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) dedent: 1.7.2(babel-plugin-macros@3.1.0) dotenv: 16.6.1 glob: 10.5.0 @@ -24821,7 +24674,7 @@ snapshots: app-root-path: 3.1.0 buffer: 6.0.3 dayjs: 1.11.21 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) dedent: 1.7.2(babel-plugin-macros@3.1.0) dotenv: 16.6.1 glob: 10.5.0 @@ -25124,7 +24977,7 @@ snapshots: vite-node@2.1.9(@types/node@22.20.1)(lightningcss@1.32.0)(terser@5.48.0): dependencies: cac: 6.7.14 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) es-module-lexer: 1.7.0 pathe: 1.1.2 vite: 5.4.21(@types/node@22.20.1)(lightningcss@1.32.0)(terser@5.48.0) @@ -25142,7 +24995,7 @@ snapshots: vite-node@2.1.9(@types/node@24.13.1)(lightningcss@1.32.0)(terser@5.48.0): dependencies: cac: 6.7.14 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) es-module-lexer: 1.7.0 pathe: 1.1.2 vite: 5.4.21(@types/node@24.13.1)(lightningcss@1.32.0)(terser@5.48.0) @@ -25189,7 +25042,7 @@ snapshots: '@vitest/spy': 2.1.9 '@vitest/utils': 2.1.9 chai: 5.3.3 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) expect-type: 1.3.0 magic-string: 0.30.21 pathe: 1.1.2 @@ -25225,7 +25078,7 @@ snapshots: '@vitest/spy': 2.1.9 '@vitest/utils': 2.1.9 chai: 5.3.3 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) expect-type: 1.3.0 magic-string: 0.30.21 pathe: 1.1.2 From 595c165820c8468eee78dbaede766eba4f3b2de2 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Wed, 5 Aug 2026 14:17:00 +0000 Subject: [PATCH 06/22] feat: implement audit logs --- apps/edr-freight-api/.env.example | 12 ++ apps/edr-freight-api/src/app.module.ts | 20 ++ .../src/config/database.config.ts | 11 +- apps/edr-freight-api/src/main.ts | 8 + .../src/modules/audit/audit.controller.ts | 32 +++ .../src/modules/audit/audit.module.ts | 13 ++ .../src/modules/audit/audit.service.ts | 59 ++++++ .../src/modules/billing/billing.service.ts | 12 ++ .../src/seed/freight-permissions.registry.ts | 4 + apps/edr-freight-web/backoffice/src/App.tsx | 16 ++ .../src/components/layout/route-meta.ts | 7 + .../backoffice/src/constants/URLS.ts | 4 + .../backoffice/src/lib/permissions.ts | 3 + .../src/pages/audit/AuditLogsPage.tsx | 185 ++++++++++++++++++ .../backoffice/src/services/api.ts | 14 ++ .../backoffice/src/services/audit.service.ts | 61 ++++++ .../intents/intents.service.cbe-bill.spec.ts | 48 ++++- .../src/modules/intents/intents.service.ts | 26 +++ 18 files changed, 533 insertions(+), 2 deletions(-) create mode 100644 apps/edr-freight-api/src/modules/audit/audit.controller.ts create mode 100644 apps/edr-freight-api/src/modules/audit/audit.module.ts create mode 100644 apps/edr-freight-api/src/modules/audit/audit.service.ts create mode 100644 apps/edr-freight-web/backoffice/src/pages/audit/AuditLogsPage.tsx create mode 100644 apps/edr-freight-web/backoffice/src/services/audit.service.ts diff --git a/apps/edr-freight-api/.env.example b/apps/edr-freight-api/.env.example index c579eb388..16ee9cd57 100644 --- a/apps/edr-freight-api/.env.example +++ b/apps/edr-freight-api/.env.example @@ -1,5 +1,17 @@ # Copy to .env for local/docker compose (not committed). PORT=3001 +# @tria-plc/auditlog's client interceptor stamps every AuditLog row's +# `application` from this env var directly, bypassing MezgebModule.forRoot's +# applicationName option (package quirk). audit.controller.ts reads the same +# var when filtering reads, so this can be anything as long as it's set. +APPLICATION_NAME=freight-api +# Also required for @tria-plc/auditlog: its producer (AuditClientModule) +# reads the RMQ URL at package IMPORT time, before MezgebModule.forRoot's +# rmqUrl option ever runs, so only an env var reaches it — an in-code +# override is too late. Without this, audit events are silently dropped +# (no error, nothing published). Point it at whatever broker/vhost your +# RabbitMQ actually has a user provisioned on. +RABBITMQ_URL=amqp://localhost:5672 # GT06 GPS tracker TCP listener port (raw TCP, must be reachable by tracker SIMs). 0 disables. GT06_TCP_PORT=5023 DB_HOST=localhost diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index 73e19709e..adb92c3b0 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -16,6 +16,7 @@ import { import { IamBaselineSeeder, IamSeedModule } from "@edr/iam-seed"; import { IamModule } from "@tria-plc/iamapi-common"; import { SharedAuthModule } from "@tria-plc/api-common/modules/auth/shared-auth.module"; +import { MezgebModule } from "@tria-plc/auditlog"; import appConfig from "./config/app.config"; import databaseConfig from "./config/database.config"; @@ -105,9 +106,14 @@ 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 { AiModule } from "./modules/ai/ai.module"; +import { AuditModule } from "./modules/audit/audit.module"; import { LoggerMiddleware } from "./logger.middleware"; import { LoginAudienceMiddleware } from "./modules/auth/login-audience.middleware"; +if (!process.env.APPLICATION_NAME) { + process.env.APPLICATION_NAME = "freight"; +} + @Module({ imports: [ ConfigModule.forRoot({ @@ -155,6 +161,19 @@ import { LoginAudienceMiddleware } from "./modules/auth/login-audience.middlewar return dataSource; }, }), + // Request + entity-level audit logging over RabbitMQ (@tria-plc/auditlog). + // Must come after TypeOrmModule above so it picks up this app's DataSource. + // rmqUrl falls back the same way notifications.module.ts's RABBITMQ_URL + // does: the dev broker only provisions the `edr` user on the `payment` + // vhost (docker-compose's RABBITMQ_DEFAULT_USER/VHOST), so an unset + // RABBITMQ_URL must land there too, not on guest@'/' (403 ACCESS_REFUSED). + MezgebModule.forRoot({ + applicationName: "freight-api", + rmqUrl: + process.env.RABBITMQ_URL ?? + process.env.PAYMENT_RABBITMQ_URL ?? + "amqp://localhost:5672", + }), SharedAuthModule, IamModule.forRoot({ applications: [EDR_FREIGHT_APPLICATION], @@ -227,6 +246,7 @@ import { LoginAudienceMiddleware } from "./modules/auth/login-audience.middlewar VerifaydaModule, FleetHistoryModule, AiModule, + AuditModule, ], providers: [ EdrOrgSeeder, diff --git a/apps/edr-freight-api/src/config/database.config.ts b/apps/edr-freight-api/src/config/database.config.ts index f699b193d..5de529d15 100644 --- a/apps/edr-freight-api/src/config/database.config.ts +++ b/apps/edr-freight-api/src/config/database.config.ts @@ -56,6 +56,11 @@ import { EmployeePositionActivePeriod } from "@tria-plc/iamapi-common/entities/i import { UnitConfiguration } from "@tria-plc/iamapi-common/entities/iam/organization-structure/unit-configuration.entity"; import { Site } from "@tria-plc/iamapi-common/entities/iam/site/site.entity"; import { SiteSetting } from "@tria-plc/iamapi-common/entities/iam/site/site-setting.entity"; +import { AuditLog, AuditLogCommand } from "@tria-plc/auditlog"; + +// @tria-plc/auditlog's entities live in node_modules, same as the iam ones — +// the glob below only matches this app's own src/**/*.entity.ts. +const auditEntities = [AuditLog, AuditLogCommand]; const iamEntities = [ UnitSetting, @@ -177,7 +182,11 @@ export function buildDataSourceOptions(): DataSourceOptions { return { ...buildConnectionOptions(), schema: "public", - entities: [__dirname + "/../**/*.entity.{ts,js}", ...iamEntities], + entities: [ + __dirname + "/../**/*.entity.{ts,js}", + ...iamEntities, + ...auditEntities, + ], migrations: [], }; } diff --git a/apps/edr-freight-api/src/main.ts b/apps/edr-freight-api/src/main.ts index a4fbefdd2..c9a718f5d 100644 --- a/apps/edr-freight-api/src/main.ts +++ b/apps/edr-freight-api/src/main.ts @@ -10,6 +10,7 @@ import { ResponseTransformInterceptor, createValidationPipe, } from "@edr/api-common"; +import { getAuditLoggerConfig } from "@tria-plc/auditlog"; import { AppModule } from "./app.module"; @@ -160,6 +161,13 @@ export async function createFreightApp(): Promise { app.useGlobalFilters(new HttpExceptionFilter()); app.useGlobalInterceptors(new ResponseTransformInterceptor()); + // Audit listener: consumes the RMQ events MezgebModule's client interceptor + // (app.module.ts) emits and persists them via the AuditLogController / + // AuditLogCommandController @EventPattern handlers. Same queue config the + // client side uses, reused from the package so the two never drift apart. + app.connectMicroservice(getAuditLoggerConfig()); + await app.startAllMicroservices(); + const config = new DocumentBuilder() .setTitle("EDR Freight API") .setDescription("API for the EDR Freight Management application") diff --git a/apps/edr-freight-api/src/modules/audit/audit.controller.ts b/apps/edr-freight-api/src/modules/audit/audit.controller.ts new file mode 100644 index 000000000..a7c8782b2 --- /dev/null +++ b/apps/edr-freight-api/src/modules/audit/audit.controller.ts @@ -0,0 +1,32 @@ +import { Controller, Get, Query } from "@nestjs/common"; +import { ApiOperation, ApiQuery, ApiTags } from "@nestjs/swagger"; + +import { BookingStaff } from "../../common/booking-guards"; +import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry"; +import { AuditService } from "./audit.service"; + +@ApiTags("audit") +@Controller("audit") +@BookingStaff(FREIGHT_PERMS.audit.view) +export class AuditController { + constructor(private readonly auditService: AuditService) {} + + @Get("logs") + @ApiOperation({ summary: "List freight-api audit log commands" }) + @ApiQuery({ name: "skip", type: Number, required: false }) + @ApiQuery({ name: "take", type: Number, required: false }) + list(@Query("skip") skip?: string, @Query("take") take?: string) { + // Same fallback chain @tria-plc/auditlog's client interceptor uses to + // stamp AuditLog.application (mezgeb/client/client-audit.interceptor.js) + // — reading it here instead of a hardcoded literal means this can't + // silently drift out of sync with whatever APPLICATION_NAME/APP_NAME + // actually is at runtime. + const application = + process.env.APPLICATION_NAME ?? process.env.APP_NAME ?? "DEFAULT"; + return this.auditService.list( + application, + skip !== undefined ? parseInt(skip, 10) : undefined, + take !== undefined ? parseInt(take, 10) : undefined, + ); + } +} diff --git a/apps/edr-freight-api/src/modules/audit/audit.module.ts b/apps/edr-freight-api/src/modules/audit/audit.module.ts new file mode 100644 index 000000000..635973fc6 --- /dev/null +++ b/apps/edr-freight-api/src/modules/audit/audit.module.ts @@ -0,0 +1,13 @@ +import { Module } from "@nestjs/common"; +import { TypeOrmModule } from "@nestjs/typeorm"; +import { AuditLogCommand } from "@tria-plc/auditlog"; + +import { AuditController } from "./audit.controller"; +import { AuditService } from "./audit.service"; + +@Module({ + imports: [TypeOrmModule.forFeature([AuditLogCommand])], + controllers: [AuditController], + providers: [AuditService], +}) +export class AuditModule {} diff --git a/apps/edr-freight-api/src/modules/audit/audit.service.ts b/apps/edr-freight-api/src/modules/audit/audit.service.ts new file mode 100644 index 000000000..8bc792591 --- /dev/null +++ b/apps/edr-freight-api/src/modules/audit/audit.service.ts @@ -0,0 +1,59 @@ +import { Injectable } from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { Repository } from "typeorm"; +import { AuditLogCommand } from "@tria-plc/auditlog"; + +export interface AuditLogListResult { + count: number; + items: AuditLogCommand[]; +} + +/** + * Own read path onto @tria-plc/auditlog's tables, gated by AuditController's + * @BookingStaff — the package's own AuditLogCommandController (mounted at + * /api/audit-log-commands) ships with no guards at all, so it can't be used + * directly for a permission-gated UI. Query mirrors the package's + * AuditLogCommandService.buildAuditLogQuery/getAllAuditLogs exactly. + */ +@Injectable() +export class AuditService { + constructor( + @InjectRepository(AuditLogCommand) + private readonly auditLogCommandRepository: Repository, + ) {} + + async list( + application: string, + skip = 0, + take = 10, + ): Promise { + const [items, count] = await this.auditLogCommandRepository + .createQueryBuilder("audit_log_commands") + .leftJoinAndSelect("audit_log_commands.auditLog", "auditLog") + .andWhere( + "(audit_log_commands.auditLogId IS NULL OR auditLog.application = :application)", + { application }, + ) + .andWhere( + "(audit_log_commands.auditLogId IS NULL OR auditLog.status = :status)", + { status: "Commit" }, + ) + .select([ + "audit_log_commands.id", + "audit_log_commands.createdAt", + "audit_log_commands.deletedAt", + "audit_log_commands.entityName", + "audit_log_commands.queryMethod", + "audit_log_commands.changes", + "audit_log_commands.payload", + "auditLog.id", + "auditLog.user", + ]) + .addOrderBy("audit_log_commands.createdAt", "DESC") + .skip(skip) + .take(take) + .getManyAndCount(); + + return { count, items }; + } +} 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 60f173596..4cd7249aa 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.ts @@ -10,6 +10,7 @@ import { import { EventEmitter2 } from "@nestjs/event-emitter"; import { DataSource, EntityManager, In } from "typeorm"; +import { Booking } from "../bookings/entities/booking.entity"; import { CompaniesService } from "../companies/companies.service"; import { applyBookingRefDirectionScope } from "../user-trade-access/trade-scope.util"; import { PaymentService } from "../payment/payment.service"; @@ -1192,6 +1193,17 @@ export class BillingService { .getRepository(Invoice) .update({ id: invoice.id }, { paymentId: result.intentId }); + // CBE_BILL: the bill reference IS the booking's PNR — the number the customer pays against + // at any CBE channel. Persist it on the booking so it survives the initiate response and + // shows on the booking/contract everywhere. The payment service reissues the same reference + // while the bill stays open, so re-initiating overwrites with an identical value. + const billReference = result.response.clientAction?.billReference; + if (billReference && invoice.source === Freight.InvoiceSource.Booking) { + await this.dataSource + .getRepository(Booking) + .update({ id: invoice.sourceId }, { pnrCode: billReference }); + } + // Settlement is driven by the payment API (webhook/outbox → payment.succeeded); // billing must not simulate it. Kept for local demos only. // An OTP intent (CAC Bank) is NOT paid yet — the payer still has to enter the diff --git a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts index c30a2ebda..0c44e704d 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts @@ -366,6 +366,7 @@ export const CONFIG_SETTINGS_PERMISSIONS: FreightPermissionSeed[] = [ perm('b4a00001-0001-4000-8000-000000000002', 'edr_freight_app:settings:file_upload:manage', 'Manage file-upload settings'), perm('b4b00001-0001-4000-8000-000000000001', 'edr_freight_app:settings:dropdown:view', 'View dropdown settings'), perm('b4b00001-0001-4000-8000-000000000002', 'edr_freight_app:settings:dropdown:manage', 'Manage dropdown settings'), + perm('b4c00001-0001-4000-8000-000000000001', 'edr_freight_app:audit:view', 'View audit logs'), ]; // M. Staff / IAM admin — NEW keys only. The employee_registration / role_assignment @@ -700,6 +701,9 @@ export const FREIGHT_PERMS = { manage: 'edr_freight_app:settings:dropdown:manage', }, }, + audit: { + view: 'edr_freight_app:audit:view', + }, staff: { roles: { view: 'edr_freight_app:staff:roles:view', diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index 6e0b5759f..462414480 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -7,6 +7,7 @@ import { FileSignature, FileText, Hammer, + History, LayoutDashboard, LayoutGrid, MapPin, @@ -76,6 +77,7 @@ import ReportsHubPage from "./pages/reports/ReportsHubPage"; import ReportPage from "./pages/reports/ReportPage"; import AiBookingMockTestPage from "./pages/ai/AiBookingMockTestPage"; import PaymentsPage from "./pages/payments/PaymentsPage"; +import AuditLogsPage from "./pages/audit/AuditLogsPage"; //import EmployeesPage from "./pages/dashboard/user-management/EmployeesPage"; import { RequirePermission } from "./components/auth/RequirePermission"; import { @@ -582,6 +584,12 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ icon: , permission: FREIGHT_PERMS.admin, }, + { + label: "Audit logs", + href: "/dashboard/audit-logs", + icon: , + permission: FREIGHT_PERMS.audit.view, + }, { label: "Configuration", href: "/dashboard/configuration", @@ -1595,6 +1603,14 @@ const App = () => { } /> + + + + } + /> = [ subtitle: "Manage dropdown options used across the platform", }, }, + { + prefix: "/dashboard/audit-logs", + meta: { + title: "Audit Logs", + subtitle: "Request and entity-level activity recorded across the freight API", + }, + }, { prefix: "/dashboard/configuration/contract-validity-periods", meta: { diff --git a/apps/edr-freight-web/backoffice/src/constants/URLS.ts b/apps/edr-freight-web/backoffice/src/constants/URLS.ts index d8993df04..dbeb641cf 100644 --- a/apps/edr-freight-web/backoffice/src/constants/URLS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/URLS.ts @@ -309,6 +309,10 @@ export const URL_CONSTANTS = { SUMMARY: "/payments/summary", }, + AUDIT: { + LOGS: "/audit/logs", + }, + LOCOMOTIVES: { BASE: "/locomotives", BY_ID: (id: string) => `/locomotives/${id}`, diff --git a/apps/edr-freight-web/backoffice/src/lib/permissions.ts b/apps/edr-freight-web/backoffice/src/lib/permissions.ts index bc70c95a2..9596dc4de 100644 --- a/apps/edr-freight-web/backoffice/src/lib/permissions.ts +++ b/apps/edr-freight-web/backoffice/src/lib/permissions.ts @@ -276,6 +276,9 @@ export const FREIGHT_PERMS = { manage: "edr_freight_app:settings:dropdown:manage", }, }, + audit: { + view: "edr_freight_app:audit:view", + }, staff: { roles: { view: "edr_freight_app:staff:roles:view", diff --git a/apps/edr-freight-web/backoffice/src/pages/audit/AuditLogsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/audit/AuditLogsPage.tsx new file mode 100644 index 000000000..d4451c2f2 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/audit/AuditLogsPage.tsx @@ -0,0 +1,185 @@ +import { Badge, Box, Card, Stack, Text } from "@mantine/core"; +import { useQuery } from "@tanstack/react-query"; + +import { PageContainer, PageHeader } from "@/components/page"; +import { api } from "@/services/api"; +import type { + AuditLogRow, + AuditQueryMethod, + AuditUser, +} from "@/services/audit.service"; +import { + DataTable, + DataTableFooter, + usePagination, + type ColumnDef, +} from "@edr/ui-common"; + +const ACTION_LABELS: Record = { + INSERT: "Created", + UPDATE: "Updated", + DELETE: "Deleted", + INSERT_CHILD: "Linked child", + DELETE_CHILD: "Unlinked child", +}; + +const ACTION_COLORS: Record = { + INSERT: "edr-green", + UPDATE: "yellow", + DELETE: "red", + INSERT_CHILD: "indigo", + DELETE_CHILD: "gray", +}; + +function formatDateTime(iso: string): string { + const d = new Date(iso); + return Number.isNaN(d.getTime()) + ? "—" + : d.toLocaleString(undefined, { + year: "numeric", + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }); +} + +// The producer (@tria-plc/auditlog's ClientLoggerInterceptor) builds +// `name` from `${auditUser?.firstName} ${auditUser?.lastName}` — this app's +// user model only has a single `name` field, and unauthenticated/customer +// flows (e.g. Fayda verification) have no auditUser at all, so this literal +// "undefined undefined" ends up stored as-is. Filter it back out on render +// rather than showing raw garbage. +function formatUser(user: AuditUser | null | undefined): string { + const name = user?.name; + if (typeof name === "string" && /^undefined(\s+undefined)?$/.test(name.trim())) { + return "—"; + } + return name ?? user?.id ?? "—"; +} + +function summarize(row: AuditLogRow): string { + if (row.changes?.length) { + return row.changes + .slice(0, 2) + .map((c) => c.field) + .join(", ") + (row.changes.length > 2 ? `, +${row.changes.length - 2} more` : ""); + } + if (row.payload) { + return row.payload.name ?? row.payload.title ?? row.payload.id ?? "—"; + } + return "—"; +} + +const tableHeader = + "text-xs font-semibold uppercase tracking-wide text-muted-foreground"; + +export default function AuditLogsPage() { + const { pagination, setPagination } = usePagination({ pageSize: 20 }); + + const filter = { + skip: pagination.pageIndex * pagination.pageSize, + take: pagination.pageSize, + }; + + const { data, isLoading, isError } = useQuery( + api.audit.list.queryOptions({ input: { filter } }), + ); + + const rows = data?.items ?? []; + const total = data?.count ?? 0; + const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize)); + + const columns: ColumnDef[] = [ + { + id: "time", + header: () => Time, + cell: ({ row }) => ( + + {formatDateTime(row.original.createdAt)} + + ), + }, + { + id: "action", + header: () => Action, + cell: ({ row }) => ( + + {ACTION_LABELS[row.original.queryMethod] ?? row.original.queryMethod} + + ), + }, + { + id: "entity", + header: () => Entity, + cell: ({ row }) => ( + + {row.original.entityName} + + ), + }, + { + id: "user", + header: () => User, + cell: ({ row }) => ( + + {formatUser(row.original.auditLog?.user)} + + ), + }, + { + id: "summary", + header: () => Summary, + cell: ({ row }) => ( + + {summarize(row.original)} + + ), + }, + ]; + + return ( + + + + + + + + {total} record{total !== 1 ? "s" : ""} + + + + + + + + + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/services/api.ts b/apps/edr-freight-web/backoffice/src/services/api.ts index a2d5f1b76..2aa864610 100644 --- a/apps/edr-freight-web/backoffice/src/services/api.ts +++ b/apps/edr-freight-web/backoffice/src/services/api.ts @@ -163,6 +163,11 @@ import { type SaveLocomotivePayload, } from "./locomotives.service"; import { overviewService } from "./overview.service"; +import { + auditService, + type AuditLogListFilter, + type PaginatedAuditLogs, +} from "./audit.service"; import { reportsService } from "./reports.service"; import type { ReportQueryInput, ReportResult } from "@/types/reports"; import { @@ -2136,6 +2141,15 @@ export const api = { ), }, + audit: { + list: endpoint<{ filter?: AuditLogListFilter }, PaginatedAuditLogs>( + "audit", + "list", + ({ filter }) => auditService.list(filter), + ({ filter }) => ["audit", "list", filter ?? {}], + ), + }, + signatures: { mySignature: endpoint( "me", diff --git a/apps/edr-freight-web/backoffice/src/services/audit.service.ts b/apps/edr-freight-web/backoffice/src/services/audit.service.ts new file mode 100644 index 000000000..498d1c77c --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/services/audit.service.ts @@ -0,0 +1,61 @@ +import { api as client } from "../auth/http"; +import { unwrap } from "@/utils/endpoint"; +import { URL_CONSTANTS } from "@/constants/URLS"; + +const A = URL_CONSTANTS.AUDIT; + +// Shape from @tria-plc/auditlog's AuditLogCommandController — see +// local-packages/FRONTEND_GUIDE.md. +export type AuditQueryMethod = + | "INSERT" + | "UPDATE" + | "DELETE" + | "INSERT_CHILD" + | "DELETE_CHILD"; + +export interface AuditFieldChange { + field: string; + from: unknown; + to: unknown; +} + +export interface AuditUser { + id?: string; + name?: string; + organizationId?: string; + organizationName?: string; + [key: string]: unknown; +} + +export interface AuditLogRow { + id?: string; + createdAt: string; + deletedAt?: string | null; + entityName: string; + queryMethod: AuditQueryMethod; + changes?: AuditFieldChange[] | null; + payload?: { name?: string; title?: string; id?: string } | null; + auditLog?: { id?: string; user?: AuditUser | null }; +} + +export interface AuditLogListFilter { + skip?: number; + take?: number; +} + +export interface PaginatedAuditLogs { + items: AuditLogRow[]; + count: number; +} + +export const auditService = { + list: async (filter?: AuditLogListFilter): Promise => { + const params: Record = { + skip: filter?.skip, + take: filter?.take, + }; + const response = await client.get(A.LOGS, { params }); + const data = unwrap(response.data) as PaginatedAuditLogs; + return { items: data.items ?? [], count: data.count ?? 0 }; + }, +}; diff --git a/apps/edr-payment-api/src/modules/intents/intents.service.cbe-bill.spec.ts b/apps/edr-payment-api/src/modules/intents/intents.service.cbe-bill.spec.ts index 715184424..acbfef4ee 100644 --- a/apps/edr-payment-api/src/modules/intents/intents.service.cbe-bill.spec.ts +++ b/apps/edr-payment-api/src/modules/intents/intents.service.cbe-bill.spec.ts @@ -24,7 +24,11 @@ describe("IntentsService CBE_BILL", () => { let repository: jest.Mocked< Pick< IntentsRepository, - "create" | "findById" | "findByIdempotencyKey" | "update" + | "create" + | "findById" + | "findByIdempotencyKey" + | "findAllByReference" + | "update" > >; let billReferenceService: { generate: jest.Mock }; @@ -46,6 +50,7 @@ describe("IntentsService CBE_BILL", () => { create: jest.fn(async (data) => ({ id: "intent-1", ...data })), findById: jest.fn(), findByIdempotencyKey: jest.fn().mockResolvedValue(null), + findAllByReference: jest.fn().mockResolvedValue([]), update: jest.fn(), } as never; billReferenceService = { @@ -79,6 +84,47 @@ describe("IntentsService CBE_BILL", () => { ); }); + it("reuses the open bill instead of minting a second reference", async () => { + repository.findAllByReference.mockResolvedValue([ + { + id: "intent-1", + provider: ProviderMethod.CBE_BILL, + status: ProviderPaymentStatus.REQUIRES_ACTION, + billReference: "000100000015", + amountMinor: 1500, + currency: "ETB", + clientAction: { + type: "SHOW_BILL_REFERENCE", + billReference: "000100000015", + }, + }, + ] as never); + + const snapshot = await service.initiate(request); + + expect(snapshot.billReference).toBe("000100000015"); + expect(billReferenceService.generate).not.toHaveBeenCalled(); + expect(repository.create).not.toHaveBeenCalled(); + }); + + it("mints a new bill when the amount changed", async () => { + repository.findAllByReference.mockResolvedValue([ + { + id: "intent-1", + provider: ProviderMethod.CBE_BILL, + status: ProviderPaymentStatus.REQUIRES_ACTION, + billReference: "000100000015", + amountMinor: 900, + currency: "ETB", + }, + ] as never); + billReferenceService.generate.mockResolvedValue("000100000023"); + + const snapshot = await service.initiate(request); + + expect(snapshot.billReference).toBe("000100000023"); + }); + it("rejects non-ETB currency (plan D8)", async () => { await expect( service.initiate({ ...request, currency: "DJF" }), diff --git a/apps/edr-payment-api/src/modules/intents/intents.service.ts b/apps/edr-payment-api/src/modules/intents/intents.service.ts index c0b246b83..0f5b41f4e 100644 --- a/apps/edr-payment-api/src/modules/intents/intents.service.ts +++ b/apps/edr-payment-api/src/modules/intents/intents.service.ts @@ -163,6 +163,32 @@ export class IntentsService { ); } + // The bill reference is issued ONCE per order: the domain app persists it (freight stores it + // as the booking's PNR) and the payer may already have written it down, so re-initiating the + // same open bill must hand back the same number. A different amount/currency means a + // different debt — /cbe/payment verifies the debited amount against the intent — so that + // case mints a fresh bill instead of silently repricing an outstanding one. + const open = ( + await this.intentsRepository.findAllByReference( + request.service, + request.referenceType, + request.referenceId, + ) + ).find( + (i) => + i.provider === ProviderMethod.CBE_BILL && + i.status === ProviderPaymentStatus.REQUIRES_ACTION && + !!i.billReference && + i.amountMinor === request.amountMinor && + i.currency === request.currency, + ); + if (open) { + this.logger.log( + `intent ${open.id} reused for ${request.service}/${request.referenceType}/${request.referenceId} via CBE_BILL (bill ${open.billReference})`, + ); + return this.toSnapshot(open); + } + const merchantOrderId = createMerchantOrderId(); const billReference = await this.billReferenceService.generate(); // expiresAt is the BOOKING's payment deadline passed by the domain app — never a provider From 2133d6a574e48e817c63686a9827d386a70cf645 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Wed, 5 Aug 2026 14:23:00 +0000 Subject: [PATCH 07/22] fix: fitler out the client side request --- .../src/modules/audit/audit.service.ts | 11 +++++++ .../src/pages/audit/AuditLogsPage.tsx | 29 +++++++++++-------- .../backoffice/src/services/audit.service.ts | 13 +++++++-- 3 files changed, 39 insertions(+), 14 deletions(-) diff --git a/apps/edr-freight-api/src/modules/audit/audit.service.ts b/apps/edr-freight-api/src/modules/audit/audit.service.ts index 8bc792591..04ea4beed 100644 --- a/apps/edr-freight-api/src/modules/audit/audit.service.ts +++ b/apps/edr-freight-api/src/modules/audit/audit.service.ts @@ -3,6 +3,8 @@ import { InjectRepository } from "@nestjs/typeorm"; import { Repository } from "typeorm"; import { AuditLogCommand } from "@tria-plc/auditlog"; +import { CLIENT_APP_HEADER } from "../auth/login-audience.middleware"; + export interface AuditLogListResult { count: number; items: AuditLogCommand[]; @@ -38,6 +40,15 @@ export class AuditService { "(audit_log_commands.auditLogId IS NULL OR auditLog.status = :status)", { status: "Commit" }, ) + // Backoffice-only view: portal (customer-facing) writes carry the same + // request-header set by every axios call from that app — see + // login-audience.middleware.ts. Rows with no linked auditLog (child/ + // event commands with no request context) stay visible; they aren't + // attributable to any frontend, so they're not portal noise either. + .andWhere( + "(audit_log_commands.auditLogId IS NULL OR auditLog.requestHeader ->> :clientAppHeader = :clientApp)", + { clientAppHeader: CLIENT_APP_HEADER, clientApp: "backoffice" }, + ) .select([ "audit_log_commands.id", "audit_log_commands.createdAt", diff --git a/apps/edr-freight-web/backoffice/src/pages/audit/AuditLogsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/audit/AuditLogsPage.tsx index d4451c2f2..ef3412a3a 100644 --- a/apps/edr-freight-web/backoffice/src/pages/audit/AuditLogsPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/audit/AuditLogsPage.tsx @@ -7,6 +7,7 @@ import type { AuditLogRow, AuditQueryMethod, AuditUser, + LocalizedText, } from "@/services/audit.service"; import { DataTable, @@ -44,18 +45,20 @@ function formatDateTime(iso: string): string { }); } -// The producer (@tria-plc/auditlog's ClientLoggerInterceptor) builds -// `name` from `${auditUser?.firstName} ${auditUser?.lastName}` — this app's -// user model only has a single `name` field, and unauthenticated/customer -// flows (e.g. Fayda verification) have no auditUser at all, so this literal -// "undefined undefined" ends up stored as-is. Filter it back out on render -// rather than showing raw garbage. +// See LocalizedText: `name`/`title` lifted from a raw audited entity can be +// a plain string or IAM's { am, en } — never render either directly. +// "undefined undefined" is the producer's own broken template when no user +// was attached at all (unauthenticated/customer flows, e.g. Fayda +// verification) — filtered out here rather than shown as raw garbage. +function localize(value: LocalizedText | null | undefined): string | undefined { + if (!value) return undefined; + if (typeof value === "object") return value.en ?? value.am ?? undefined; + if (/^undefined(\s+undefined)?$/.test(value.trim())) return undefined; + return value; +} + function formatUser(user: AuditUser | null | undefined): string { - const name = user?.name; - if (typeof name === "string" && /^undefined(\s+undefined)?$/.test(name.trim())) { - return "—"; - } - return name ?? user?.id ?? "—"; + return localize(user?.name) ?? user?.id ?? "—"; } function summarize(row: AuditLogRow): string { @@ -66,7 +69,9 @@ function summarize(row: AuditLogRow): string { .join(", ") + (row.changes.length > 2 ? `, +${row.changes.length - 2} more` : ""); } if (row.payload) { - return row.payload.name ?? row.payload.title ?? row.payload.id ?? "—"; + return ( + localize(row.payload.name) ?? localize(row.payload.title) ?? row.payload.id ?? "—" + ); } return "—"; } diff --git a/apps/edr-freight-web/backoffice/src/services/audit.service.ts b/apps/edr-freight-web/backoffice/src/services/audit.service.ts index 498d1c77c..ed0642d83 100644 --- a/apps/edr-freight-web/backoffice/src/services/audit.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/audit.service.ts @@ -19,9 +19,18 @@ export interface AuditFieldChange { to: unknown; } +// IAM entities (users, orgs, positions, ...) name themselves bilingually — +// see edr-org.seeder.ts. Any `name`/`title` field lifted from a raw audited +// entity (auditLog.user, payload) can come back as either a plain string or +// this shape; both `name` fields below reflect that. +export type LocalizedText = string | { am?: string; en?: string }; + +// The vendored interceptor's own broken template produces a plain string +// ("undefined undefined") when no user was attached at all (unauthenticated/ +// customer flows) — that's the non-bilingual string case for `name` here. export interface AuditUser { id?: string; - name?: string; + name?: LocalizedText; organizationId?: string; organizationName?: string; [key: string]: unknown; @@ -34,7 +43,7 @@ export interface AuditLogRow { entityName: string; queryMethod: AuditQueryMethod; changes?: AuditFieldChange[] | null; - payload?: { name?: string; title?: string; id?: string } | null; + payload?: { name?: LocalizedText; title?: LocalizedText; id?: string } | null; auditLog?: { id?: string; user?: AuditUser | null }; } From bf8906f9837a0e210fd5a710eea3f1ba6305d5d0 Mon Sep 17 00:00:00 2001 From: Stephanos A Date: Wed, 5 Aug 2026 21:32:23 +0300 Subject: [PATCH 08/22] Boarding status update issue fix --- .../src/modules/tickets/tickets.service.ts | 47 ++++++++++++++++--- .../test/ticketing.e2e-spec.ts | 18 +++++++ 2 files changed, 59 insertions(+), 6 deletions(-) diff --git a/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts b/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts index 0bbd1c7ae..6a45eeb69 100644 --- a/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts +++ b/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts @@ -177,7 +177,7 @@ export class TicketsService { } : null, status: t.status, validatedAt: t.validatedAt, - boardedAt: t.validatedAt, + boardedAt: t.boardedAt ?? t.validatedAt, qrCode: t.qrPayload ?? null, createdAt: t.issuedAt, }; @@ -663,6 +663,13 @@ export class TicketsService { // Use existing validation logic to handle round trips properly const result = await this.validate(bookingRef, validatorId, gateId); + if ((result as any).alreadyValidated) { + return { + success: false, + error: 'Ticket already used', + errorCode: 'ALREADY_USED', + }; + } // Get seat information const seatInfo = (booking as any).seats[0]; @@ -756,12 +763,23 @@ export class TicketsService { const type = booking.bookingType; const now = new Date(); + const markTicketUsed = async () => { + if (ticket.status !== 'USED') { + await this.prisma.ticket.update({ where: { id: ticket.id }, data: { status: 'USED' } }); + ticket.status = 'USED'; + } + }; + // ── ONE_WAY / TRANSIT (single scan) ─────────────────────────────────── if (type === 'ONE_WAY') { if (ticket.validatedAt) { + await markTicketUsed(); return { validated: true, ticketId: ticket.id, validatedAt: ticket.validatedAt, alreadyValidated: true }; } - await this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: now, boardedAt: now, validatorId: resolvedValidatorId } }); + await this.prisma.ticket.update({ + where: { id: ticket.id }, + data: { validatedAt: now, boardedAt: now, validatorId: resolvedValidatorId, status: 'USED' }, + }); await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, status: 'APPROVED' } }); this.fireBoardingPassNotification(booking, ticket, null); await this.auditService.log({ action: 'VERIFY', entityType: 'Ticket', entityId: ticket.id, newData: { bookingRef, validatorId: resolvedValidatorId, leg: 'ONE_WAY' } }); @@ -778,13 +796,22 @@ export class TicketsService { const alreadyValidated = logs.some(l => l.leg === resolvedLeg); if (alreadyValidated) { await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, leg: resolvedLeg, status: 'REJECTED', reason: `${resolvedLeg}_ALREADY_USED` } as any }); + await markTicketUsed(); throw new BadRequestException(`${resolvedLeg} already validated`); } - if (!ticket.validatedAt) await this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: now, boardedAt: now, validatorId: resolvedValidatorId } }); + const validatedAt = ticket.validatedAt ?? now; + if (!ticket.validatedAt) { + await this.prisma.ticket.update({ + where: { id: ticket.id }, + data: { validatedAt: now, boardedAt: now, validatorId: resolvedValidatorId, status: 'USED' }, + }); + } else { + await markTicketUsed(); + } await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, leg: resolvedLeg, status: 'APPROVED' } as any }); this.fireBoardingPassNotification(booking, ticket, resolvedLeg); await this.auditService.log({ action: 'VERIFY', entityType: 'Ticket', entityId: ticket.id, newData: { bookingRef, validatorId: resolvedValidatorId, leg: resolvedLeg } }); - return { validated: true, ticketId: ticket.id, leg: resolvedLeg, validatedAt: now }; + return { validated: true, ticketId: ticket.id, leg: resolvedLeg, validatedAt }; } // ── ROUND_TRIP — leg=OUTBOUND or leg=RETURN ──────────────────────── @@ -798,12 +825,14 @@ export class TicketsService { if (resolvedLeg === 'OUTBOUND') { if ((booking as any).outboundBoardedAt) { await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, leg: resolvedLeg, status: 'REJECTED', reason: 'OUTBOUND_ALREADY_USED' } as any }); + await markTicketUsed(); throw new BadRequestException('Outbound leg already validated'); } bookingData.outboundBoardedAt = now; } else if (resolvedLeg === 'RETURN') { if ((booking as any).returnBoardedAt) { await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, leg: resolvedLeg, status: 'REJECTED', reason: 'RETURN_ALREADY_USED' } as any }); + await markTicketUsed(); throw new BadRequestException('Return leg already validated'); } bookingData.returnBoardedAt = now; @@ -812,13 +841,19 @@ export class TicketsService { } await this.prisma.booking.update({ where: { id: booking.id }, data: bookingData }); + const validatedAt = ticket.validatedAt ?? now; if (!ticket.validatedAt) { - await this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: now, boardedAt: now, validatorId: resolvedValidatorId } }); + await this.prisma.ticket.update({ + where: { id: ticket.id }, + data: { validatedAt: now, boardedAt: now, validatorId: resolvedValidatorId, status: 'USED' }, + }); + } else { + await markTicketUsed(); } await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, leg: resolvedLeg, status: 'APPROVED' } as any }); this.fireBoardingPassNotification(booking, ticket, resolvedLeg); await this.auditService.log({ action: 'VERIFY', entityType: 'Ticket', entityId: ticket.id, newData: { bookingRef, validatorId: resolvedValidatorId, leg: resolvedLeg } }); - return { validated: true, ticketId: ticket.id, leg: resolvedLeg, validatedAt: now }; + return { validated: true, ticketId: ticket.id, leg: resolvedLeg, validatedAt }; } throw new BadRequestException(`Unsupported booking type: ${type}`); diff --git a/apps/edr-passenger-api/test/ticketing.e2e-spec.ts b/apps/edr-passenger-api/test/ticketing.e2e-spec.ts index b8a380577..ee5baeb7d 100644 --- a/apps/edr-passenger-api/test/ticketing.e2e-spec.ts +++ b/apps/edr-passenger-api/test/ticketing.e2e-spec.ts @@ -228,6 +228,24 @@ describe("Ticketing — generate / scanAndBoard / validate / smart-reassign", () const ticket = await harness.prisma.ticket.findFirst({ where: { bookingId: booking.id } }); expect(ticket?.validatedAt).toBeTruthy(); + expect(ticket?.status).toBe('USED'); + }); + + it("does not allow boarding the same ticket twice", async () => { + const { schedule, seats } = await createTestSchedule({ trainNumber: `TIX-BOARD-REUSE-${Date.now()}`, departureAt: future(60), arrivalAt: future(120) }); + const booking = await createOneWayBooking(schedule.id, seats[0].id); + await markSucceeded(booking.id); + await ticketsService.generate(booking.id); + + const first = await ticketsService.scanAndBoard(booking.bookingRef, "gate-validator-1"); + expect(first.success).toBe(true); + + const second = await ticketsService.scanAndBoard(booking.bookingRef, "gate-validator-1"); + expect(second.success).toBe(false); + expect(second.error).toMatch(/already used/i); + + const ticket = await harness.prisma.ticket.findFirst({ where: { bookingId: booking.id } }); + expect(ticket?.status).toBe('USED'); }); it("refuses boarding before the boarding window opens", async () => { From 1f4d659ffb36dd903092d3e9e3becf40fcf3fd58 Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Wed, 5 Aug 2026 19:58:41 +0000 Subject: [PATCH 09/22] Last mile confirmation request , approval, payment Feature --- apps/edr-freight-api/src/app.module.ts | 2 + .../3250000000000-CreateLastMileRequests.ts | 87 +++++ .../dto/approve-last-mile-request.dto.ts | 15 + .../dto/reject-last-mile-request.dto.ts | 10 + .../dto/submit-last-mile-request.dto.ts | 15 + .../entities/last-mile-request.entity.ts | 71 ++++ .../last-mile-requests.controller.ts | 86 +++++ .../last-mile-requests.module.ts | 25 ++ .../last-mile-requests.repository.ts | 16 + .../last-mile-requests.service.ts | 337 ++++++++++++++++++ .../src/scripts/seed-warehouse-demo.ts | 25 +- .../src/seed/edr-freight.seed.ts | 1 + .../src/seed/freight-permissions.registry.ts | 19 + .../src/seed/freight-staff-users.seeder.ts | 1 + .../paid-import-export-mile-demo.seeder.ts | 25 +- .../operations/LastMileRequestsPanel.tsx | 290 +++++++++++++++ .../backoffice/src/constants/QUERY_KEYS.ts | 7 + .../backoffice/src/constants/URLS.ts | 8 + .../backoffice/src/lib/permissions.ts | 3 + .../src/pages/operations/LastMilePage.tsx | 31 ++ .../services/last-mile-requests.service.ts | 51 +++ apps/edr-freight-web/portal/src/App.tsx | 5 + .../portal/src/constants/URLS.ts | 5 + .../last-mile-confirm/LastMileConfirmPage.tsx | 160 +++++++++ .../services/last-mile-requests.service.ts | 33 ++ packages/types/src/freight/index.ts | 14 + 26 files changed, 1334 insertions(+), 8 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/3250000000000-CreateLastMileRequests.ts create mode 100644 apps/edr-freight-api/src/modules/last-mile-requests/dto/approve-last-mile-request.dto.ts create mode 100644 apps/edr-freight-api/src/modules/last-mile-requests/dto/reject-last-mile-request.dto.ts create mode 100644 apps/edr-freight-api/src/modules/last-mile-requests/dto/submit-last-mile-request.dto.ts create mode 100644 apps/edr-freight-api/src/modules/last-mile-requests/entities/last-mile-request.entity.ts create mode 100644 apps/edr-freight-api/src/modules/last-mile-requests/last-mile-requests.controller.ts create mode 100644 apps/edr-freight-api/src/modules/last-mile-requests/last-mile-requests.module.ts create mode 100644 apps/edr-freight-api/src/modules/last-mile-requests/last-mile-requests.repository.ts create mode 100644 apps/edr-freight-api/src/modules/last-mile-requests/last-mile-requests.service.ts create mode 100644 apps/edr-freight-web/backoffice/src/components/operations/LastMileRequestsPanel.tsx create mode 100644 apps/edr-freight-web/backoffice/src/services/last-mile-requests.service.ts create mode 100644 apps/edr-freight-web/portal/src/pages/bookings/last-mile-confirm/LastMileConfirmPage.tsx create mode 100644 apps/edr-freight-web/portal/src/services/last-mile-requests.service.ts diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index adb92c3b0..d41b77dce 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -103,6 +103,7 @@ import { ProcurementModule } from "./modules/procurement/procurement.module"; import { GpsTrackingModule } from "./modules/gps-tracking/gps-tracking.module"; import { FirstMileModule } from "./modules/first-mile/first-mile.module"; import { LastMileModule } from "./modules/last-mile/last-mile.module"; +import { LastMileRequestsModule } from "./modules/last-mile-requests/last-mile-requests.module"; import { InterchangeDocumentsModule } from "./modules/interchange-documents/interchange-documents.module"; import { ImportOperationsModule } from "./modules/import-operations/import-operations.module"; import { AiModule } from "./modules/ai/ai.module"; @@ -241,6 +242,7 @@ if (!process.env.APPLICATION_NAME) { GpsTrackingModule, FirstMileModule, LastMileModule, + LastMileRequestsModule, InterchangeDocumentsModule, ImportOperationsModule, VerifaydaModule, diff --git a/apps/edr-freight-api/src/migrations/3250000000000-CreateLastMileRequests.ts b/apps/edr-freight-api/src/migrations/3250000000000-CreateLastMileRequests.ts new file mode 100644 index 000000000..0763e32a3 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3250000000000-CreateLastMileRequests.ts @@ -0,0 +1,87 @@ +import { MigrationInterface, QueryRunner, Table, TableForeignKey } from 'typeorm'; + +/** + * Create freight.last_mile_requests — the pre-approval confirmation stage that + * sits in front of freight.last_mile: a train departs Djibouti, the customer + * confirms which containers go via EDR last-mile, and the Truck & Machinery + * chief approves/rejects before a freight.last_mile execution record exists. + */ +export class CreateLastMileRequests3250000000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + const exists = await queryRunner.hasTable('freight.last_mile_requests'); + if (exists) return; + + await queryRunner.createTable( + new Table({ + name: 'freight.last_mile_requests', + columns: [ + { name: 'id', type: 'uuid', isPrimary: true, default: 'gen_random_uuid()' }, + { name: 'booking_id', type: 'uuid', isNullable: false }, + { name: 'train_schedule_id', type: 'uuid', isNullable: false }, + { + name: 'status', + type: 'varchar', + length: '30', + default: `'AWAITING_CONFIRMATION'`, + isNullable: false, + }, + { name: 'requested_container_numbers', type: 'text', isArray: true, isNullable: true }, + { name: 'reminder_sent_at', type: 'timestamptz', isNullable: true }, + { name: 'submitted_by_user_id', type: 'uuid', isNullable: true }, + { name: 'submitted_at', type: 'timestamptz', isNullable: true }, + { name: 'reviewed_by_staff_id', type: 'uuid', isNullable: true }, + { name: 'reviewed_at', type: 'timestamptz', isNullable: true }, + { name: 'rejection_reason', type: 'text', isNullable: true }, + { name: 'resulting_last_mile_id', type: 'uuid', isNullable: true }, + { name: 'created_at', type: 'timestamptz', default: 'now()' }, + { name: 'updated_at', type: 'timestamptz', default: 'now()' }, + { name: 'deleted_at', type: 'timestamptz', isNullable: true }, + ], + }), + true, + ); + + await queryRunner.createForeignKey( + 'freight.last_mile_requests', + new TableForeignKey({ + columnNames: ['booking_id'], + referencedTableName: 'freight.bookings', + referencedColumnNames: ['id'], + onDelete: 'CASCADE', + }), + ); + await queryRunner.createForeignKey( + 'freight.last_mile_requests', + new TableForeignKey({ + columnNames: ['train_schedule_id'], + referencedTableName: 'freight.train_schedules', + referencedColumnNames: ['id'], + onDelete: 'CASCADE', + }), + ); + await queryRunner.createForeignKey( + 'freight.last_mile_requests', + new TableForeignKey({ + columnNames: ['resulting_last_mile_id'], + referencedTableName: 'freight.last_mile', + referencedColumnNames: ['id'], + onDelete: 'SET NULL', + }), + ); + + // One request per booking per departure — remind()/submit() are idempotent on this. + await queryRunner.query( + `CREATE UNIQUE INDEX IF NOT EXISTS "IDX_last_mile_requests_booking_schedule" ON "freight"."last_mile_requests" ("booking_id", "train_schedule_id") WHERE "deleted_at" IS NULL`, + ); + await queryRunner.query( + `CREATE INDEX IF NOT EXISTS "IDX_last_mile_requests_status" ON "freight"."last_mile_requests" ("status")`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + const exists = await queryRunner.hasTable('freight.last_mile_requests'); + if (exists) { + await queryRunner.dropTable('freight.last_mile_requests'); + } + } +} diff --git a/apps/edr-freight-api/src/modules/last-mile-requests/dto/approve-last-mile-request.dto.ts b/apps/edr-freight-api/src/modules/last-mile-requests/dto/approve-last-mile-request.dto.ts new file mode 100644 index 000000000..170eafb25 --- /dev/null +++ b/apps/edr-freight-api/src/modules/last-mile-requests/dto/approve-last-mile-request.dto.ts @@ -0,0 +1,15 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Transform } from 'class-transformer'; +import { IsNumber, Min } from 'class-validator'; + +export class ApproveLastMileRequestDto { + // ponytail: flat manual advance amount — no rate model exists yet at this + // pre-distance stage (delivery-fee invoicing needs assigned-truck distance, + // which isn't known until after payment). Wire a FeeRule-based estimate + // (see double-handling/truck-detention fee rules) once one exists. + @ApiProperty({ description: 'Advance amount the customer must pay before execution proceeds', example: 3000 }) + @Transform(({ value }) => Number(value)) + @IsNumber() + @Min(0.01) + advanceAmount!: number; +} diff --git a/apps/edr-freight-api/src/modules/last-mile-requests/dto/reject-last-mile-request.dto.ts b/apps/edr-freight-api/src/modules/last-mile-requests/dto/reject-last-mile-request.dto.ts new file mode 100644 index 000000000..10cee19c3 --- /dev/null +++ b/apps/edr-freight-api/src/modules/last-mile-requests/dto/reject-last-mile-request.dto.ts @@ -0,0 +1,10 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsNotEmpty, IsString, MaxLength } from 'class-validator'; + +export class RejectLastMileRequestDto { + @ApiProperty({ description: 'Why the request is rejected (e.g. no truck available)' }) + @IsString() + @IsNotEmpty() + @MaxLength(500) + reason!: string; +} diff --git a/apps/edr-freight-api/src/modules/last-mile-requests/dto/submit-last-mile-request.dto.ts b/apps/edr-freight-api/src/modules/last-mile-requests/dto/submit-last-mile-request.dto.ts new file mode 100644 index 000000000..b84595f7c --- /dev/null +++ b/apps/edr-freight-api/src/modules/last-mile-requests/dto/submit-last-mile-request.dto.ts @@ -0,0 +1,15 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { ArrayNotEmpty, ArrayUnique, IsArray, IsString } from 'class-validator'; + +export class SubmitLastMileRequestDto { + @ApiProperty({ + type: [String], + description: + 'Container numbers the customer wants delivered via EDR last-mile — pass every booking container to select "all".', + }) + @IsArray() + @ArrayNotEmpty() + @ArrayUnique() + @IsString({ each: true }) + containerNumbers!: string[]; +} diff --git a/apps/edr-freight-api/src/modules/last-mile-requests/entities/last-mile-request.entity.ts b/apps/edr-freight-api/src/modules/last-mile-requests/entities/last-mile-request.entity.ts new file mode 100644 index 000000000..0077d7f0a --- /dev/null +++ b/apps/edr-freight-api/src/modules/last-mile-requests/entities/last-mile-request.entity.ts @@ -0,0 +1,71 @@ +import { BaseEntity } from '@edr/api-common'; +import { LastMileRequestStatus } from '@edr/types'; +import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm'; + +import { Booking } from '../../bookings/entities/booking.entity'; +import { TrainSchedule } from '../../train-schedules/entities/train-schedule.entity'; +import { LastMile } from '../../last-mile/entities/last-mile.entity'; + +export const LAST_MILE_REQUEST_STATUSES = [ + LastMileRequestStatus.AwaitingConfirmation, + LastMileRequestStatus.Submitted, + LastMileRequestStatus.Approved, + LastMileRequestStatus.Rejected, +] as const; + +/** + * The pre-approval confirmation stage in front of `LastMile`: fired when a + * train departs Djibouti, filled by the customer, reviewed by the Truck & + * Machinery chief. One row per (bookingId, trainScheduleId) — a booking whose + * containers arrive across several departures gets a request per departure. + */ +@Entity({ name: 'last_mile_requests', schema: 'freight' }) +@Index(['bookingId']) +@Index(['status']) +export class LastMileRequest extends BaseEntity { + @Column({ name: 'booking_id', type: 'uuid' }) + bookingId!: string; + + @ManyToOne(() => Booking, { nullable: false, eager: false }) + @JoinColumn({ name: 'booking_id' }) + booking?: Booking; + + @Column({ name: 'train_schedule_id', type: 'uuid' }) + trainScheduleId!: string; + + @ManyToOne(() => TrainSchedule, { nullable: false, eager: false }) + @JoinColumn({ name: 'train_schedule_id' }) + trainSchedule?: TrainSchedule; + + @Column({ name: 'status', type: 'varchar', length: 30, default: LastMileRequestStatus.AwaitingConfirmation }) + status!: LastMileRequestStatus; + + /** Customer's container selection — "all" is just every booking container listed here. */ + @Column({ name: 'requested_container_numbers', type: 'text', array: true, nullable: true }) + requestedContainerNumbers?: string[] | null; + + @Column({ name: 'reminder_sent_at', type: 'timestamptz', nullable: true }) + reminderSentAt?: Date | null; + + @Column({ name: 'submitted_by_user_id', type: 'uuid', nullable: true }) + submittedByUserId?: string | null; + + @Column({ name: 'submitted_at', type: 'timestamptz', nullable: true }) + submittedAt?: Date | null; + + @Column({ name: 'reviewed_by_staff_id', type: 'uuid', nullable: true }) + reviewedByStaffId?: string | null; + + @Column({ name: 'reviewed_at', type: 'timestamptz', nullable: true }) + reviewedAt?: Date | null; + + @Column({ name: 'rejection_reason', type: 'text', nullable: true }) + rejectionReason?: string | null; + + @Column({ name: 'resulting_last_mile_id', type: 'uuid', nullable: true }) + resultingLastMileId?: string | null; + + @ManyToOne(() => LastMile, { nullable: true, eager: false }) + @JoinColumn({ name: 'resulting_last_mile_id' }) + resultingLastMile?: LastMile | null; +} diff --git a/apps/edr-freight-api/src/modules/last-mile-requests/last-mile-requests.controller.ts b/apps/edr-freight-api/src/modules/last-mile-requests/last-mile-requests.controller.ts new file mode 100644 index 000000000..0c71a21c6 --- /dev/null +++ b/apps/edr-freight-api/src/modules/last-mile-requests/last-mile-requests.controller.ts @@ -0,0 +1,86 @@ +import { Body, Controller, Get, Param, ParseUUIDPipe, Post, Query } from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; + +import { CurrentUser } from '@edr/api-common'; +import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type'; +import { LastMileRequestStatus } from '@edr/types'; + +import { BookingStaff } from '../../common/booking-guards'; +import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; +import { ApproveLastMileRequestDto } from './dto/approve-last-mile-request.dto'; +import { RejectLastMileRequestDto } from './dto/reject-last-mile-request.dto'; +import { SubmitLastMileRequestDto } from './dto/submit-last-mile-request.dto'; +import { LastMileRequestsService } from './last-mile-requests.service'; + +@ApiTags('last-mile-requests') +@ApiBearerAuth() +@Controller('last-mile-requests') +export class LastMileRequestsController { + constructor(private readonly requestsService: LastMileRequestsService) {} + + @Get() + @BookingStaff(FREIGHT_PERMS.lastMile.requestView) + @ApiOperation({ summary: 'List last-mile confirmation requests' }) + findAll( + @Query('status') status?: string, + @Query('bookingId') bookingId?: string, + @Query('page') page?: string, + @Query('pageSize') pageSize?: string, + ) { + return this.requestsService.findAll({ + status: status as LastMileRequestStatus | undefined, + bookingId, + page: page ? parseInt(page, 10) : undefined, + pageSize: pageSize ? parseInt(pageSize, 10) : undefined, + }); + } + + @Get('free-truck-count') + @BookingStaff(FREIGHT_PERMS.lastMile.requestView) + @ApiOperation({ summary: 'Free (ACTIVE + unassigned) trucks — informational context for approval' }) + freeTruckCount() { + return this.requestsService.freeTruckCount().then((count) => ({ count })); + } + + @Get(':id') + @BookingStaff(FREIGHT_PERMS.lastMile.requestView) + @ApiOperation({ summary: 'Get a last-mile confirmation request by ID' }) + findOne(@Param('id', ParseUUIDPipe) id: string) { + return this.requestsService.findById(id); + } + + // No @BookingStaff — the customer (portal) fills this, not backoffice staff. + // TODO: integrate @edr/auth — @CurrentUser is a stub until then; the service + // still cross-checks the request's booking against the resolved company. + @Post(':id/submit') + @ApiOperation({ summary: "Customer confirms which containers go via EDR last-mile" }) + submit( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: SubmitLastMileRequestDto, + @CurrentUser() user: TCurrentUser, + ) { + return this.requestsService.submit(id, user?.id ?? null, dto.containerNumbers); + } + + @Post(':id/approve') + @BookingStaff(FREIGHT_PERMS.lastMile.requestApprove) + @ApiOperation({ summary: 'Truck & Machinery chief approves the request — generates the advance invoice' }) + approve( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: ApproveLastMileRequestDto, + @CurrentUser() user: TCurrentUser, + ) { + return this.requestsService.approve(id, user?.id ?? null, dto.advanceAmount); + } + + @Post(':id/reject') + @BookingStaff(FREIGHT_PERMS.lastMile.requestApprove) + @ApiOperation({ summary: 'Truck & Machinery chief rejects the request with a reason' }) + reject( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: RejectLastMileRequestDto, + @CurrentUser() user: TCurrentUser, + ) { + return this.requestsService.reject(id, user?.id ?? null, dto.reason); + } +} diff --git a/apps/edr-freight-api/src/modules/last-mile-requests/last-mile-requests.module.ts b/apps/edr-freight-api/src/modules/last-mile-requests/last-mile-requests.module.ts new file mode 100644 index 000000000..944954e65 --- /dev/null +++ b/apps/edr-freight-api/src/modules/last-mile-requests/last-mile-requests.module.ts @@ -0,0 +1,25 @@ +import { Module, forwardRef } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; + +import { BillingModule } from '../billing/billing.module'; +import { BookingsModule } from '../bookings/bookings.module'; +import { LastMileModule } from '../last-mile/last-mile.module'; +import { NotificationInboxModule } from '../notification-inbox/notification-inbox.module'; +import { LastMileRequest } from './entities/last-mile-request.entity'; +import { LastMileRequestsController } from './last-mile-requests.controller'; +import { LastMileRequestsRepository } from './last-mile-requests.repository'; +import { LastMileRequestsService } from './last-mile-requests.service'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([LastMileRequest]), + BillingModule, + forwardRef(() => BookingsModule), + LastMileModule, + NotificationInboxModule, + ], + controllers: [LastMileRequestsController], + providers: [LastMileRequestsRepository, LastMileRequestsService], + exports: [LastMileRequestsService], +}) +export class LastMileRequestsModule {} diff --git a/apps/edr-freight-api/src/modules/last-mile-requests/last-mile-requests.repository.ts b/apps/edr-freight-api/src/modules/last-mile-requests/last-mile-requests.repository.ts new file mode 100644 index 000000000..536a6c022 --- /dev/null +++ b/apps/edr-freight-api/src/modules/last-mile-requests/last-mile-requests.repository.ts @@ -0,0 +1,16 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { BaseRepository } from '@edr/api-common'; + +import { LastMileRequest } from './entities/last-mile-request.entity'; + +@Injectable() +export class LastMileRequestsRepository extends BaseRepository { + constructor( + @InjectRepository(LastMileRequest) + repository: Repository, + ) { + super(repository); + } +} diff --git a/apps/edr-freight-api/src/modules/last-mile-requests/last-mile-requests.service.ts b/apps/edr-freight-api/src/modules/last-mile-requests/last-mile-requests.service.ts new file mode 100644 index 000000000..e21706c9f --- /dev/null +++ b/apps/edr-freight-api/src/modules/last-mile-requests/last-mile-requests.service.ts @@ -0,0 +1,337 @@ +import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common'; +import { Cron } from '@nestjs/schedule'; +import { DataSource, FindOptionsWhere } from 'typeorm'; +import { Freight, LastMileRequestStatus } from '@edr/types'; + +import { usesEdrMileService } from '../../common/mile-haulage.util'; +import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; +import { BookingsRepository } from '../bookings/bookings.repository'; +import { BookingsService } from '../bookings/bookings.service'; +import { Booking } from '../bookings/entities/booking.entity'; +import { BillingService } from '../billing/billing.service'; +import { NotificationInboxService } from '../notification-inbox/notification-inbox.service'; +import { NotificationAudience, NotificationPriority, NotificationType } from '@edr/types'; +import { LastMileService } from '../last-mile/last-mile.service'; +import { Vehicle, VehicleAvailability, VehicleStatus } from '../vehicles/entities/vehicle.entity'; +import { LastMileRequest } from './entities/last-mile-request.entity'; +import { LastMileRequestsRepository } from './last-mile-requests.repository'; + +type ListFilter = { + status?: LastMileRequestStatus; + bookingId?: string; + page?: number; + pageSize?: number; +}; + +/** Just what remind() needs off a departed schedule — deliberately not the full + * `TrainSchedule` entity so this module never has to import train-scheduling code. */ +type DepartedSchedule = { id: string; trainNumber?: string | null }; + +@Injectable() +export class LastMileRequestsService { + private readonly logger = new Logger(LastMileRequestsService.name); + + constructor( + private readonly requestsRepository: LastMileRequestsRepository, + private readonly bookingsRepository: BookingsRepository, + private readonly bookingsService: BookingsService, + private readonly lastMileService: LastMileService, + private readonly billing: BillingService, + private readonly notifications: NotificationInboxService, + private readonly dataSource: DataSource, + ) {} + + /** Container numbers on the booking (upper-cased) — mirrors LastMileService's own helper. */ + private async bookingContainerNumbers(bookingId: string): Promise { + const rows: Array<{ containerNumber: string }> = await this.dataSource.query( + `SELECT bcu.container_number AS "containerNumber" + FROM freight.booking_container_units bcu + JOIN freight.booking_container bc + ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL + WHERE bc.booking_id = $1 AND bcu.deleted_at IS NULL`, + [bookingId], + ); + return rows.map((r) => r.containerNumber.trim().toUpperCase()); + } + + /** + * Poll for trains that have departed Djibouti and remind their eligible + * bookings. Deliberately a self-contained poller (raw SQL against + * `import_djibouti_operations`/`train_schedules`, no import of train-scheduling + * module code) rather than a hook inside `TrainSchedulingService.dispatchSchedule` + * — keeps this feature decoupled from that module entirely. `remindForDeparture` + * is idempotent per (bookingId, scheduleId), so re-scanning the same recent + * window on every tick is safe — a schedule already fully reminded is a no-op. + */ + @Cron('*/2 * * * *', { name: 'last-mile-request-departure-scan' }) + async scanDepartedSchedules(): Promise { + let schedules: DepartedSchedule[] = []; + try { + schedules = await this.dataSource.query( + `SELECT ts.id AS "id", ts.train_number AS "trainNumber" + FROM freight.import_djibouti_operations op + JOIN freight.train_schedules ts + ON ts.id = op.train_schedule_id AND ts.deleted_at IS NULL + WHERE op.deleted_at IS NULL + AND op.departed_from_djibouti_at IS NOT NULL + AND op.departed_from_djibouti_at > now() - interval '14 days'`, + ); + } catch (err) { + this.logger.warn(`Failed to scan for departed schedules: ${(err as Error).message}`); + return; + } + for (const schedule of schedules) { + await this.remindForDeparture(schedule); + } + } + + /** + * Fired for a train that has departed Djibouti (import direction). For every booking + * already loaded on this schedule that bought EDR last-mile, idempotently + * creates the AWAITING_CONFIRMATION request and reminds both the customer and + * the Truck & Machinery department. Fire-and-forget per booking — one bad + * booking must never block the rest of the departure notification. + */ + async remindForDeparture(schedule: DepartedSchedule): Promise { + let bookingIds: string[] = []; + try { + const rows: Array<{ bookingId: string }> = await this.dataSource.query( + `SELECT booking_id AS "bookingId" + FROM freight.train_schedule_bookings + WHERE train_schedule_id = $1 AND loading_status = 'LOADED' AND deleted_at IS NULL`, + [schedule.id], + ); + bookingIds = rows.map((r) => r.bookingId); + } catch (err) { + this.logger.warn(`Failed to load schedule bookings for ${schedule.id}: ${(err as Error).message}`); + return; + } + if (!bookingIds.length) return; + + for (const bookingId of bookingIds) { + try { + const booking = await this.bookingsRepository.findById(bookingId); + if (!booking) continue; + if ( + !usesEdrMileService({ + tradeDirection: booking.tradeDirection, + firstMile: booking.firstMilePickupAddress ?? null, + lastMile: booking.lastMileDeliveryAddress ?? null, + }) + ) { + continue; + } + await this.remind(booking, schedule); + } catch (err) { + this.logger.warn(`Failed to remind booking ${bookingId} for schedule ${schedule.id}: ${(err as Error).message}`); + } + } + } + + private async remind(booking: Booking, schedule: DepartedSchedule): Promise { + const [existing] = await this.requestsRepository.findAll({ + where: { bookingId: booking.id, trainScheduleId: schedule.id }, + take: 1, + }); + if (existing) return; // already reminded for this departure + + const request = await this.requestsRepository.create({ + bookingId: booking.id, + trainScheduleId: schedule.id, + status: LastMileRequestStatus.AwaitingConfirmation, + reminderSentAt: new Date(), + }); + + const trainLabel = schedule.trainNumber ? `train ${schedule.trainNumber}` : 'your train'; + if (booking.companyId) { + void this.notifications.notify({ + recipients: { companyId: booking.companyId }, + audience: NotificationAudience.PORTAL, + type: NotificationType.SCHEDULE_UPDATE, + title: 'Confirm your last-mile delivery', + body: `${trainLabel} carrying booking ${booking.reference ?? booking.id} has departed Djibouti. Confirm which containers go via EDR last-mile.`, + link: `/bookings/${booking.id}/last-mile-confirm?requestId=${request.id}`, + data: { bookingId: booking.id, requestId: request.id }, + priority: NotificationPriority.HIGH, + }); + } + void this.notifications.notify({ + recipients: { permissionKeys: [FREIGHT_PERMS.lastMile.requestReview] }, + audience: NotificationAudience.BACKOFFICE, + type: NotificationType.SCHEDULE_UPDATE, + title: 'Last-mile confirmation expected', + body: `${trainLabel} carrying booking ${booking.reference ?? booking.id} has departed Djibouti — awaiting the customer's last-mile confirmation.`, + link: `/dashboard/operations/last-mile?tab=requests`, + data: { bookingId: booking.id, requestId: request.id }, + priority: NotificationPriority.HIGH, + }); + } + + async findAll(filter: ListFilter = {}): Promise<{ + data: LastMileRequest[]; + meta: { total: number; page: number; pageSize: number; totalPages: number }; + }> { + const page = filter.page ?? 1; + const pageSize = filter.pageSize ?? 50; + const where: FindOptionsWhere = {}; + if (filter.status) where.status = filter.status; + if (filter.bookingId) where.bookingId = filter.bookingId; + + const [data, total] = await this.requestsRepository.findAndCount({ + where, + relations: { booking: { company: true } }, + order: { createdAt: 'DESC' }, + skip: (page - 1) * pageSize, + take: pageSize, + }); + + return { + data, + meta: { total, page, pageSize, totalPages: Math.max(1, Math.ceil(total / pageSize)) }, + }; + } + + async findById(id: string): Promise { + const record = await this.requestsRepository.findById(id, { + relations: { booking: { company: true } }, + }); + if (!record) throw new NotFoundException(`Last-mile request ${id} not found`); + return record; + } + + /** Free (ACTIVE + unassigned) truck count — informational only for the approval screen. */ + async freeTruckCount(): Promise { + return this.dataSource.manager.count(Vehicle, { + where: { status: VehicleStatus.ACTIVE, availability: VehicleAvailability.FREE }, + }); + } + + async submit(id: string, userId: string | null, containerNumbers: string[]): Promise { + const request = await this.findById(id); + if (request.status !== LastMileRequestStatus.AwaitingConfirmation) { + throw new BadRequestException(`Request is already ${request.status.toLowerCase()}`); + } + + if (userId) { + const companyId = await this.bookingsService.resolveCustomerCompanyId(userId); + if (companyId && request.booking?.companyId && companyId !== request.booking.companyId) { + throw new BadRequestException('This request does not belong to your company'); + } + } + + const bookingNumbers = await this.bookingContainerNumbers(request.bookingId); + const selected = containerNumbers.map((n) => n.trim().toUpperCase()); + const unknown = selected.filter((n) => !bookingNumbers.includes(n)); + if (unknown.length) { + throw new BadRequestException(`Container(s) not on this booking: ${unknown.join(', ')}`); + } + + await this.requestsRepository.update(id, { + requestedContainerNumbers: selected, + status: LastMileRequestStatus.Submitted, + submittedByUserId: userId, + submittedAt: new Date(), + } as Partial); + + void this.notifications.notify({ + recipients: { permissionKeys: [FREIGHT_PERMS.lastMile.requestReview] }, + audience: NotificationAudience.BACKOFFICE, + type: NotificationType.REQUEST_SUBMITTED, + title: 'Last-mile request ready for review', + body: `Booking ${request.booking?.reference ?? request.bookingId} confirmed ${selected.length} container(s) for EDR last-mile.`, + link: `/dashboard/operations/last-mile?tab=requests`, + data: { bookingId: request.bookingId, requestId: request.id }, + priority: NotificationPriority.NORMAL, + }); + + return this.findById(id); + } + + async approve(id: string, staffId: string | null, advanceAmount: number): Promise { + const request = await this.findById(id); + if (request.status !== LastMileRequestStatus.Submitted) { + throw new BadRequestException(`Only a submitted request can be approved (current status: ${request.status})`); + } + const booking = request.booking ?? (await this.bookingsRepository.findById(request.bookingId)); + if (!booking) throw new NotFoundException(`Booking ${request.bookingId} not found`); + + // Idempotent per booking — reuses the record if one already exists. + const lastMile = await this.lastMileService.create({ + bookingId: request.bookingId, + status: 'PAYMENT_PENDING', + advancedPayment: 0, + }); + + await this.billing.generateInvoice({ + // 'last_mile' (not the InvoiceSource.LastMile enum value "lastmile") to + // match the existing source string LastMileInvoiceService/LastMileService + // already query by (findBySourceIds/findPayable/attachInvoices). + source: 'last_mile' as Freight.InvoiceSource, + sourceId: lastMile.id, + type: 'LAST_MILE_ADVANCE', + companyId: booking.companyId, + companyProfileId: booking.companyProfileId || '', + currency: booking.paymentCurrency || 'ETB', + lines: [ + { + chargeType: 'LAST_MILE_ADVANCE', + description: 'Last-mile delivery advance', + amount: advanceAmount, + }, + ], + totalAmount: advanceAmount, + }); + + await this.requestsRepository.update(id, { + status: LastMileRequestStatus.Approved, + reviewedByStaffId: staffId, + reviewedAt: new Date(), + resultingLastMileId: lastMile.id, + } as Partial); + + if (booking.companyId) { + void this.notifications.notify({ + recipients: { companyId: booking.companyId }, + audience: NotificationAudience.PORTAL, + type: NotificationType.INVOICE_ISSUED, + title: 'Last-mile request approved — payment due', + body: `Your last-mile request for booking ${booking.reference ?? booking.id} was approved. Pay the advance invoice to proceed.`, + link: '/billing/invoices', + data: { bookingId: booking.id, requestId: id, lastMileId: lastMile.id }, + priority: NotificationPriority.HIGH, + }); + } + + return this.findById(id); + } + + async reject(id: string, staffId: string | null, reason: string): Promise { + const request = await this.findById(id); + if (request.status !== LastMileRequestStatus.Submitted) { + throw new BadRequestException(`Only a submitted request can be rejected (current status: ${request.status})`); + } + const booking = request.booking ?? (await this.bookingsRepository.findById(request.bookingId)); + + await this.requestsRepository.update(id, { + status: LastMileRequestStatus.Rejected, + reviewedByStaffId: staffId, + reviewedAt: new Date(), + rejectionReason: reason, + } as Partial); + + if (booking?.companyId) { + void this.notifications.notify({ + recipients: { companyId: booking.companyId }, + audience: NotificationAudience.PORTAL, + type: NotificationType.BOOKING_STATUS, + title: 'Last-mile request rejected', + body: `Your last-mile request for booking ${booking.reference ?? booking.id} was rejected: ${reason}`, + link: `/bookings/${booking.id}`, + data: { bookingId: booking.id, requestId: id }, + priority: NotificationPriority.HIGH, + }); + } + + return this.findById(id); + } +} diff --git a/apps/edr-freight-api/src/scripts/seed-warehouse-demo.ts b/apps/edr-freight-api/src/scripts/seed-warehouse-demo.ts index 593abb305..4c226f8ab 100644 --- a/apps/edr-freight-api/src/scripts/seed-warehouse-demo.ts +++ b/apps/edr-freight-api/src/scripts/seed-warehouse-demo.ts @@ -35,13 +35,24 @@ async function main() { // Demo seeders are intentionally not AppModule providers (they'd run on every // boot), so construct them against the app's DataSource instead of via DI. const dataSource = app.get(DataSource); - await new PricingDataSeeder(dataSource).run(); - await new IndodeFacilitySeeder(dataSource).run(); - await new Batch14TestDataSeeder(dataSource).run(); - await new Batch5TestDataSeeder(dataSource).run(); - await new Batch7TestDataSeeder(dataSource).run(); - await new Batch8TestDataSeeder(dataSource).run(); - await new WarehouseDemoSeeder(dataSource).run(); + + // Each bucket is independent: a seeder that has drifted from the current + // schema shouldn't stop the rest of the demo data from landing. + const step = async (name: string, run: () => Promise) => { + try { + await run(); + } catch (error) { + console.warn(` ! ${name} skipped: ${error instanceof Error ? error.message : String(error)}`); + } + }; + + await step('PricingDataSeeder', () => new PricingDataSeeder(dataSource).run()); + await step('IndodeFacilitySeeder', () => new IndodeFacilitySeeder(dataSource).run()); + await step('Batch14TestDataSeeder', () => new Batch14TestDataSeeder(dataSource).run()); + await step('Batch5TestDataSeeder', () => new Batch5TestDataSeeder(dataSource).run()); + await step('Batch7TestDataSeeder', () => new Batch7TestDataSeeder(dataSource).run()); + await step('Batch8TestDataSeeder', () => new Batch8TestDataSeeder(dataSource).run()); + await step('WarehouseDemoSeeder', () => new WarehouseDemoSeeder(dataSource).run()); console.log('Warehouse demo data seeded.'); } finally { diff --git a/apps/edr-freight-api/src/seed/edr-freight.seed.ts b/apps/edr-freight-api/src/seed/edr-freight.seed.ts index 7ebcfb89c..22f7fe5ef 100644 --- a/apps/edr-freight-api/src/seed/edr-freight.seed.ts +++ b/apps/edr-freight-api/src/seed/edr-freight.seed.ts @@ -306,4 +306,5 @@ export const EDR_FREIGHT_POSITIONS: FreightSeedPosition[] = [ { key: "operation", name: { en: "Operation" }, rank: 4, permissionKeys: [...POSITION_PERMISSION_PRESETS.operation] }, { key: "operations_chief", name: { en: "Operations Chief" }, rank: 2, permissionKeys: [...POSITION_PERMISSION_PRESETS.operationsChief] }, { key: "dispatcher", name: { en: "Dispatcher" }, rank: 4, permissionKeys: [...POSITION_PERMISSION_PRESETS.dispatcher] }, + { key: "truck_machinery_chief", name: { en: "Truck & Machinery Chief" }, rank: 2, permissionKeys: [...POSITION_PERMISSION_PRESETS.truckMachineryChief] }, ]; diff --git a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts index 0c44e704d..11cccd782 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts @@ -227,6 +227,9 @@ export const MILE_PERMISSIONS: FreightPermissionSeed[] = [ perm('d3b00001-0001-4000-8000-000000000006', 'edr_freight_app:last_mile:assign_vehicles', 'Assign last-mile vehicles'), perm('d3b00001-0001-4000-8000-000000000007', 'edr_freight_app:last_mile:set_distances', 'Set last-mile distances'), perm('d3b00001-0001-4000-8000-000000000008', 'edr_freight_app:last_mile:generate_invoice', 'Generate last-mile invoice'), + perm('d3b00001-0001-4000-8000-000000000009', 'edr_freight_app:last_mile:request_view', 'View last-mile confirmation requests'), + perm('d3b00001-0001-4000-8000-00000000000a', 'edr_freight_app:last_mile:request_review', 'Review last-mile confirmation requests (T&M dept)'), + perm('d3b00001-0001-4000-8000-00000000000b', 'edr_freight_app:last_mile:request_approve', 'Approve/reject last-mile confirmation requests'), ]; // F. Fleet — rail assets (splits the flat fleet:view/manage) @@ -528,6 +531,11 @@ export const FREIGHT_PERMS = { assignVehicles: 'edr_freight_app:last_mile:assign_vehicles', setDistances: 'edr_freight_app:last_mile:set_distances', generateInvoice: 'edr_freight_app:last_mile:generate_invoice', + // Pre-approval confirmation stage (Truck & Machinery department): view/review + // a submitted request, approve/reject it. + requestView: 'edr_freight_app:last_mile:request_view', + requestReview: 'edr_freight_app:last_mile:request_review', + requestApprove: 'edr_freight_app:last_mile:request_approve', }, locomotives: { view: 'edr_freight_app:locomotives:view', @@ -1002,6 +1010,17 @@ export const POSITION_PERMISSION_PRESETS = { FREIGHT_PERMS.trainScheduling.view, FREIGHT_PERMS.bookings.operations, ]), + // Truck & Machinery chief: reviews and approves/rejects last-mile + // confirmation requests (the pre-approval gate ahead of vehicle assignment), + // plus enough fleet visibility to judge truck availability. + truckMachineryChief: dedupe([ + FREIGHT_PERMS.lastMile.view, + FREIGHT_PERMS.lastMile.requestView, + FREIGHT_PERMS.lastMile.requestReview, + FREIGHT_PERMS.lastMile.requestApprove, + FREIGHT_PERMS.fleetDashboard.view, + FREIGHT_PERMS.vehicles.view, + ]), } as const; /** Derive the module bucket from the resource segment of a permission key. */ diff --git a/apps/edr-freight-api/src/seed/freight-staff-users.seeder.ts b/apps/edr-freight-api/src/seed/freight-staff-users.seeder.ts index 6de3bc4de..1b5d776e9 100644 --- a/apps/edr-freight-api/src/seed/freight-staff-users.seeder.ts +++ b/apps/edr-freight-api/src/seed/freight-staff-users.seeder.ts @@ -29,6 +29,7 @@ const STAFF_USERS = [ { email: 'operation@edr.local', username: 'operation', roleKey: 'edr_operations_officer', positionKey: 'operation' }, { email: 'gl-et@edr.local', username: 'gl_et', roleKey: 'edr_gl_ethiopia', positionKey: 'ethiopian_gl' }, { email: 'gl-dj@edr.local', username: 'gl_dj', roleKey: 'edr_gl_djibouti', positionKey: 'djibouti_gl' }, + { email: 'tm-chief@edr.local', username: 'tm_chief', roleKey: 'edr_operations_officer', positionKey: 'truck_machinery_chief' }, ] as const; @Injectable() diff --git a/apps/edr-freight-api/src/seed/paid-import-export-mile-demo.seeder.ts b/apps/edr-freight-api/src/seed/paid-import-export-mile-demo.seeder.ts index f60d7bb75..654f4f77f 100644 --- a/apps/edr-freight-api/src/seed/paid-import-export-mile-demo.seeder.ts +++ b/apps/edr-freight-api/src/seed/paid-import-export-mile-demo.seeder.ts @@ -4,6 +4,11 @@ import { DataSource } from 'typeorm'; import { BookingContainer } from '../modules/bookings/entities/booking-container.entity'; import { Booking } from '../modules/bookings/entities/booking.entity'; +import { + CompanyProfile, + ProfileStatus, + ProfileType, +} from '../modules/companies/entities/company-profile.entity'; import { Company, CompanyStatus, CompanyType } from '../modules/companies/entities/company.entity'; import { FirstMile } from '../modules/first-mile/entities/first-mile.entity'; import { LastMile } from '../modules/last-mile/entities/last-mile.entity'; @@ -13,7 +18,8 @@ import { ServiceType } from '../modules/rule-engine/entities/service-type.entity import { Yard } from '../modules/rule-engine/entities/yard.entity'; const SERVICE_TYPE_CODE = 'RAIL_CONTAINER_PAID_MILE'; -const COMPANY_TIN = 'PAIDMILE001'; +// companies.tin is varchar(10) — an 11-char TIN 22001s the whole seeder. +const COMPANY_TIN = 'PAIDMILE01'; const COMPANY_EMAIL = 'paid-mile-demo@edr.local'; const YARDS = [ @@ -183,6 +189,22 @@ export class PaidImportExportMileDemoSeeder { manager.getRepository(ContainerType).find(), ]); + // bookings.company_profile_id is NOT NULL — the demo company needs an + // approved importer profile of its own (no unique key to upsert on). + const profileRepo = manager.getRepository(CompanyProfile); + const companyProfile = + (await profileRepo.findOne({ + where: { companyId: company.id, type: ProfileType.importer }, + })) ?? + (await profileRepo.save( + profileRepo.create({ + companyId: company.id, + type: ProfileType.importer, + status: ProfileStatus.Active, + businessLicense: 'PMD-LIC-0001', + }), + )); + const yardByCode = new Map(yards.map((yard) => [yard.code, yard])); const containerTypeByCode = new Map( containerTypes.map((containerType) => [containerType.code, containerType]), @@ -206,6 +228,7 @@ export class PaidImportExportMileDemoSeeder { { reference: demoBooking.reference, companyId: company.id, + companyProfileId: companyProfile.id, status: 'APPROVED', scheduledDate: new Date(demoBooking.scheduledDate), estimatedShipmentDate: new Date(demoBooking.scheduledDate), diff --git a/apps/edr-freight-web/backoffice/src/components/operations/LastMileRequestsPanel.tsx b/apps/edr-freight-web/backoffice/src/components/operations/LastMileRequestsPanel.tsx new file mode 100644 index 000000000..6b5e15d99 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/operations/LastMileRequestsPanel.tsx @@ -0,0 +1,290 @@ +import { useState } from "react"; +import { + Badge, + Box, + Button, + Card, + Group, + Modal, + NumberInput, + Stack, + Text, + Textarea, +} from "@mantine/core"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import type { ColumnDef } from "@edr/ui-common"; +import { DataTable, DataTableFooter, usePagination } from "@edr/ui-common"; + +import { QUERY_KEYS } from "@/constants/QUERY_KEYS"; +import { useToast } from "@/hooks/use-toast"; +import { useAuth } from "@/auth/useAuth"; +import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions"; +import { + lastMileRequestsService, + type LastMileRequest, + type LastMileRequestStatus, +} from "@/services/last-mile-requests.service"; + +const STATUS_META: Record = { + AWAITING_CONFIRMATION: { label: "Awaiting Confirmation", color: "gray" }, + SUBMITTED: { label: "Submitted", color: "yellow" }, + APPROVED: { label: "Approved", color: "green" }, + REJECTED: { label: "Rejected", color: "red" }, +}; + +type StatusFilter = "ALL" | LastMileRequestStatus; + +const FILTER_OPTIONS: { value: StatusFilter; label: string }[] = [ + { value: "SUBMITTED", label: "Submitted" }, + { value: "APPROVED", label: "Approved" }, + { value: "REJECTED", label: "Rejected" }, + { value: "AWAITING_CONFIRMATION", label: "Awaiting Confirmation" }, + { value: "ALL", label: "All" }, +]; + +const fmtDate = (iso?: string | null) => + iso ? new Date(iso).toLocaleString("en-US", { dateStyle: "medium", timeStyle: "short" }) : "—"; + +export function LastMileRequestsPanel() { + const { toast } = useToast(); + const qc = useQueryClient(); + const { user } = useAuth(); + const canApprove = hasPermission(user, FREIGHT_PERMS.lastMile.requestApprove); + + const [statusFilter, setStatusFilter] = useState("SUBMITTED"); + const { pagination, setPagination } = usePagination({ pageSize: 10 }); + const [approveTarget, setApproveTarget] = useState(null); + const [rejectTarget, setRejectTarget] = useState(null); + const [advanceAmount, setAdvanceAmount] = useState(""); + const [rejectReason, setRejectReason] = useState(""); + + const filter = { + ...(statusFilter !== "ALL" ? { status: statusFilter } : {}), + page: pagination.pageIndex + 1, + pageSize: pagination.pageSize, + }; + + const { data, isLoading } = useQuery({ + queryKey: QUERY_KEYS.LAST_MILE_REQUESTS.list(filter), + queryFn: async () => (await lastMileRequestsService.list(filter)).data, + }); + const rows = data?.data ?? []; + const meta = data?.meta; + + const { data: freeTrucks } = useQuery({ + queryKey: QUERY_KEYS.LAST_MILE_REQUESTS.freeTruckCount, + queryFn: async () => (await lastMileRequestsService.freeTruckCount()).data, + }); + + const invalidate = () => + qc.invalidateQueries({ queryKey: QUERY_KEYS.LAST_MILE_REQUESTS.ROOT }); + + const approve = useMutation({ + mutationFn: () => + lastMileRequestsService.approve(approveTarget!.id, Number(advanceAmount)), + onSuccess: () => { + void invalidate(); + toast({ title: "Request approved" }); + setApproveTarget(null); + setAdvanceAmount(""); + }, + onError: (e: unknown) => { + const description = (e as { response?: { data?: { message?: string } } })?.response?.data?.message; + toast({ title: "Approve failed", description, variant: "destructive" }); + }, + }); + + const reject = useMutation({ + mutationFn: () => lastMileRequestsService.reject(rejectTarget!.id, rejectReason.trim()), + onSuccess: () => { + void invalidate(); + toast({ title: "Request rejected" }); + setRejectTarget(null); + setRejectReason(""); + }, + onError: (e: unknown) => { + const description = (e as { response?: { data?: { message?: string } } })?.response?.data?.message; + toast({ title: "Reject failed", description, variant: "destructive" }); + }, + }); + + const columns: ColumnDef[] = [ + { + id: "booking", + header: () => Booking, + cell: ({ row }) => { + const r = row.original; + return ( + + {r.booking?.reference ?? r.bookingId} + {r.booking?.company?.name ?? "—"} + + ); + }, + }, + { + id: "containers", + header: () => Requested Containers, + cell: ({ row }) => { + const nums = row.original.requestedContainerNumbers; + return {nums?.length ? nums.join(", ") : "—"}; + }, + }, + { + id: "submittedAt", + header: () => Submitted, + cell: ({ row }) => {fmtDate(row.original.submittedAt)}, + }, + { + id: "status", + header: () => Status, + cell: ({ row }) => { + const meta = STATUS_META[row.original.status]; + return ( + + {meta.label} + + ); + }, + }, + ...(canApprove + ? [ + { + id: "actions", + header: () => Actions, + cell: ({ row }: { row: { original: LastMileRequest } }) => { + const r = row.original; + if (r.status !== "SUBMITTED") return null; + return ( + + + + + ); + }, + } as ColumnDef, + ] + : []), + ]; + + return ( + + + + + + {freeTrucks?.count ?? 0} truck{freeTrucks?.count === 1 ? "" : "s"} currently free + + + {FILTER_OPTIONS.map((option) => { + const active = statusFilter === option.value; + return ( + + ); + })} + + + + + ( + + )} + /> + + + setApproveTarget(null)} + title={Approve request{approveTarget?.booking?.reference ? ` · ${approveTarget.booking.reference}` : ""}} + centered + > + + + + + + + + + + setRejectTarget(null)} + title={Reject request{rejectTarget?.booking?.reference ? ` · ${rejectTarget.booking.reference}` : ""}} + centered + > + +