From 7888dc771ede40323ae35acaaa19ef87f66b982d Mon Sep 17 00:00:00 2001 From: Abubeker Yasin Date: Fri, 7 Aug 2026 10:47:55 +0300 Subject: [PATCH 01/51] fix: (passenger): block payment initiation too close to the booking deadline --- .../common/utils/payment-deadline.utils.ts | 30 ++++++-- .../src/modules/payments/payments.dto.ts | 4 ++ .../modules/payments/payments.service.spec.ts | 68 +++++++++++++++++++ .../src/modules/payments/payments.service.ts | 48 +++++++++++-- .../src/providers/dmoney/dmoney.provider.ts | 2 +- 5 files changed, 141 insertions(+), 11 deletions(-) diff --git a/apps/edr-passenger-api/src/common/utils/payment-deadline.utils.ts b/apps/edr-passenger-api/src/common/utils/payment-deadline.utils.ts index e125bb0bf..8c391aef1 100644 --- a/apps/edr-passenger-api/src/common/utils/payment-deadline.utils.ts +++ b/apps/edr-passenger-api/src/common/utils/payment-deadline.utils.ts @@ -13,11 +13,17 @@ export const MAX_PAYMENT_HOURS = 2; export const CUTOFF_MINUTES = 30; /** - * payment_deadline = MIN(booking_time + MAX_PAYMENT_HOURS, segment_departure - checkinMinutes) - * - * checkinMinutes defaults to CUTOFF_MINUTES but callers should pass the route-level - * checkinMinutesBefore so that each route's own window is respected. + * How long a passenger is given to finish one provider payment session, once opened. + * 5 minutes of actual paying (redirect → PIN/OTP → provider callback) + 1 minute of slack. */ +export const PAYMENT_SESSION_MINUTES = 6; + + +export const MIN_PAYMENT_WINDOW_MINUTES = 7; + +export const PAYMENT_SETTLE_MARGIN_SECONDS = 60; + + export function computePaymentDeadline( createdAt: Date, departureAt: Date, @@ -27,3 +33,19 @@ export function computePaymentDeadline( const cutoffDeadline = new Date(departureAt.getTime() - checkinMinutes * 60 * 1000); return maxDeadline < cutoffDeadline ? maxDeadline : cutoffDeadline; } + + +export function canOpenPaymentSession( + paymentDeadline: Date, + now: Date = new Date(), +): boolean { + return paymentDeadline.getTime() - now.getTime() >= MIN_PAYMENT_WINDOW_MINUTES * 60 * 1000; +} + +export function computePaymentSessionExpiry( + paymentDeadline: Date, + now: Date = new Date(), +): Date { + const sessionEnd = new Date(now.getTime() + PAYMENT_SESSION_MINUTES * 60 * 1000); + return sessionEnd < paymentDeadline ? sessionEnd : paymentDeadline; +} diff --git a/apps/edr-passenger-api/src/modules/payments/payments.dto.ts b/apps/edr-passenger-api/src/modules/payments/payments.dto.ts index dc389e5a1..1d033e5ff 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.dto.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.dto.ts @@ -154,6 +154,10 @@ export class InitiateResponseDto { @ApiPropertyOptional({ type: ClientActionDto }) clientAction?: ClientActionDto; @ApiPropertyOptional() merchantOrderId?: string; + /** When this payment session stops being offered — PAYMENT_SESSION_MINUTES from initiation, capped at paymentDeadline. Drives the client-side countdown. */ + @ApiPropertyOptional() sessionExpiresAt?: string; + /** The booking's payment deadline: after it, the booking is auto-cancelled. */ + @ApiPropertyOptional() paymentDeadline?: string; } export class IntentStatusDto { diff --git a/apps/edr-passenger-api/src/modules/payments/payments.service.spec.ts b/apps/edr-passenger-api/src/modules/payments/payments.service.spec.ts index 8c4edf083..186383335 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.service.spec.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.service.spec.ts @@ -16,6 +16,11 @@ import { ProviderMethod, ProviderPaymentStatus, } from "@edr/types"; +import { + MAX_PAYMENT_HOURS, + MIN_PAYMENT_WINDOW_MINUTES, + PAYMENT_SESSION_MINUTES, +} from "../../common/utils/payment-deadline.utils"; describe("PaymentsService", () => { let service: PaymentsService; @@ -163,6 +168,69 @@ describe("PaymentsService", () => { ).rejects.toThrow(BadRequestException); }); + /** + * A booking whose payment deadline lands exactly `minutesLeft` from now: the deadline is + * MIN(createdAt + MAX_PAYMENT_HOURS, departure - checkin), so back-date createdAt and keep + * departure far away. Derived from MAX_PAYMENT_HOURS so the test survives changes to it. + */ + const bookingWithDeadlineIn = (minutesLeft: number) => ({ + ...mockBooking, + createdAt: new Date( + Date.now() - (MAX_PAYMENT_HOURS * 60 - minutesLeft) * 60 * 1000, + ), + originStationId: null, + schedule: { + departureAt: new Date(Date.now() + 10 * 60 * 60 * 1000), + stopTimes: [], + route: null, + }, + }); + + it("should refuse to open a provider session that cannot finish before auto-cancel", async () => { + // 2 minutes left — the real incident: the session was opened, the provider captured the + // money, and the auto-cancel cron had already cancelled the booking by then. + mockPrisma.booking.findUnique.mockResolvedValue(bookingWithDeadlineIn(2)); + + await expect( + service.initiatePayment({ + bookingId: "booking-1", + method: "TELEBIRR" as any, + }), + ).rejects.toThrow(BadRequestException); + + // Nothing may reach the provider — no session, no capture, no orphan payment. + expect(mockPaymentClient.initiate).not.toHaveBeenCalled(); + }); + + it("should open a session and report its expiry when the window is wide enough", async () => { + const minutesLeft = MIN_PAYMENT_WINDOW_MINUTES + 3; + mockPrisma.booking.findUnique.mockResolvedValue( + bookingWithDeadlineIn(minutesLeft), + ); + mockPaymentClient.initiate.mockResolvedValue( + requiresActionSnapshot(ProviderMethod.TELEBIRR), + ); + mockPrisma.paymentIntent.upsert.mockResolvedValue({ + id: "intent-1", + status: PaymentIntentStatus.REQUIRES_ACTION, + merchantOrderId: "PSG-MERCH-123", + }); + + const result = await service.initiatePayment({ + bookingId: "booking-1", + method: "TELEBIRR" as any, + }); + + expect(mockPaymentClient.initiate).toHaveBeenCalled(); + // Session ends PAYMENT_SESSION_MINUTES from now — before the deadline, not at it. + const sessionMs = + new Date(result.sessionExpiresAt!).getTime() - Date.now(); + expect(sessionMs).toBeLessThanOrEqual(PAYMENT_SESSION_MINUTES * 60 * 1000); + expect(new Date(result.sessionExpiresAt!).getTime()).toBeLessThan( + new Date(result.paymentDeadline!).getTime(), + ); + }); + it("should initiate a provider payment through the payment microservice", async () => { mockPrisma.booking.findUnique.mockResolvedValue(mockBooking); mockPaymentClient.initiate.mockResolvedValue( diff --git a/apps/edr-passenger-api/src/modules/payments/payments.service.ts b/apps/edr-passenger-api/src/modules/payments/payments.service.ts index 14e48dce5..2b1b14cf1 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.service.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.service.ts @@ -29,7 +29,13 @@ import { MarkPaidResponseDto, BillQueryResponseDto, } from "./internal-payments.dto"; -import { computePaymentDeadline } from "../../common/utils/payment-deadline.utils"; +import { + computePaymentDeadline, + computePaymentSessionExpiry, + canOpenPaymentSession, + MIN_PAYMENT_WINDOW_MINUTES, + PAYMENT_SETTLE_MARGIN_SECONDS, +} from "../../common/utils/payment-deadline.utils"; import { PaymentClientService, PaymentDiagnostic, @@ -260,6 +266,27 @@ export class PaymentsService { return this.initiateWalletPayment(booking); } + // Refuse to open a provider session that cannot finish before auto-cancel. Everything below + // this point hands the passenger off to an external provider (redirect/HPP/OTP), which takes + // minutes; TasksService cancels the booking the first cron tick after its payment deadline. + // Opening a session with less than MIN_PAYMENT_WINDOW_MINUTES left produces the worst possible + // outcome — the provider captures the money and the booking is already CANCELLED when the + // capture lands. WALLET is exempt (returned above): it is an instant internal balance debit. + const paymentDeadline = await this.computeBookingPaymentDeadline(booking.id); + const sessionExpiresAt = paymentDeadline + ? computePaymentSessionExpiry(paymentDeadline) + : undefined; + if (paymentDeadline && !canOpenPaymentSession(paymentDeadline)) { + const remainingMs = paymentDeadline.getTime() - Date.now(); + throw new BadRequestException( + remainingMs <= 0 + ? "The payment window for this booking has expired. Please make a new booking." + : `Too little time is left to start a payment (${Math.ceil(remainingMs / 60000)} minute(s) ` + + `until this booking expires; at least ${MIN_PAYMENT_WINDOW_MINUTES} are required). ` + + `Please make a new booking.`, + ); + } + // Free method changes: no reuse/blocking. Every initiate opens a fresh provider session; the // single passenger projection row (upserted by bookingId below) tracks the latest session. // Confirm-once is enforced when a payment succeeds (finalizePaymentSuccess), not here. @@ -317,9 +344,7 @@ export class PaymentsService { payerName = booking.seats.find((s) => s.leg === 1)?.passengerName ?? booking.seats[0]?.passengerName; - expiresAt = ( - await this.computeBookingPaymentDeadline(booking.id) - )?.toISOString(); + expiresAt = paymentDeadline?.toISOString(); } const snapshot = await this.paymentClient.initiate({ @@ -350,7 +375,11 @@ export class PaymentsService { where: { id: intent.id }, }); } - return this.formatIntentResponse(intent); + return { + ...this.formatIntentResponse(intent), + sessionExpiresAt: sessionExpiresAt?.toISOString(), + paymentDeadline: paymentDeadline?.toISOString(), + }; } /** @@ -436,8 +465,15 @@ export class PaymentsService { if (booking.status !== "PENDING_PAYMENT") { return { ...base, stillPayable: false, reason: "NOT_PAYABLE" }; } + // A CBE debit confirmed now lands in seconds, so this doesn't need the full + // MIN_PAYMENT_WINDOW_MINUTES that opening a session does — but it must not be confirmed so + // close to the deadline that the auto-cancel cron cancels the booking before the capture is + // registered. Refusing here is what keeps CBE from debiting a passenger for a dead booking. const deadline = await this.computeBookingPaymentDeadline(booking.id); - if (deadline && deadline.getTime() < Date.now()) { + if ( + deadline && + deadline.getTime() - PAYMENT_SETTLE_MARGIN_SECONDS * 1000 < Date.now() + ) { return { ...base, stillPayable: false, reason: "EXPIRED" }; } return { ...base, stillPayable: true, reason: null }; diff --git a/packages/payment-providers/src/providers/dmoney/dmoney.provider.ts b/packages/payment-providers/src/providers/dmoney/dmoney.provider.ts index 51e90381b..3af351e32 100644 --- a/packages/payment-providers/src/providers/dmoney/dmoney.provider.ts +++ b/packages/payment-providers/src/providers/dmoney/dmoney.provider.ts @@ -353,7 +353,7 @@ export class DMoneyProvider implements PaymentProvider { return this.config.get("dmoney.returnUrl") ?? ""; } private get timeoutExpress(): string { - return this.config.get("dmoney.timeoutExpress") ?? "120m"; + return this.config.get("dmoney.timeoutExpress") ?? "5m"; } private get language(): string { return this.config.get("dmoney.language") ?? "en"; From da08a9b0859da246d7c609cf44f467e73498291c Mon Sep 17 00:00:00 2001 From: Nathnael Date: Fri, 7 Aug 2026 08:42:53 +0000 Subject: [PATCH 02/51] fix(auth): list every route key on the class-level guard Nest runs class and method guards together, so a class gate naming only the view key silently required view AND action. Staff granted just an action were denied before their key was checked. Each class gate now names every key its routes use, and FleetView accepts an array so the fleet controllers keep their coarse fallback. Drops the one-off grant mapping SQL with it: already applied to dev, and this fix removes the companion-view rule that was its recurring part. --- .../src/common/booking-guards.ts | 9 +- .../src/modules/billing/billing.controller.ts | 7 +- .../src/modules/cargoes/cargoes.controller.ts | 9 +- .../compliance/compliance.controller.ts | 7 +- .../consignments/consignments.controller.ts | 7 +- .../containers.controller.ts | 9 +- .../src/modules/drivers/drivers.controller.ts | 9 +- .../facilities/facilities.controller.ts | 7 +- .../gps-tracking/gps-tracking.controller.ts | 7 +- .../modules/incidents/incidents.controller.ts | 9 +- .../interchange-documents.controller.ts | 9 +- .../procurement/procurement.controller.ts | 9 +- .../src/modules/routes/routes.controller.ts | 10 +- .../trains/train-builder.controller.ts | 10 +- .../src/modules/trains/trains.controller.ts | 9 +- .../modules/vehicles/vehicles.controller.ts | 9 +- .../warehouse-inspection.controller.ts | 4 + .../warehouses/warehouse-zones.controller.ts | 8 +- deploy/position-type-grant-mapping.sql | 119 ------------------ 19 files changed, 130 insertions(+), 137 deletions(-) delete mode 100644 deploy/position-type-grant-mapping.sql diff --git a/apps/edr-freight-api/src/common/booking-guards.ts b/apps/edr-freight-api/src/common/booking-guards.ts index 49bd31c61..d1b5364c3 100644 --- a/apps/edr-freight-api/src/common/booking-guards.ts +++ b/apps/edr-freight-api/src/common/booking-guards.ts @@ -95,9 +95,14 @@ export const TrainSchedulingRulesManage = () => * wagons:delete, …). The legacy coarse fleet:view / fleet:manage keys remain * valid as a one-of fallback so existing role grants keep working. */ -export const FleetView = (granular?: string) => +export const FleetView = (granular?: string | string[]) => BookingStaff( - granular ? [granular, FREIGHT_PERMS.fleet.view] : FREIGHT_PERMS.fleet.view, + granular + ? [ + ...(Array.isArray(granular) ? granular : [granular]), + FREIGHT_PERMS.fleet.view, + ] + : FREIGHT_PERMS.fleet.view, ); export const FleetManage = (granular?: string) => diff --git a/apps/edr-freight-api/src/modules/billing/billing.controller.ts b/apps/edr-freight-api/src/modules/billing/billing.controller.ts index d72528310..ea43c9c74 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.controller.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.controller.ts @@ -20,7 +20,12 @@ import { FilterInvoiceDto } from "./dto/filter-invoice.dto"; @ApiTags("billing") @Controller("billing") -@BookingStaff(FREIGHT_PERMS.invoices.view) +// Class gate lists every key its routes use: Nest runs class AND method +// guards, so a key missing here would deny before the route's own key runs. +@BookingStaff([ + FREIGHT_PERMS.invoices.view, + FREIGHT_PERMS.invoices.export, +]) @ApiBearerAuth() export class BillingController { constructor( diff --git a/apps/edr-freight-api/src/modules/cargoes/cargoes.controller.ts b/apps/edr-freight-api/src/modules/cargoes/cargoes.controller.ts index ac3adb7e8..09c9b677a 100644 --- a/apps/edr-freight-api/src/modules/cargoes/cargoes.controller.ts +++ b/apps/edr-freight-api/src/modules/cargoes/cargoes.controller.ts @@ -20,7 +20,14 @@ import { CargoesService } from './cargoes.service'; @ApiTags('cargoes') @Controller('cargoes') -@FleetView(FREIGHT_PERMS.cargoes.view) +// Class gate lists every key its routes use: Nest runs class AND method +// guards, so a key missing here would deny before the route's own key runs. +@FleetView([ + FREIGHT_PERMS.cargoes.view, + FREIGHT_PERMS.cargoes.create, + FREIGHT_PERMS.cargoes.update, + FREIGHT_PERMS.cargoes.delete, +]) export class CargoesController { constructor(private readonly cargoesService: CargoesService) {} diff --git a/apps/edr-freight-api/src/modules/compliance/compliance.controller.ts b/apps/edr-freight-api/src/modules/compliance/compliance.controller.ts index 0e5949300..25d010253 100644 --- a/apps/edr-freight-api/src/modules/compliance/compliance.controller.ts +++ b/apps/edr-freight-api/src/modules/compliance/compliance.controller.ts @@ -11,7 +11,12 @@ import { ComplianceType } from './entities/compliance-record.entity'; @ApiTags('Vehicle Compliance') @Controller('compliance') -@BookingStaff(FREIGHT_PERMS.compliance.view) +// Class gate lists every key its routes use: Nest runs class AND method +// guards, so a key missing here would deny before the route's own key runs. +@BookingStaff([ + FREIGHT_PERMS.compliance.view, + FREIGHT_PERMS.compliance.manage, +]) export class ComplianceController { constructor(private readonly complianceService: ComplianceService) {} diff --git a/apps/edr-freight-api/src/modules/consignments/consignments.controller.ts b/apps/edr-freight-api/src/modules/consignments/consignments.controller.ts index 579b9ee26..a9aaa73e3 100644 --- a/apps/edr-freight-api/src/modules/consignments/consignments.controller.ts +++ b/apps/edr-freight-api/src/modules/consignments/consignments.controller.ts @@ -17,7 +17,12 @@ import { FilterConsignmentDto } from "./dto/filter-consignment.dto"; @ApiTags("consignments") @Controller("consignments") -@FleetView(FREIGHT_PERMS.consignments.view) +// Class gate lists every key its routes use: Nest runs class AND method +// guards, so a key missing here would deny before the route's own key runs. +@FleetView([ + FREIGHT_PERMS.consignments.view, + FREIGHT_PERMS.consignments.create, +]) export class ConsignmentsController { constructor(private readonly consignmentsService: ConsignmentsService) {} diff --git a/apps/edr-freight-api/src/modules/container-management/containers.controller.ts b/apps/edr-freight-api/src/modules/container-management/containers.controller.ts index 0a5e6bb0f..a209e2609 100644 --- a/apps/edr-freight-api/src/modules/container-management/containers.controller.ts +++ b/apps/edr-freight-api/src/modules/container-management/containers.controller.ts @@ -19,7 +19,14 @@ import { ContainersService } from './containers.service'; @ApiTags('containers') @Controller('containers') -@FleetView(FREIGHT_PERMS.containers.view) +// Class gate lists every key its routes use: Nest runs class AND method +// guards, so a key missing here would deny before the route's own key runs. +@FleetView([ + FREIGHT_PERMS.containers.view, + FREIGHT_PERMS.containers.create, + FREIGHT_PERMS.containers.update, + FREIGHT_PERMS.containers.delete, +]) export class ContainersController { constructor(private readonly containersService: ContainersService) {} diff --git a/apps/edr-freight-api/src/modules/drivers/drivers.controller.ts b/apps/edr-freight-api/src/modules/drivers/drivers.controller.ts index e5b6ae146..8d2be55df 100644 --- a/apps/edr-freight-api/src/modules/drivers/drivers.controller.ts +++ b/apps/edr-freight-api/src/modules/drivers/drivers.controller.ts @@ -24,7 +24,14 @@ import { FleetHistoryService } from '../fleet-history/fleet-history.service'; @ApiTags('drivers') @ApiBearerAuth() @Controller('drivers') -@BookingStaff(FREIGHT_PERMS.drivers.view) +// Class gate lists every key its routes use: Nest runs class AND method +// guards, so a key missing here would deny before the route's own key runs. +@BookingStaff([ + FREIGHT_PERMS.drivers.view, + FREIGHT_PERMS.drivers.create, + FREIGHT_PERMS.drivers.update, + FREIGHT_PERMS.drivers.delete, +]) export class DriversController { constructor( private readonly driversService: DriversService, diff --git a/apps/edr-freight-api/src/modules/facilities/facilities.controller.ts b/apps/edr-freight-api/src/modules/facilities/facilities.controller.ts index 451c4d03b..7db35d432 100644 --- a/apps/edr-freight-api/src/modules/facilities/facilities.controller.ts +++ b/apps/edr-freight-api/src/modules/facilities/facilities.controller.ts @@ -10,7 +10,12 @@ import { FacilitiesService } from './facilities.service'; @ApiTags('Facilities') @Controller('facilities') -@BookingStaff(FREIGHT_PERMS.facilities.view) +// Class gate lists every key its routes use: Nest runs class AND method +// guards, so a key missing here would deny before the route's own key runs. +@BookingStaff([ + FREIGHT_PERMS.facilities.view, + FREIGHT_PERMS.facilities.manage, +]) export class FacilitiesController { constructor(private readonly facilitiesService: FacilitiesService) {} diff --git a/apps/edr-freight-api/src/modules/gps-tracking/gps-tracking.controller.ts b/apps/edr-freight-api/src/modules/gps-tracking/gps-tracking.controller.ts index 8bd4a31d8..d0cc2d1b4 100644 --- a/apps/edr-freight-api/src/modules/gps-tracking/gps-tracking.controller.ts +++ b/apps/edr-freight-api/src/modules/gps-tracking/gps-tracking.controller.ts @@ -19,7 +19,12 @@ import { RegisterDeviceDto, UpdateDeviceDto } from './dto/gps-device.dto'; @ApiTags('gps-tracking') @ApiBearerAuth() @Controller('gps') -@BookingStaff(FREIGHT_PERMS.tracking.view) +// Class gate lists every key its routes use: Nest runs class AND method +// guards, so a key missing here would deny before the route's own key runs. +@BookingStaff([ + FREIGHT_PERMS.tracking.view, + FREIGHT_PERMS.tracking.manage, +]) export class GpsTrackingController { constructor(private readonly gps: GpsTrackingService) {} diff --git a/apps/edr-freight-api/src/modules/incidents/incidents.controller.ts b/apps/edr-freight-api/src/modules/incidents/incidents.controller.ts index b9cebd653..2e9dfb169 100644 --- a/apps/edr-freight-api/src/modules/incidents/incidents.controller.ts +++ b/apps/edr-freight-api/src/modules/incidents/incidents.controller.ts @@ -22,7 +22,14 @@ import { IncidentStatus, IncidentType } from './entities/incident.entity'; // No incidents-specific permission exists in the registry, so this reuses the // (real) drivers.* fleet-road keys — incident records are driver-safety data // (driver stats / incident history). TODO: add a dedicated incidents:* key. -@BookingStaff(FREIGHT_PERMS.drivers.view) +// Class gate lists every key its routes use: Nest runs class AND method +// guards, so a key missing here would deny before the route's own key runs. +@BookingStaff([ + FREIGHT_PERMS.drivers.view, + FREIGHT_PERMS.drivers.create, + FREIGHT_PERMS.drivers.update, + FREIGHT_PERMS.drivers.delete, +]) export class IncidentsController { constructor(private readonly incidentsService: IncidentsService) {} diff --git a/apps/edr-freight-api/src/modules/interchange-documents/interchange-documents.controller.ts b/apps/edr-freight-api/src/modules/interchange-documents/interchange-documents.controller.ts index 61a13744e..b5e92ca39 100644 --- a/apps/edr-freight-api/src/modules/interchange-documents/interchange-documents.controller.ts +++ b/apps/edr-freight-api/src/modules/interchange-documents/interchange-documents.controller.ts @@ -15,7 +15,14 @@ import { InterchangeDocumentsService } from './interchange-documents.service'; @ApiBearerAuth() @Controller('interchange-documents') // Class-level view guard; each write route adds its own manage permission below. -@BookingStaff(FREIGHT_PERMS.interchangeDocuments.view) +// Class gate lists every key its routes use: Nest runs class AND method +// guards, so a key missing here would deny before the route's own key runs. +@BookingStaff([ + FREIGHT_PERMS.interchangeDocuments.view, + FREIGHT_PERMS.interchangeDocuments.generate, + FREIGHT_PERMS.interchangeDocuments.acknowledge, + FREIGHT_PERMS.interchangeDocuments.dispute, +]) export class InterchangeDocumentsController { constructor(private readonly service: InterchangeDocumentsService) {} diff --git a/apps/edr-freight-api/src/modules/procurement/procurement.controller.ts b/apps/edr-freight-api/src/modules/procurement/procurement.controller.ts index 374b2bd53..e96b9dc74 100644 --- a/apps/edr-freight-api/src/modules/procurement/procurement.controller.ts +++ b/apps/edr-freight-api/src/modules/procurement/procurement.controller.ts @@ -13,7 +13,14 @@ import { @ApiTags('Procurement & Asset Lifecycle') @Controller('procurement') -@BookingStaff(FREIGHT_PERMS.procurement.view) +// Class gate lists every key its routes use: Nest runs class AND method +// guards, so a key missing here would deny before the route's own key runs. +@BookingStaff([ + FREIGHT_PERMS.procurement.view, + FREIGHT_PERMS.procurement.vendorManage, + FREIGHT_PERMS.procurement.acquisitionManage, + FREIGHT_PERMS.procurement.disposalManage, +]) export class ProcurementController { constructor(private readonly procurementService: ProcurementService) {} diff --git a/apps/edr-freight-api/src/modules/routes/routes.controller.ts b/apps/edr-freight-api/src/modules/routes/routes.controller.ts index ed61f08b0..96812275d 100644 --- a/apps/edr-freight-api/src/modules/routes/routes.controller.ts +++ b/apps/edr-freight-api/src/modules/routes/routes.controller.ts @@ -27,7 +27,15 @@ import { RoutesService } from './routes.service'; @ApiTags('routes') @ApiBearerAuth() @Controller('routes') -@FleetView(FREIGHT_PERMS.routes.view) +// Class gate lists every key its routes use: Nest runs class AND method +// guards, so a key missing here would deny before the route's own key runs. +@FleetView([ + FREIGHT_PERMS.routes.view, + FREIGHT_PERMS.routes.create, + FREIGHT_PERMS.routes.update, + FREIGHT_PERMS.routes.hardDelete, + FREIGHT_PERMS.routes.delete, +]) export class RoutesController { constructor(private readonly routesService: RoutesService) {} diff --git a/apps/edr-freight-api/src/modules/trains/train-builder.controller.ts b/apps/edr-freight-api/src/modules/trains/train-builder.controller.ts index a5cd1ff5c..834663170 100644 --- a/apps/edr-freight-api/src/modules/trains/train-builder.controller.ts +++ b/apps/edr-freight-api/src/modules/trains/train-builder.controller.ts @@ -31,7 +31,15 @@ import { TrainBuilderService } from './train-builder.service'; @ApiTags('train-builder') @ApiBearerAuth() @Controller('train-builder') -@FleetView(FREIGHT_PERMS.trains.view) +// Class gate lists every key its routes use: Nest runs class AND method +// guards, so a key missing here would deny before the route's own key runs. +@FleetView([ + FREIGHT_PERMS.trains.view, + FREIGHT_PERMS.trains.create, + FREIGHT_PERMS.trains.update, + FREIGHT_PERMS.trains.assignWagons, + FREIGHT_PERMS.trains.delete, +]) export class TrainBuilderController { constructor(private readonly trainBuilderService: TrainBuilderService) {} diff --git a/apps/edr-freight-api/src/modules/trains/trains.controller.ts b/apps/edr-freight-api/src/modules/trains/trains.controller.ts index 173a738fa..ede540ce2 100644 --- a/apps/edr-freight-api/src/modules/trains/trains.controller.ts +++ b/apps/edr-freight-api/src/modules/trains/trains.controller.ts @@ -19,7 +19,14 @@ import { TrainsService } from "./trains.service"; @ApiTags("trains") @Controller("trains") -@FleetView(FREIGHT_PERMS.trains.view) +// Class gate lists every key its routes use: Nest runs class AND method +// guards, so a key missing here would deny before the route's own key runs. +@FleetView([ + FREIGHT_PERMS.trains.view, + FREIGHT_PERMS.trains.create, + FREIGHT_PERMS.trains.update, + FREIGHT_PERMS.trains.delete, +]) export class TrainsController { constructor(private readonly trainsService: TrainsService) {} diff --git a/apps/edr-freight-api/src/modules/vehicles/vehicles.controller.ts b/apps/edr-freight-api/src/modules/vehicles/vehicles.controller.ts index 737574dd9..d9e2ba224 100644 --- a/apps/edr-freight-api/src/modules/vehicles/vehicles.controller.ts +++ b/apps/edr-freight-api/src/modules/vehicles/vehicles.controller.ts @@ -20,7 +20,14 @@ import { FleetHistoryService } from '../fleet-history/fleet-history.service'; @ApiTags('vehicles') @ApiBearerAuth() @Controller('vehicles') -@BookingStaff(FREIGHT_PERMS.vehicles.view) +// Class gate lists every key its routes use: Nest runs class AND method +// guards, so a key missing here would deny before the route's own key runs. +@BookingStaff([ + FREIGHT_PERMS.vehicles.view, + FREIGHT_PERMS.vehicles.create, + FREIGHT_PERMS.vehicles.update, + FREIGHT_PERMS.vehicles.delete, +]) export class VehiclesController { constructor( private readonly vehiclesService: VehiclesService, diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inspection.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inspection.controller.ts index cdf34cd66..551f840b3 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inspection.controller.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inspection.controller.ts @@ -23,9 +23,13 @@ import { WarehouseInspectionService } from './warehouse-inspection.service'; // Baseline read: inspection reports are opened from inventory screens too — // either view permission grants reads; writes stack their own per route. @Controller() +// Class gate lists every key its routes use: Nest runs class AND method +// guards, so a key missing here would deny before the route's own key runs. @BookingStaff([ FREIGHT_PERMS.warehouseInspectionReports.view, FREIGHT_PERMS.warehouseInventory.view, + FREIGHT_PERMS.warehouseInspectionReports.create, + FREIGHT_PERMS.warehouseInspectionReports.update, ]) export class WarehouseInspectionController { constructor(private readonly inspectionService: WarehouseInspectionService) {} diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-zones.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-zones.controller.ts index 594fd7a6f..04cbacd28 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-zones.controller.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-zones.controller.ts @@ -12,7 +12,13 @@ import { WarehouseZonesService } from './warehouse-zones.service'; // receive/move pickers) — either view permission grants reads; writes stack // their specific permission per route. @Controller('warehouse-zones') -@BookingStaff([FREIGHT_PERMS.warehouseZones.view, FREIGHT_PERMS.warehouseInventory.view]) +// Class gate lists every key its routes use: Nest runs class AND method +// guards, so a key missing here would deny before the route's own key runs. +@BookingStaff([ + FREIGHT_PERMS.warehouseZones.view, + FREIGHT_PERMS.warehouseInventory.view, + FREIGHT_PERMS.warehouseZones.update, +]) export class WarehouseZonesController { constructor(private readonly zonesService: WarehouseZonesService) {} diff --git a/deploy/position-type-grant-mapping.sql b/deploy/position-type-grant-mapping.sql deleted file mode 100644 index 901191dd4..000000000 --- a/deploy/position-type-grant-mapping.sql +++ /dev/null @@ -1,119 +0,0 @@ --- Position-type grant mapping for the granular permission system rollout. --- Grants the NEW granular keys to every hand-curated position type that holds --- the old broad key whose routes the new keys took over. Idempotent (unique --- constraint on (position_type_id, permission_id) + ON CONFLICT DO NOTHING). --- --- PREREQUISITE: run the seeded API once first (SEED_EDR_ORG=true) so --- EdrOrgSeeder has created the new permission rows this script references. --- Running it too early is not destructive but silently under-applies: keys that --- do not exist yet simply match nothing (measured: 11 of 42 rows land pre-seed, --- because invoices:view/export and payments:view already exist on dev). Re-run --- after seeding — it is safe to run any number of times. --- --- Verified 2026-08-07 on a virgin restore of the live dev DB: seeded boot, then --- this script → 42 rows inserted, second run → 0 rows, final per-key grant --- counts identical to the reference environment. Per-user API probes across 20 --- departmental test accounts confirm the keys resolve through /me and gate --- routes correctly. - -BEGIN; - --- Helper shape used throughout: --- holders of => also grant - --- 1. bookings:view holders => dashboard/read keys that replaced blanket access -INSERT INTO iam.position_type_permissions (position_type_id, permission_id) -SELECT DISTINCT ptp.position_type_id, pnew.id -FROM iam.position_type_permissions ptp -JOIN iam.permissions pold ON pold.id = ptp.permission_id - AND pold.key = 'edr_freight_app:bookings:view' -JOIN iam.permissions pnew ON pnew.key IN ( - 'edr_freight_app:overview:view', - 'edr_freight_app:reports:view', - 'edr_freight_app:invoices:view', - 'edr_freight_app:invoices:export', - 'edr_freight_app:payments:view' -) -ON CONFLICT (position_type_id, permission_id) DO NOTHING; - --- 2. train_scheduling:update holders => the write actions split out of it -INSERT INTO iam.position_type_permissions (position_type_id, permission_id) -SELECT DISTINCT ptp.position_type_id, pnew.id -FROM iam.position_type_permissions ptp -JOIN iam.permissions pold ON pold.id = ptp.permission_id - AND pold.key = 'edr_freight_app:train_scheduling:update' -JOIN iam.permissions pnew ON pnew.key IN ( - 'edr_freight_app:train_scheduling:dispatch', - 'edr_freight_app:train_scheduling:mark_paid', - 'edr_freight_app:train_scheduling:expire_booking' -) -ON CONFLICT (position_type_id, permission_id) DO NOTHING; - --- 3. GL Djibouti clearance holders => final-invoice raise + confirm -INSERT INTO iam.position_type_permissions (position_type_id, permission_id) -SELECT DISTINCT ptp.position_type_id, pnew.id -FROM iam.position_type_permissions ptp -JOIN iam.permissions pold ON pold.id = ptp.permission_id - AND pold.key = 'edr_freight_app:contracts:clearance_dj_actions' -JOIN iam.permissions pnew ON pnew.key IN ( - 'edr_freight_app:contracts:final_invoice_raise', - 'edr_freight_app:contracts:final_invoice_confirm' -) -ON CONFLICT (position_type_id, permission_id) DO NOTHING; - --- 4. GL Ethiopia clearance holders => final-invoice confirm -INSERT INTO iam.position_type_permissions (position_type_id, permission_id) -SELECT DISTINCT ptp.position_type_id, pnew.id -FROM iam.position_type_permissions ptp -JOIN iam.permissions pold ON pold.id = ptp.permission_id - AND pold.key = 'edr_freight_app:contracts:clearance_et_actions' -JOIN iam.permissions pnew ON pnew.key = 'edr_freight_app:contracts:final_invoice_confirm' -ON CONFLICT (position_type_id, permission_id) DO NOTHING; - --- 5. Contract-intake holders (any staff_accept flavour) => edit_document -INSERT INTO iam.position_type_permissions (position_type_id, permission_id) -SELECT DISTINCT ptp.position_type_id, pnew.id -FROM iam.position_type_permissions ptp -JOIN iam.permissions pold ON pold.id = ptp.permission_id - AND pold.key IN ( - 'edr_freight_app:bookings:staff_accept', - 'edr_freight_app:contracts:staff_accept:bulk', - 'edr_freight_app:contracts:staff_accept:container' - ) -JOIN iam.permissions pnew ON pnew.key = 'edr_freight_app:contracts:edit_document' -ON CONFLICT (position_type_id, permission_id) DO NOTHING; - --- 6. Support inbox ownership (decision 2026-08-07): marketing department types -INSERT INTO iam.position_type_permissions (position_type_id, permission_id) -SELECT pt.id, p.id -FROM iam.position_types pt -CROSS JOIN iam.permissions p -WHERE (pt.name::text ILIKE '%marketing%' OR pt.key ILIKE '%marketing%') - AND p.key IN ( - 'edr_freight_app:support:agent_view', - 'edr_freight_app:support:agent_send' - ) -ON CONFLICT (position_type_id, permission_id) DO NOTHING; - --- 7. Companion view keys. --- Most freight controllers carry a class-level `:view` guard, and Nest --- runs class AND method guards — so a type holding only `:` is --- denied before the action key is ever checked. Grant the module's view key --- alongside every action key the type already holds. View-only, so it widens --- reads within a module the type already operates in, never across modules. -INSERT INTO iam.position_type_permissions (position_type_id, permission_id) -SELECT DISTINCT ptp.position_type_id, pview.id -FROM iam.position_type_permissions ptp -JOIN iam.permissions pact ON pact.id = ptp.permission_id - AND pact.key LIKE 'edr_freight_app:%' -JOIN iam.permissions pview ON pview.key = regexp_replace(pact.key, ':[^:]+$', ':view') -ON CONFLICT (position_type_id, permission_id) DO NOTHING; - -COMMIT; - --- Verification: expected non-zero counts per new key after running. --- SELECT p.key, count(*) FROM iam.position_type_permissions ptp --- JOIN iam.permissions p ON p.id = ptp.permission_id --- WHERE p.key IN ('edr_freight_app:overview:view','edr_freight_app:support:agent_view', --- 'edr_freight_app:train_scheduling:dispatch','edr_freight_app:contracts:final_invoice_raise') --- GROUP BY p.key; From f51ee7cf598f838ca1373ca8b75d6c90179bba30 Mon Sep 17 00:00:00 2001 From: Abubeker Yasin Date: Fri, 7 Aug 2026 13:48:58 +0300 Subject: [PATCH 03/51] feat: (payment) add telebirr mini-app in-app payment flow --- .../src/modules/payment/payments.dto.ts | 28 +++- .../modules/payments/payments.controller.ts | 11 +- .../src/modules/payments/payments.dto.ts | 38 ++++- .../payments/supplementary-charges.service.ts | 3 +- .../portal/src/app/booking/payment/page.tsx | 133 +++++++++++++++++- .../portal/src/lib/telebirr-bridge.ts | 106 ++++++++++++++ .../intents/dto/initiate-payment.dto.ts | 9 +- .../providers/telebirr/telebirr.provider.ts | 81 +++++++++-- packages/types/src/common/payments.ts | 13 +- 9 files changed, 393 insertions(+), 29 deletions(-) create mode 100644 apps/edr-passenger-web/portal/src/lib/telebirr-bridge.ts diff --git a/apps/edr-freight-api/src/modules/payment/payments.dto.ts b/apps/edr-freight-api/src/modules/payment/payments.dto.ts index 255da0ea2..ee7b703d2 100644 --- a/apps/edr-freight-api/src/modules/payment/payments.dto.ts +++ b/apps/edr-freight-api/src/modules/payment/payments.dto.ts @@ -60,14 +60,38 @@ export class RefundDto { } export class ClientActionDto { + // INVOKE_BRIDGE (SuperApp mini-app payload) is part of the shared ClientAction union and so + // must be assignable here, but freight never requests platform=inapp and therefore never + // receives one. Passenger owns that flow — see docs/telebirr-miniapp/. @ApiProperty({ - enum: ["REDIRECT", "LAUNCH_APP", "COLLECT_OTP", "SHOW_BILL_REFERENCE"], + enum: [ + "REDIRECT", + "LAUNCH_APP", + "INVOKE_BRIDGE", + "COLLECT_OTP", + "SHOW_BILL_REFERENCE", + ], }) - type!: "REDIRECT" | "LAUNCH_APP" | "COLLECT_OTP" | "SHOW_BILL_REFERENCE"; + type!: + | "REDIRECT" + | "LAUNCH_APP" + | "INVOKE_BRIDGE" + | "COLLECT_OTP" + | "SHOW_BILL_REFERENCE"; @ApiPropertyOptional({ description: "Set when type=REDIRECT (web flow)" }) url?: string; + @ApiPropertyOptional({ + description: "Set when type=INVOKE_BRIDGE (SuperApp mini app) — not used by freight", + }) + bridge?: "TELEBIRR"; + + @ApiPropertyOptional({ + description: "Set when type=INVOKE_BRIDGE (SuperApp mini app) — not used by freight", + }) + rawRequest?: string; + @ApiPropertyOptional({ description: "Set when type=LAUNCH_APP (mobile flow)" }) appId?: string; diff --git a/apps/edr-passenger-api/src/modules/payments/payments.controller.ts b/apps/edr-passenger-api/src/modules/payments/payments.controller.ts index 7da37df33..9c1bfa65d 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.controller.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.controller.ts @@ -56,7 +56,7 @@ class WaiveSupplementaryChargeDto { class PaySupplementaryChargeDto { @ApiProperty({ enum: PaymentMethodTypeEnum, example: 'TELEBIRR' }) @IsEnum(PaymentMethodTypeEnum) method: PaymentMethodTypeEnum; - @ApiPropertyOptional({ enum: ['web', 'mobile'], default: 'web' }) @IsOptional() @IsIn(['web', 'mobile']) platform?: 'web' | 'mobile'; + @ApiPropertyOptional({ enum: ['web', 'mobile', 'inapp'], default: 'web' }) @IsOptional() @IsIn(['web', 'mobile', 'inapp']) platform?: PaymentPlatformDto; } @ApiTags("Payment") @@ -307,7 +307,14 @@ export class PaymentsController { }) @ApiQuery({ name: "bookingId", required: true }) @ApiQuery({ name: "method", enum: PaymentMethodTypeEnum, required: true }) - @ApiQuery({ name: "platform", enum: ["web", "mobile"], required: false }) + @ApiQuery({ + name: "platform", + enum: ["web", "mobile"], + required: false, + description: + "Browser-only endpoint — `inapp` is not offered here. A mini-app payer has no browser " + + "to redirect and must go through POST /payments/initiate for the bridge payload.", + }) @ApiProduces("text/html") async checkout( @Query("bookingId") bookingId: string, diff --git a/apps/edr-passenger-api/src/modules/payments/payments.dto.ts b/apps/edr-passenger-api/src/modules/payments/payments.dto.ts index 1d033e5ff..448aa4e7b 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.dto.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.dto.ts @@ -28,7 +28,8 @@ export enum PaymentMethodTypeEnum { CBE_BILL = "CBE_BILL", // Ethiopia (pay at any CBE channel by bill number) } -export type PaymentPlatformDto = "web" | "mobile"; +/** Mirrors `PaymentPlatform` in @edr/types — see there for what each surface means. */ +export type PaymentPlatformDto = "web" | "mobile" | "inapp"; export class InitiatePaymentDto { @ApiProperty({ example: "booking-uuid" }) @IsString() bookingId: string; @@ -45,12 +46,14 @@ export class InitiatePaymentDto { @IsString() paymentMethodId?: string; @ApiPropertyOptional({ - enum: ["web", "mobile"], + enum: ["web", "mobile", "inapp"], default: "web", - description: "Payment platform (web or mobile)", + description: + "Payer surface. `inapp` = the portal is running inside a SuperApp mini-app WebView " + + "(Telebirr), which cannot follow redirect flows and gets a bridge payload instead.", }) @IsOptional() - @IsIn(["web", "mobile"]) + @IsIn(["web", "mobile", "inapp"]) platform?: PaymentPlatformDto; @ApiPropertyOptional({ description: @@ -117,9 +120,20 @@ export class SupportedPaymentMethodDto { export class ClientActionDto { @ApiProperty({ - enum: ["REDIRECT", "LAUNCH_APP", "COLLECT_OTP", "SHOW_BILL_REFERENCE"], + enum: [ + "REDIRECT", + "LAUNCH_APP", + "INVOKE_BRIDGE", + "COLLECT_OTP", + "SHOW_BILL_REFERENCE", + ], }) - type: "REDIRECT" | "LAUNCH_APP" | "COLLECT_OTP" | "SHOW_BILL_REFERENCE"; + type: + | "REDIRECT" + | "LAUNCH_APP" + | "INVOKE_BRIDGE" + | "COLLECT_OTP" + | "SHOW_BILL_REFERENCE"; @ApiPropertyOptional({ description: "Set when type=REDIRECT (web flow)" }) url?: string; @ApiPropertyOptional({ @@ -134,6 +148,18 @@ export class ClientActionDto { description: "Set when type=LAUNCH_APP (mobile flow)", }) shortCode?: string; + @ApiPropertyOptional({ + description: + "Set when type=INVOKE_BRIDGE (telebirr mini app) — which SuperApp host bridge to call", + enum: ["TELEBIRR"], + }) + bridge?: "TELEBIRR"; + @ApiPropertyOptional({ + description: + "Set when type=INVOKE_BRIDGE (telebirr mini app). Signed query string handed verbatim " + + "to the host bridge (js_fun_start_pay). NOT a URL — never navigate to it.", + }) + rawRequest?: string; @ApiPropertyOptional({ description: "Set when type=COLLECT_OTP (e.g. CAC Bank)" }) providerOrderId?: string; @ApiPropertyOptional({ description: "Set when type=COLLECT_OTP" }) diff --git a/apps/edr-passenger-api/src/modules/payments/supplementary-charges.service.ts b/apps/edr-passenger-api/src/modules/payments/supplementary-charges.service.ts index f8c316d87..079792366 100644 --- a/apps/edr-passenger-api/src/modules/payments/supplementary-charges.service.ts +++ b/apps/edr-passenger-api/src/modules/payments/supplementary-charges.service.ts @@ -5,6 +5,7 @@ import { SmsClientService } from '../notifications/sms-client.service'; import { EmailClientService } from '../notifications/email-client.service'; import { PaymentClientService } from './payment-client.service'; import { PaymentReferenceType, PaymentService as PaymentServiceEnum, ProviderMethod } from '@edr/types'; +import { PaymentPlatformDto } from './payments.dto'; const CHARGE_TTL_MS = 72 * 60 * 60 * 1000; // 72 hours @@ -119,7 +120,7 @@ export class SupplementaryChargesService { async pay( token: string, method: string, - platform?: 'web' | 'mobile', + platform?: PaymentPlatformDto, requestOrigin?: string | null, ) { const charge = await this.getByToken(token); // validates status/expiry diff --git a/apps/edr-passenger-web/portal/src/app/booking/payment/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/payment/page.tsx index 685ba5ee2..166b1c799 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/payment/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/payment/page.tsx @@ -6,7 +6,12 @@ import { usePaymentStore } from "@/lib/payment-store"; import { useMutation, useQuery } from "@tanstack/react-query"; import { apiClient } from "@/lib/api-client"; import { resolvePaymentRedirectUrl } from "@/lib/payment-redirect"; -import { useState, useEffect } from "react"; +import { + isTelebirrMiniApp, + onTelebirrPayResult, + startTelebirrPay, +} from "@/lib/telebirr-bridge"; +import { useState, useEffect, useCallback, useMemo } from "react"; import { PaymentMethod } from "@/types"; import { format } from "date-fns"; import { formatTime, getTimePeriod, toZonedDate } from '@/utils/format'; @@ -55,6 +60,17 @@ export default function PaymentPage() { } | null>(null); const [billCopied, setBillCopied] = useState(false); + // Telebirr mini app: the SuperApp payment sheet is open (or just closed) and we're + // polling our own status endpoint for the webhook-backed outcome. + const [verifyingPayment, setVerifyingPayment] = useState(false); + + // Resolved once on mount — SSR has no `window`, so this must not be read during render + // of the first (server) pass. + const [inMiniApp, setInMiniApp] = useState(false); + useEffect(() => { + setInMiniApp(isTelebirrMiniApp()); + }, []); + const isRoundTrip = searchCriteria?.tripType === 'ROUND_TRIP'; // Use the same display currency as the review page — stored on the schedule at search time. @@ -72,8 +88,26 @@ export default function PaymentPage() { }, }); + // Inside the telebirr SuperApp only telebirr can complete: every other method is a + // redirect/HPP flow, and the mini-app WebView cannot follow the scheme handoffs those + // gateways use. Offering them would strand the payer on a dead page. + // Memoised: this feeds an effect's dep array, and a fresh array identity every render + // would re-run that effect on every render. + const availableMethods = useMemo( + () => paymentMethods.filter((m) => m.enabled && (!inMiniApp || m.type === 'TELEBIRR')), + [paymentMethods, inMiniApp], + ); + const selectedPaymentMethod = paymentMethods.find(m => m.type === selectedMethod) || null; + // A method chosen before the container was known (or carried over in state) may no longer + // be offerable — drop it rather than letting Pay fire against a hidden method. + useEffect(() => { + if (selectedMethod && !availableMethods.some((m) => m.type === selectedMethod)) { + setSelectedMethod(null); + } + }, [selectedMethod, availableMethods]); + // Derive charge currency directly from the selected method — no separate state that can lag. const amountCurrency = (selectedPaymentMethod?.currency || 'ETB').toUpperCase(); @@ -128,6 +162,70 @@ export default function PaymentPage() { } }, [selectedMethod, dataReady, bookingAmountData, reviewedTotal, displayCurrency, setCurrency, setPaidAmount]); + /** + * Poll our own status endpoint until the payment reaches a terminal state. + * + * Used by the telebirr mini-app flow, where nothing navigates and therefore no return page + * ever runs. The bridge callback only tells us the sheet closed; the authoritative outcome + * is the webhook-backed status the API reports here. + */ + const pollPaymentStatus = useCallback( + async (attemptsLeft: number): Promise => { + if (!bookingId) return; + try { + const res: any = await apiClient.get(`/payments/status/${bookingId}`); + if (res?.status === 'SUCCEEDED') { + setVerifyingPayment(false); + updateStatus("SUCCEEDED"); + router.push("/booking/confirmation"); + return; + } + if (res?.status === 'FAILED' || res?.status === 'CANCELLED') { + setVerifyingPayment(false); + setIsProcessing(false); + updateStatus("FAILED"); + setPaymentError(res?.failureMessage || "Payment was not completed. Please try again."); + return; + } + } catch { + // Transient read failure — keep polling; the attempt budget bounds it. + } + + if (attemptsLeft <= 0) { + // Don't call it failed: telebirr may have taken the money and the webhook is simply + // still in flight. Stop spinning, tell the truth, and let the payer re-check. + setVerifyingPayment(false); + setIsProcessing(false); + setPaymentError( + "We haven't received confirmation yet. If you completed the payment, your booking " + + "will be confirmed shortly — check My Bookings in a moment before paying again.", + ); + return; + } + setTimeout(() => void pollPaymentStatus(attemptsLeft - 1), 1500); + }, + [bookingId, router, updateStatus], + ); + + /** + * Telebirr mini app reports the sheet outcome on a global callback rather than a redirect. + * Registered on mount — the SuperApp can call back the moment the sheet closes, so it must + * already be installed before the bridge is invoked. + */ + useEffect(() => { + return onTelebirrPayResult((succeeded) => { + if (!succeeded) { + setVerifyingPayment(false); + setIsProcessing(false); + updateStatus("FAILED"); + setPaymentError("Payment was cancelled or declined. Please try again."); + return; + } + setVerifyingPayment(true); + void pollPaymentStatus(15); + }); + }, [pollPaymentStatus, updateStatus]); + const paymentMutation = useMutation({ mutationFn: async (data: any) => { return await apiClient.post("/payments/initiate", { @@ -135,7 +233,7 @@ export default function PaymentPage() { method: data.method, paymentMethodId: data.paymentMethodId, payerAccount: data.payerAccount, - platform: 'web', + platform: isTelebirrMiniApp() ? 'inapp' : 'web', }); }, onSuccess: async (data: any) => { @@ -163,6 +261,23 @@ export default function PaymentPage() { return; } + // Telebirr mini app: hand the signed rawRequest to the SuperApp bridge. Nothing + // navigates — telebirr draws its payment sheet over the WebView and reports back on + // the global callback registered above, which starts the status polling. + if (data?.clientAction?.type === 'INVOKE_BRIDGE') { + setPaymentIntent(data.intentId); + updateStatus("REQUIRES_ACTION"); + if (!startTelebirrPay(data.clientAction.rawRequest)) { + setIsProcessing(false); + updateStatus("FAILED"); + setPaymentError( + "Couldn't open the telebirr payment sheet. Please reopen this page from the " + + "telebirr app and try again.", + ); + } + return; + } + if ((selectedMethod === 'TELEBIRR' || selectedMethod === 'WAAFI' || selectedMethod === 'DMONEY') && data?.clientAction?.type === 'REDIRECT') { setPaymentIntent(data.intentId); updateStatus("REQUIRES_ACTION"); @@ -505,7 +620,15 @@ export default function PaymentPage() { {isProcessing && (
- {paymentMutation.isSuccess ? ( + {verifyingPayment ? ( + <> + +

Confirming payment

+

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

+ + ) : paymentMutation.isSuccess ? ( <>

Loading...

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

Failed to load payment methods. Please refresh.

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

No payment methods available at the moment.

) : (
- {paymentMethods.filter(m => m.enabled).map((method) => { + {availableMethods.map((method) => { const Icon = getIconForMethod(method.type); const isSelected = selectedMethod === method.type; return ( diff --git a/apps/edr-passenger-web/portal/src/lib/telebirr-bridge.ts b/apps/edr-passenger-web/portal/src/lib/telebirr-bridge.ts new file mode 100644 index 000000000..fd2a9ccea --- /dev/null +++ b/apps/edr-passenger-web/portal/src/lib/telebirr-bridge.ts @@ -0,0 +1,106 @@ +/** + * Telebirr SuperApp mini-app bridge. + * + * When the portal runs inside the telebirr SuperApp, the ordinary web checkout is unusable: + * the H5 paygate page hands off to the native wallet with a custom scheme + * (`kcbconsumer://h5checkout?...`) that the SuperApp's WebView cannot resolve, so the payer + * only ever sees `net::ERR_UNKNOWN_URL_SCHEME`. + * + * The in-app flow never navigates. The API returns a signed `rawRequest` string + * (clientAction.type === "INVOKE_BRIDGE") which is handed to the host's JS bridge; telebirr + * renders its own payment sheet over the WebView and reports the outcome on a global callback. + * + * See docs/telebirr-miniapp/inapp-payment-plan.md. + */ + +/** Name of the global the SuperApp calls back into. Must be a property of `window`. */ +export const TELEBIRR_PAY_CALLBACK = "handleEdrPaymentCallback"; + +type ConsumerApp = { evaluate: (payload: string) => void }; + +declare global { + interface Window { + /** Injected by the telebirr SuperApp WebView. Absent everywhere else. */ + consumerapp?: ConsumerApp; + [TELEBIRR_PAY_CALLBACK]?: (response: unknown) => void; + } +} + +/** + * True only when the telebirr host bridge is actually present. + * + * Deliberately does NOT sniff the user agent. A UA match without `window.consumerapp` would + * make us request `platform: "inapp"` and get back a bare rawRequest we have no way to use — + * there is no navigating our way out of that, because by then the server has already committed + * to the bridge payload. Gating on the bridge object keeps the decision and the capability in + * sync: if we can't call it, we don't ask for it. + */ +export function isTelebirrMiniApp(): boolean { + return typeof window !== "undefined" && typeof window.consumerapp?.evaluate === "function"; +} + +/** + * Hand a signed rawRequest to the SuperApp to open its payment sheet. + * + * Register the callback (see `onTelebirrPayResult`) BEFORE calling this — the host may invoke + * it as soon as the sheet closes. Returns false when the bridge is missing or throws, so the + * caller can surface an error instead of leaving the payer on a dead spinner. + */ +export function startTelebirrPay(rawRequest: string): boolean { + if (!isTelebirrMiniApp()) return false; + try { + window.consumerapp!.evaluate( + JSON.stringify({ + functionName: "js_fun_start_pay", + params: { + rawRequest, + functionCallBackName: TELEBIRR_PAY_CALLBACK, + }, + }), + ); + return true; + } catch (err) { + console.error("[telebirr] bridge evaluate failed:", err); + return false; + } +} + +/** + * Install the global result callback; returns a disposer for effect cleanup. + * + * The result is a TRIGGER TO VERIFY, never proof of payment — the payer can close the sheet, + * the host can report success before settlement lands, and the payload shape is not a contract. + * Confirmation always comes from polling our own payment status (webhook-backed). + */ +export function onTelebirrPayResult(handler: (succeeded: boolean) => void): () => void { + if (typeof window === "undefined") return () => {}; + window[TELEBIRR_PAY_CALLBACK] = (response: unknown) => { + handler(isSuccessResponse(response)); + }; + return () => { + delete window[TELEBIRR_PAY_CALLBACK]; + }; +} + +/** + * Telebirr reports `code: 0` (number or string) for success. The payload arrives as either a + * JSON string or an object depending on host version, and an unparseable payload is treated as + * success on purpose: polling is what decides the outcome, and a false "failed" would strand a + * payer who actually paid. + */ +function isSuccessResponse(response: unknown): boolean { + let parsed: unknown = response; + if (typeof response === "string") { + try { + parsed = JSON.parse(response); + } catch { + return true; + } + } + if (parsed && typeof parsed === "object" && "code" in parsed) { + const code = (parsed as { code: unknown }).code; + if (code === undefined || code === null) return true; + return code === 0 || code === "0"; + } + return true; +} diff --git a/apps/edr-payment-api/src/modules/intents/dto/initiate-payment.dto.ts b/apps/edr-payment-api/src/modules/intents/dto/initiate-payment.dto.ts index 6d602b28a..fa7587b2e 100644 --- a/apps/edr-payment-api/src/modules/intents/dto/initiate-payment.dto.ts +++ b/apps/edr-payment-api/src/modules/intents/dto/initiate-payment.dto.ts @@ -62,9 +62,14 @@ export class InitiatePaymentRequestDto implements InitiatePaymentRequest { @IsEnum(ProviderMethod) provider!: ProviderMethod; - @ApiPropertyOptional({ enum: ["web", "mobile"] }) + @ApiPropertyOptional({ + enum: ["web", "mobile", "inapp"], + description: + "Payer surface. `inapp` = running inside a SuperApp mini-app WebView (Telebirr), " + + "which cannot follow redirect/HPP flows and gets a bridge payload instead.", + }) @IsOptional() - @IsIn(["web", "mobile"]) + @IsIn(["web", "mobile", "inapp"]) platform?: PaymentPlatform; @ApiPropertyOptional({ diff --git a/packages/payment-providers/src/providers/telebirr/telebirr.provider.ts b/packages/payment-providers/src/providers/telebirr/telebirr.provider.ts index d732a51e3..32bf859b1 100644 --- a/packages/payment-providers/src/providers/telebirr/telebirr.provider.ts +++ b/packages/payment-providers/src/providers/telebirr/telebirr.provider.ts @@ -2,6 +2,8 @@ import { Injectable, Logger } from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; import { HttpService } from "@nestjs/axios"; import { + ClientAction, + PaymentPlatform, PaymentProvider, ProviderInitiationInput, ProviderInitiationResult, @@ -67,15 +69,7 @@ export class TelebirrProvider implements PaymentProvider { requestBody.biz_content.timeout_express, ); const platform = input.platform ?? "web"; - const clientAction = - platform === "mobile" - ? { - type: "LAUNCH_APP" as const, - appId: this.merchantAppId, - receiveCode: response.biz_content?.receiveCode, - shortCode: this.merchantCode, - } - : { type: "REDIRECT" as const, url: this.buildCheckoutUrl(prepayId) }; + const clientAction = this.buildClientAction(platform, prepayId, response); return { providerOrderId: prepayId, @@ -199,6 +193,11 @@ export class TelebirrProvider implements PaymentProvider { input: ProviderInitiationInput, ): CreateOrderRequest { const totalAmount = String(input.amountMinor); + // In-app pays inside the SuperApp overlay and never navigates, so there is no browser + // to send back — telebirr's own in-app integration omits redirect_url entirely. Keep it + // absent rather than undefined: a signed-but-unsent field is what produced the earlier + // "verify sign failed" (see docs/payment-service + telebirr.crypto skip-undefined). + const wantsRedirect = input.platform !== "inapp" && !!input.redirectUrl; const req = { timestamp: createTimestamp(), nonce_str: createNonceStr(), @@ -214,7 +213,7 @@ export class TelebirrProvider implements PaymentProvider { total_amount: totalAmount, trans_currency: input.currency, timeout_express: this.timeoutExpress, - ...(input.redirectUrl ? { redirect_url: input.redirectUrl } : {}), + ...(wantsRedirect ? { redirect_url: input.redirectUrl! } : {}), }, }; const sign = signRequestObject( @@ -245,6 +244,68 @@ export class TelebirrProvider implements PaymentProvider { return { ...req, sign, sign_type: "SHA256WithRSA" }; } + /** + * Telebirr exposes the same pre-order three ways; only the launch payload differs. + * + * - `mobile` — native app hands off to the wallet app with a receiveCode. + * - `inapp` — the portal is running inside the telebirr SuperApp mini-app WebView. The + * H5 checkout page is unusable there: it deep-links to `kcbconsumer://…`, + * which the WebView cannot resolve (`net::ERR_UNKNOWN_URL_SCHEME`). The + * signed rawRequest goes to the host JS bridge instead — no navigation. + * - `web` — ordinary browser; redirect to the H5 checkout page. + */ + private buildClientAction( + platform: PaymentPlatform, + prepayId: string, + response: CreateOrderResponse, + ): ClientAction { + switch (platform) { + case "mobile": + return { + type: "LAUNCH_APP", + appId: this.merchantAppId, + receiveCode: response.biz_content?.receiveCode, + shortCode: this.merchantCode, + }; + case "inapp": + return { + type: "INVOKE_BRIDGE", + bridge: "TELEBIRR", + rawRequest: this.buildInAppRawRequest(prepayId), + }; + default: + return { type: "REDIRECT", url: this.buildCheckoutUrl(prepayId) }; + } + } + + /** + * Signed request handed verbatim to the SuperApp bridge (`js_fun_start_pay`). + * + * Emits `appid, merch_code, nonce_str, prepay_id, timestamp, sign_type, sign` in that + * order — no `webBaseUrl` prefix and no `version`/`trade_type` tail, because the bridge + * takes the bare query string rather than a URL. + * + * `sign_type` sits in the map purely so it lands in the output in the right position; + * `buildCanonicalString` excludes it (as does telebirr's own reference implementation), + * so the signature covers the same five fields as the web checkout URL. + * + * Kept separate from `buildCheckoutUrl` rather than sharing a builder: the two payloads + * are consumed by different validators, and the web flow is live. + */ + private buildInAppRawRequest(prepayId: string): string { + const map: Record = { + appid: this.merchantAppId, + merch_code: this.merchantCode, + nonce_str: createNonceStr(), + prepay_id: prepayId, + timestamp: createTimestamp(), + sign_type: "SHA256WithRSA", + }; + const sign = signRequestObject(map, this.privateKey); + const fields = Object.entries(map).map(([k, v]) => `${k}=${v}`); + return [...fields, `sign=${sign}`].join("&"); + } + private buildCheckoutUrl(prepayId: string): string { const map: Record = { appid: this.merchantAppId, diff --git a/packages/types/src/common/payments.ts b/packages/types/src/common/payments.ts index 04849f54d..05be21682 100644 --- a/packages/types/src/common/payments.ts +++ b/packages/types/src/common/payments.ts @@ -32,7 +32,8 @@ export enum ProviderMethod { CBE_BILL = "CBE_BILL", } -export type PaymentPlatform = "web" | "mobile"; + +export type PaymentPlatform = "web" | "mobile" | "inapp"; export type ClientAction = | { type: "REDIRECT"; url: string } @@ -42,6 +43,16 @@ export type ClientAction = receiveCode?: string; shortCode: string; } + | { + type: "INVOKE_BRIDGE"; + /** Which SuperApp host bridge the payload targets. */ + bridge: "TELEBIRR"; + /** + * Signed query string handed verbatim to the host bridge (`js_fun_start_pay`). + * NOT a URL — it has no scheme or host and must never be navigated to. + */ + rawRequest: string; + } | { type: "COLLECT_OTP"; providerOrderId: string; From 756325c81479bb5e040121168b0d3309b843d21e Mon Sep 17 00:00:00 2001 From: Marshal Date: Fri, 7 Aug 2026 11:17:44 +0000 Subject: [PATCH 04/51] fix permission --- .../modules/contracts/contracts.controller.ts | 41 +++++++++++++++---- 1 file changed, 32 insertions(+), 9 deletions(-) diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts index 6273be365..0ff9b7c24 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts @@ -1074,16 +1074,23 @@ export class ContractsController { // ── Booking under contract (Path A customer / Path B GL ET) ──────────────── @Post(':id/bookings') - @BookingStaff(FREIGHT_PERMS.contracts.createBooking) + // Path A is a customer flow — both audiences must reach the service, whose + // assertGate decides per role. Staff still need contracts:create_booking. + @MixedAudience(FREIGHT_PERMS.contracts.createBooking) @ApiOperation({ summary: 'Create a shipment booking under a contract — Path A (customer) or Path B (GL Ethiopia).', }) - createBooking( + async createBooking( @Param('id', ParseUUIDPipe) id: string, @Body() dto: CreateBookingUnderContractDto, - @CurrentUser() user: AuthUserPayload, + @CurrentUser() user: TCurrentUser & { sub?: string }, ) { + // Customer callers may only book on their own contract. + if (!hasFreightPermission(user, FREIGHT_PERMS.contracts.createBooking)) { + const contract = await this.contractsService.findById(id); + await this.contractsService.assertCustomerCanAccessContract(user?.id, contract); + } // The service decides the execution path from the contract: // Path A (customs disabled) → customer/staff create; status checks apply. // Path B (customs enabled) → GL Ethiopia only, once clearance is ready. @@ -1096,16 +1103,25 @@ export class ContractsController { } @Post(':id/bookings/initiate') - @BookingStaff(FREIGHT_PERMS.contracts.createBooking) + // Customer initiates their own ONE_TIME instance; GL initiates on customs + // contracts — the service's assertGate decides per role, so both audiences + // must reach it. Staff still need contracts:create_booking. + @MixedAudience(FREIGHT_PERMS.contracts.createBooking) @ApiOperation({ summary: 'Initiate a bare booking instance under an import/export contract (ONE_TIME or GENERAL) — no cargo, no date; enters per-booking clearance (AWAITING_DOCUMENTS). ONE_TIME customs instances are opened by the customer (or GL); GENERAL customs comes from a shipment request.', }) - initiateBooking( + async initiateBooking( @Param('id', ParseUUIDPipe) id: string, @Body() dto: CreateBookingUnderContractDto, - @CurrentUser() user: AuthUserPayload, + @CurrentUser() user: TCurrentUser & { sub?: string }, ) { + // Customer callers may only initiate on their own contract; the service's + // assertGate then decides what a customer may do on it. + if (!hasFreightPermission(user, FREIGHT_PERMS.contracts.createBooking)) { + const contract = await this.contractsService.findById(id); + await this.contractsService.assertCustomerCanAccessContract(user?.id, contract); + } return this.contractBookingService.initiateUnderContract( id, { contractRouteId: dto?.contractRouteId }, @@ -1115,17 +1131,24 @@ export class ContractsController { } @Post(':id/bookings/:bookingId/complete') - @BookingStaff(FREIGHT_PERMS.contracts.createBooking) + // Customers complete their own initiated (non-customs) instances; the + // service keeps customs completion GL-only via the actor's permissions. + @MixedAudience(FREIGHT_PERMS.contracts.createBooking) @ApiOperation({ summary: 'Complete an initiated booking after Operations finalized its clearance — cargo + binding day, window and departure checks, pricing and invoicing.', }) - completeBooking( + async completeBooking( @Param('id', ParseUUIDPipe) id: string, @Param('bookingId', ParseUUIDPipe) bookingId: string, @Body() dto: CreateBookingUnderContractDto, - @CurrentUser() user: AuthUserPayload, + @CurrentUser() user: TCurrentUser & { sub?: string }, ) { + // Customer callers may only complete bookings on their own contract. + if (!hasFreightPermission(user, FREIGHT_PERMS.contracts.createBooking)) { + const contract = await this.contractsService.findById(id); + await this.contractsService.assertCustomerCanAccessContract(user?.id, contract); + } // Customs (Path B) instances may only be completed by GL Ethiopia — the // service checks the actor's contracts:create_booking permission. return this.contractBookingService.completeUnderContract( From c77f5200a3051801f5c2f6ac426424e29918183f Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Fri, 7 Aug 2026 11:43:18 +0000 Subject: [PATCH 05/51] feat(last-mile): default the approval advance to the live rate The chief's typed advance was mandatory, so the rule-based estimate shown in the approve dialog had to be retyped and could silently diverge from it. advanceAmount is now optional: the advance defaults to the live last-mile rate estimate (km x rate) and the typed value is only an override. When no rate covers the job the request is rejected with a message telling the chief to enter the amount manually, rather than approving a zero advance. The advance invoice now bills in the rate's currency from the snapshotted contract summary, falling back to the booking payment currency only when the amount came from a manual override. Co-Authored-By: Claude Opus 5 --- .../dto/approve-last-mile-request.dto.ts | 20 +++++++++----- .../last-mile-requests.controller.ts | 2 +- .../last-mile-requests.service.ts | 27 ++++++++++++++----- 3 files changed, 35 insertions(+), 14 deletions(-) diff --git a/apps/edr-freight-api/src/modules/last-mile-requests/dto/approve-last-mile-request.dto.ts b/apps/edr-freight-api/src/modules/last-mile-requests/dto/approve-last-mile-request.dto.ts index 56be0c545..0fa7457a8 100644 --- a/apps/edr-freight-api/src/modules/last-mile-requests/dto/approve-last-mile-request.dto.ts +++ b/apps/edr-freight-api/src/modules/last-mile-requests/dto/approve-last-mile-request.dto.ts @@ -1,13 +1,19 @@ -import { ApiProperty } from '@nestjs/swagger'; +import { ApiPropertyOptional } from '@nestjs/swagger'; import { Transform } from 'class-transformer'; -import { IsNumber, Min } from 'class-validator'; +import { IsNumber, IsOptional, Min } from 'class-validator'; export class ApproveLastMileRequestDto { - // The approve dialog prefills this from GET :id/price-estimate (rule-based), - // but the chief can still override — the typed value is what's invoiced. - @ApiProperty({ description: 'Advance amount the customer must pay before execution proceeds', example: 3000 }) - @Transform(({ value }) => Number(value)) + // Omitted = the rule-based last-mile rate estimate is the advance. The chief + // can still override with an explicit amount (required when no rate covers + // the job). + @ApiPropertyOptional({ + description: + 'Advance override. Omitted = the amount comes from the live last-mile rates (km × rate).', + example: 3000, + }) + @IsOptional() + @Transform(({ value }) => (value === null || value === undefined || value === '' ? undefined : Number(value))) @IsNumber() @Min(0.01) - advanceAmount!: number; + advanceAmount?: number; } diff --git a/apps/edr-freight-api/src/modules/last-mile-requests/last-mile-requests.controller.ts b/apps/edr-freight-api/src/modules/last-mile-requests/last-mile-requests.controller.ts index 4c4ac88e5..6f96d12c6 100644 --- a/apps/edr-freight-api/src/modules/last-mile-requests/last-mile-requests.controller.ts +++ b/apps/edr-freight-api/src/modules/last-mile-requests/last-mile-requests.controller.ts @@ -110,7 +110,7 @@ export class LastMileRequestsController { @Post(':id/approve') @BookingStaff(FREIGHT_PERMS.lastMile.requestApprove) - @ApiOperation({ summary: 'Truck & Machinery chief approves the request — LM contract becomes signable; the advance invoice follows the customer signature' }) + @ApiOperation({ summary: 'Truck & Machinery chief approves the request — the advance defaults to the live last-mile rate; LM contract becomes signable and the advance invoice follows the customer signature' }) approve( @Param('id', ParseUUIDPipe) id: string, @Body() dto: ApproveLastMileRequestDto, diff --git a/apps/edr-freight-api/src/modules/last-mile-requests/last-mile-requests.service.ts b/apps/edr-freight-api/src/modules/last-mile-requests/last-mile-requests.service.ts index 901e7b9b5..b89f06479 100644 --- a/apps/edr-freight-api/src/modules/last-mile-requests/last-mile-requests.service.ts +++ b/apps/edr-freight-api/src/modules/last-mile-requests/last-mile-requests.service.ts @@ -299,7 +299,11 @@ export class LastMileRequestsService { return this.findById(id); } - async approve(id: string, staffId: string | null, advanceAmount: number): Promise { + async approve( + id: string, + staffId: string | null, + advanceOverride?: number | null, + ): Promise { const request = await this.findById(id); if (request.status !== LastMileRequestStatus.Submitted) { throw new BadRequestException(`Only a submitted request can be approved (current status: ${request.status})`); @@ -307,6 +311,18 @@ export class LastMileRequestsService { const booking = request.booking ?? (await this.bookingsRepository.findById(request.bookingId)); if (!booking) throw new NotFoundException(`Booking ${request.bookingId} not found`); + // The live last-mile rates are the authority on the advance (km × rate); + // the chief's typed amount is only an override — and the only path when no + // rate covers the job. Snapshotted so the contract and invoice stay immune + // to later rate edits. + const estimate = await this.priceEstimate(id); + const advanceAmount = advanceOverride ?? estimate.total; + if (!advanceAmount || advanceAmount <= 0) { + throw new BadRequestException( + 'No live last-mile rate covers this job — enter the advance amount manually.', + ); + } + // Idempotent per booking — reuses the record if one already exists. const lastMile = await this.lastMileService.create({ bookingId: request.bookingId, @@ -316,10 +332,6 @@ export class LastMileRequestsService { // No invoice yet: the advance is invoiced by LastMileContractService.sign() // once the customer has signed the LM contract — doc first, then payment. - // Snapshot the rate estimate now so the contract shows the numbers the - // chief actually approved against, immune to later rate edits. - const estimate = await this.priceEstimate(id); - await this.requestsRepository.update(id, { status: LastMileRequestStatus.Approved, reviewedByStaffId: staffId, @@ -364,7 +376,10 @@ export class LastMileRequestsService { type: 'LAST_MILE_ADVANCE', companyId: booking.companyId, companyProfileId: booking.companyProfileId || '', - currency: booking.paymentCurrency || 'ETB', + // The advance is priced by the last-mile rate, so it bills in that + // rate's currency (birr for domestic trucking) — the booking's payment + // currency is only the fallback when the amount was a manual override. + currency: request.contractSummary?.currency || booking.paymentCurrency || 'ETB', lines: [ { chargeType: 'LAST_MILE_ADVANCE', From 95ae057cb94e218b09457937cee1287ec29dafce Mon Sep 17 00:00:00 2001 From: Yonas Tewabe Date: Fri, 7 Aug 2026 14:50:21 +0300 Subject: [PATCH 06/51] Create sync-env-from-env-manager.sh --- scripts/deploy/sync-env-from-env-manager.sh | 86 +++++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 scripts/deploy/sync-env-from-env-manager.sh diff --git a/scripts/deploy/sync-env-from-env-manager.sh b/scripts/deploy/sync-env-from-env-manager.sh new file mode 100644 index 000000000..6a405ab28 --- /dev/null +++ b/scripts/deploy/sync-env-from-env-manager.sh @@ -0,0 +1,86 @@ +#!/usr/bin/env bash +# Sync .env files from the Env Manager API into the repo. +# +# Usage: +# ENV_MANAGER_TOKEN=xxx ./scripts/deploy/sync-env-from-server.sh freight-api freight-portal freight-backoffice +# +# You normally only need to pass the token via the action secret, and BRANCH +# via the job-level env (e.g. `BRANCH: ${{ github.ref_name }}` in the workflow): +# env: +# BRANCH: ${{ github.ref_name }} +# ENV_MANAGER_TOKEN: ${{ secrets.ENV_MANAGER_TOKEN }} +# +# API layout (one endpoint per service): +# GET https://env.smart.aaca.gov.et/api/env/edr//?format=dotenv +# Header: Authorization: Bearer +set -euo pipefail + +PROJECT="edr" +ENV_MANAGER_URL="https://env.smart.aaca.gov.et" +BRANCH="${BRANCH:?BRANCH is required}" +ENV_MANAGER_TOKEN="${ENV_MANAGER_TOKEN:?ENV_MANAGER_TOKEN is required}" + +declare -A SERVICE_ENV_TARGET=( + ["freight_api"]="apps/edr-freight-api/.env" + ["freight_portal"]="apps/edr-freight-web/portal/.env" + ["freight_backoffice"]="apps/edr-freight-web/backoffice/.env" + ["gps_tracker"]="apps/edr-gps-tracker/.env" + ["passenger_api"]="apps/edr-passenger-api/.env" + ["passenger_portal"]="apps/edr-passenger-web/portal/.env" + ["passenger_backoffice"]="apps/edr-passenger-web/backoffice/.env" + ["payment_api"]="apps/edr-payment-api/.env" +) + +for service in "$@"; do + branch_api_name="${BRANCH//-/_}" + service_api_name="${service//-/_}" + dest="${SERVICE_ENV_TARGET[${service_api_name}]:-}" + if [[ -z "${dest}" ]]; then + echo "Unknown service: ${service}" >&2 + exit 1 + fi + + url="${ENV_MANAGER_URL}/api/env/${PROJECT}/${branch_api_name}/${service_api_name}?format=dotenv" + mkdir -p "$(dirname "${dest}")" + + tmp_file="$(mktemp)" + trap 'rm -f "${tmp_file}"' RETURN 2>/dev/null || true + + http_status=$(curl -fsS -o "${tmp_file}" -w "%{http_code}" \ + -H "Authorization: Bearer ${ENV_MANAGER_TOKEN}" \ + "${url}") || { + echo "Failed to fetch env for '${service}' from ${url}" >&2 + rm -f "${tmp_file}" + exit 1 + } + + if [[ "${http_status}" != "200" ]]; then + echo "Env Manager returned HTTP ${http_status} for '${service}' (${url})" >&2 + rm -f "${tmp_file}" + exit 1 + fi + + if [[ ! -s "${tmp_file}" ]]; then + echo "Env Manager returned an empty response for '${service}' (${url})" >&2 + rm -f "${tmp_file}" + exit 1 + fi + + mv "${tmp_file}" "${dest}" + echo "Synced ${url} -> ${dest}" + + port_value=$(sed -n -E 's/^[[:space:]]*PORT[[:space:]]*=[[:space:]]*"?([^"#]+)"?[[:space:]]*(#.*)?$/\1/p' "${dest}" | head -n1 | tr -d '[:space:]') + if [[ -z "${port_value}" ]]; then + echo "Missing required PORT in env file for '${service}' (${dest})" >&2 + exit 1 + fi + + if [[ -n "${GITHUB_ENV:-}" ]]; then + service_var=$(echo "${service}" | tr '[:lower:]-' '[:upper:]_') + echo "${service_var}_PORT=${port_value}" >> "${GITHUB_ENV}" + echo "Exported ${service_var}_PORT from ${dest}" + # Forward NEXT_PUBLIC_* and VITE_* vars so docker compose build can inject them as build args. + grep -E '^[[:space:]]*(NEXT_PUBLIC_|VITE_)[A-Za-z0-9_]+=' "${dest}" \ + | sed -E 's/^[[:space:]]*//' >> "${GITHUB_ENV}" || true + fi +done From e1a6c6ca01a4fcf586ed4b1464b222eaa55afaa2 Mon Sep 17 00:00:00 2001 From: Yonas Tewabe Date: Fri, 7 Aug 2026 14:51:43 +0300 Subject: [PATCH 07/51] Update deploy.yml --- .github/workflows/deploy.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index f8a1eb68e..7bb7d2de9 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -110,6 +110,7 @@ jobs: DEPLOY_USER: tria DOCKER_BUILDKIT: "1" COMPOSE_DOCKER_CLI_BUILD: "1" + ENV_MANAGER_TOKEN: ${{ secrets.ENV_MANAGER_TOKEN }} steps: - name: Checkout @@ -138,7 +139,7 @@ jobs: - name: Sync environment from server run: | chmod +x scripts/deploy/*.sh - ./scripts/deploy/sync-env-from-server.sh "${{ matrix.service }}" + ./scripts/deploy/sync-env-from-env-manager.sh "${{ matrix.service }}" - name: Set compose project name run: | From d5d7c91e24f4f90c40cbfba1e5afb49f47301883 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Fri, 7 Aug 2026 11:48:50 +0000 Subject: [PATCH 08/51] feat(auth): add :read for API access without UI exposure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `:view` gates the backoffice sidebar entry, the route, and the API read all at once, so granting a user another module's list endpoint for a form dropdown also hands them that module's whole page. Seed a `:read` twin for every `:view` key and teach the freight guards to accept it wherever the matching `:view` is required — on GET/HEAD/OPTIONS only, since class and method guards AND together and a write route without its own method gate would otherwise be reachable. The frontend never checks `:read`, which is what keeps the module hidden. Twins are derived, not hand-written, so a new `:view` gets one for free. Grants stay hand-curated in iam.position_type_permissions. --- .../src/common/freight-permission.guard.ts | 41 ++++++++++-- .../src/seed/edr-freight.seed.ts | 12 +++- .../seed/freight-permissions.registry.spec.ts | 37 ++++++++++ .../src/seed/freight-permissions.registry.ts | 67 ++++++++++++++++++- 4 files changed, 151 insertions(+), 6 deletions(-) diff --git a/apps/edr-freight-api/src/common/freight-permission.guard.ts b/apps/edr-freight-api/src/common/freight-permission.guard.ts index db6275c07..9cb002891 100644 --- a/apps/edr-freight-api/src/common/freight-permission.guard.ts +++ b/apps/edr-freight-api/src/common/freight-permission.guard.ts @@ -9,6 +9,7 @@ import { import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type'; import { hasFreightPermission, isSuperAdmin } from './freight-permission.util'; +import { readTwinOf } from '../seed/freight-permissions.registry'; // String literals on purpose (same reasoning as login-audience.middleware.ts): // the values are wire-format constants from iam.users.user_type, and importing @@ -22,13 +23,43 @@ const userTypeOf = (user: TCurrentUser): string | undefined => const isEmployee = (user: TCurrentUser): boolean => userTypeOf(user) === 'employee' || isSuperAdmin(user); +const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']); + +/** + * Does the caller satisfy a required permission? + * + * Holding the key outright always passes. A required `:view` is ALSO + * satisfied by the weaker `:read` — the key that buys API reads + * without putting the module in the backoffice sidebar — but only on a safe + * HTTP method. + * + * The method restriction is load-bearing, not caution. Nest runs class AND + * method guards, so controllers list every route key on the class gate, + * `:view` among the write keys. Without this check a `:read` holder would + * clear that class gate and then reach any write route that has no method + * gate of its own. Keying on the HTTP verb closes that by construction rather + * than by an audit that goes stale the next time a route is added. + */ +const satisfiedBy = ( + user: TCurrentUser, + required: string, + method: string, +): boolean => { + if (hasFreightPermission(user, required)) return true; + if (!SAFE_METHODS.has(method)) return false; + const readTwin = readTwinOf(required); + return Boolean(readTwin && hasFreightPermission(user, readTwin)); +}; + export function FreightPermissionGuard( permissions: string[], ): Type { @Injectable() class FreightPermissionsGuard implements CanActivate { canActivate(context: ExecutionContext): boolean { - const request = context.switchToHttp().getRequest<{ user?: TCurrentUser }>(); + const request = context + .switchToHttp() + .getRequest<{ user?: TCurrentUser; method: string }>(); const user = request.user; if (!user) { @@ -39,7 +70,7 @@ export function FreightPermissionGuard( } if (!permissions?.length) return true; - if (permissions.some((p) => hasFreightPermission(user, p))) { + if (permissions.some((p) => satisfiedBy(user, p, request.method))) { return true; } @@ -79,7 +110,9 @@ export function MixedAudienceGuard(permissions: string[]): Type { @Injectable() class MixedAudiencesGuard implements CanActivate { canActivate(context: ExecutionContext): boolean { - const request = context.switchToHttp().getRequest<{ user?: TCurrentUser }>(); + const request = context + .switchToHttp() + .getRequest<{ user?: TCurrentUser; method: string }>(); const user = request.user; if (!user) { @@ -93,7 +126,7 @@ export function MixedAudienceGuard(permissions: string[]): Type { } if ( !permissions?.length || - permissions.some((p) => hasFreightPermission(user, p)) + permissions.some((p) => satisfiedBy(user, p, request.method)) ) { return true; } 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 22f7fe5ef..d90fe7b80 100644 --- a/apps/edr-freight-api/src/seed/edr-freight.seed.ts +++ b/apps/edr-freight-api/src/seed/edr-freight.seed.ts @@ -1,6 +1,7 @@ import { BOOKING_RULE_ENGINE_PERMISSIONS, BOOKING_RULE_ENGINE_PERMISSION_KEYS, + deriveReadPermissions, POSITION_PERMISSION_PRESETS, ROLE_PERMISSION_PRESETS, } from './freight-permissions.registry'; @@ -190,7 +191,7 @@ const POSITION_TYPE_PERMISSIONS = [ }, ] as const; -export const EDR_FREIGHT_PERMISSIONS = [ +const EDR_FREIGHT_VIEWABLE_PERMISSIONS = [ ...EMPLOYEE_REGISTRATION_PERMISSIONS, ...ROLE_ASSIGNMENT_PERMISSIONS, ...HIERARCHY_UNIT_PERMISSIONS, @@ -200,6 +201,15 @@ export const EDR_FREIGHT_PERMISSIONS = [ ...BOOKING_RULE_ENGINE_PERMISSIONS, ]; +export const EDR_FREIGHT_PERMISSIONS = [ + ...EDR_FREIGHT_VIEWABLE_PERMISSIONS, + // API-read twin of every `:view` key above — grants the module's GET routes + // without putting it in the backoffice sidebar. Seeded so they can be + // assigned; no role or position preset below grants one, that stays + // hand-curated in iam.position_type_permissions. + ...deriveReadPermissions(EDR_FREIGHT_VIEWABLE_PERMISSIONS), +]; + export { BOOKING_RULE_ENGINE_PERMISSION_KEYS } from './freight-permissions.registry'; export const EDR_FREIGHT_ROLES: FreightSeedRole[] = [ diff --git a/apps/edr-freight-api/src/seed/freight-permissions.registry.spec.ts b/apps/edr-freight-api/src/seed/freight-permissions.registry.spec.ts index caab85c3e..b69dcff71 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.spec.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.spec.ts @@ -1,4 +1,5 @@ import { EDR_FREIGHT_PERMISSIONS } from './edr-freight.seed'; +import { readTwinOf } from './freight-permissions.registry'; describe('EDR_FREIGHT_PERMISSIONS', () => { // The seeder inserts the whole catalog in one ON CONFLICT (key) DO UPDATE @@ -10,4 +11,40 @@ describe('EDR_FREIGHT_PERMISSIONS', () => { expect(duplicates).toEqual([]); }); + + // The `:read` ids are derived rather than hand-written, so a collision + // would surface here instead of as a primary-key violation on a fresh + // database. + it('has no duplicate ids', () => { + const ids = EDR_FREIGHT_PERMISSIONS.map((permission) => permission.id); + const duplicates = [...new Set(ids.filter((id, i) => ids.indexOf(id) !== i))]; + + expect(duplicates).toEqual([]); + }); + + // `:read` grants a module's GET routes without putting it in the backoffice + // sidebar. Every `:view` needs its twin or that module has no way to be + // granted API-only access. + it('gives every :view key a :read twin', () => { + const keys = new Set(EDR_FREIGHT_PERMISSIONS.map((p) => p.key)); + const missing = [...keys] + .filter((key) => key.endsWith(':view')) + .map((key) => readTwinOf(key)) + .filter((twin): twin is string => twin !== null && !keys.has(twin)); + + expect(missing).toEqual([]); + }); + + it('mints every :read id in the v5 block', () => { + const reads = EDR_FREIGHT_PERMISSIONS.filter((p) => p.key.endsWith(':read')); + + expect(reads.length).toBeGreaterThan(0); + // Version nibble 5 — the source `:view` ids are all v4, so the two sets + // cannot overlap however many keys are added. + for (const read of reads) { + expect(read.id).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-5[0-9a-f]{3}-[0-9a-f]{4}-[0-9a-f]{12}$/, + ); + } + }); }); 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 11f0ee40c..cbe2fcf11 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts @@ -31,6 +31,15 @@ export type RuleEngineResourceSlug = const slugToResourceKey = (slug: RuleEngineResourceSlug): string => slug.replace(/-/g, "_"); +const VIEW_KEY_SUFFIX = ":view"; +const READ_KEY_SUFFIX = ":read"; + +/** The `:read` twin of a `:view` key, or null if `key` is not a view key. */ +export const readTwinOf = (key: string): string | null => + key.endsWith(VIEW_KEY_SUFFIX) + ? `${key.slice(0, -VIEW_KEY_SUFFIX.length)}${READ_KEY_SUFFIX}` + : null; + const perm = (id: string, key: string, en: string): FreightPermissionSeed => ({ id, key, @@ -1352,6 +1361,59 @@ export const BOOKING_RULE_ENGINE_PERMISSIONS = [ export const BOOKING_RULE_ENGINE_PERMISSION_KEYS = BOOKING_RULE_ENGINE_PERMISSIONS.map((p) => p.key); +/** + * Id for a derived `:read` key: the source `:view` id with its version nibble + * moved 4 → 5. + * + * Unique because the source ids are, and disjoint from every hand-written id + * because those are all v4-shaped. Nothing index-derived, so a new `:view` + * landing mid-list cannot shift ids already seeded — the trap the + * RULE_ENGINE_RESOURCE_SLUGS comment warns about. + * + * (`EdrOrgSeeder.ensurePermissions` deliberately never sends an id — the + * column default wins and `key` is the identity every consumer resolves by. + * These exist to satisfy the seed type and keep the array self-consistent.) + */ +const readPermissionId = (viewId: string): string => + `${viewId.slice(0, 14)}5${viewId.slice(15)}`; + +/** + * API-read twin of every `:view` key. + * + * `:view` does three jobs at once — backoffice sidebar entry (App.tsx), route + * admission (RequirePermission), and API read. That bundling means a user who + * only needs another module's list endpoint for a form dropdown has to be + * granted the whole module, page and all. `:read` unbundles it: the + * guard accepts it wherever the matching `:view` is required on a GET, and the + * frontend never looks at it, so the module stays out of the menu. + * + * Derived rather than hand-written so a new `:view` key gets its twin for + * free. Grants stay hand-curated in `iam.position_type_permissions` — nothing + * here hands a `:read` to anyone. + */ +export const deriveReadPermissions = ( + seeds: readonly FreightPermissionSeed[], +): FreightPermissionSeed[] => + seeds + .filter((p) => p.key.endsWith(VIEW_KEY_SUFFIX)) + .map((p) => + perm( + readPermissionId(p.id), + readTwinOf(p.key) as string, + `Read ${p.name.en.replace(/^View /, "")} (API only)`, + ), + ); + +/** + * Read twins of the freight-domain catalog, for `PERMISSIONS_CATALOG`. The + * IAM/hierarchy keys seeded alongside it live in `edr-freight.seed.ts` and get + * theirs there — each twin is derived from its own source row, so the two call + * sites agree on any key they share without coordination. + */ +export const FREIGHT_READ_PERMISSIONS = deriveReadPermissions( + BOOKING_RULE_ENGINE_PERMISSIONS, +); + export const FREIGHT_PERMS = { bookings: { view: "edr_freight_app:bookings:view", @@ -2069,7 +2131,10 @@ export const POSITION_PERMISSION_PRESETS = { /** Derive the module bucket from the resource segment of a permission key. */ const moduleOf = (key: string): string => key.split(":")[1] ?? "other"; -export const PERMISSIONS_CATALOG = BOOKING_RULE_ENGINE_PERMISSIONS.map((p) => ({ +export const PERMISSIONS_CATALOG = [ + ...BOOKING_RULE_ENGINE_PERMISSIONS, + ...FREIGHT_READ_PERMISSIONS, +].map((p) => ({ key: p.key, label: p.name.en, module: moduleOf(p.key), From 70215a9f37d5b2d80d3a1faa3b6e2899ac2ba1c1 Mon Sep 17 00:00:00 2001 From: Marshal Date: Fri, 7 Aug 2026 12:10:43 +0000 Subject: [PATCH 09/51] feat(wagons): audited maintenance/availability toggle --- .../3310000000000-WagonStatusLogs.ts | 32 +++ .../wagons/dto/bulk-set-wagon-status.dto.ts | 16 +- .../entities/wagon-status-log.entity.ts | 32 +++ .../src/modules/wagons/wagons.controller.ts | 21 +- .../src/modules/wagons/wagons.module.ts | 2 + .../src/modules/wagons/wagons.service.ts | 29 ++- .../src/seed/freight-permissions.registry.ts | 10 + .../components/wagons/WagonStatusActions.tsx | 226 ++++++++++++++++++ .../backoffice/src/lib/permissions.ts | 2 + .../src/pages/fleet/FleetResourcePage.tsx | 43 ++-- .../backoffice/src/services/api.ts | 17 +- .../backoffice/src/services/wagon.service.ts | 20 +- 12 files changed, 419 insertions(+), 31 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/3310000000000-WagonStatusLogs.ts create mode 100644 apps/edr-freight-api/src/modules/wagons/entities/wagon-status-log.entity.ts create mode 100644 apps/edr-freight-web/backoffice/src/components/wagons/WagonStatusActions.tsx diff --git a/apps/edr-freight-api/src/migrations/3310000000000-WagonStatusLogs.ts b/apps/edr-freight-api/src/migrations/3310000000000-WagonStatusLogs.ts new file mode 100644 index 000000000..10c3d4799 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3310000000000-WagonStatusLogs.ts @@ -0,0 +1,32 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Audit trail for wagon status flips (Available ⇄ Maintenance and any other + * bulk-status change): who moved which wagon from what to what, when, and why. + * Written inside the same transaction as the status update itself. + */ +export class WagonStatusLogs3310000000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.wagon_status_logs ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + wagon_id uuid NOT NULL REFERENCES freight.wagons(id), + from_status varchar(30) NOT NULL, + to_status varchar(30) NOT NULL, + changed_by_user_id uuid, + note text, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz + ) + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_wagon_status_logs_wagon + ON freight.wagon_status_logs (wagon_id, created_at DESC) + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS freight.wagon_status_logs`); + } +} diff --git a/apps/edr-freight-api/src/modules/wagons/dto/bulk-set-wagon-status.dto.ts b/apps/edr-freight-api/src/modules/wagons/dto/bulk-set-wagon-status.dto.ts index 6f28418aa..f2e8b6814 100644 --- a/apps/edr-freight-api/src/modules/wagons/dto/bulk-set-wagon-status.dto.ts +++ b/apps/edr-freight-api/src/modules/wagons/dto/bulk-set-wagon-status.dto.ts @@ -1,5 +1,13 @@ import { WagonStatus } from '@edr/types'; -import { ArrayNotEmpty, IsArray, IsEnum, IsUUID } from 'class-validator'; +import { + ArrayNotEmpty, + IsArray, + IsEnum, + IsOptional, + IsString, + IsUUID, + MaxLength, +} from 'class-validator'; export class BulkSetWagonStatusDto { @IsArray() @@ -9,4 +17,10 @@ export class BulkSetWagonStatusDto { @IsEnum(WagonStatus) status!: WagonStatus; + + /** Reason shown in the wagon's status history (maintenance/availability flips). */ + @IsOptional() + @IsString() + @MaxLength(500) + note?: string; } diff --git a/apps/edr-freight-api/src/modules/wagons/entities/wagon-status-log.entity.ts b/apps/edr-freight-api/src/modules/wagons/entities/wagon-status-log.entity.ts new file mode 100644 index 000000000..2aa4a0683 --- /dev/null +++ b/apps/edr-freight-api/src/modules/wagons/entities/wagon-status-log.entity.ts @@ -0,0 +1,32 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm'; + +import { Wagon } from './wagon.entity'; + +/** + * One wagon status flip (Available ⇄ Maintenance, Detained, …) — the audit + * trail behind the maintenance/availability buttons on the wagons desk. + * Written in the same transaction as the status change. + */ +@Entity({ schema: 'freight', name: 'wagon_status_logs' }) +@Index(['wagonId', 'createdAt']) +export class WagonStatusLog extends BaseEntity { + @Column({ name: 'wagon_id', type: 'uuid' }) + wagonId!: string; + + @ManyToOne(() => Wagon) + @JoinColumn({ name: 'wagon_id' }) + wagon?: Wagon; + + @Column({ name: 'from_status', type: 'varchar', length: 30 }) + fromStatus!: string; + + @Column({ name: 'to_status', type: 'varchar', length: 30 }) + toStatus!: string; + + @Column({ name: 'changed_by_user_id', type: 'uuid', nullable: true }) + changedByUserId?: string | null; + + @Column({ name: 'note', type: 'text', nullable: true }) + note?: string | null; +} diff --git a/apps/edr-freight-api/src/modules/wagons/wagons.controller.ts b/apps/edr-freight-api/src/modules/wagons/wagons.controller.ts index 19c0bda8f..e4492e5cb 100644 --- a/apps/edr-freight-api/src/modules/wagons/wagons.controller.ts +++ b/apps/edr-freight-api/src/modules/wagons/wagons.controller.ts @@ -119,9 +119,22 @@ export class WagonsController { } @Post('bulk-status') - @FleetManage(FREIGHT_PERMS.wagons.update) - @ApiOperation({ summary: 'Set the status of multiple wagons' }) - bulkSetStatus(@Body() dto: BulkSetWagonStatusDto) { - return this.wagonsService.bulkSetStatus(dto); + // One-of: the dedicated maintenance⇄availability key, full wagon edit, or + // the legacy coarse fleet:manage — operations/OCC hold statusToggle only. + @BookingStaff([ + FREIGHT_PERMS.wagons.statusToggle, + FREIGHT_PERMS.wagons.update, + FREIGHT_PERMS.fleet.manage, + ]) + @ApiOperation({ summary: 'Set the status of multiple wagons (audited in wagon_status_logs)' }) + bulkSetStatus(@Body() dto: BulkSetWagonStatusDto, @CurrentUser() user: TCurrentUser) { + return this.wagonsService.bulkSetStatus(dto, user?.id); + } + + @Get(':id/status-history') + @FleetView(FREIGHT_PERMS.wagons.view) + @ApiOperation({ summary: 'Status-flip history of a wagon (maintenance ⇄ availability audit)' }) + statusHistory(@Param('id', ParseUUIDPipe) id: string) { + return this.wagonsService.statusHistory(id); } } diff --git a/apps/edr-freight-api/src/modules/wagons/wagons.module.ts b/apps/edr-freight-api/src/modules/wagons/wagons.module.ts index 8c8a0d11f..ec915a553 100644 --- a/apps/edr-freight-api/src/modules/wagons/wagons.module.ts +++ b/apps/edr-freight-api/src/modules/wagons/wagons.module.ts @@ -2,6 +2,7 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { Wagon } from './entities/wagon.entity'; import { WagonMovement } from './entities/wagon-movement.entity'; +import { WagonStatusLog } from './entities/wagon-status-log.entity'; import { WagonTransferRequest } from './entities/wagon-transfer-request.entity'; import { Train } from '../trains/entities/train.entity'; import { Yard } from '../rule-engine/entities/yard.entity'; @@ -16,6 +17,7 @@ import { WagonTransferRequestsService } from './wagon-transfer-requests.service' TypeOrmModule.forFeature([ Wagon, WagonMovement, + WagonStatusLog, WagonTransferRequest, Train, Yard, diff --git a/apps/edr-freight-api/src/modules/wagons/wagons.service.ts b/apps/edr-freight-api/src/modules/wagons/wagons.service.ts index 038ff83b3..2d8950fb4 100644 --- a/apps/edr-freight-api/src/modules/wagons/wagons.service.ts +++ b/apps/edr-freight-api/src/modules/wagons/wagons.service.ts @@ -15,6 +15,7 @@ import { AssignWagonToTrainDto } from './dto/assign-wagon-to-train.dto'; import { BulkTransferWagonsDto } from './dto/bulk-transfer-wagons.dto'; import { BulkSetWagonStatusDto } from './dto/bulk-set-wagon-status.dto'; import { Wagon } from './entities/wagon.entity'; +import { WagonStatusLog } from './entities/wagon-status-log.entity'; import { WagonMovement } from './entities/wagon-movement.entity'; import { Train } from '../trains/entities/train.entity'; import { Yard } from '../rule-engine/entities/yard.entity'; @@ -440,7 +441,10 @@ export class WagonsService { * from Available to Assigned in the yard workspace). Only the `status` column * is touched — train assignment is managed through the assign/unassign flow. */ - async bulkSetStatus(dto: BulkSetWagonStatusDto): Promise<{ updated: number }> { + async bulkSetStatus( + dto: BulkSetWagonStatusDto, + changedByUserId?: string, + ): Promise<{ updated: number }> { const { wagonIds, status } = dto; if (!wagonIds.length) return { updated: 0 }; @@ -466,10 +470,24 @@ export class WagonsService { ); } + // Audit trail rides the same transaction — a status flip without its + // history row can't happen. No-change wagons write no log row. + const logs = wagons + .filter((w) => w.status !== status) + .map((w) => + queryRunner.manager.create(WagonStatusLog, { + wagonId: w.id, + fromStatus: w.status, + toStatus: status, + changedByUserId: changedByUserId ?? null, + note: dto.note ?? null, + }), + ); for (const wagon of wagons) { wagon.status = status; } await queryRunner.manager.save(Wagon, wagons); + if (logs.length) await queryRunner.manager.save(WagonStatusLog, logs); await queryRunner.commitTransaction(); return { updated: wagons.length }; @@ -481,4 +499,13 @@ export class WagonsService { } } + /** Status-flip history of one wagon, newest first (maintenance/availability audit). */ + async statusHistory(wagonId: string): Promise { + return this.dataSource.getRepository(WagonStatusLog).find({ + where: { wagonId }, + order: { createdAt: 'DESC' }, + take: 100, + }); + } + } 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 11f0ee40c..b103dead4 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts @@ -644,6 +644,13 @@ export const FLEET_RAIL_PERMISSIONS: FreightPermissionSeed[] = [ "edr_freight_app:wagons:hard_delete", "Permanently delete wagon", ), + // Maintenance ⇄ availability flip on the wagons desk — its own key so + // operations/OCC can flip readiness without holding full wagon edit. + perm( + "e1b00001-0001-4000-8000-00000000000c", + "edr_freight_app:wagons:status_toggle", + "Flip wagon between maintenance and available", + ), perm( "e1c00001-0001-4000-8000-000000000001", "edr_freight_app:trains:view", @@ -1511,6 +1518,8 @@ export const FREIGHT_PERMS = { transferView: "edr_freight_app:wagons:transfer_view", /** Withdraw a request that has not moved any wagon yet. */ transferCancel: "edr_freight_app:wagons:transfer_cancel", + /** Maintenance ⇄ availability flip on the wagons desk (audited). */ + statusToggle: "edr_freight_app:wagons:status_toggle", /** End a request short — anyone who can fulfil may also do this. */ transferCloseShort: "edr_freight_app:wagons:transfer_close_short", // Admin: read every staffer's transfer history. Without it, a user only sees @@ -1777,6 +1786,7 @@ const FLEET_GRANULAR_KEYS: string[] = [ FREIGHT_PERMS.wagons.create, FREIGHT_PERMS.wagons.update, FREIGHT_PERMS.wagons.delete, + FREIGHT_PERMS.wagons.statusToggle, FREIGHT_PERMS.trains.view, FREIGHT_PERMS.trains.create, FREIGHT_PERMS.trains.update, diff --git a/apps/edr-freight-web/backoffice/src/components/wagons/WagonStatusActions.tsx b/apps/edr-freight-web/backoffice/src/components/wagons/WagonStatusActions.tsx new file mode 100644 index 000000000..10c7bd82a --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/wagons/WagonStatusActions.tsx @@ -0,0 +1,226 @@ +import { useState } from "react"; +import { Freight } from "@edr/types"; +import { + ActionIcon, + Button, + Center, + Group, + Loader, + Modal, + Stack, + Table, + Text, + Textarea, + Tooltip, +} from "@mantine/core"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { Activity, ArrowRight } from "lucide-react"; + +import { api } from "@/services/api"; +import { useAuth } from "@/auth/useAuth"; +import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions"; +import { useToast } from "@/hooks/use-toast"; +import { formatFleetCell } from "@/components/fleet/fleetFormat"; +import type { FleetRecord } from "@/services/fleet/fleet.service"; +import type { WagonStatusLog } from "@/services/wagon.service"; + +export interface WagonStatusActionsProps { + record: FleetRecord; + /** Caller's wagons-update permission — the toggle hides without it. */ + canUpdate: boolean; +} + +const AVAILABLE = Freight.WagonStatus.Available; +const MAINTENANCE = Freight.WagonStatus.Maintenance; + +const fmt = (iso: string) => { + const d = new Date(iso); + return Number.isNaN(d.getTime()) ? iso : d.toLocaleString(); +}; + +/** + * Wagons-only row actions on the fleet desk: an availability/maintenance + * toggle (with confirm + optional note, audited server-side) and the wagon's + * status-change history. + */ +const WagonStatusActions = ({ record, canUpdate }: WagonStatusActionsProps) => { + const r = record as unknown as Record; + const id = r.id ? String(r.id) : ""; + const wagonNumber = r.wagonNumber ? String(r.wagonNumber) : ""; + const status = String(r.status ?? ""); + const { toast } = useToast(); + const { user } = useAuth(); + // Dedicated statusToggle key lets operations/OCC flip readiness without + // holding full wagon edit; full editors keep the button too. + const canToggle = + canUpdate || hasPermission(user, FREIGHT_PERMS.wagons.statusToggle); + const [confirmOpen, setConfirmOpen] = useState(false); + const [historyOpen, setHistoryOpen] = useState(false); + const [note, setNote] = useState(""); + + // Only these two statuses toggle — ASSIGNED/DETAINED/... have their own flows. + const target = + status === MAINTENANCE ? AVAILABLE : status === AVAILABLE ? MAINTENANCE : null; + + const setStatus = useMutation(api.wagons.bulkSetStatus.mutationOptions()); + + const { data: logs = [], isLoading: logsLoading } = useQuery( + api.wagons.statusHistory.queryOptions({ + input: { id }, + enabled: historyOpen && Boolean(id), + }), + ); + + const closeConfirm = () => { + setConfirmOpen(false); + setNote(""); + }; + + const handleConfirm = async () => { + if (!target || !id) return; + try { + await setStatus.mutateAsync({ + wagonIds: [id], + status: target, + note: note.trim() || undefined, + }); + toast({ + title: + target === AVAILABLE + ? `Wagon ${wagonNumber} marked available` + : `Wagon ${wagonNumber} sent to maintenance`, + }); + closeConfirm(); + } catch (err: unknown) { + const message = + (err as { response?: { data?: { message?: string } } })?.response?.data + ?.message ?? "Status change failed"; + toast({ + title: "Status change failed", + description: String(message), + variant: "destructive", + }); + } + }; + + return ( + <> + {canToggle && target ? ( + + ) : null} + + + setHistoryOpen(true)} + > + + + + + + {target === AVAILABLE ? "Mark available" : "Send to maintenance"} + + } + radius="lg" + centered + > + + + + Wagon{" "} + + {wagonNumber} + + + {formatFleetCell(status, "statusBadge")} + + {formatFleetCell(target ?? "", "statusBadge")} + +