diff --git a/apps/edr-freight-api/.env.example b/apps/edr-freight-api/.env.example index e02391ccd..5bce9de95 100644 --- a/apps/edr-freight-api/.env.example +++ b/apps/edr-freight-api/.env.example @@ -55,8 +55,31 @@ REDIS_HOST=localhost REDIS_PORT=6379 # --- Notification broker (RabbitMQ) --------------------------------------------- -# SMS OTP / notifications are queued to RabbitMQ (consumed by the shared SMS service). -# Set RABBITMQ_ENABLED=false to skip the broker entirely (dev without a local broker). +# SMS/email OTP + notifications are queued to RabbitMQ (consumed by the shared +# SMS/email services). Set RABBITMQ_ENABLED=false to skip the broker entirely +# (dev without a local broker). RABBITMQ_ENABLED=false RABBITMQ_URL=amqp://localhost:5672 SMS_QUEUE=sms_queue + +# ── VeriFayda 2.0 (eSignet OIDC) identity verification ────────────────────── +# Disabled by default; /fayda/verification/start returns 503 until enabled. +FAYDA_ENABLED=false +FAYDA_CLIENT_ID= +FAYDA_AUTHORIZATION_ENDPOINT= +FAYDA_TOKEN_ENDPOINT= +FAYDA_USERINFO_ENDPOINT= +# Base64-encoded RSA private JWK used for the private_key_jwt client assertion +FAYDA_PRIVATE_KEY_BASE64= +# OAuth redirect_uri for MOBILE clients (must be registered with eSignet) +FAYDA_REDIRECT_URI=http://localhost:3001/api/fayda/verification/complete +# OAuth redirect_uri for WEB clients. Defaults to FAYDA_REDIRECT_URI when unset. +FAYDA_WEB_REDIRECT_URI=http://localhost:3000/callback +CLIENT_ASSERTION_TYPE=urn:ietf:params:oauth:client-assertion-type:jwt-bearer +FAYDA_SCOPE=openid profile email phone address +FAYDA_ACR_VALUES=mosip:idp:acr:generated-code +FAYDA_CLAIMS_LOCALES=en am +FAYDA_SESSION_TTL_MINUTES=10 +EXPIRATION_TIME=15 +ALGORITHM=RS256 +EMAIL_QUEUE=email_queue diff --git a/apps/edr-freight-api/package.json b/apps/edr-freight-api/package.json index a0a119861..c148dd226 100644 --- a/apps/edr-freight-api/package.json +++ b/apps/edr-freight-api/package.json @@ -63,6 +63,7 @@ "dotenv": "^17.4.2", "dotenv-cli": "^11.0.0", "handlebars": "^4.7.9", + "jose": "^5.10.0", "libphonenumber-js": "^1.13.6", "minio": "7.1.3", "pg": "^8.13.0", diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index f40e4f40f..b090624ff 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -1,4 +1,8 @@ -import { Module, OnApplicationBootstrap } from "@nestjs/common"; +import { + MiddlewareConsumer, + Module, + OnApplicationBootstrap, +} from "@nestjs/common"; import { ConfigModule, ConfigService } from "@nestjs/config"; import { TypeOrmModule, TypeOrmModuleOptions } from "@nestjs/typeorm"; import { ScheduleModule } from "@nestjs/schedule"; @@ -12,6 +16,7 @@ import appConfig from "./config/app.config"; import databaseConfig from "./config/database.config"; import telebirrConfig from "./config/telebirr.config"; import rabbitmqConfig from "./config/rabbitmq.config"; +import faydaConfig from "./config/fayda.config"; import { BookingsModule } from "./modules/bookings/bookings.module"; import { ContractsModule } from "./modules/contracts/contracts.module"; @@ -61,26 +66,29 @@ import { GovCompaniesSeeder } from "./seed/gov-companies.seeder"; import { ApprovedFirstLastMileDemoBookingsSeeder } from "./seed/approved-first-lastmile-demo-bookings.seeder"; //New Trains, Wagons, Container and Cargo management modules import { TrainsModule } from "./modules/trains/trains.module"; -import { WagonsModule } from './modules/wagons/wagons.module'; -import { ContainersModule } from './modules/container-management/containers.module'; -import { CargoesModule } from './modules/cargoes/cargoes.module'; -import { RoutesModule } from './modules/routes/routes.module'; -import { WarehousesModule } from './modules/warehouses/warehouses.module'; -import { OverviewModule } from './modules/overview/overview.module'; -import { VehiclesModule } from './modules/vehicles/vehicles.module'; -import { DriversModule } from './modules/drivers/drivers.module'; -import { FuelModule } from './modules/fuel/fuel.module'; -import { MaintenanceModule } from './modules/maintenance/maintenance.module'; -import { FirstMileModule } from './modules/first-mile/first-mile.module'; -import { LastMileModule } from './modules/last-mile/last-mile.module'; -import { InterchangeDocumentsModule } from './modules/interchange-documents/interchange-documents.module'; -import { ImportOperationsModule } from './modules/import-operations/import-operations.module'; +import { VerifaydaModule } from './modules/verifayda/verifayda.module'; +import { FleetHistoryModule } from './modules/fleet-history/fleet-history.module'; +import { WagonsModule } from "./modules/wagons/wagons.module"; +import { ContainersModule } from "./modules/container-management/containers.module"; +import { CargoesModule } from "./modules/cargoes/cargoes.module"; +import { RoutesModule } from "./modules/routes/routes.module"; +import { WarehousesModule } from "./modules/warehouses/warehouses.module"; +import { OverviewModule } from "./modules/overview/overview.module"; +import { VehiclesModule } from "./modules/vehicles/vehicles.module"; +import { DriversModule } from "./modules/drivers/drivers.module"; +import { FuelModule } from "./modules/fuel/fuel.module"; +import { MaintenanceModule } from "./modules/maintenance/maintenance.module"; +import { FirstMileModule } from "./modules/first-mile/first-mile.module"; +import { LastMileModule } from "./modules/last-mile/last-mile.module"; +import { InterchangeDocumentsModule } from "./modules/interchange-documents/interchange-documents.module"; +import { ImportOperationsModule } from "./modules/import-operations/import-operations.module"; +import { LoggerMiddleware } from "./logger.middleware"; @Module({ imports: [ ConfigModule.forRoot({ isGlobal: true, - load: [appConfig, databaseConfig, telebirrConfig, rabbitmqConfig], + load: [appConfig, databaseConfig, telebirrConfig, rabbitmqConfig, faydaConfig], }), ScheduleModule.forRoot(), EventEmitterModule.forRoot(), @@ -141,6 +149,8 @@ import { ImportOperationsModule } from './modules/import-operations/import-opera LastMileModule, InterchangeDocumentsModule, ImportOperationsModule, + VerifaydaModule, + FleetHistoryModule, ], providers: [ EdrOrgSeeder, @@ -211,4 +221,8 @@ export class AppModule implements OnApplicationBootstrap { // bookings bill to. Idempotent — keyed by fixed IDs. await this.govCompaniesSeeder.run(); } + + configure(consumer: MiddlewareConsumer) { + consumer.apply(LoggerMiddleware).forRoutes("*"); + } } diff --git a/apps/edr-freight-api/src/config/fayda.config.ts b/apps/edr-freight-api/src/config/fayda.config.ts new file mode 100644 index 000000000..a25289159 --- /dev/null +++ b/apps/edr-freight-api/src/config/fayda.config.ts @@ -0,0 +1,126 @@ +import { registerAs } from '@nestjs/config'; + +export interface FaydaJwk { + kty: 'RSA'; + use?: string; + kid?: string; + alg?: string; + n: string; + e: string; + d: string; + p?: string; + q?: string; + dp?: string; + dq?: string; + qi?: string; +} + +export type FaydaPlatform = 'WEB' | 'MOBILE'; + +export interface FaydaConfig { + enabled: boolean; + clientId: string; + authorizationEndpoint: string; + tokenEndpoint: string; + userInfoEndpoint: string; + /** OAuth redirect_uri sent to eSignet for MOBILE clients. */ + redirectUri: string; + /** OAuth redirect_uri sent to eSignet for WEB clients. Falls back to `redirectUri`. */ + webRedirectUri: string; + privateJwk: FaydaJwk; + scope: string; + acrValues: string; + claimsLocales: string; + sessionTtlMinutes: number; +} + +const REQUIRED_VARS = [ + 'FAYDA_CLIENT_ID', + 'FAYDA_AUTHORIZATION_ENDPOINT', + 'FAYDA_TOKEN_ENDPOINT', + 'FAYDA_USERINFO_ENDPOINT', + 'FAYDA_PRIVATE_KEY_BASE64', +] as const; + +function decodePrivateJwk(base64: string): FaydaJwk { + let jwk: unknown; + try { + const json = Buffer.from(base64, 'base64').toString('utf8'); + jwk = JSON.parse(json); + } catch (err) { + throw new Error( + `FAYDA_PRIVATE_KEY_BASE64 is not valid Base64-encoded JSON: ${(err as Error).message}`, + ); + } + if (!jwk || typeof jwk !== 'object') { + throw new Error('FAYDA_PRIVATE_KEY_BASE64 must decode to a JSON object'); + } + const candidate = jwk as Partial; + if (candidate.kty !== 'RSA') { + throw new Error('FAYDA_PRIVATE_KEY_BASE64 JWK must have kty="RSA"'); + } + if (!candidate.n || !candidate.e || !candidate.d) { + throw new Error( + 'FAYDA_PRIVATE_KEY_BASE64 JWK is missing required RSA private-key fields (n, e, d)', + ); + } + return candidate as FaydaJwk; +} + +export default registerAs('fayda', (): FaydaConfig => { + const enabled = (process.env.FAYDA_ENABLED ?? 'false').toLowerCase() === 'true'; + // `profile` covers name/birthdate/gender/picture; `email`, `phone`, `address` + // are needed so the matching essential claims aren't rejected as out-of-scope. + const scope = process.env.FAYDA_SCOPE ?? 'openid profile email phone address'; + const acrValues = process.env.FAYDA_ACR_VALUES ?? 'mosip:idp:acr:generated-code'; + const claimsLocales = process.env.FAYDA_CLAIMS_LOCALES ?? 'en am'; + const sessionTtl = Number.parseInt(process.env.FAYDA_SESSION_TTL_MINUTES ?? '10', 10); + const redirectUri = process.env.FAYDA_REDIRECT_URI ?? ''; + const webRedirectUri = process.env.FAYDA_WEB_REDIRECT_URI || redirectUri; + if (!enabled) { + return { + enabled: false, + clientId: process.env.FAYDA_CLIENT_ID ?? '', + authorizationEndpoint: process.env.FAYDA_AUTHORIZATION_ENDPOINT ?? '', + tokenEndpoint: process.env.FAYDA_TOKEN_ENDPOINT ?? '', + userInfoEndpoint: process.env.FAYDA_USERINFO_ENDPOINT ?? '', + redirectUri, + webRedirectUri, + privateJwk: { kty: 'RSA', n: '', e: '', d: '' }, + scope, + acrValues, + claimsLocales, + sessionTtlMinutes: Number.isNaN(sessionTtl) || sessionTtl <= 0 ? 10 : sessionTtl, + }; + } + + const missing = REQUIRED_VARS.filter((name) => !process.env[name]); + if (missing.length > 0) { + throw new Error( + `Fayda integration is enabled (FAYDA_ENABLED=true) but the following env vars are missing: ${missing.join(', ')}`, + ); + } + if (!redirectUri) { + throw new Error( + 'Fayda integration is enabled but the redirect URI is missing: set FAYDA_REDIRECT_URI', + ); + } + if (Number.isNaN(sessionTtl) || sessionTtl <= 0) { + throw new Error('FAYDA_SESSION_TTL_MINUTES must be a positive integer'); + } + + return { + enabled: true, + clientId: process.env.FAYDA_CLIENT_ID!, + authorizationEndpoint: process.env.FAYDA_AUTHORIZATION_ENDPOINT!, + tokenEndpoint: process.env.FAYDA_TOKEN_ENDPOINT!, + userInfoEndpoint: process.env.FAYDA_USERINFO_ENDPOINT!, + redirectUri, + webRedirectUri, + privateJwk: decodePrivateJwk(process.env.FAYDA_PRIVATE_KEY_BASE64!), + scope, + acrValues, + claimsLocales, + sessionTtlMinutes: sessionTtl, + }; +}); diff --git a/apps/edr-freight-api/src/logger.middleware.ts b/apps/edr-freight-api/src/logger.middleware.ts new file mode 100644 index 000000000..dd7532ec8 --- /dev/null +++ b/apps/edr-freight-api/src/logger.middleware.ts @@ -0,0 +1,21 @@ +import { Injectable, NestMiddleware, Logger } from "@nestjs/common"; +import { Request, Response, NextFunction } from "express"; + +@Injectable() +export class LoggerMiddleware implements NestMiddleware { + private readonly logger = new Logger("HTTP"); + + use(req: Request, res: Response, next: NextFunction) { + const start = Date.now(); + + res.on("finish", () => { + const duration = Date.now() - start; + + this.logger.log( + `${req.method} ${req.originalUrl} ${res.statusCode} ${duration}ms`, + ); + }); + + next(); + } +} diff --git a/apps/edr-freight-api/src/main.ts b/apps/edr-freight-api/src/main.ts index 0107956e4..0fa1056dd 100644 --- a/apps/edr-freight-api/src/main.ts +++ b/apps/edr-freight-api/src/main.ts @@ -38,7 +38,8 @@ async function bootstrap() { maxAge: 86400, // cache preflight for 24h to cut chatter in dev }); - app.setGlobalPrefix("api"); + // /callback stays un-prefixed: it's the Fayda OAuth redirect_uri ack endpoint. + app.setGlobalPrefix("api", { exclude: ["callback"] }); // enableImplicitConversion is OFF: class-transformer's implicit boolean // coercion turns any non-empty multipart/form-data string (including the // literal "false") into `true`, silently corrupting flags like isHazardous diff --git a/apps/edr-freight-api/src/migrations/1890000000002-AddFaydaVerificationSessions.ts b/apps/edr-freight-api/src/migrations/1890000000002-AddFaydaVerificationSessions.ts new file mode 100644 index 000000000..10b358eea --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1890000000002-AddFaydaVerificationSessions.ts @@ -0,0 +1,44 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Session store for the VeriFayda 2.0 OIDC verification flow (ported from + * passenger-api). One row per started verification; `state` is the + * single-use CSRF token linking the eSignet redirect back to the session. + */ +export class AddFaydaVerificationSessions1890000000002 implements MigrationInterface { + name = "AddFaydaVerificationSessions1890000000002"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.fayda_verification_sessions ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + state varchar NOT NULL UNIQUE, + code_verifier varchar NOT NULL, + purpose varchar NOT NULL DEFAULT 'VERIFY', + platform varchar NOT NULL DEFAULT 'WEB', + save_to_account boolean NOT NULL DEFAULT false, + status varchar NOT NULL DEFAULT 'PENDING', + error_code varchar, + error_description text, + iam_user_id uuid, + expires_at timestamptz NOT NULL, + completed_at timestamptz, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz + ) + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "IDX_FAYDA_SESSIONS_EXPIRES_AT" + ON freight.fayda_verification_sessions (expires_at) + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "IDX_FAYDA_SESSIONS_IAM_USER_ID" + ON freight.fayda_verification_sessions (iam_user_id) + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS freight.fayda_verification_sessions`); + } +} diff --git a/apps/edr-freight-api/src/migrations/1890000000003-AddDriverFaydaVerification.ts b/apps/edr-freight-api/src/migrations/1890000000003-AddDriverFaydaVerification.ts new file mode 100644 index 000000000..ba8003143 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1890000000003-AddDriverFaydaVerification.ts @@ -0,0 +1,26 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Track Fayda identity verification on drivers: whether the driver's + * identity was verified through VeriFayda and the OIDC subject it was + * verified against. + */ +export class AddDriverFaydaVerification1890000000003 implements MigrationInterface { + name = "AddDriverFaydaVerification1890000000003"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.drivers + ADD COLUMN IF NOT EXISTS fayda_verified boolean DEFAULT false, + ADD COLUMN IF NOT EXISTS fayda_sub varchar + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.drivers + DROP COLUMN IF EXISTS fayda_verified, + DROP COLUMN IF EXISTS fayda_sub + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/1890000000004-AddLastMileVehicleAssignments.ts b/apps/edr-freight-api/src/migrations/1890000000004-AddLastMileVehicleAssignments.ts new file mode 100644 index 000000000..7c1413617 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1890000000004-AddLastMileVehicleAssignments.ts @@ -0,0 +1,39 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Allow more than one vehicle per last-mile delivery. Junction table joins + * last_mile ⇄ vehicles; existing single vehicle_id values are backfilled as + * the first assignment so nothing is lost. + */ +export class AddLastMileVehicleAssignments1890000000004 implements MigrationInterface { + name = "AddLastMileVehicleAssignments1890000000004"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.last_mile_vehicle_assignments ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + last_mile_id uuid NOT NULL REFERENCES freight.last_mile(id) ON DELETE CASCADE, + vehicle_id uuid NOT NULL REFERENCES freight.vehicles(id), + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + CONSTRAINT "UQ_LAST_MILE_VEHICLE" UNIQUE (last_mile_id, vehicle_id) + ) + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "IDX_LM_VEHICLE_ASSIGNMENTS_VEHICLE" + ON freight.last_mile_vehicle_assignments (vehicle_id) + `); + // Backfill: existing single-vehicle assignments become the first row + await queryRunner.query(` + INSERT INTO freight.last_mile_vehicle_assignments (last_mile_id, vehicle_id) + SELECT id, vehicle_id FROM freight.last_mile + WHERE vehicle_id IS NOT NULL AND deleted_at IS NULL + ON CONFLICT (last_mile_id, vehicle_id) DO NOTHING + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS freight.last_mile_vehicle_assignments`); + } +} diff --git a/apps/edr-freight-api/src/migrations/1890000000005-AddDriverGender.ts b/apps/edr-freight-api/src/migrations/1890000000005-AddDriverGender.ts new file mode 100644 index 000000000..2ec26e034 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1890000000005-AddDriverGender.ts @@ -0,0 +1,24 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Store the driver's gender. Prefilled from the Fayda VERIFY response + * (Male/Female) but editable; nullable so existing rows and manual, + * non-Fayda driver records stay valid. + */ +export class AddDriverGender1890000000005 implements MigrationInterface { + name = "AddDriverGender1890000000005"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.drivers + ADD COLUMN IF NOT EXISTS gender varchar + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.drivers + DROP COLUMN IF EXISTS gender + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/1890000000006-AddDriverFaydaSubUnique.ts b/apps/edr-freight-api/src/migrations/1890000000006-AddDriverFaydaSubUnique.ts new file mode 100644 index 000000000..04c9a7000 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1890000000006-AddDriverFaydaSubUnique.ts @@ -0,0 +1,23 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Enforce one driver record per verified Fayda identity. A unique index on + * fayda_sub blocks a second driver from being created against the same Fayda + * OIDC subject; NULLs stay distinct so legacy/unverified rows are unaffected. + */ +export class AddDriverFaydaSubUnique1890000000006 implements MigrationInterface { + name = "AddDriverFaydaSubUnique1890000000006"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS "UQ_DRIVERS_FAYDA_SUB" + ON freight.drivers (fayda_sub) + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + DROP INDEX IF EXISTS freight."UQ_DRIVERS_FAYDA_SUB" + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/1890000000007-DriverUniquePartialSoftDelete.ts b/apps/edr-freight-api/src/migrations/1890000000007-DriverUniquePartialSoftDelete.ts new file mode 100644 index 000000000..77d0a3043 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1890000000007-DriverUniquePartialSoftDelete.ts @@ -0,0 +1,62 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Make driver uniqueness soft-delete aware. The original table used plain + * column UNIQUE constraints (drivers_email_key, etc.) which count soft-deleted + * rows, so deleting a driver then re-adding the same email/phone/license/Fayda + * identity failed at the DB with a raw 500 — even though the service's own + * (deleted_at-excluding) duplicate check saw nothing. Replace them with partial + * unique indexes scoped to live rows (deleted_at IS NULL) so uniqueness matches + * what the service enforces and freed values become reusable after deletion. + */ +export class DriverUniquePartialSoftDelete1890000000007 implements MigrationInterface { + name = "DriverUniquePartialSoftDelete1890000000007"; + + public async up(queryRunner: QueryRunner): Promise { + // Drop the full-table unique constraints from CreateDriversTable... + await queryRunner.query(` + ALTER TABLE freight.drivers + DROP CONSTRAINT IF EXISTS drivers_email_key, + DROP CONSTRAINT IF EXISTS drivers_phone_number_key, + DROP CONSTRAINT IF EXISTS drivers_license_number_key + `); + // ...and the plain fayda_sub unique index from 1890000000006. + await queryRunner.query(`DROP INDEX IF EXISTS freight."UQ_DRIVERS_FAYDA_SUB"`); + + // Re-add each as a partial unique index scoped to non-deleted rows. + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS "UQ_DRIVERS_EMAIL_ACTIVE" + ON freight.drivers (email) WHERE deleted_at IS NULL + `); + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS "UQ_DRIVERS_PHONE_ACTIVE" + ON freight.drivers (phone_number) WHERE deleted_at IS NULL + `); + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS "UQ_DRIVERS_LICENSE_ACTIVE" + ON freight.drivers (license_number) WHERE deleted_at IS NULL + `); + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS "UQ_DRIVERS_FAYDA_SUB_ACTIVE" + ON freight.drivers (fayda_sub) WHERE deleted_at IS NULL + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX IF EXISTS freight."UQ_DRIVERS_EMAIL_ACTIVE"`); + await queryRunner.query(`DROP INDEX IF EXISTS freight."UQ_DRIVERS_PHONE_ACTIVE"`); + await queryRunner.query(`DROP INDEX IF EXISTS freight."UQ_DRIVERS_LICENSE_ACTIVE"`); + await queryRunner.query(`DROP INDEX IF EXISTS freight."UQ_DRIVERS_FAYDA_SUB_ACTIVE"`); + + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS "UQ_DRIVERS_FAYDA_SUB" + ON freight.drivers (fayda_sub) + `); + await queryRunner.query(` + ALTER TABLE freight.drivers + ADD CONSTRAINT drivers_email_key UNIQUE (email), + ADD CONSTRAINT drivers_phone_number_key UNIQUE (phone_number), + ADD CONSTRAINT drivers_license_number_key UNIQUE (license_number) + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/1890000000008-AddFleetEvents.ts b/apps/edr-freight-api/src/migrations/1890000000008-AddFleetEvents.ts new file mode 100644 index 000000000..8fbb688d3 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1890000000008-AddFleetEvents.ts @@ -0,0 +1,43 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Append-only audit log for fleet activity (driver↔vehicle assignments, vehicle + * status/availability transitions, first/last-mile vehicle assignments + mile + * status changes). Queried by vehicle_id or driver_id to build a per-record + * timeline. Populated going forward — existing records have no back-history. + */ +export class AddFleetEvents1890000000008 implements MigrationInterface { + name = "AddFleetEvents1890000000008"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.fleet_events ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + event_type varchar NOT NULL, + vehicle_id uuid, + driver_id uuid, + first_mile_id uuid, + last_mile_id uuid, + from_value varchar, + to_value varchar, + label varchar, + metadata jsonb, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz + ) + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "IDX_FLEET_EVENTS_VEHICLE" + ON freight.fleet_events (vehicle_id, created_at) + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "IDX_FLEET_EVENTS_DRIVER" + ON freight.fleet_events (driver_id, created_at) + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS freight.fleet_events`); + } +} diff --git a/apps/edr-freight-api/src/migrations/1900000000000-AddEmailToOtpVerifications.ts b/apps/edr-freight-api/src/migrations/1900000000000-AddEmailToOtpVerifications.ts new file mode 100644 index 000000000..1bd3bbc27 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1900000000000-AddEmailToOtpVerifications.ts @@ -0,0 +1,35 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Support email as a second OTP channel alongside phone (e.g. signup lets the + * user choose which one to verify). `phone` becomes nullable since an + * email-channel row has none, and `email` is added as a nullable unique column + * mirroring `phone`'s shape. + */ +export class AddEmailToOtpVerifications1900000000000 + implements MigrationInterface +{ + name = "AddEmailToOtpVerifications1900000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE public.otp_verifications + ALTER COLUMN phone DROP NOT NULL + `); + await queryRunner.query(` + ALTER TABLE public.otp_verifications + ADD COLUMN IF NOT EXISTS email varchar UNIQUE + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE public.otp_verifications + DROP COLUMN IF EXISTS email + `); + await queryRunner.query(` + ALTER TABLE public.otp_verifications + ALTER COLUMN phone SET NOT NULL + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/1900000000000-AddLoadingStatusToTrainScheduleBookings.ts b/apps/edr-freight-api/src/migrations/1900000000000-AddLoadingStatusToTrainScheduleBookings.ts new file mode 100644 index 000000000..6c7235e3b --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1900000000000-AddLoadingStatusToTrainScheduleBookings.ts @@ -0,0 +1,25 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Track per-booking loading confirmation (LOADED/UNLOADED) on train_schedule_bookings. + * Tracking only — does not gate dispatch. + */ +export class AddLoadingStatusToTrainScheduleBookings1900000000000 + implements MigrationInterface +{ + name = "AddLoadingStatusToTrainScheduleBookings1900000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.train_schedule_bookings + ADD COLUMN IF NOT EXISTS loading_status varchar(20) NOT NULL DEFAULT 'UNLOADED' + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.train_schedule_bookings + DROP COLUMN IF EXISTS loading_status + `); + } +} diff --git a/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts index e87112bc2..9bb4b2de6 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts @@ -19,6 +19,7 @@ import { CargoTypesService } from '../rule-engine/services/cargo-types.service'; import { DropdownSettingsService } from '../dropdown-settings/dropdown-settings.service'; import { FilesService } from '../files/files.service'; import { SignaturesService } from '../signatures/signatures.service'; +import { OtpService } from '../otp/otp.service'; import { ContractPricingService } from './contract-pricing.service'; import { ClearanceMilestoneService } from './clearance-milestone.service'; import { ContractsRepository } from './contracts.repository'; @@ -63,6 +64,7 @@ export class ContractTransitionService { private readonly renderer: ContractRendererService, private readonly pdfService: ContractPdfService, private readonly minioService: MinioService, + private readonly otpService: OtpService, ) {} /** Customer submits the contract for approval → SUBMITTED; freeze unit rates. */ @@ -520,6 +522,12 @@ export class ContractTransitionService { if (existing) { throw new BadRequestException('Customer has already signed this contract'); } + // Sudo-mode gate: a fresh, single-use OTP (SMS'd to the customer's phone) + // must be verified before the signature is applied. + if (!dto.otpPhone || !dto.otp) { + throw new BadRequestException('OTP verification is required to sign the contract'); + } + await this.otpService.verifyOtpForAction(dto.otpPhone, dto.otp); await this.applySignature(contract, dto, options); await this.contractsRepository.update(contractId, { status: 'SIGNED_CUSTOMER', diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.module.ts b/apps/edr-freight-api/src/modules/contracts/contracts.module.ts index 48476a7ea..a9f9dcf5d 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.module.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.module.ts @@ -11,6 +11,7 @@ import { RuleEngineModule } from '../rule-engine/rule-engine.module'; import { FileUploadSettingsModule } from '../file-upload-settings/file-upload-settings.module'; import { DropdownSettingsModule } from '../dropdown-settings/dropdown-settings.module'; import { SignaturesModule } from '../signatures/signatures.module'; +import { OtpModule } from '../otp/otp.module'; import { BookingsModule } from '../bookings/bookings.module'; import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.module'; @@ -73,6 +74,7 @@ import { ContractDocumentViewModelBuilder } from '../../contracts/contract-docum FilesModule, MinioModule, SignaturesModule, + OtpModule, CompaniesModule, // BookingsModule provides BookingsRepository/BookingPricingService used by the // contract PDF builders (they read a Booking today — see docs/new-doc.md §3.3). diff --git a/apps/edr-freight-api/src/modules/contracts/dto/sign-contract.dto.ts b/apps/edr-freight-api/src/modules/contracts/dto/sign-contract.dto.ts index febe7a83b..f0676b629 100644 --- a/apps/edr-freight-api/src/modules/contracts/dto/sign-contract.dto.ts +++ b/apps/edr-freight-api/src/modules/contracts/dto/sign-contract.dto.ts @@ -1,5 +1,5 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { IsIn, IsOptional, IsString, MinLength } from 'class-validator'; +import { IsIn, IsOptional, IsString, Matches, MinLength } from 'class-validator'; export class SignContractDto { @ApiProperty({ enum: ['CUSTOMER', 'STAFF', 'DIRECTOR', 'CEO'] }) @@ -26,4 +26,19 @@ export class SignContractDto { @IsOptional() @IsString() consentText?: string; + + // Sudo-mode OTP challenge. Required when role=CUSTOMER: a fresh 6-digit code + // SMS'd to the signer's phone, verified server-side before the signature is + // applied. `otpPhone` is the number the code was sent to (the signed-in + // customer's registered phone). + @ApiPropertyOptional({ description: '6-digit OTP; required when role=CUSTOMER' }) + @IsOptional() + @IsString() + @Matches(/^\d{6}$/, { message: 'otp must be 6 digits' }) + otp?: string; + + @ApiPropertyOptional({ description: 'Phone the OTP was sent to; required when role=CUSTOMER' }) + @IsOptional() + @IsString() + otpPhone?: string; } diff --git a/apps/edr-freight-api/src/modules/drivers/drivers.controller.ts b/apps/edr-freight-api/src/modules/drivers/drivers.controller.ts index eb0628b96..b4da558e2 100644 --- a/apps/edr-freight-api/src/modules/drivers/drivers.controller.ts +++ b/apps/edr-freight-api/src/modules/drivers/drivers.controller.ts @@ -14,13 +14,17 @@ import { FleetManage, FleetView } from '../../common/booking-guards'; import { DriversService } from './drivers.service'; import { CreateDriverDto } from './dto/create-driver.dto'; import { UpdateDriverDto } from './dto/update-driver.dto'; +import { FleetHistoryService } from '../fleet-history/fleet-history.service'; @ApiTags('drivers') @ApiBearerAuth() @Controller('drivers') @FleetView() export class DriversController { - constructor(private readonly driversService: DriversService) {} + constructor( + private readonly driversService: DriversService, + private readonly fleetHistory: FleetHistoryService, + ) {} @Post() @FleetManage() @@ -55,6 +59,12 @@ export class DriversController { return this.driversService.findById(id); } + @Get(':id/history') + @ApiOperation({ summary: 'Get driver assignment & activity history' }) + history(@Param('id', ParseUUIDPipe) id: string) { + return this.fleetHistory.getDriverHistory(id); + } + @Patch(':id') @FleetManage() @ApiOperation({ summary: 'Update a driver' }) diff --git a/apps/edr-freight-api/src/modules/drivers/drivers.service.ts b/apps/edr-freight-api/src/modules/drivers/drivers.service.ts index 2464a26ac..6e7c1f69c 100644 --- a/apps/edr-freight-api/src/modules/drivers/drivers.service.ts +++ b/apps/edr-freight-api/src/modules/drivers/drivers.service.ts @@ -1,18 +1,27 @@ -import { Injectable, NotFoundException, ConflictException } from '@nestjs/common'; +import { Injectable, NotFoundException, ConflictException, BadRequestException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { CreateDriverDto } from './dto/create-driver.dto'; import { UpdateDriverDto } from './dto/update-driver.dto'; import { Driver, DriverStatus } from './entities/driver.entity'; +import { FleetHistoryService } from '../fleet-history/fleet-history.service'; +import { FleetEventType } from '../fleet-history/entities/fleet-event.entity'; @Injectable() export class DriversService { constructor( @InjectRepository(Driver) private readonly driverRepo: Repository, + private readonly history: FleetHistoryService, ) {} async create(dto: CreateDriverDto): Promise { + if (dto.faydaVerified !== true) { + throw new BadRequestException( + 'Driver identity must be verified with Fayda before saving', + ); + } + const existing = await this.driverRepo.findOne({ where: [ { licenseNumber: dto.licenseNumber }, @@ -33,8 +42,28 @@ export class DriversService { } } + if (dto.faydaSub) { + const dupe = await this.driverRepo.findOne({ + where: { faydaSub: dto.faydaSub }, + }); + if (dupe) { + throw new ConflictException( + 'A driver is already registered for this Fayda identity', + ); + } + } + const driver = this.driverRepo.create(dto); - return this.driverRepo.save(driver); + const saved = await this.driverRepo.save(driver); + + await this.history.record({ + eventType: FleetEventType.DRIVER_REGISTERED, + driverId: saved.id, + label: `${saved.firstName ?? ''} ${saved.lastName ?? ''}`.trim() || null, + toValue: saved.status ?? null, + }); + + return saved; } async findAll(query: { @@ -106,7 +135,25 @@ export class DriversService { } } + if (dto.faydaSub && dto.faydaSub !== driver.faydaSub) { + const dupe = await this.driverRepo.findOne({ + where: { faydaSub: dto.faydaSub }, + }); + if (dupe) { + throw new ConflictException( + 'A driver is already registered for this Fayda identity', + ); + } + } + Object.assign(driver, dto); + + if (driver.faydaVerified !== true) { + throw new BadRequestException( + 'Driver identity must be verified with Fayda before saving', + ); + } + return this.driverRepo.save(driver); } diff --git a/apps/edr-freight-api/src/modules/drivers/dto/create-driver.dto.ts b/apps/edr-freight-api/src/modules/drivers/dto/create-driver.dto.ts index d8e2aec4a..c2f0bca81 100644 --- a/apps/edr-freight-api/src/modules/drivers/dto/create-driver.dto.ts +++ b/apps/edr-freight-api/src/modules/drivers/dto/create-driver.dto.ts @@ -1,5 +1,5 @@ -import { IsString, IsEmail, IsDateString, IsEnum, IsOptional, IsArray } from 'class-validator'; -import { DriverStatus } from '../entities/driver.entity'; +import { IsString, IsEmail, IsDateString, IsEnum, IsOptional, IsArray, IsBoolean } from 'class-validator'; +import { DriverStatus, DriverGender } from '../entities/driver.entity'; export class CreateDriverDto { @IsString() @@ -20,6 +20,10 @@ export class CreateDriverDto { @IsDateString() dateOfBirth!: string; + @IsOptional() + @IsEnum(DriverGender) + gender?: DriverGender; + @IsDateString() licenseExpiryDate!: string; @@ -42,4 +46,12 @@ export class CreateDriverDto { @IsOptional() @IsString() notes?: string; + + @IsOptional() + @IsBoolean() + faydaVerified?: boolean; + + @IsOptional() + @IsString() + faydaSub?: string; } diff --git a/apps/edr-freight-api/src/modules/drivers/entities/driver.entity.ts b/apps/edr-freight-api/src/modules/drivers/entities/driver.entity.ts index b3defe2db..58a08b849 100644 --- a/apps/edr-freight-api/src/modules/drivers/entities/driver.entity.ts +++ b/apps/edr-freight-api/src/modules/drivers/entities/driver.entity.ts @@ -8,6 +8,12 @@ export enum DriverStatus { ON_LEAVE = 'ON_LEAVE', } +export enum DriverGender { + MALE = 'MALE', + FEMALE = 'FEMALE', + OTHER = 'OTHER', +} + @Entity({ name: 'drivers', schema: 'freight' }) export class Driver extends BaseEntity { @Column({ name: 'license_number', unique: true, nullable: true }) @@ -28,6 +34,9 @@ export class Driver extends BaseEntity { @Column({ name: 'date_of_birth', type: 'date', nullable: true }) dateOfBirth?: Date; + @Column({ type: 'varchar', nullable: true }) + gender?: DriverGender | null; + @Column({ name: 'license_expiry_date', type: 'date', nullable: true }) licenseExpiryDate?: Date; @@ -51,4 +60,12 @@ export class Driver extends BaseEntity { @Column({ type: 'numeric', precision: 3, scale: 2, nullable: true }) rating?: number | null; + + @Column({ name: 'fayda_verified', type: 'boolean', default: false, nullable: true }) + faydaVerified?: boolean; + + /** Fayda OIDC subject the identity was verified against. Unique — one driver + * record per verified Fayda identity (NULLs allowed for legacy/unverified). */ + @Column({ name: 'fayda_sub', type: 'varchar', unique: true, nullable: true }) + faydaSub?: string | null; } diff --git a/apps/edr-freight-api/src/modules/first-mile/first-mile-invoice.service.ts b/apps/edr-freight-api/src/modules/first-mile/first-mile-invoice.service.ts index 22520aac1..f7d9ee11f 100644 --- a/apps/edr-freight-api/src/modules/first-mile/first-mile-invoice.service.ts +++ b/apps/edr-freight-api/src/modules/first-mile/first-mile-invoice.service.ts @@ -71,7 +71,7 @@ export class FirstMileInvoiceService { type: 'DELIVERY_FEE', companyId: fm.booking!.companyId, companyProfileId: fm.booking!.companyProfileId || '', - currency: 'ETB', + currency: fm.booking!.paymentCurrency || 'ETB', lines: [ { chargeType: 'DELIVERY', diff --git a/apps/edr-freight-api/src/modules/first-mile/first-mile.controller.ts b/apps/edr-freight-api/src/modules/first-mile/first-mile.controller.ts index d5f53ae0c..444cbee87 100644 --- a/apps/edr-freight-api/src/modules/first-mile/first-mile.controller.ts +++ b/apps/edr-freight-api/src/modules/first-mile/first-mile.controller.ts @@ -92,6 +92,7 @@ export class FirstMileController { const record = await this.firstMileService.update(id, dto); // Auto-generate invoice if distance or payment was updated const booking = await this.bookingsService.findById(record.bookingId); + const currency = booking.paymentCurrency || "ETB"; if (dto.exactKm !== undefined || dto.exactKm != record.exactKm || dto.remainingPayment !== undefined || dto.remainingPayment !== record.remainingPayment) { await this.billingService.generateInvoice({ source: Freight.InvoiceSource.FirstMile, @@ -99,7 +100,7 @@ export class FirstMileController { type: "FIRST_MILE", companyId: booking.companyId, companyProfileId: booking.companyProfileId, - currency: "ETB", + currency, lines: [ { @@ -108,7 +109,7 @@ export class FirstMileController { quantity: 1, unitRate: record.remainingPayment, amount: record.remainingPayment, - currency: "ETB", + currency, }, ], diff --git a/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts b/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts index 40d163220..1a7db810d 100644 --- a/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts +++ b/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts @@ -1,5 +1,5 @@ -import { Injectable, Logger, NotFoundException } from '@nestjs/common'; -import { FindOptionsWhere, In } from 'typeorm'; +import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common'; +import { FindOptionsWhere, In, IsNull, Not } from 'typeorm'; import { InjectDataSource } from '@nestjs/typeorm'; import { DataSource } from 'typeorm'; import { VehicleAvailability } from '../vehicles/entities/vehicle.entity'; @@ -14,6 +14,8 @@ import { FirstMileContainerAllocation } from "./entities/first-mile-container-al import { FirstMileRepository } from "./first-mile.repository"; import { OnEvent } from "@nestjs/event-emitter"; import { InvoiceEventPayload } from "../billing/billing.service"; +import { FleetHistoryService } from "../fleet-history/fleet-history.service"; +import { FleetEventType } from "../fleet-history/entities/fleet-event.entity"; type FirstMileListFilter = { status?: FirstMileStatus; @@ -43,8 +45,54 @@ export class FirstMileService { private readonly vehiclesService: VehiclesService, private readonly driversService: DriversService, private readonly smsClient: SmsClientService, + private readonly history: FleetHistoryService, ) { } + /** Resolve a vehicle's driver + human labels, for stamping mile events onto + * the driver's timeline and naming the vehicle. Best-effort — never throws. */ + private async vehicleInfo( + vehicleId?: string | null, + ): Promise<{ driverId: string | null; plate: string | null; driverName: string | null }> { + if (!vehicleId) return { driverId: null, plate: null, driverName: null }; + try { + const v = await this.vehiclesService.findById(vehicleId); + return { + driverId: v.assignedDriverId ?? null, + plate: v.plateNumber ?? v.code ?? null, + driverName: v.assignedDriverName ?? null, + }; + } catch { + return { driverId: null, plate: null, driverName: null }; + } + } + + /** A leg counts as having a vehicle if it has a direct assignment or at least + * one container allocation carrying a vehicle. Gates the IN_TRANSIT move. */ + private async hasAssignedVehicle( + recordId: string, + directVehicleId?: string | null, + ): Promise { + if (directVehicleId) return true; + const count = await this.dataSource.manager.count(FirstMileContainerAllocation, { + where: { firstMileId: recordId, vehicleId: Not(IsNull()) }, + }); + return count > 0; + } + + /** Human booking reference for a first-mile record, for the history timeline. */ + private async resolveBookingRef(record: FirstMile): Promise { + const loaded = (record as FirstMile & { booking?: { reference?: string } }) + .booking?.reference; + if (loaded) return loaded; + if (!record.bookingId) return null; + try { + const b = await this.bookingsRepository.findById(record.bookingId); + return (b as { reference?: string } | null)?.reference ?? null; + } catch { + return null; + } + } + /** * Look up a booking by its human-readable reference and confirm it has been * paid before any first-mile work proceeds. Throws if the reference is @@ -210,6 +258,20 @@ export class FirstMileService { if (dto.vehicleId) { await this.vehiclesService.setAvailability(dto.vehicleId, VehicleAvailability.BUSY); + const info = await this.vehicleInfo(dto.vehicleId); + await this.history.record({ + eventType: FleetEventType.MILE_VEHICLE_ASSIGNED, + vehicleId: dto.vehicleId, + firstMileId: record.id, + driverId: info.driverId, + label: record.status, + metadata: { + mile: 'FIRST', + bookingRef: await this.resolveBookingRef(record), + vehiclePlate: info.plate, + driverName: info.driverName, + }, + }); } return record; @@ -248,6 +310,18 @@ export class FirstMileService { async update(id: string, dto: UpdateFirstMileDto): Promise { const existing = await this.findById(id); + // A leg can only go IN_TRANSIT once a vehicle is assigned (allowing a vehicle + // assigned in this same request). + if (dto.status === 'IN_TRANSIT' && existing.status !== 'IN_TRANSIT') { + const vehicleId = + dto.vehicleId !== undefined ? dto.vehicleId : existing.vehicleId; + if (!(await this.hasAssignedVehicle(id, vehicleId))) { + throw new BadRequestException( + 'Assign a vehicle before marking this first-mile leg in transit', + ); + } + } + const dtoAny = dto as any; const updated = await this.firstMileRepository.update(id, { ...(dto.bookingId !== undefined ? { bookingId: dto.bookingId } : {}), @@ -278,6 +352,39 @@ export class FirstMileService { if (existing.vehicleId) { await this.vehiclesService.releaseIfUnused([existing.vehicleId]); } + // Audit the mile↔vehicle (re)assignment on both vehicle and driver lines. + const bookingRef = await this.resolveBookingRef(existing); + if (existing.vehicleId) { + const info = await this.vehicleInfo(existing.vehicleId); + await this.history.record({ + eventType: FleetEventType.MILE_VEHICLE_RELEASED, + vehicleId: existing.vehicleId, + firstMileId: id, + driverId: info.driverId, + metadata: { + mile: 'FIRST', + bookingRef, + vehiclePlate: info.plate, + driverName: info.driverName, + }, + }); + } + if (dto.vehicleId) { + const info = await this.vehicleInfo(dto.vehicleId); + await this.history.record({ + eventType: FleetEventType.MILE_VEHICLE_ASSIGNED, + vehicleId: dto.vehicleId, + firstMileId: id, + driverId: info.driverId, + label: updated.status, + metadata: { + mile: 'FIRST', + bookingRef, + vehiclePlate: info.plate, + driverName: info.driverName, + }, + }); + } } // Notify assigned driver on every explicit vehicle assignment or reassignment @@ -285,6 +392,25 @@ export class FirstMileService { void this.notifyDriverAssignment(dto.vehicleId, existing); } + if (dto.status !== undefined && dto.status !== existing.status) { + const vehicleId = updated.vehicleId ?? existing.vehicleId ?? null; + const info = await this.vehicleInfo(vehicleId); + await this.history.record({ + eventType: FleetEventType.MILE_STATUS_CHANGED, + firstMileId: id, + vehicleId, + driverId: info.driverId, + fromValue: existing.status, + toValue: dto.status, + metadata: { + mile: 'FIRST', + bookingRef: await this.resolveBookingRef(existing), + vehiclePlate: info.plate, + driverName: info.driverName, + }, + }); + } + // Trip finished — release the vehicles it was holding if (dto.status === 'RECEIVED_TO_PORT' && existing.status !== 'RECEIVED_TO_PORT') { await this.releaseVehicles(updated); @@ -295,12 +421,40 @@ export class FirstMileService { async updateStatus(id: string, status: FirstMileStatus): Promise { const existing = await this.findById(id); + + if (status === 'IN_TRANSIT' && existing.status !== 'IN_TRANSIT') { + if (!(await this.hasAssignedVehicle(id, existing.vehicleId))) { + throw new BadRequestException( + 'Assign a vehicle before marking this first-mile leg in transit', + ); + } + } + const updated = await this.firstMileRepository.update(id, { status }); if (!updated) { throw new NotFoundException(`First-mile record ${id} not found`); } + if (status !== existing.status) { + const vehicleId = updated.vehicleId ?? existing.vehicleId ?? null; + const info = await this.vehicleInfo(vehicleId); + await this.history.record({ + eventType: FleetEventType.MILE_STATUS_CHANGED, + firstMileId: id, + vehicleId, + driverId: info.driverId, + fromValue: existing.status, + toValue: status, + metadata: { + mile: 'FIRST', + bookingRef: await this.resolveBookingRef(existing), + vehiclePlate: info.plate, + driverName: info.driverName, + }, + }); + } + if (status === 'RECEIVED_TO_PORT' && existing.status !== 'RECEIVED_TO_PORT') { await this.releaseVehicles(updated); } @@ -430,6 +584,34 @@ export class FirstMileService { previousVehicleIds.filter((id) => !vehicleIds.has(id)), ); + // History: one event per vehicle actually added or removed by this + // multi-car (re)allocation, so reassignments show on every timeline. + const prevSet = new Set(previousVehicleIds); + const bookingRef = await this.resolveBookingRef(firstMile); + for (const vehicleId of vehicleIds) { + if (prevSet.has(vehicleId)) continue; + const info = await this.vehicleInfo(vehicleId); + await this.history.record({ + eventType: FleetEventType.MILE_VEHICLE_ASSIGNED, + vehicleId, + firstMileId, + driverId: info.driverId, + label: firstMile.status, + metadata: { mile: 'FIRST', bookingRef, vehiclePlate: info.plate, driverName: info.driverName }, + }); + } + for (const vehicleId of previousVehicleIds) { + if (vehicleIds.has(vehicleId)) continue; + const info = await this.vehicleInfo(vehicleId); + await this.history.record({ + eventType: FleetEventType.MILE_VEHICLE_RELEASED, + vehicleId, + firstMileId, + driverId: info.driverId, + metadata: { mile: 'FIRST', bookingRef, vehiclePlate: info.plate, driverName: info.driverName }, + }); + } + return { success: true, allocated: allocations.length, diff --git a/apps/edr-freight-api/src/modules/fleet-history/entities/fleet-event.entity.ts b/apps/edr-freight-api/src/modules/fleet-history/entities/fleet-event.entity.ts new file mode 100644 index 000000000..5096adbc6 --- /dev/null +++ b/apps/edr-freight-api/src/modules/fleet-history/entities/fleet-event.entity.ts @@ -0,0 +1,55 @@ +import { Entity, Column, Index } from 'typeorm'; +import { BaseEntity } from '@edr/api-common'; + +/** + * Append-only audit log for fleet activity. One row per transition. Queried by + * `vehicleId` (vehicle timeline) or `driverId` (driver timeline); an event may + * carry both so a driver↔vehicle assignment or a mile assignment shows on both. + * `createdAt` (from BaseEntity) is the event time. + */ +export enum FleetEventType { + DRIVER_REGISTERED = 'DRIVER_REGISTERED', + VEHICLE_REGISTERED = 'VEHICLE_REGISTERED', + DRIVER_ASSIGNED = 'DRIVER_ASSIGNED', + DRIVER_UNASSIGNED = 'DRIVER_UNASSIGNED', + VEHICLE_STATUS_CHANGED = 'VEHICLE_STATUS_CHANGED', + VEHICLE_AVAILABILITY_CHANGED = 'VEHICLE_AVAILABILITY_CHANGED', + MILE_VEHICLE_ASSIGNED = 'MILE_VEHICLE_ASSIGNED', + MILE_VEHICLE_RELEASED = 'MILE_VEHICLE_RELEASED', + MILE_STATUS_CHANGED = 'MILE_STATUS_CHANGED', +} + +@Entity({ name: 'fleet_events', schema: 'freight' }) +export class FleetEvent extends BaseEntity { + @Column({ name: 'event_type', type: 'varchar' }) + eventType!: FleetEventType; + + @Index() + @Column({ name: 'vehicle_id', type: 'uuid', nullable: true }) + vehicleId?: string | null; + + @Index() + @Column({ name: 'driver_id', type: 'uuid', nullable: true }) + driverId?: string | null; + + @Column({ name: 'first_mile_id', type: 'uuid', nullable: true }) + firstMileId?: string | null; + + @Column({ name: 'last_mile_id', type: 'uuid', nullable: true }) + lastMileId?: string | null; + + /** Previous value for a transition (e.g. old status/availability). */ + @Column({ name: 'from_value', type: 'varchar', nullable: true }) + fromValue?: string | null; + + /** New value for a transition (e.g. new status/availability). */ + @Column({ name: 'to_value', type: 'varchar', nullable: true }) + toValue?: string | null; + + /** Human-readable summary token (driver name, plate, booking ref, mile). */ + @Column({ name: 'label', type: 'varchar', nullable: true }) + label?: string | null; + + @Column({ name: 'metadata', type: 'jsonb', nullable: true }) + metadata?: Record | null; +} diff --git a/apps/edr-freight-api/src/modules/fleet-history/fleet-history.module.ts b/apps/edr-freight-api/src/modules/fleet-history/fleet-history.module.ts new file mode 100644 index 000000000..14828e0a9 --- /dev/null +++ b/apps/edr-freight-api/src/modules/fleet-history/fleet-history.module.ts @@ -0,0 +1,17 @@ +import { Global, Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { FleetEvent } from './entities/fleet-event.entity'; +import { FleetHistoryService } from './fleet-history.service'; + +/** + * Global so any fleet-touching service (vehicles, drivers, first/last-mile) can + * inject FleetHistoryService to append audit events without each module having + * to import this one. + */ +@Global() +@Module({ + imports: [TypeOrmModule.forFeature([FleetEvent])], + providers: [FleetHistoryService], + exports: [FleetHistoryService], +}) +export class FleetHistoryModule {} diff --git a/apps/edr-freight-api/src/modules/fleet-history/fleet-history.service.ts b/apps/edr-freight-api/src/modules/fleet-history/fleet-history.service.ts new file mode 100644 index 000000000..9c61119b9 --- /dev/null +++ b/apps/edr-freight-api/src/modules/fleet-history/fleet-history.service.ts @@ -0,0 +1,54 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { FleetEvent, FleetEventType } from './entities/fleet-event.entity'; + +export interface FleetEventInput { + eventType: FleetEventType; + vehicleId?: string | null; + driverId?: string | null; + firstMileId?: string | null; + lastMileId?: string | null; + fromValue?: string | null; + toValue?: string | null; + label?: string | null; + metadata?: Record | null; +} + +@Injectable() +export class FleetHistoryService { + private readonly logger = new Logger(FleetHistoryService.name); + + constructor( + @InjectRepository(FleetEvent) + private readonly eventRepo: Repository, + ) {} + + /** + * Append an audit event. Best-effort: recording history must never break the + * business operation that triggered it, so failures are logged and swallowed. + */ + async record(input: FleetEventInput): Promise { + try { + await this.eventRepo.save(this.eventRepo.create(input)); + } catch (err) { + this.logger.error( + `Failed to record fleet event ${input.eventType}: ${String(err)}`, + ); + } + } + + getVehicleHistory(vehicleId: string): Promise { + return this.eventRepo.find({ + where: { vehicleId }, + order: { createdAt: 'DESC' }, + }); + } + + getDriverHistory(driverId: string): Promise { + return this.eventRepo.find({ + where: { driverId }, + order: { createdAt: 'DESC' }, + }); + } +} diff --git a/apps/edr-freight-api/src/modules/last-mile/entities/last-mile-vehicle-assignment.entity.ts b/apps/edr-freight-api/src/modules/last-mile/entities/last-mile-vehicle-assignment.entity.ts new file mode 100644 index 000000000..0ba0f7d06 --- /dev/null +++ b/apps/edr-freight-api/src/modules/last-mile/entities/last-mile-vehicle-assignment.entity.ts @@ -0,0 +1,30 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index, JoinColumn, ManyToOne, Unique } from 'typeorm'; + +import { Vehicle } from '../../vehicles/entities/vehicle.entity'; +import { LastMile } from './last-mile.entity'; + +/** + * One row per vehicle assigned to a last-mile delivery. A delivery can be + * served by several vehicles at once (multi-truck bookings); the legacy + * `last_mile.vehicle_id` column keeps pointing at the first assignment for + * backward compatibility. + */ +@Entity({ name: 'last_mile_vehicle_assignments', schema: 'freight' }) +@Unique(['lastMileId', 'vehicleId']) +@Index(['vehicleId']) +export class LastMileVehicleAssignment extends BaseEntity { + @Column({ name: 'last_mile_id', type: 'uuid' }) + lastMileId!: string; + + @ManyToOne(() => LastMile, (lm) => lm.vehicleAssignments, { nullable: false, onDelete: 'CASCADE' }) + @JoinColumn({ name: 'last_mile_id' }) + lastMile?: LastMile; + + @Column({ name: 'vehicle_id', type: 'uuid' }) + vehicleId!: string; + + @ManyToOne(() => Vehicle, { nullable: false, eager: false }) + @JoinColumn({ name: 'vehicle_id' }) + vehicle?: Vehicle; +} diff --git a/apps/edr-freight-api/src/modules/last-mile/entities/last-mile.entity.ts b/apps/edr-freight-api/src/modules/last-mile/entities/last-mile.entity.ts index 85d01b7f0..1f8bda8fc 100644 --- a/apps/edr-freight-api/src/modules/last-mile/entities/last-mile.entity.ts +++ b/apps/edr-freight-api/src/modules/last-mile/entities/last-mile.entity.ts @@ -4,6 +4,7 @@ import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm import { Booking } from '../../bookings/entities/booking.entity'; import { Vehicle } from '../../vehicles/entities/vehicle.entity'; import { LastMileContainerAllocation } from './last-mile-container-allocation.entity'; +import { LastMileVehicleAssignment } from './last-mile-vehicle-assignment.entity'; export const LAST_MILE_STATUSES = [ 'PAYMENT_PENDING', @@ -57,4 +58,7 @@ export class LastMile extends BaseEntity { @OneToMany(() => LastMileContainerAllocation, (ca) => ca.lastMile) containerAllocations?: LastMileContainerAllocation[]; + + @OneToMany(() => LastMileVehicleAssignment, (va) => va.lastMile) + vehicleAssignments?: LastMileVehicleAssignment[]; } diff --git a/apps/edr-freight-api/src/modules/last-mile/last-mile-invoice.service.ts b/apps/edr-freight-api/src/modules/last-mile/last-mile-invoice.service.ts index c304a89e8..e40e94509 100644 --- a/apps/edr-freight-api/src/modules/last-mile/last-mile-invoice.service.ts +++ b/apps/edr-freight-api/src/modules/last-mile/last-mile-invoice.service.ts @@ -61,7 +61,7 @@ export class LastMileInvoiceService { type: 'DELIVERY_FEE', companyId: lm.booking!.companyId, companyProfileId: lm.booking!.companyProfileId || '', - currency: 'ETB', + currency: lm.booking!.paymentCurrency || 'ETB', lines: [ { chargeType: 'DELIVERY', diff --git a/apps/edr-freight-api/src/modules/last-mile/last-mile.controller.ts b/apps/edr-freight-api/src/modules/last-mile/last-mile.controller.ts index 88a96e6c2..9ad5a00bf 100644 --- a/apps/edr-freight-api/src/modules/last-mile/last-mile.controller.ts +++ b/apps/edr-freight-api/src/modules/last-mile/last-mile.controller.ts @@ -86,6 +86,7 @@ export class LastMileController { const record = await this.lastMileService.update(id, dto); // Auto-generate invoice if distance or payment was updated const booking = await this.bookingsService.findById(record.bookingId); + const currency = booking.paymentCurrency || "ETB"; if (dto.exactKm !== undefined || dto.exactKm != record.exactKm || dto.remainingPayment !== undefined || dto.remainingPayment !== record.remainingPayment) { await this.billingService.generateInvoice({ source: Freight.InvoiceSource.LastMile, @@ -93,8 +94,8 @@ export class LastMileController { type: "LAST_MILE", companyId: booking.companyId, companyProfileId: booking.companyProfileId, - currency: "ETB", - + currency, + lines: [ { chargeType: "LAST_MILE", @@ -102,7 +103,7 @@ export class LastMileController { quantity: 1, unitRate: record.remainingPayment, amount: record.remainingPayment, - currency: "ETB", + currency, }, ], diff --git a/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts b/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts index 69eec29ae..884a02aae 100644 --- a/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts +++ b/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts @@ -1,10 +1,11 @@ -import { Injectable, Logger, NotFoundException } from '@nestjs/common'; -import { DataSource, FindOptionsWhere } from 'typeorm'; +import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common'; +import { DataSource, FindOptionsWhere, In, IsNull, Not } from 'typeorm'; import { BookingsRepository } from '../bookings/bookings.repository'; import { DriversService } from '../drivers/drivers.service'; import { SmsClientService } from '../notifications/sms-client.service'; import { VehiclesService } from '../vehicles/vehicles.service'; +import { VehicleAvailability } from '../vehicles/entities/vehicle.entity'; import { CreateLastMileDto } from './dto/create-last-mile.dto'; import { UpdateLastMileDto } from './dto/update-last-mile.dto'; import { LastMile, LastMileStatus } from './entities/last-mile.entity'; @@ -12,6 +13,8 @@ import { LastMileContainerAllocation } from './entities/last-mile-container-allo import { LastMileRepository } from './last-mile.repository'; import { InvoiceEventPayload } from '../billing/billing.service'; import { OnEvent } from '@nestjs/event-emitter'; +import { FleetHistoryService } from '../fleet-history/fleet-history.service'; +import { FleetEventType } from '../fleet-history/entities/fleet-event.entity'; type LastMileListFilter = { status?: LastMileStatus; @@ -41,8 +44,57 @@ export class LastMileService { private readonly driversService: DriversService, private readonly smsClient: SmsClientService, private readonly dataSource: DataSource, + private readonly history: FleetHistoryService, ) {} + /** Resolve a vehicle's driver + human labels, for stamping mile events onto + * the driver's timeline and naming the vehicle. Best-effort — never throws. */ + private async vehicleInfo( + vehicleId?: string | null, + ): Promise<{ driverId: string | null; plate: string | null; driverName: string | null }> { + if (!vehicleId) return { driverId: null, plate: null, driverName: null }; + try { + const v = await this.vehiclesService.findById(vehicleId); + return { + driverId: v.assignedDriverId ?? null, + plate: v.plateNumber ?? v.code ?? null, + driverName: v.assignedDriverName ?? null, + }; + } catch { + return { driverId: null, plate: null, driverName: null }; + } + } + + /** A leg counts as having a vehicle if it has a direct assignment or at least + * one container allocation carrying a vehicle. Gates the IN_TRANSIT move. */ + private async hasAssignedVehicle( + recordId: string, + directVehicleId?: string | null, + ): Promise { + if (directVehicleId) return true; + const count = await this.dataSource.manager.count(LastMileContainerAllocation, { + where: { lastMileId: recordId, vehicleId: Not(IsNull()) }, + }); + return count > 0; + } + + /** Human booking reference for a last-mile record, for the history timeline. + * Uses the already-loaded relation when present, else looks it up. */ + private async resolveBookingRef( + record: LastMile, + ): Promise { + const loaded = (record as LastMile & { booking?: { reference?: string } }) + .booking?.reference; + if (loaded) return loaded; + if (!record.bookingId) return null; + try { + const booking = await this.bookingsRepository.findById(record.bookingId); + return (booking as { reference?: string } | null)?.reference ?? null; + } catch { + return null; + } + } + async acceptBooking(bookingReference: string): Promise { const booking = await this.bookingsRepository.findByReference(bookingReference); @@ -132,7 +184,7 @@ export class LastMileService { } async create(dto: CreateLastMileDto): Promise { - return this.lastMileRepository.create({ + const record = await this.lastMileRepository.create({ bookingId: dto.bookingId, status: dto.status ?? 'READY_TO_TRANSIT', advancedPayment: dto.advancedPayment ?? 0, @@ -142,6 +194,26 @@ export class LastMileService { vehicleId: dto.vehicleId ?? null, paid: (dto as any).paid ?? false, }); + + if (dto.vehicleId) { + await this.vehiclesService.setAvailability(dto.vehicleId, VehicleAvailability.BUSY); + const info = await this.vehicleInfo(dto.vehicleId); + await this.history.record({ + eventType: FleetEventType.MILE_VEHICLE_ASSIGNED, + vehicleId: dto.vehicleId, + lastMileId: record.id, + driverId: info.driverId, + label: record.status, + metadata: { + mile: 'LAST', + bookingRef: await this.resolveBookingRef(record), + vehiclePlate: info.plate, + driverName: info.driverName, + }, + }); + } + + return record; } @OnEvent("lastmile.invoice.paid") @@ -159,6 +231,18 @@ export class LastMileService { async update(id: string, dto: UpdateLastMileDto): Promise { const existing = await this.findById(id); + // A leg can only go IN_TRANSIT once a vehicle is assigned (allowing a vehicle + // assigned in this same request). + if (dto.status === 'IN_TRANSIT' && existing.status !== 'IN_TRANSIT') { + const vehicleId = + dto.vehicleId !== undefined ? dto.vehicleId : existing.vehicleId; + if (!(await this.hasAssignedVehicle(id, vehicleId))) { + throw new BadRequestException( + 'Assign a vehicle before marking this last-mile leg in transit', + ); + } + } + const dtoAny = dto as any; const updated = await this.lastMileRepository.update(id, { ...(dto.bookingId !== undefined ? { bookingId: dto.bookingId } : {}), @@ -180,9 +264,95 @@ export class LastMileService { void this.notifyDriverAssignment(dto.vehicleId, existing); } + // Audit the mile↔vehicle (re)assignment on both vehicle and driver lines. + const bookingRef = await this.resolveBookingRef(existing); + if (dto.vehicleId !== undefined && dto.vehicleId !== existing.vehicleId) { + // Keep vehicle availability in sync: new vehicle goes BUSY, replaced one + // is freed if no other active trip still holds it. + if (dto.vehicleId) { + await this.vehiclesService.setAvailability(dto.vehicleId, VehicleAvailability.BUSY); + } + if (existing.vehicleId) { + await this.vehiclesService.releaseIfUnused([existing.vehicleId]); + } + if (existing.vehicleId) { + const info = await this.vehicleInfo(existing.vehicleId); + await this.history.record({ + eventType: FleetEventType.MILE_VEHICLE_RELEASED, + vehicleId: existing.vehicleId, + lastMileId: id, + driverId: info.driverId, + metadata: { + mile: 'LAST', + bookingRef, + vehiclePlate: info.plate, + driverName: info.driverName, + }, + }); + } + if (dto.vehicleId) { + const info = await this.vehicleInfo(dto.vehicleId); + await this.history.record({ + eventType: FleetEventType.MILE_VEHICLE_ASSIGNED, + vehicleId: dto.vehicleId, + lastMileId: id, + driverId: info.driverId, + label: updated.status, + metadata: { + mile: 'LAST', + bookingRef, + vehiclePlate: info.plate, + driverName: info.driverName, + }, + }); + } + } + + if (dto.status !== undefined && dto.status !== existing.status) { + const vehicleId = updated.vehicleId ?? existing.vehicleId ?? null; + const info = await this.vehicleInfo(vehicleId); + await this.history.record({ + eventType: FleetEventType.MILE_STATUS_CHANGED, + lastMileId: id, + vehicleId, + driverId: info.driverId, + fromValue: existing.status, + toValue: dto.status, + metadata: { + mile: 'LAST', + bookingRef, + vehiclePlate: info.plate, + driverName: info.driverName, + }, + }); + } + + // Delivery finished — free the vehicles this trip was holding. + if (dto.status === 'DELIVERED' && existing.status !== 'DELIVERED') { + await this.releaseVehicles(updated); + } + return updated; } + /** + * Free every vehicle held by this record (direct assignment + container + * allocations), unless still in use by another active trip. + */ + private async releaseVehicles(record: LastMile): Promise { + const recordAllocations = await this.dataSource.manager.find( + LastMileContainerAllocation, + { where: { lastMileId: record.id } }, + ); + const vehicleIds = recordAllocations + .map((a) => a.vehicleId) + .filter((id): id is string => Boolean(id)); + if (record.vehicleId) { + vehicleIds.push(record.vehicleId); + } + await this.vehiclesService.releaseIfUnused(vehicleIds); + } + private async notifyDriverAssignment(vehicleId: string, record: LastMile): Promise { try { const vehicle = await this.vehiclesService.findById(vehicleId); @@ -236,6 +406,21 @@ export class LastMileService { throw new NotFoundException(`Last-mile record ${lastMileId} not found`); } + // Capture the vehicles currently on these containers so a reallocation can + // be diffed into assigned/released history events below. + const previousAllocations = await this.dataSource.manager.find( + LastMileContainerAllocation, + { + where: { + lastMileId, + containerId: In(allocations.map((a) => a.containerId)), + }, + }, + ); + const previousVehicleIds = previousAllocations + .map((a) => a.vehicleId) + .filter((id): id is string => Boolean(id)); + await this.dataSource.transaction(async (manager) => { for (const allocation of allocations) { await manager.delete(LastMileContainerAllocation, { @@ -252,6 +437,46 @@ export class LastMileService { } }); + // Keep vehicle availability in sync: newly-allocated cars go BUSY, cars no + // longer on any of these containers are freed if unused elsewhere. + const vehicleIds = new Set(allocations.map((a) => a.vehicleId)); + await Promise.all( + [...vehicleIds].map((id) => + this.vehiclesService.setAvailability(id, VehicleAvailability.BUSY), + ), + ); + await this.vehiclesService.releaseIfUnused( + previousVehicleIds.filter((id) => !vehicleIds.has(id)), + ); + + // History: one event per vehicle actually added or removed by this + // multi-car (re)allocation, so reassignments show on every timeline. + const prevSet = new Set(previousVehicleIds); + const bookingRef = await this.resolveBookingRef(lastMile); + for (const vehicleId of vehicleIds) { + if (prevSet.has(vehicleId)) continue; + const info = await this.vehicleInfo(vehicleId); + await this.history.record({ + eventType: FleetEventType.MILE_VEHICLE_ASSIGNED, + vehicleId, + lastMileId, + driverId: info.driverId, + label: lastMile.status, + metadata: { mile: 'LAST', bookingRef, vehiclePlate: info.plate, driverName: info.driverName }, + }); + } + for (const vehicleId of previousVehicleIds) { + if (vehicleIds.has(vehicleId)) continue; + const info = await this.vehicleInfo(vehicleId); + await this.history.record({ + eventType: FleetEventType.MILE_VEHICLE_RELEASED, + vehicleId, + lastMileId, + driverId: info.driverId, + metadata: { mile: 'LAST', bookingRef, vehiclePlate: info.plate, driverName: info.driverName }, + }); + } + return { success: true, allocated: allocations.length, diff --git a/apps/edr-freight-api/src/modules/notifications/dtos/email.dto.ts b/apps/edr-freight-api/src/modules/notifications/dtos/email.dto.ts new file mode 100644 index 000000000..79a6547bc --- /dev/null +++ b/apps/edr-freight-api/src/modules/notifications/dtos/email.dto.ts @@ -0,0 +1,30 @@ +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { IsEmail, IsNotEmpty, IsOptional, IsString } from "class-validator"; + +export class SendEmailDto { + @ApiProperty({ + description: "Recipient email address", + example: "customer@example.com", + }) + @IsEmail() + @IsNotEmpty() + to!: string; + + @ApiProperty({ + description: "Email subject", + example: "Your EDR Freight verification code", + }) + @IsString() + @IsNotEmpty() + subject!: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + text?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + html?: string; +} diff --git a/apps/edr-freight-api/src/modules/notifications/email-client.service.ts b/apps/edr-freight-api/src/modules/notifications/email-client.service.ts new file mode 100644 index 000000000..161b2486a --- /dev/null +++ b/apps/edr-freight-api/src/modules/notifications/email-client.service.ts @@ -0,0 +1,51 @@ +import { + Inject, + Injectable, + Logger, + OnApplicationBootstrap, +} from "@nestjs/common"; +import { ClientProxy } from "@nestjs/microservices"; +import { SendEmailDto } from "./dtos/email.dto"; + +@Injectable() +export class EmailClientService implements OnApplicationBootstrap { + private readonly logger = new Logger(EmailClientService.name); + + constructor( + @Inject("EMAIL_SERVICE") + private readonly emailClient: ClientProxy, + ) {} + + private readonly enabled = process.env.RABBITMQ_ENABLED !== "false"; + + async onApplicationBootstrap() { + if (!this.enabled) return; + this.emailClient + .connect() + .then(() => this.logger.log("connected to Email service")) + .catch((err) => { + console.error("Error happened at Email service", err); + }); + } + + async sendEmail(dto: SendEmailDto): Promise<{ queued: boolean }> { + if (!this.enabled) { + this.logger.warn(`RABBITMQ disabled — skipped EMAIL to=${dto.to}`); + return { queued: false }; + } + this.emailClient.emit("send-email", { + to: dto.to, + subject: dto.subject, + text: dto.text, + html: dto.html, + appKey: "IFHCRS-LICENSE-MANAGEMENT", + }); + // Fire-and-forget enqueue: confirms hand-off to RabbitMQ, NOT delivery. + this.logger.log( + `EMAIL queued to RabbitMQ [${process.env.EMAIL_QUEUE ?? "email_queue"}] pattern='send-email'`, + ); + // Recipient + content are PII — debug only. + this.logger.debug(`EMAIL payload to=${dto.to} subject="${dto.subject}"`); + return { queued: true }; + } +} diff --git a/apps/edr-freight-api/src/modules/notifications/notifications.module.ts b/apps/edr-freight-api/src/modules/notifications/notifications.module.ts index 663f931ef..4e56c8b70 100644 --- a/apps/edr-freight-api/src/modules/notifications/notifications.module.ts +++ b/apps/edr-freight-api/src/modules/notifications/notifications.module.ts @@ -4,6 +4,7 @@ import { ClientsModule, Transport } from "@nestjs/microservices"; import { NotificationsService } from "./notifications.service"; import { SmsClientService } from "./sms-client.service"; +import { EmailClientService } from "./email-client.service"; import { EmailNotificationStrategy } from "./strategies/notification.email.strategy"; import { SmsNotificationStrategy } from "./strategies/notification.sms.strategy"; @@ -20,10 +21,25 @@ import { SmsNotificationStrategy } from "./strategies/notification.sms.strategy" queueOptions: { durable: true }, }, }, + { + name: "EMAIL_SERVICE", + transport: Transport.RMQ, + options: { + urls: [process.env.RABBITMQ_URL as string], + queue: process.env.EMAIL_QUEUE ?? "email_queue", + queueOptions: { durable: true }, + }, + }, ]), ], controllers: [], - providers: [EmailNotificationStrategy, SmsNotificationStrategy, NotificationsService, SmsClientService], - exports: [NotificationsService, SmsClientService], + providers: [ + EmailNotificationStrategy, + SmsNotificationStrategy, + NotificationsService, + SmsClientService, + EmailClientService, + ], + exports: [NotificationsService, SmsClientService, EmailClientService], }) export class NotificationsModule {} diff --git a/apps/edr-freight-api/src/modules/otp/otp.controller.ts b/apps/edr-freight-api/src/modules/otp/otp.controller.ts index 5850cbb1a..155657a74 100644 --- a/apps/edr-freight-api/src/modules/otp/otp.controller.ts +++ b/apps/edr-freight-api/src/modules/otp/otp.controller.ts @@ -1,15 +1,24 @@ // otp.controller.ts import { + BadRequestException, Body, Controller, Post, } from "@nestjs/common"; -import { OtpService } from "./otp.service"; +import { OtpService, OtpTarget } from "./otp.service"; import { Public } from "@edr/api-common"; +// Exactly one of phone/email must be present per request — the channel the +// code is sent through / checked against. +function toTarget(phone?: string, email?: string): OtpTarget { + if (email) return { email }; + if (phone) return { phone }; + throw new BadRequestException("phone or email is required"); +} + @Controller("otp") @Public() export class OtpController { @@ -24,9 +33,12 @@ export class OtpController { @Post("send") async sendOtp( @Body("phone") - phone: string + phone?: string, + + @Body("email") + email?: string ) { - return this.otpService.sendOtp(phone); + return this.otpService.sendOtp(toTarget(phone, email)); } // --------------------------------------------------------------------------- @@ -36,13 +48,16 @@ export class OtpController { @Post("verify") async verifyOtp( @Body("phone") - phone: string, + phone: string | undefined, + + @Body("email") + email: string | undefined, @Body("otp") otp: string ) { return this.otpService.verifyOtp( - phone, + toTarget(phone, email), otp ); } diff --git a/apps/edr-freight-api/src/modules/otp/otp.entity.ts b/apps/edr-freight-api/src/modules/otp/otp.entity.ts index f5900f6b8..022bbf767 100644 --- a/apps/edr-freight-api/src/modules/otp/otp.entity.ts +++ b/apps/edr-freight-api/src/modules/otp/otp.entity.ts @@ -10,10 +10,19 @@ import { BaseEntity } from "@edr/api-common"; name: "otp_verifications", }) export class OtpVerification extends BaseEntity{ + // Exactly one of phone/email is set per row — the channel the code was sent + // through. @Column({ unique: true, + nullable: true, }) - phone!: string; + phone?: string; + + @Column({ + unique: true, + nullable: true, + }) + email?: string; @Column() otp!: string; diff --git a/apps/edr-freight-api/src/modules/otp/otp.module.ts b/apps/edr-freight-api/src/modules/otp/otp.module.ts index ec1d9f9ed..511fe4bbb 100644 --- a/apps/edr-freight-api/src/modules/otp/otp.module.ts +++ b/apps/edr-freight-api/src/modules/otp/otp.module.ts @@ -31,6 +31,7 @@ import { NotificationsModule } from "../notifications/notifications.module"; exports: [ OtpRepository, + OtpService, ], }) export class OtpModule {} \ No newline at end of file diff --git a/apps/edr-freight-api/src/modules/otp/otp.repository.ts b/apps/edr-freight-api/src/modules/otp/otp.repository.ts index 8aa69dcd6..7abd434d8 100644 --- a/apps/edr-freight-api/src/modules/otp/otp.repository.ts +++ b/apps/edr-freight-api/src/modules/otp/otp.repository.ts @@ -31,17 +31,44 @@ export class OtpRepository { }); } + // --------------------------------------------------------------------------- + // Find By Email + // --------------------------------------------------------------------------- + + async findByEmail( + email: string + ) { + return this.repository.findOne({ + where: { + email, + }, + }); + } + + // --------------------------------------------------------------------------- + // Find By Target (either channel) + // --------------------------------------------------------------------------- + + async findByTarget( + target: { phone?: string; email?: string } + ) { + return target.email + ? this.findByEmail(target.email) + : this.findByPhone(target.phone!); + } + // --------------------------------------------------------------------------- // Create OTP // --------------------------------------------------------------------------- async createOtp( - phone: string, + target: { phone?: string; email?: string }, otp: string ) { const entity = this.repository.create({ - phone, + phone: target.phone, + email: target.email, otp, verified: false, }); @@ -70,10 +97,10 @@ export class OtpRepository { } // --------------------------------------------------------------------------- - // Verify Phone + // Mark Verified // --------------------------------------------------------------------------- - async verifyPhone( + async markVerified( otpVerification: OtpVerification ) { otpVerification.verified = @@ -83,4 +110,18 @@ export class OtpRepository { otpVerification ); } + + // --------------------------------------------------------------------------- + // Delete OTP (single-use consume) + // --------------------------------------------------------------------------- + + // Hard delete so the unique `phone` row is freed and a fresh code can be + // requested for the same number on the next action. + async deleteOtp( + otpVerification: OtpVerification + ) { + return this.repository.remove( + otpVerification + ); + } } \ No newline at end of file diff --git a/apps/edr-freight-api/src/modules/otp/otp.service.ts b/apps/edr-freight-api/src/modules/otp/otp.service.ts index ffa9c4e68..67fbdec9b 100644 --- a/apps/edr-freight-api/src/modules/otp/otp.service.ts +++ b/apps/edr-freight-api/src/modules/otp/otp.service.ts @@ -1,80 +1,80 @@ // otp.service.ts -import { - BadRequestException, - Injectable, -} from "@nestjs/common"; +import { BadRequestException, Injectable, Logger } from "@nestjs/common"; import { OtpRepository } from "./otp.repository"; import { SmsClientService } from "../notifications/sms-client.service"; +import { EmailClientService } from "../notifications/email-client.service"; + +// Exactly one of phone/email is set — enforced by the controller before it +// reaches here. +export type OtpTarget = { phone?: string; email?: string }; @Injectable() export class OtpService { + logger = new Logger(OtpService.name); constructor( private readonly otpRepository: OtpRepository, - private readonly smsClient: SmsClientService - ) {} + private readonly smsClient: SmsClientService, + private readonly emailClient: EmailClientService, + ) { } // --------------------------------------------------------------------------- // Generate OTP // --------------------------------------------------------------------------- generateOtp(): string { - return Math.floor( - 100000 + Math.random() * 900000 - ).toString(); + return Math.floor(100000 + Math.random() * 900000).toString(); } // --------------------------------------------------------------------------- // Send OTP // --------------------------------------------------------------------------- - async sendOtp(phone: string) { + async sendOtp(target: OtpTarget) { try { // The verification code is generated server-side — never supplied by the // caller — so the OTP stays a secret known only to the server and the - // recipient of the SMS. + // recipient of the SMS/email. const otp = this.generateOtp(); - // find existing phone - const existingPhone = - await this.otpRepository.findByPhone( - phone - ); + // find existing row for this channel + const existing = await this.otpRepository.findByTarget(target); // update existing otp - if (existingPhone) { - await this.otpRepository.updateOtp( - existingPhone, - otp - ); + if (existing) { + await this.otpRepository.updateOtp(existing, otp); } else { // create new otp - await this.otpRepository.createOtp( - phone, - otp - ); + await this.otpRepository.createOtp(target, otp); } - // send sms (queued to RabbitMQ via the shared SMS service) - await this.smsClient.sendSms({ - to: phone, - message: `Your verification code is ${otp}`, - }); + if (target.email) { + // send email (queued to RabbitMQ via the shared Email service) + await this.emailClient.sendEmail({ + to: target.email, + subject: "Your EDR Freight verification code", + text: `Your verification code is ${otp}`, + }); + } else { + // send sms (queued to RabbitMQ via the shared SMS service) + await this.smsClient.sendSms({ + to: target.phone as string, + message: `Your verification code is ${otp}`, + }); + } + this.logger.log(`OTP send for ${target.email ?? target.phone}: ${otp}`); return { success: true, - message: - "OTP sent successfully", + message: "OTP sent successfully", }; } catch (error) { console.log(error); - throw new BadRequestException( - "Failed to send OTP" - ); + throw new BadRequestException("Failed to send OTP"); } } @@ -82,40 +82,70 @@ export class OtpService { // Verify OTP // --------------------------------------------------------------------------- - async verifyOtp( - phone: string, - otp: string - ) { - // find phone - const otpData = - await this.otpRepository.findByPhone( - phone - ); + async verifyOtp(target: OtpTarget, otp: string) { + // find the channel's row + const otpData = await this.otpRepository.findByTarget(target); - // phone not found + // not found if (!otpData) { throw new BadRequestException( - "Phone number not found" + target.email ? "Email address not found" : "Phone number not found", ); } // invalid otp if (otpData.otp !== otp) { - throw new BadRequestException( - "Invalid OTP" - ); + throw new BadRequestException("Invalid OTP"); } - // verify phone - await this.otpRepository.verifyPhone( - otpData - ); + // mark verified + await this.otpRepository.markVerified(otpData); return { success: true, - message: - "Phone verified successfully", + message: target.email + ? "Email verified successfully" + : "Phone verified successfully", }; } -} \ No newline at end of file + + // --------------------------------------------------------------------------- + // Verify OTP for a sensitive action (sudo mode) + // --------------------------------------------------------------------------- + + // Fresh, single-use challenge gating a sensitive action (e.g. applying a + // contract signature). Unlike verifyOtp above — which marks a phone verified + // and leaves the code in place — this enforces a short TTL and consumes the + // code on success so it can never be replayed. + private readonly ACTION_OTP_TTL_MS = 5 * 60 * 1000; + + async verifyOtpForAction(phone: string, otp: string) { + const otpData = await this.otpRepository.findByPhone(phone); + + if (!otpData) { + throw new BadRequestException( + "No verification code was requested for this phone", + ); + } + + const ageMs = Date.now() - new Date(otpData.updatedAt).getTime(); + + if (ageMs > this.ACTION_OTP_TTL_MS) { + await this.otpRepository.deleteOtp(otpData); + + throw new BadRequestException( + "Verification code has expired. Request a new one.", + ); + } + + if (otpData.otp !== otp) { + throw new BadRequestException("Invalid verification code"); + } + + // single-use: consume on success + await this.otpRepository.deleteOtp(otpData); + + return { success: true }; + } +} diff --git a/apps/edr-freight-api/src/modules/payment/internal-payment.controller.ts b/apps/edr-freight-api/src/modules/payment/internal-payment.controller.ts index 57c95eab3..0fc5a6ba5 100644 --- a/apps/edr-freight-api/src/modules/payment/internal-payment.controller.ts +++ b/apps/edr-freight-api/src/modules/payment/internal-payment.controller.ts @@ -1,9 +1,10 @@ import { - Body, - Controller, - HttpCode, - HttpStatus, - Post, + Body, + Controller, + HttpCode, + HttpStatus, + Logger, + Post, } from "@nestjs/common"; import { ApiOperation, ApiTags } from "@nestjs/swagger"; import { Public } from "@edr/api-common"; @@ -22,14 +23,17 @@ import { PaymentService } from "./payment.service"; @Public() @Controller("internal/payments") export class InternalPaymentController { - constructor(private readonly paymentService: PaymentService) { } + private readonly logger = new Logger(InternalPaymentController.name); + constructor(private readonly paymentService: PaymentService) { } - @Post("mark-paid") - @HttpCode(HttpStatus.OK) - @ApiOperation({ - summary: "Apply a payment.succeeded / payment.failed event from the payment service (idempotent)", - }) - async markPaid(@Body() event: PaymentEventDto): Promise { - return this.paymentService.handlePaymentEvent(event); - } + @Post("mark-paid") + @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: + "Apply a payment.succeeded / payment.failed event from the payment service (idempotent)", + }) + async markPaid(@Body() event: PaymentEventDto): Promise { + this.logger.log(`Marking payment ${event} as PAID`); + return this.paymentService.handlePaymentEvent(event); + } } diff --git a/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule-booking.entity.ts b/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule-booking.entity.ts index 4ffecea26..352704eea 100644 --- a/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule-booking.entity.ts +++ b/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule-booking.entity.ts @@ -1,9 +1,15 @@ import { BaseEntity } from '@edr/api-common'; +import { LoadingStatus } from '@edr/types'; import { Entity, Index, JoinColumn, ManyToOne, Column } from 'typeorm'; import { Booking } from '../../bookings/entities/booking.entity'; import { TrainSchedule } from './train-schedule.entity'; +export const TRAIN_SCHEDULE_BOOKING_LOADING_STATUSES = [ + LoadingStatus.Unloaded, + LoadingStatus.Loaded, +] as const; + @Entity({ schema: 'freight', name: 'train_schedule_bookings' }) @Index(['trainScheduleId', 'bookingId'], { unique: true }) @Index(['bookingId'], { unique: true }) @@ -23,4 +29,7 @@ export class TrainScheduleBooking extends BaseEntity { @ManyToOne(() => Booking) @JoinColumn({ name: 'booking_id' }) booking?: Booking; + + @Column({ name: 'loading_status', type: 'varchar', length: 20, default: 'UNLOADED' }) + loadingStatus!: string; } diff --git a/apps/edr-freight-api/src/modules/train-schedules/train-schedule-bookings.repository.ts b/apps/edr-freight-api/src/modules/train-schedules/train-schedule-bookings.repository.ts index 4ccfcd469..64607ecb8 100644 --- a/apps/edr-freight-api/src/modules/train-schedules/train-schedule-bookings.repository.ts +++ b/apps/edr-freight-api/src/modules/train-schedules/train-schedule-bookings.repository.ts @@ -47,4 +47,27 @@ export class TrainScheduleBookingsRepository extends BaseRepository { + return this.repo(manager).find({ + where: { trainScheduleId }, + select: { id: true, bookingId: true, trainScheduleId: true, loadingStatus: true }, + }); + } + + async updateLoadingStatusMany( + trainScheduleId: string, + bookingIds: string[], + loadingStatus: string, + manager?: EntityManager, + ): Promise { + if (!bookingIds.length) return; + await this.repo(manager).update( + { trainScheduleId, bookingId: In(bookingIds) }, + { loadingStatus }, + ); + } } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/update-import-loading-status.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/update-import-loading-status.dto.ts new file mode 100644 index 000000000..25943050c --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/update-import-loading-status.dto.ts @@ -0,0 +1,15 @@ +import { LoadingStatus } from '@edr/types'; +import { ApiProperty } from '@nestjs/swagger'; +import { ArrayMinSize, IsArray, IsEnum, IsUUID } from 'class-validator'; + +export class UpdateImportLoadingStatusDto { + @ApiProperty({ type: [String] }) + @IsArray() + @ArrayMinSize(1) + @IsUUID('4', { each: true }) + bookingIds!: string[]; + + @ApiProperty({ enum: LoadingStatus }) + @IsEnum(LoadingStatus) + loadingStatus!: LoadingStatus; +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts index 268b0a0b9..2d591f2e0 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts @@ -28,6 +28,7 @@ import { GetEligibleBulkBookingsDto } from "./dto/get-eligible-bulk-bookings.dto import { GetEligibleContainerBookingsDto } from "./dto/get-eligible-container-bookings.dto"; import { PinWagonsDto } from "./dto/pin-wagons.dto"; import { UpdateContainerItemDto } from "./dto/update-container-item.dto"; +import { UpdateImportLoadingStatusDto } from "./dto/update-import-loading-status.dto"; import { PreviewBulkTrainScheduleDto } from "./dto/preview-bulk-train-schedule.dto"; import { PreviewContainerTrainScheduleDto } from "./dto/preview-container-train-schedule.dto"; import { PreviewTrainScheduleDto } from "./dto/preview-train-schedule.dto"; @@ -336,6 +337,28 @@ export class TrainSchedulingController { return this.trainSchedulingService.getCompositionRemovals(id); } + @Get("schedules/:id/import-loading-bookings") + @TrainSchedulingView() + @ApiOperation({ + summary: "List import bookings eligible for loading confirmation on this schedule", + }) + getImportLoadingBookings(@Param("id", ParseUUIDPipe) id: string) { + return this.trainSchedulingService.getImportLoadingBookings(id); + } + + @Patch("schedules/:id/import-loading-status") + @TrainSchedulingManage() + @ApiOperation({ + summary: + "Mark import bookings loaded/unloaded on this schedule (tracking only, does not affect dispatch)", + }) + updateImportLoadingStatus( + @Param("id", ParseUUIDPipe) id: string, + @Body() dto: UpdateImportLoadingStatusDto, + ) { + return this.trainSchedulingService.updateImportLoadingStatus(id, dto); + } + @Post("schedules/:id/pin-wagons") @TrainSchedulingManage() @ApiOperation({ summary: "Pin physical wagons to train set slots" }) diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts index fbb2ed4cd..8768b16b1 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts @@ -1,5 +1,6 @@ import { AllocationLoadType, + LoadingStatus, SchedulingStatus, TrainCheckpointKind, TrainScheduleStatus as TrainScheduleStatusEnum, @@ -46,6 +47,7 @@ import { GetEligibleBulkBookingsDto } from './dto/get-eligible-bulk-bookings.dto import { GetEligibleContainerBookingsDto } from './dto/get-eligible-container-bookings.dto'; import { PinWagonsDto } from './dto/pin-wagons.dto'; import { UpdateContainerItemDto } from './dto/update-container-item.dto'; +import { UpdateImportLoadingStatusDto } from './dto/update-import-loading-status.dto'; import { PreviewBulkTrainScheduleDto } from './dto/preview-bulk-train-schedule.dto'; import { PreviewContainerTrainScheduleDto } from './dto/preview-container-train-schedule.dto'; import { PreviewTrainScheduleDto } from './dto/preview-train-schedule.dto'; @@ -866,6 +868,87 @@ export class TrainSchedulingService { ); } + async getImportLoadingBookings(scheduleId: string) { + const schedule = await this.trainSchedulesRepository.findById(scheduleId); + if (!schedule) { + throw new NotFoundException(`Train schedule ${scheduleId} not found`); + } + + const [scheduleBookings, allocations] = await Promise.all([ + this.trainScheduleBookingsRepository.findByScheduleId(scheduleId), + this.wagonBookingAllocationsRepository.findByScheduleId(scheduleId), + ]); + if (!scheduleBookings.length) { + return { count: 0, items: [] }; + } + + const allocatedBookingIds = new Set(allocations.map((a) => a.bookingId)); + const statusByBookingId = new Map( + scheduleBookings.map((sb) => [sb.bookingId, sb.loadingStatus]), + ); + const candidateIds = scheduleBookings + .map((sb) => sb.bookingId) + .filter((id) => allocatedBookingIds.has(id)); + if (!candidateIds.length) { + return { count: 0, items: [] }; + } + + const bookings = await this.bookingsRepository.findByIdsForScheduling(candidateIds); + const items = bookings + .filter((b) => b.tradeDirection === 'IMPORT' && b.paymentStatus === 'PAID') + .map((b) => ({ + id: b.id, + reference: b.reference ?? null, + customer: b.company?.name ?? null, + weightTons: b.cargoTotalWeightVgm, + loadingStatus: statusByBookingId.get(b.id) ?? LoadingStatus.Unloaded, + })); + return { count: items.length, items }; + } + + async updateImportLoadingStatus(scheduleId: string, dto: UpdateImportLoadingStatusDto) { + const schedule = await this.trainSchedulesRepository.findById(scheduleId); + if (!schedule) { + throw new NotFoundException(`Train schedule ${scheduleId} not found`); + } + + const [scheduleBookings, allocations, bookings] = await Promise.all([ + this.trainScheduleBookingsRepository.findByScheduleId(scheduleId), + this.wagonBookingAllocationsRepository.findByScheduleId(scheduleId), + this.bookingsRepository.findByIdsForScheduling(dto.bookingIds), + ]); + + const scheduledIds = new Set(scheduleBookings.map((sb) => sb.bookingId)); + const allocatedIds = new Set(allocations.map((a) => a.bookingId)); + const bookingById = new Map(bookings.map((b) => [b.id, b])); + + const invalid: string[] = []; + for (const id of dto.bookingIds) { + const booking = bookingById.get(id); + if ( + !scheduledIds.has(id) || + !allocatedIds.has(id) || + !booking || + booking.tradeDirection !== 'IMPORT' || + booking.paymentStatus !== 'PAID' + ) { + invalid.push(id); + } + } + if (invalid.length) { + throw new BadRequestException( + `Not eligible for import loading confirmation on this schedule: ${invalid.join(', ')}`, + ); + } + + await this.trainScheduleBookingsRepository.updateLoadingStatusMany( + scheduleId, + dto.bookingIds, + dto.loadingStatus, + ); + return this.getImportLoadingBookings(scheduleId); + } + async pinWagons(scheduleId: string, dto: PinWagonsDto) { const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); if (!schedule) { diff --git a/apps/edr-freight-api/src/modules/vehicles/vehicles.controller.ts b/apps/edr-freight-api/src/modules/vehicles/vehicles.controller.ts index 8e6d8a0a8..f0a77791a 100644 --- a/apps/edr-freight-api/src/modules/vehicles/vehicles.controller.ts +++ b/apps/edr-freight-api/src/modules/vehicles/vehicles.controller.ts @@ -14,13 +14,17 @@ import { FleetManage, FleetView } from '../../common/booking-guards'; import { VehiclesService } from './vehicles.service'; import { CreateVehicleDto } from './dto/create-vehicle.dto'; import { UpdateVehicleDto } from './dto/update-vehicle.dto'; +import { FleetHistoryService } from '../fleet-history/fleet-history.service'; @ApiTags('vehicles') @ApiBearerAuth() @Controller('vehicles') @FleetView() export class VehiclesController { - constructor(private readonly vehiclesService: VehiclesService) {} + constructor( + private readonly vehiclesService: VehiclesService, + private readonly fleetHistory: FleetHistoryService, + ) {} @Post() @FleetManage() @@ -57,6 +61,12 @@ export class VehiclesController { return this.vehiclesService.findById(id); } + @Get(':id/history') + @ApiOperation({ summary: 'Get vehicle assignment, status & mile history' }) + history(@Param('id', ParseUUIDPipe) id: string) { + return this.fleetHistory.getVehicleHistory(id); + } + @Patch(':id') @FleetManage() @ApiOperation({ summary: 'Update a vehicle' }) diff --git a/apps/edr-freight-api/src/modules/vehicles/vehicles.service.ts b/apps/edr-freight-api/src/modules/vehicles/vehicles.service.ts index 69568e483..25260e86f 100644 --- a/apps/edr-freight-api/src/modules/vehicles/vehicles.service.ts +++ b/apps/edr-freight-api/src/modules/vehicles/vehicles.service.ts @@ -9,12 +9,15 @@ import { FirstMileContainerAllocation } from '../first-mile/entities/first-mile- import { LastMile, LastMileStatus } from '../last-mile/entities/last-mile.entity'; import { LastMileContainerAllocation } from '../last-mile/entities/last-mile-container-allocation.entity'; import { BookingContainerAllocation } from '../bookings/entities/booking-container-allocation.entity'; +import { FleetHistoryService } from '../fleet-history/fleet-history.service'; +import { FleetEventType } from '../fleet-history/entities/fleet-event.entity'; @Injectable() export class VehiclesService { constructor( @InjectRepository(Vehicle) private readonly vehicleRepo: Repository, + private readonly history: FleetHistoryService, ) {} async create(dto: CreateVehicleDto): Promise { @@ -34,7 +37,28 @@ export class VehiclesService { registrationNumber, }); - return this.vehicleRepo.save(vehicle); + const saved = await this.vehicleRepo.save(vehicle); + + await this.history.record({ + eventType: FleetEventType.VEHICLE_REGISTERED, + vehicleId: saved.id, + label: saved.plateNumber ?? saved.code ?? null, + toValue: saved.availability ?? null, + }); + if (saved.assignedDriverId) { + await this.history.record({ + eventType: FleetEventType.DRIVER_ASSIGNED, + vehicleId: saved.id, + driverId: saved.assignedDriverId, + label: saved.assignedDriverName ?? null, + metadata: { + vehiclePlate: saved.plateNumber ?? saved.code ?? null, + driverName: saved.assignedDriverName ?? null, + }, + }); + } + + return saved; } async findAll(query: { @@ -97,12 +121,76 @@ export class VehiclesService { } } + const prev = { + assignedDriverId: vehicle.assignedDriverId, + assignedDriverName: vehicle.assignedDriverName, + status: vehicle.status, + availability: vehicle.availability, + }; + Object.assign(vehicle, dto); - return this.vehicleRepo.save(vehicle); + const saved = await this.vehicleRepo.save(vehicle); + + // Driver (re)assignment — emit an unassign for the old driver and/or an + // assign for the new one so both drivers' timelines and the vehicle's line up. + if ( + dto.assignedDriverId !== undefined && + dto.assignedDriverId !== prev.assignedDriverId + ) { + const vehiclePlate = saved.plateNumber ?? saved.code ?? null; + if (prev.assignedDriverId) { + await this.history.record({ + eventType: FleetEventType.DRIVER_UNASSIGNED, + vehicleId: id, + driverId: prev.assignedDriverId, + label: prev.assignedDriverName ?? null, + metadata: { vehiclePlate, driverName: prev.assignedDriverName ?? null }, + }); + } + if (saved.assignedDriverId) { + await this.history.record({ + eventType: FleetEventType.DRIVER_ASSIGNED, + vehicleId: id, + driverId: saved.assignedDriverId, + label: saved.assignedDriverName ?? null, + metadata: { vehiclePlate, driverName: saved.assignedDriverName ?? null }, + }); + } + } + if (dto.status !== undefined && dto.status !== prev.status) { + await this.history.record({ + eventType: FleetEventType.VEHICLE_STATUS_CHANGED, + vehicleId: id, + fromValue: prev.status ?? null, + toValue: saved.status ?? null, + }); + } + if (dto.availability !== undefined && dto.availability !== prev.availability) { + await this.history.record({ + eventType: FleetEventType.VEHICLE_AVAILABILITY_CHANGED, + vehicleId: id, + fromValue: prev.availability ?? null, + toValue: saved.availability ?? null, + }); + } + + return saved; } async setAvailability(id: string, availability: VehicleAvailability): Promise { + // Read the current value so the audit event records an accurate from→to and + // we skip logging no-op writes (setAvailability is called in release loops). + const vehicle = await this.vehicleRepo.findOne({ where: { id } }); + const previous = vehicle?.availability; await this.vehicleRepo.update(id, { availability }); + if (previous !== availability) { + await this.history.record({ + eventType: FleetEventType.VEHICLE_AVAILABILITY_CHANGED, + vehicleId: id, + fromValue: previous ?? null, + toValue: availability, + }); + } } /** diff --git a/apps/edr-freight-api/src/modules/verifayda/entities/fayda-verification-session.entity.ts b/apps/edr-freight-api/src/modules/verifayda/entities/fayda-verification-session.entity.ts new file mode 100644 index 000000000..3435acabc --- /dev/null +++ b/apps/edr-freight-api/src/modules/verifayda/entities/fayda-verification-session.entity.ts @@ -0,0 +1,49 @@ +import { Column, Entity, Index } from 'typeorm'; +import { BaseEntity } from '@edr/api-common'; + +/** + * One row per started Fayda verification. Mirrors the passenger-api Prisma + * model `FaydaVerificationSession`, but stored in the freight schema via + * TypeORM. `state` is the single-use CSRF token that links the eSignet + * redirect back to this session. + */ +@Entity({ name: 'fayda_verification_sessions', schema: 'freight' }) +@Index(['expiresAt']) +@Index(['iamUserId']) +export class FaydaVerificationSession extends BaseEntity { + @Column({ name: 'state', unique: true }) + state!: string; + + @Column({ name: 'code_verifier' }) + codeVerifier!: string; + + /** VERIFY | LOGIN */ + @Column({ name: 'purpose', default: 'VERIFY' }) + purpose!: string; + + /** WEB | MOBILE — recorded for audit */ + @Column({ name: 'platform', default: 'WEB' }) + platform!: string; + + @Column({ name: 'save_to_account', type: 'boolean', default: false }) + saveToAccount!: boolean; + + /** PENDING | COMPLETED | FAILED */ + @Column({ name: 'status', default: 'PENDING' }) + status!: string; + + @Column({ name: 'error_code', type: 'varchar', nullable: true }) + errorCode?: string | null; + + @Column({ name: 'error_description', type: 'text', nullable: true }) + errorDescription?: string | null; + + @Column({ name: 'iam_user_id', type: 'uuid', nullable: true }) + iamUserId?: string | null; + + @Column({ name: 'expires_at', type: 'timestamptz' }) + expiresAt!: Date; + + @Column({ name: 'completed_at', type: 'timestamptz', nullable: true }) + completedAt?: Date | null; +} diff --git a/apps/edr-freight-api/src/modules/verifayda/fayda-callback.controller.ts b/apps/edr-freight-api/src/modules/verifayda/fayda-callback.controller.ts new file mode 100644 index 000000000..1c5569750 --- /dev/null +++ b/apps/edr-freight-api/src/modules/verifayda/fayda-callback.controller.ts @@ -0,0 +1,33 @@ +import { Controller, Get, Query } from '@nestjs/common'; +import { ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator'; +import { VerifaydaCallbackDto } from './verifayda.dto'; + +/** + * Plain acknowledgement endpoint for the Fayda redirect_uri when it points at + * the API instead of the web app (e.g. MOBILE clients or connectivity checks). + * Registered at /callback (excluded from the global /api prefix in main.ts). + * It does NOT consume the verification session — the client must still call + * GET /api/fayda/verification/complete with the echoed code+state. + */ +@ApiTags('Fayda Verification') +@Controller('callback') +export class FaydaCallbackController { + @Get() + @IsPublic() + @ApiOperation({ summary: 'Acknowledge a Fayda redirect (returns OK, echoes code/state)' }) + @ApiOkResponse({ + schema: { example: { status: 'ok', code: '...', state: '...' } }, + }) + ok(@Query() query: VerifaydaCallbackDto) { + return { + status: 'ok', + ...(query.code ? { code: query.code } : {}), + ...(query.state ? { state: query.state } : {}), + ...(query.error ? { error: query.error } : {}), + ...(query.error_description ? { error_description: query.error_description } : {}), + }; + } +} + +// return res.redirect(url.toString()); diff --git a/apps/edr-freight-api/src/modules/verifayda/optional-jwt.guard.ts b/apps/edr-freight-api/src/modules/verifayda/optional-jwt.guard.ts new file mode 100644 index 000000000..8673aa60e --- /dev/null +++ b/apps/edr-freight-api/src/modules/verifayda/optional-jwt.guard.ts @@ -0,0 +1,30 @@ +import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { InjectDataSource } from '@nestjs/typeorm'; +import { JwtGuard as IamJwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard'; +import { DataSource } from 'typeorm'; + +/** + * Like the IAM JwtGuard, but never rejects the request. + * + * When a valid IAM bearer token is present, `request.user` is populated with + * the package `TCurrentUser`. Missing or invalid tokens continue as guests. + */ +@Injectable() +export class OptionalJwtGuard extends IamJwtGuard implements CanActivate { + constructor( + reflector: Reflector, + @InjectDataSource() dataSource: DataSource, + ) { + super(reflector, dataSource); + } + + async canActivate(context: ExecutionContext): Promise { + try { + await super.canActivate(context); + } catch { + context.switchToHttp().getRequest().user = undefined; + } + return true; + } +} diff --git a/apps/edr-freight-api/src/modules/verifayda/utils/client-assertion.util.spec.ts b/apps/edr-freight-api/src/modules/verifayda/utils/client-assertion.util.spec.ts new file mode 100644 index 000000000..9b4316fc7 --- /dev/null +++ b/apps/edr-freight-api/src/modules/verifayda/utils/client-assertion.util.spec.ts @@ -0,0 +1,71 @@ +import { exportJWK, generateKeyPair, importJWK, jwtVerify, type JWK } from 'jose'; +import { generateClientAssertion } from './client-assertion.util'; + +describe('generateClientAssertion', () => { + let privateJwk: JWK; + let publicJwk: JWK; + + beforeAll(async () => { + const kp = await generateKeyPair('RS256', { extractable: true }); + privateJwk = await exportJWK(kp.privateKey); + publicJwk = await exportJWK(kp.publicKey); + }); + + it('produces a JWT verifiable with the matching public key', async () => { + const jwt = await generateClientAssertion({ + clientId: 'edr-passenger-test', + audience: 'https://esignet.example.com/token', + privateJwk, + }); + + const verifier = await importJWK(publicJwk, 'RS256'); + const { payload, protectedHeader } = await jwtVerify(jwt, verifier, { + issuer: 'edr-passenger-test', + subject: 'edr-passenger-test', + audience: 'https://esignet.example.com/token', + }); + + expect(protectedHeader.alg).toBe('RS256'); + expect(protectedHeader.typ).toBe('JWT'); + expect(payload.iss).toBe('edr-passenger-test'); + expect(payload.sub).toBe('edr-passenger-test'); + expect(payload.aud).toBe('https://esignet.example.com/token'); + expect(typeof payload.iat).toBe('number'); + expect(typeof payload.exp).toBe('number'); + }); + + it('defaults exp to 120 seconds after iat', async () => { + const jwt = await generateClientAssertion({ + clientId: 'c', + audience: 'https://a/token', + privateJwk, + }); + const verifier = await importJWK(publicJwk, 'RS256'); + const { payload } = await jwtVerify(jwt, verifier); + expect(payload.exp! - payload.iat!).toBe(120); + }); + + it('honors a custom expiresIn', async () => { + const jwt = await generateClientAssertion({ + clientId: 'c', + audience: 'https://a/token', + privateJwk, + expiresIn: '5m', + }); + const verifier = await importJWK(publicJwk, 'RS256'); + const { payload } = await jwtVerify(jwt, verifier); + expect(payload.exp! - payload.iat!).toBe(300); + }); + + it('fails verification against a wrong audience', async () => { + const jwt = await generateClientAssertion({ + clientId: 'c', + audience: 'https://a/token', + privateJwk, + }); + const verifier = await importJWK(publicJwk, 'RS256'); + await expect( + jwtVerify(jwt, verifier, { audience: 'https://other/token' }), + ).rejects.toThrow(); + }); +}); diff --git a/apps/edr-freight-api/src/modules/verifayda/utils/client-assertion.util.ts b/apps/edr-freight-api/src/modules/verifayda/utils/client-assertion.util.ts new file mode 100644 index 000000000..dc3558ccc --- /dev/null +++ b/apps/edr-freight-api/src/modules/verifayda/utils/client-assertion.util.ts @@ -0,0 +1,22 @@ +import { SignJWT, importJWK, type JWK } from 'jose'; + +export interface GenerateClientAssertionInput { + clientId: string; + audience: string; + privateJwk: JWK; + expiresIn?: string; +} + +export async function generateClientAssertion( + input: GenerateClientAssertionInput, +): Promise { + const privateKey = await importJWK(input.privateJwk, 'RS256'); + return new SignJWT({}) + .setProtectedHeader({ alg: 'RS256', typ: 'JWT' }) + .setIssuer(input.clientId) + .setSubject(input.clientId) + .setAudience(input.audience) + .setIssuedAt() + .setExpirationTime(input.expiresIn ?? '2m') + .sign(privateKey); +} diff --git a/apps/edr-freight-api/src/modules/verifayda/utils/pkce.util.spec.ts b/apps/edr-freight-api/src/modules/verifayda/utils/pkce.util.spec.ts new file mode 100644 index 000000000..d359a07f2 --- /dev/null +++ b/apps/edr-freight-api/src/modules/verifayda/utils/pkce.util.spec.ts @@ -0,0 +1,65 @@ +import { createHash } from 'crypto'; +import { + base64Url, + generateCodeChallenge, + generateCodeVerifier, + generateState, +} from './pkce.util'; + +describe('pkce.util', () => { + describe('base64Url', () => { + it('strips padding and replaces + and / with - and _', () => { + const input = Buffer.from([0xfb, 0xff, 0xbf, 0xfe]); + const out = base64Url(input); + expect(out).not.toMatch(/[+/=]/); + }); + }); + + describe('generateCodeVerifier', () => { + it('returns a base64url-safe string', () => { + expect(generateCodeVerifier()).toMatch(/^[A-Za-z0-9_-]+$/); + }); + + it('produces unique values across calls', () => { + const a = generateCodeVerifier(); + const b = generateCodeVerifier(); + expect(a).not.toEqual(b); + }); + + it('produces at least 43 characters (RFC 7636 minimum)', () => { + expect(generateCodeVerifier().length).toBeGreaterThanOrEqual(43); + }); + }); + + describe('generateCodeChallenge', () => { + it('equals base64url(sha256(verifier))', () => { + const verifier = 'fixed-test-verifier'; + const expected = createHash('sha256') + .update(verifier) + .digest('base64') + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=/g, ''); + expect(generateCodeChallenge(verifier)).toBe(expected); + }); + + it('is deterministic for the same verifier', () => { + const verifier = generateCodeVerifier(); + expect(generateCodeChallenge(verifier)).toBe(generateCodeChallenge(verifier)); + }); + + it('differs for different verifiers', () => { + expect(generateCodeChallenge('a')).not.toBe(generateCodeChallenge('b')); + }); + }); + + describe('generateState', () => { + it('returns a base64url-safe string', () => { + expect(generateState()).toMatch(/^[A-Za-z0-9_-]+$/); + }); + + it('produces unique values across calls', () => { + expect(generateState()).not.toEqual(generateState()); + }); + }); +}); diff --git a/apps/edr-freight-api/src/modules/verifayda/utils/pkce.util.ts b/apps/edr-freight-api/src/modules/verifayda/utils/pkce.util.ts new file mode 100644 index 000000000..89e9437d2 --- /dev/null +++ b/apps/edr-freight-api/src/modules/verifayda/utils/pkce.util.ts @@ -0,0 +1,21 @@ +import { createHash, randomBytes } from 'crypto'; + +export function base64Url(buffer: Buffer): string { + return buffer + .toString('base64') + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=/g, ''); +} + +export function generateCodeVerifier(): string { + return base64Url(randomBytes(64)); +} + +export function generateCodeChallenge(codeVerifier: string): string { + return base64Url(createHash('sha256').update(codeVerifier).digest()); +} + +export function generateState(): string { + return base64Url(randomBytes(32)); +} diff --git a/apps/edr-freight-api/src/modules/verifayda/verifayda.controller.ts b/apps/edr-freight-api/src/modules/verifayda/verifayda.controller.ts new file mode 100644 index 000000000..977e677f8 --- /dev/null +++ b/apps/edr-freight-api/src/modules/verifayda/verifayda.controller.ts @@ -0,0 +1,107 @@ +import { + Body, + Controller, + Get, + HttpCode, + HttpStatus, + Post, + Query, + Req, + UseGuards, +} from '@nestjs/common'; +import { + ApiBearerAuth, + ApiOkResponse, + ApiOperation, + ApiTags, +} from '@nestjs/swagger'; +import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type'; +import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator'; +import { JwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard'; +import { OptionalJwtGuard } from './optional-jwt.guard'; +import { + CompleteVerificationResultDto, + StartVerificationDto, + VerifaydaCallbackDto, + VerificationStatusDto, +} from './verifayda.dto'; +import { VerifaydaService } from './verifayda.service'; + +/** Minimal slices of the Express req we touch (avoids a hard dependency on + * `@types/express`, which isn't resolved in this package). */ +interface RequestWithOptionalUser { + user?: TCurrentUser; +} +interface RequestWithUser { + user: TCurrentUser; +} + +@ApiTags('Fayda Verification') +@Controller('fayda/verification') +export class VerifaydaController { + constructor(private readonly service: VerifaydaService) {} + + @Post('start') + @IsPublic() + @HttpCode(HttpStatus.OK) + @UseGuards(OptionalJwtGuard) + @ApiBearerAuth('JWT-auth') + @ApiOperation({ + summary: 'Start a VeriFayda 2.0 verification session', + description: `Creates a verification session and returns the eSignet authorize URL the frontend should send the user to. + +- Works for **logged-in users** and **guests**. If a valid bearer token is present, the verification is tied to that user. +- **VERIFY** (default): the user proves their identity and \`/complete\` returns the verified attributes (name, email, phone, dob, gender). +- **LOGIN**: \`/complete\` resolves/creates the user and returns a JWT. +- The returned \`authorizationUrl\` already carries the PKCE \`code_challenge\`, CSRF \`state\`, requested \`claims\`, and \`code_challenge_method=S256\`. The frontend simply navigates to it (full page or popup).`, + }) + @ApiOkResponse({ + description: 'Authorize URL the frontend should redirect the user to.', + schema: { + example: { + authorizationUrl: + 'https://esignet.example.com/authorize?client_id=...&state=...&code_challenge=...', + }, + }, + }) + async start( + @Body() dto: StartVerificationDto, + @Req() req: RequestWithOptionalUser, + ): Promise<{ authorizationUrl: string }> { + const authorizationUrl = await this.service.startVerification({ + purpose: dto.purpose ?? 'VERIFY', + platform: dto.platform ?? 'WEB', + userId: req.user?.id, + wantsPasswordSetup: dto.wantsPasswordSetup ?? false, + }); + return { authorizationUrl }; + } + + @Get('complete') + @IsPublic() + @ApiOperation({ + summary: 'Complete a verification (Fayda redirect / client callback lands here)', + description: `This is the registered Fayda \`redirect_uri\`. Fayda redirects the browser here with \`?code&state\``, + }) + @ApiOkResponse({ type: CompleteVerificationResultDto }) + async complete( + @Query() dto: VerifaydaCallbackDto, + ): Promise { + return this.service.completeVerification(dto); + } + + @Get('status') + @UseGuards(JwtGuard) + @ApiBearerAuth('JWT-auth') + @ApiOperation({ + summary: "Get the current user's Fayda verification status", + description: + 'Returns whether the authenticated user has linked a verified Fayda identity to their account, when, and the name on file.', + }) + @ApiOkResponse({ type: VerificationStatusDto }) + async status( + @Req() req: RequestWithUser, + ): Promise { + return this.service.getVerificationStatus(req.user.id); + } +} diff --git a/apps/edr-freight-api/src/modules/verifayda/verifayda.dto.ts b/apps/edr-freight-api/src/modules/verifayda/verifayda.dto.ts new file mode 100644 index 000000000..1885b11e9 --- /dev/null +++ b/apps/edr-freight-api/src/modules/verifayda/verifayda.dto.ts @@ -0,0 +1,105 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsIn, IsOptional, IsString } from 'class-validator'; + +export class StartVerificationDto { + @ApiPropertyOptional({ + enum: ['LOGIN', 'VERIFY'], + default: 'VERIFY', + description: + 'Reason for verification. VERIFY returns the verified identity attributes; LOGIN resolves/creates a user and returns a JWT.', + }) + @IsOptional() + @IsIn(['LOGIN', 'VERIFY']) + purpose?: 'LOGIN' | 'VERIFY'; + + @ApiPropertyOptional({ + enum: ['WEB', 'MOBILE'], + default: 'WEB', + description: + 'Client platform. Selects which OAuth redirect_uri is sent to eSignet: WEB uses FAYDA_WEB_REDIRECT_URI, MOBILE uses FAYDA_REDIRECT_URI. Both land on the same /complete endpoint with identical handling.', + }) + @IsOptional() + @IsIn(['WEB', 'MOBILE']) + platform?: 'WEB' | 'MOBILE'; + + @ApiPropertyOptional({ + type: Boolean, + default: false, + description: + 'Set to true when the user opts in to full account registration (checkbox). ' + + 'When true, the /complete response includes a short-lived token and promptPasswordSetup=true ' + + 'so the frontend can immediately prompt for a password via POST /v1/auth/set-fayda-password.', + }) + @IsOptional() + wantsPasswordSetup?: boolean; +} + +export class CompleteVerificationResultDto { + @ApiProperty({ enum: ['LOGIN', 'VERIFY'] }) + purpose!: 'LOGIN' | 'VERIFY'; + + @ApiProperty() verified!: boolean; + + @ApiPropertyOptional({ description: 'JWT. LOGIN: session token for the authenticated user. VERIFY: short-lived token for calling /v1/auth/set-fayda-password.' }) + token?: string; + + @ApiPropertyOptional() + refreshToken?: string; + + @ApiPropertyOptional({ + description: 'Authenticated user summary (LOGIN flow only; same shape as /auth/login).', + }) + user?: { + id: string; + email: string; + role: string; + passengerId?: string; + agentId?: string; + }; + + @ApiPropertyOptional({ description: 'Verified full name from Fayda (VERIFY flow).' }) + fullName?: string; + + @ApiPropertyOptional({ description: 'Verified email from Fayda (VERIFY flow).' }) + email?: string; + + @ApiPropertyOptional({ description: 'Verified phone number from Fayda (VERIFY flow).' }) + phoneNumber?: string; + + @ApiPropertyOptional({ + description: 'Verified date of birth from Fayda, ISO yyyy-MM-dd (VERIFY flow).', + }) + birthdate?: string; + + @ApiPropertyOptional({ description: 'Verified gender from Fayda (VERIFY flow).' }) + gender?: string; + + @ApiPropertyOptional({ description: 'Whether the verified identity was saved to IAM. False if the IAM write failed.' }) + userDataSaved?: boolean; + + @ApiPropertyOptional({ description: 'IAM user ID of the verified identity (VERIFY flow).' }) + iamUserId?: string; + + @ApiPropertyOptional({ description: 'True when the IAM account has not yet set a password (VERIFY flow).' }) + requiresPassword?: boolean; + + @ApiPropertyOptional({ + description: + 'True when the user opted in to immediate password setup (wantsPasswordSetup=true at start) ' + + 'AND they have not yet set a password. Frontend should navigate to the set-password screen.', + }) + promptPasswordSetup?: boolean; +} + +export class VerifaydaCallbackDto { + @ApiPropertyOptional() @IsOptional() @IsString() code?: string; + @ApiPropertyOptional() @IsOptional() @IsString() state?: string; + @ApiPropertyOptional() @IsOptional() @IsString() error?: string; + @ApiPropertyOptional() @IsOptional() @IsString() error_description?: string; +} + +export class VerificationStatusDto { + @ApiProperty() verified!: boolean; + @ApiPropertyOptional() verifiedAt?: Date; + @ApiPropertyOptional() fullName?: string; +} diff --git a/apps/edr-freight-api/src/modules/verifayda/verifayda.errors.ts b/apps/edr-freight-api/src/modules/verifayda/verifayda.errors.ts new file mode 100644 index 000000000..a7d531102 --- /dev/null +++ b/apps/edr-freight-api/src/modules/verifayda/verifayda.errors.ts @@ -0,0 +1,19 @@ +import { BadGatewayException, ConflictException } from '@nestjs/common'; + +export class FaydaTokenExchangeException extends BadGatewayException { + constructor(message = 'Fayda token exchange failed') { + super({ code: 'FAYDA_TOKEN_EXCHANGE_FAILED', message }); + } +} + +export class FaydaUserInfoException extends BadGatewayException { + constructor(message = 'Fayda userinfo fetch failed') { + super({ code: 'FAYDA_USERINFO_FAILED', message }); + } +} + +export class FaydaIdentityConflictException extends ConflictException { + constructor(message = 'This Fayda identity is already linked to another account') { + super({ code: 'FAYDA_IDENTITY_CONFLICT', message }); + } +} diff --git a/apps/edr-freight-api/src/modules/verifayda/verifayda.module.ts b/apps/edr-freight-api/src/modules/verifayda/verifayda.module.ts new file mode 100644 index 000000000..82fb9435c --- /dev/null +++ b/apps/edr-freight-api/src/modules/verifayda/verifayda.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { VerifaydaController } from './verifayda.controller'; +import { FaydaCallbackController } from './fayda-callback.controller'; +import { VerifaydaService } from './verifayda.service'; +import { FaydaVerificationSession } from './entities/fayda-verification-session.entity'; + +@Module({ + imports: [TypeOrmModule.forFeature([FaydaVerificationSession])], + controllers: [VerifaydaController, FaydaCallbackController], + providers: [VerifaydaService], + exports: [VerifaydaService], +}) +export class VerifaydaModule {} diff --git a/apps/edr-freight-api/src/modules/verifayda/verifayda.service.ts b/apps/edr-freight-api/src/modules/verifayda/verifayda.service.ts new file mode 100644 index 000000000..6e3e2e095 --- /dev/null +++ b/apps/edr-freight-api/src/modules/verifayda/verifayda.service.ts @@ -0,0 +1,597 @@ +import { + BadRequestException, + Injectable, + Logger, + ServiceUnavailableException, + UnauthorizedException, +} from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { InjectDataSource, InjectRepository } from '@nestjs/typeorm'; +import { DataSource, Repository } from 'typeorm'; +import { generateToken, generateRefreshToken } from '@tria-plc/api-common/utils/token'; +import { FaydaConfig, FaydaPlatform } from '../../config/fayda.config'; +import { FaydaVerificationSession } from './entities/fayda-verification-session.entity'; +import { + generateCodeChallenge, + generateCodeVerifier, + generateState, +} from './utils/pkce.util'; +import { generateClientAssertion } from './utils/client-assertion.util'; +import { VerifaydaCallbackDto, VerificationStatusDto } from './verifayda.dto'; +import { + FaydaTokenExchangeException, + FaydaUserInfoException, +} from './verifayda.errors'; +import { + FaydaTokenResponse, + FaydaUserInfo, + NormalizedFaydaUserInfo, + VerifaydaPurpose, +} from './verifayda.types'; + +export interface StartVerificationInput { + purpose: VerifaydaPurpose; + platform?: FaydaPlatform; + userId?: string; // iamUserId of the authenticated user, if any + wantsPasswordSetup?: boolean; +} + +export interface FaydaUserSummary { + id: string; + email: string; + role: string; +} + +/** + * Result of completing a verification. `verified` is always true on success. + * LOGIN additionally returns a JWT + user; VERIFY returns the verified identity + * attributes (name, email, phone, dob, gender) for the caller to consume. + */ +export interface CompleteVerificationResult { + purpose: VerifaydaPurpose; + verified: boolean; + token?: string; + refreshToken?: string; + requiresPassword?: boolean; + promptPasswordSetup?: boolean; + iamUserId?: string; + user?: FaydaUserSummary; + fullName?: string; + email?: string; + phoneNumber?: string; + birthdate?: string; + gender?: string; + userDataSaved?: boolean; +} + +@Injectable() +export class VerifaydaService { + private readonly logger = new Logger(VerifaydaService.name); + + private readonly faydaConfig: FaydaConfig; + + constructor( + private readonly config: ConfigService, + @InjectRepository(FaydaVerificationSession) + private readonly sessionRepo: Repository, + @InjectDataSource() private readonly dataSource: DataSource, + ) { + const fayda = this.config.get('fayda'); + if (!fayda) { + throw new Error('Fayda config namespace not registered'); + } + this.faydaConfig = fayda; + } + + // ========================================================================== + // OIDC flow + // ========================================================================== + + async startVerification(input: StartVerificationInput): Promise { + if (!this.faydaConfig.enabled) { + throw new ServiceUnavailableException({ + code: 'FAYDA_DISABLED', + message: 'Fayda integration is not enabled', + }); + } + + const state = generateState(); + const codeVerifier = generateCodeVerifier(); + const codeChallenge = generateCodeChallenge(codeVerifier); + const expiresAt = new Date( + Date.now() + this.faydaConfig.sessionTtlMinutes * 60_000, + ); + + await this.sessionRepo.save( + this.sessionRepo.create({ + state, + codeVerifier, + purpose: input.purpose, + platform: input.platform ?? 'WEB', + saveToAccount: input.wantsPasswordSetup ?? false, + iamUserId: input.userId ?? null, + expiresAt, + }), + ); + + this.logger.log( + `Fayda verification started: purpose=${input.purpose} platform=${input.platform ?? 'WEB'} userId=${input.userId ?? 'none'}`, + ); + + return this.buildAuthorizationUrl({ + state, + codeChallenge, + redirectUri: this.redirectUriForPlatform(input.platform ?? 'WEB'), + }); + } + + /** WEB clients use `webRedirectUri`; MOBILE uses the base `redirectUri`. */ + private redirectUriForPlatform(platform?: FaydaPlatform): string { + return platform === 'MOBILE' + ? this.faydaConfig.redirectUri + : this.faydaConfig.webRedirectUri; + } + + async completeVerification( + query: VerifaydaCallbackDto, + ): Promise { + if (query.error) { + this.logger.warn(`Fayda callback returned error: ${query.error}`); + if (query.state) { + await this.markSessionFailed( + query.state, + query.error, + query.error_description, + ); + } + throw new BadRequestException({ + code: 'FAYDA_AUTH_ERROR', + message: query.error, + description: query.error_description, + }); + } + + if (!query.code || !query.state) { + throw new BadRequestException({ + code: 'FAYDA_MISSING_PARAMETERS', + message: 'code and state are required', + }); + } + + const session = await this.sessionRepo.findOne({ + where: { state: query.state }, + }); + if (!session || session.status !== 'PENDING') { + this.logger.warn('Fayda complete with unknown or non-pending state'); + throw new BadRequestException({ + code: 'FAYDA_INVALID_STATE', + message: 'Verification session is invalid or already used', + }); + } + if (session.expiresAt.getTime() < Date.now()) { + await this.markSessionFailed(query.state, 'session_expired'); + throw new BadRequestException({ + code: 'FAYDA_SESSION_EXPIRED', + message: 'Verification session has expired; start again', + }); + } + + try { + const tokens = await this.exchangeCodeForTokens( + query.code, + session.codeVerifier, + this.redirectUriForPlatform(session.platform as FaydaPlatform), + ); + const userInfo = await this.fetchUserInfo(tokens.access_token); + const normalized = this.normalizeUserInfo(userInfo); + + if (!normalized.sub) { + throw new FaydaUserInfoException('Fayda userinfo missing required sub'); + } + + let result: CompleteVerificationResult; + if (session.purpose === 'LOGIN') { + const { userId } = await this.handleLoginSuccess(normalized); + const login = await this.issueLoginToken(userId); + result = { purpose: 'LOGIN', verified: true, ...login }; + } else { + // VERIFY — prove identity, save to IAM, return verified attributes + short-lived token. + const { iamUserId, userDataSaved } = await this.upsertIamUser(normalized); + + let sessionToken: { token: string; refreshToken: string; requiresPassword: boolean } | undefined; + if (iamUserId) { + try { + sessionToken = await this.createFaydaSession(iamUserId); + } catch (err) { + this.logger.warn(`Fayda session creation failed: ${(err as Error).message}`); + } + } + + result = { + purpose: 'VERIFY', + verified: true, + fullName: normalized.fullName, + email: normalized.email, + phoneNumber: normalized.phoneNumber, + birthdate: normalized.birthdate, + gender: normalized.gender, + userDataSaved, + iamUserId: iamUserId ?? undefined, + token: sessionToken?.token, + refreshToken: sessionToken?.refreshToken, + requiresPassword: sessionToken?.requiresPassword, + promptPasswordSetup: session.saveToAccount && (sessionToken?.requiresPassword ?? false), + }; + } + + await this.sessionRepo.update(session.id, { + status: 'COMPLETED', + completedAt: new Date(), + codeVerifier: '', + }); + + this.logger.log( + `Fayda verification completed: purpose=${session.purpose} platform=${session.platform}`, + ); + return result; + } catch (err) { + const reason = this.classifyFailureReason(err); + this.logger.error( + `Fayda verification failed: reason=${reason} message=${(err as Error).message}`, + ); + await this.markSessionFailed( + query.state, + reason, + (err as Error).message, + ); + throw err; + } + } + + private async issueLoginToken( + _userId: string, + ): Promise<{ token: string; user: FaydaUserSummary }> { + throw new UnauthorizedException({ + code: 'FAYDA_LOGIN_MIGRATED_TO_IAM', + message: 'Fayda login tokens are issued by the IAM package auth endpoints.', + }); + } + + async getVerificationStatus(iamUserId: string): Promise { + const rows = await this.dataSource.query<{ verified_by: string | null; updated_at: Date | null; name: { en: string; am: string } | null }[]>( + `SELECT verified_by, updated_at, name FROM iam.users WHERE id = $1 LIMIT 1`, + [iamUserId], + ); + const iam = rows[0] ?? null; + const faydaVerified = iam?.verified_by === 'fayda'; + const faydaVerifiedAt = faydaVerified && iam?.updated_at ? new Date(iam.updated_at) : undefined; + const fullName = iam?.name?.en ?? iam?.name?.am ?? undefined; + return { verified: faydaVerified, verifiedAt: faydaVerifiedAt, fullName }; + } + + // ========================================================================== + // OIDC internals + // ========================================================================== + + private buildAuthorizationUrl(args: { + state: string; + codeChallenge: string; + redirectUri: string; + }): string { + const params = new URLSearchParams({ + client_id: this.faydaConfig.clientId, + response_type: 'code', + redirect_uri: args.redirectUri, + scope: this.faydaConfig.scope, + state: args.state, + code_challenge: args.codeChallenge, + code_challenge_method: 'S256', + acr_values: this.faydaConfig.acrValues, + claims_locales: this.faydaConfig.claimsLocales, + }); + + // Every claim is marked essential so eSignet shows them locked/pre-checked + // on the consent screen — the user cannot toggle any off; they either + // consent to all of them or the whole flow is cancelled (?error=...). + const claims = { + userinfo: { + name: { essential: true }, + phone_number: { essential: true }, + email: { essential: true }, + birthdate: { essential: true }, + gender: { essential: true }, + address: { essential: true }, + nationality: { essential: true }, + picture: { essential: true }, + }, + id_token: {}, + }; + params.set('claims', JSON.stringify(claims)); + + return `${this.faydaConfig.authorizationEndpoint}?${params.toString()}`; + } + + private async exchangeCodeForTokens( + code: string, + codeVerifier: string, + redirectUri: string, + ): Promise { + const clientAssertion = await generateClientAssertion({ + clientId: this.faydaConfig.clientId, + audience: this.faydaConfig.tokenEndpoint, + privateJwk: this.faydaConfig.privateJwk, + }); + + const body = new URLSearchParams({ + grant_type: 'authorization_code', + code, + redirect_uri: redirectUri, + client_id: this.faydaConfig.clientId, + client_assertion_type: + 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer', + client_assertion: clientAssertion, + code_verifier: codeVerifier, + }); + + const response = await fetch(this.faydaConfig.tokenEndpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body, + }); + + if (!response.ok) { + let detail = ''; + try { + detail = await response.text(); + } catch { + // ignore + } + throw new FaydaTokenExchangeException( + `Fayda token endpoint returned ${response.status}${detail ? `: ${detail}` : ''}`, + ); + } + + return (await response.json()) as FaydaTokenResponse; + } + + private async fetchUserInfo(accessToken: string): Promise { + const response = await fetch(this.faydaConfig.userInfoEndpoint, { + method: 'GET', + headers: { Authorization: `Bearer ${accessToken}` }, + }); + + if (!response.ok) { + throw new FaydaUserInfoException( + `Fayda userinfo endpoint returned ${response.status}`, + ); + } + + const contentType = response.headers.get('content-type') ?? ''; + const raw = await response.text(); + + if (contentType.includes('application/json')) { + return JSON.parse(raw) as FaydaUserInfo; + } + + // Signed JWT response — decode payload (signature verification = production TODO) + if (raw.split('.').length === 3) { + const payloadB64 = raw.split('.')[1]; + const normalizedB64 = payloadB64.replace(/-/g, '+').replace(/_/g, '/'); + const json = Buffer.from(normalizedB64, 'base64').toString('utf8'); + return JSON.parse(json) as FaydaUserInfo; + } + + throw new FaydaUserInfoException( + 'Unsupported Fayda userinfo response format', + ); + } + + private normalizeUserInfo(raw: FaydaUserInfo): NormalizedFaydaUserInfo { + const nameEn = raw['name#en'] as string | undefined; + const nameAm = raw['name#am'] as string | undefined; + const genderEn = raw['gender#en'] as string | undefined; + const genderAm = raw['gender#am'] as string | undefined; + const addressEn = raw['address#en'] as string | undefined; + const addressAm = raw['address#am'] as string | undefined; + const rawPhone = (raw.phone_number ?? raw['phone_number#en'] ?? raw['phone_number#am'] ?? raw.phone) as string | undefined; + + return { + sub: raw.sub, + fullName: (raw.name as string | undefined) ?? nameEn ?? nameAm, + phoneNumber: rawPhone ? this.standardizePhoneNumber(rawPhone) : undefined, + rawPhoneNumber: rawPhone, + email: raw.email as string | undefined, + gender: genderEn ?? genderAm ?? (raw.gender as string | undefined), + birthdate: raw.birthdate as string | undefined, + picture: raw.picture as string | undefined, + nameEn, + nameAm, + genderEn, + genderAm, + addressEn, + addressAm, + }; + } + + private standardizePhoneNumber(phone: string): string { + const digits = phone.replace(/\D/g, ''); + if (digits.startsWith('251')) return `+${digits}`; + if (digits.startsWith('0')) return `+251${digits.slice(1)}`; + return `+${digits}`; + } + + // LOGIN via Fayda is handled entirely by the IAM package's own OIDC flow. + // This method is kept as a stub so completeVerification() still compiles; + // it throws immediately without touching the database. + private async handleLoginSuccess( + _normalized: NormalizedFaydaUserInfo, + ): Promise<{ userId: string }> { + throw new UnauthorizedException({ + code: 'FAYDA_LOGIN_MIGRATED_TO_IAM', + message: 'Fayda login tokens are issued by the IAM package at /v1/auth/fayda endpoints.', + }); + } + + private async upsertIamUser( + normalized: NormalizedFaydaUserInfo, + ): Promise<{ iamUserId: string | null; userDataSaved: boolean }> { + try { + const iamMetadata = { + sub: normalized.sub, + address: { am: normalized.addressAm ?? '', en: normalized.addressEn ?? '' }, + email: normalized.email ?? '', + gender: { am: normalized.genderAm ?? '', en: normalized.genderEn ?? '' }, + name: { am: normalized.nameAm ?? '', en: normalized.nameEn ?? '' }, + phoneNumber: normalized.rawPhoneNumber ?? '', + }; + + // Step 1 — already linked to this Fayda sub; ensure verified_by is set + const bySub = await this.dataSource.query<{ id: string }[]>( + `SELECT id FROM iam.users WHERE metadata->>'sub' = $1 LIMIT 1`, + [normalized.sub], + ); + if (bySub.length > 0) { + await this.dataSource.query( + `UPDATE iam.users SET verified_by = 'fayda', updated_at = NOW() WHERE id = $1`, + [bySub[0].id], + ); + return { iamUserId: bySub[0].id, userDataSaved: true }; + } + + // Step 2 — existing user by phone or email, not yet Fayda-verified + const conditions: string[] = []; + const params: unknown[] = []; + if (normalized.phoneNumber) { + params.push(normalized.phoneNumber); + conditions.push(`phone_number = $${params.length}`); + } + if (normalized.email) { + params.push(normalized.email); + conditions.push(`email = $${params.length}`); + } + if (conditions.length > 0) { + const byContact = await this.dataSource.query<{ id: string }[]>( + `SELECT id FROM iam.users WHERE ${conditions.join(' OR ')} LIMIT 1`, + params, + ); + if (byContact.length > 0) { + const existingId = byContact[0].id; + await this.dataSource.query( + `UPDATE iam.users + SET metadata = COALESCE(metadata, '{}'::jsonb) || $1::jsonb, + verified_by = 'fayda', + updated_at = NOW() + WHERE id = $2`, + [JSON.stringify(iamMetadata), existingId], + ); + return { iamUserId: existingId, userDataSaved: true }; + } + } + + // Step 3 — new user + const name = { am: normalized.nameAm ?? '', en: normalized.nameEn ?? '' }; + const username = normalized.phoneNumber ?? normalized.email ?? normalized.sub; + const inserted = await this.dataSource.query<{ id: string }[]>( + `INSERT INTO iam.users ( + id, name, username, email, phone_number, metadata, + user_type, status, is_active, has_set_password, + is_phone_number_verified, verified_by, + created_at, updated_at + ) VALUES ( + gen_random_uuid(), $1::jsonb, $2, $3, $4, $5::jsonb, + 'individual', 'submitted', true, false, + false, 'fayda', + NOW(), NOW() + ) RETURNING id`, + [ + JSON.stringify(name), + username, + normalized.email ?? null, + normalized.phoneNumber ?? null, + JSON.stringify(iamMetadata), + ], + ); + return { iamUserId: inserted[0].id, userDataSaved: true }; + } catch (err) { + this.logger.error(`Fayda IAM upsert failed: ${(err as Error).message}`); + return { iamUserId: null, userDataSaved: false }; + } + } + + private async createFaydaSession( + iamUserId: string, + ): Promise<{ token: string; refreshToken: string; requiresPassword: boolean }> { + const rows = await this.dataSource.query<{ + id: string; + email: string; + name: { en: string; am: string } | null; + username: string; + phone_number: string | null; + has_set_password: boolean; + status: string; + }[]>( + `SELECT id, email, name, username, phone_number, has_set_password, status + FROM iam.users WHERE id = $1 LIMIT 1`, + [iamUserId], + ); + if (!rows.length) throw new Error(`IAM user ${iamUserId} not found`); + const u = rows[0]; + + const userInfo = { + id: u.id, + email: u.email ?? '', + name: u.name ?? { en: '', am: '' }, + userType: 'individual', + status: u.status, + hasSetPassword: u.has_set_password, + isPhoneNumberVerified: false, + hasFinishedRegistration: false, + hasFinishedDMSOnboarding: false, + username: u.username, + phoneNumber: u.phone_number ?? '', + roles: [], + permissions: [], + employee: [], + }; + + const sessions = await this.dataSource.query<{ id: string }[]>( + `INSERT INTO iam.sessions + (id, email, device, "userInfo", expiry_time, refresh_count, status, user_id) + VALUES (gen_random_uuid(), $1, 'fayda-verify', $2::jsonb, NOW() + INTERVAL '1 day', 0, 'ACTIVE', $3) + ON CONFLICT (user_id, device) DO UPDATE + SET status = 'ACTIVE', "userInfo" = EXCLUDED."userInfo", + expiry_time = NOW() + INTERVAL '1 day', updated_at = NOW() + RETURNING id`, + [u.email ?? '', JSON.stringify(userInfo), iamUserId], + ); + + const sessionId = sessions[0].id; + const token = generateToken({ id: sessionId }); + const refreshToken = generateRefreshToken({ id: sessionId }); + + return { token, refreshToken, requiresPassword: !u.has_set_password }; + } + + private async markSessionFailed( + state: string, + errorCode: string, + errorDescription?: string, + ): Promise { + await this.sessionRepo.update( + { state, status: 'PENDING' }, + { + status: 'FAILED', + errorCode, + errorDescription: errorDescription ?? null, + completedAt: new Date(), + codeVerifier: '', + }, + ); + } + + private classifyFailureReason(err: unknown): string { + if (err instanceof FaydaTokenExchangeException) return 'token_exchange_failed'; + if (err instanceof FaydaUserInfoException) return 'userinfo_failed'; + return 'verification_failed'; + } +} diff --git a/apps/edr-freight-api/src/modules/verifayda/verifayda.types.ts b/apps/edr-freight-api/src/modules/verifayda/verifayda.types.ts new file mode 100644 index 000000000..442a22d2f --- /dev/null +++ b/apps/edr-freight-api/src/modules/verifayda/verifayda.types.ts @@ -0,0 +1,45 @@ +export type VerifaydaPurpose = 'LOGIN' | 'VERIFY'; + +export interface FaydaTokenResponse { + access_token: string; + id_token?: string; + token_type: string; + expires_in?: number; + scope?: string; +} + +export interface FaydaUserInfo { + sub: string; + name?: string; + 'name#en'?: string; + 'name#am'?: string; + phone_number?: string; + 'phone_number#en'?: string; + 'phone_number#am'?: string; + phone?: string; + email?: string; + gender?: string; + birthdate?: string; + picture?: string; + address?: Record; + [key: string]: unknown; +} + +export interface NormalizedFaydaUserInfo { + sub: string; + // Convenience / display fields + fullName?: string; + phoneNumber?: string; // standardized e.g. +251911234567 + email?: string; + gender?: string; + birthdate?: string; + picture?: string; + // Raw localized fields — preserved for IAM-identical writes + nameEn?: string; + nameAm?: string; + genderEn?: string; + genderAm?: string; + addressEn?: string; + addressAm?: string; + rawPhoneNumber?: string; // unstandardized, stored in IAM metadata +} diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/allocation-rule.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/allocation-rule.dto.ts index 43cd6f61a..8d18d2a1f 100644 --- a/apps/edr-freight-api/src/modules/warehouses/dto/allocation-rule.dto.ts +++ b/apps/edr-freight-api/src/modules/warehouses/dto/allocation-rule.dto.ts @@ -1,9 +1,10 @@ import { ApiProperty, ApiPropertyOptional, PartialType } from '@nestjs/swagger'; -import { IsBoolean, IsInt, IsOptional, IsString } from 'class-validator'; +import { IsBoolean, IsInt, IsOptional, IsString, Matches } from 'class-validator'; export class CreateAllocationRuleDto { @ApiProperty() @IsString() + @Matches(/^[A-Za-z\s]+$/, { message: 'name may only contain letters and spaces' }) name!: string; @ApiPropertyOptional({ default: 100 }) diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/create-warehouse.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/create-warehouse.dto.ts index 788c798bf..5a8025948 100644 --- a/apps/edr-freight-api/src/modules/warehouses/dto/create-warehouse.dto.ts +++ b/apps/edr-freight-api/src/modules/warehouses/dto/create-warehouse.dto.ts @@ -1,5 +1,5 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { IsEnum, IsNumber, IsOptional, IsString, IsUUID, MaxLength, Min } from 'class-validator'; +import { IsEnum, IsNumber, IsOptional, IsString, IsUUID, Matches, MaxLength, Min } from 'class-validator'; import { WAREHOUSE_TYPES, WarehouseType } from '../entities/warehouse.entity'; @@ -7,6 +7,7 @@ export class CreateWarehouseDto { @ApiProperty() @IsString() @MaxLength(160) + @Matches(/^[A-Za-z\s]+$/, { message: 'name may only contain letters and spaces' }) name!: string; @ApiProperty() diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/fee-rule.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/fee-rule.dto.ts index e2c20901d..22e9f9491 100644 --- a/apps/edr-freight-api/src/modules/warehouses/dto/fee-rule.dto.ts +++ b/apps/edr-freight-api/src/modules/warehouses/dto/fee-rule.dto.ts @@ -1,6 +1,6 @@ import { ApiProperty, ApiPropertyOptional, PartialType } from '@nestjs/swagger'; import { Type } from 'class-transformer'; -import { IsArray, IsEnum, IsInt, IsNumber, IsOptional, IsString, IsUUID, Min, ValidateNested } from 'class-validator'; +import { IsArray, IsEnum, IsInt, IsNumber, IsOptional, IsString, IsUUID, Matches, Min, ValidateNested } from 'class-validator'; import { FEE_RULE_TYPES, FeeRuleType } from '../entities/warehouse-fee-rule.entity'; @@ -25,6 +25,7 @@ export class FeeRuleTierDto { export class CreateFeeRuleDto { @ApiProperty() @IsString() + @Matches(/^[A-Za-z\s]+$/, { message: 'name may only contain letters and spaces' }) name!: string; @ApiProperty({ enum: FEE_RULE_TYPES }) diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/filter-inventory.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/filter-inventory.dto.ts index 9f89e7c2c..49fb22fde 100644 --- a/apps/edr-freight-api/src/modules/warehouses/dto/filter-inventory.dto.ts +++ b/apps/edr-freight-api/src/modules/warehouses/dto/filter-inventory.dto.ts @@ -52,6 +52,11 @@ export class FilterWarehouseInventoryDto { @IsEnum(WAREHOUSE_INVENTORY_STATUSES) status?: WarehouseInventoryStatus; + @ApiPropertyOptional({ enum: ['IMPORT', 'EXPORT'] }) + @IsOptional() + @IsEnum(['IMPORT', 'EXPORT']) + direction?: 'IMPORT' | 'EXPORT'; + @ApiPropertyOptional() @IsOptional() @IsString() diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts index 11f34c892..ac6b71c89 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts @@ -430,6 +430,7 @@ export class WarehouseInventoryService { ...(filter.status ? { status: filter.status } : {}), ...(createdAt ? { createdAt } : {}), ...(filter.facilityId ? { warehouse: { stationId: filter.facilityId } } : {}), + ...(filter.direction ? { booking: { tradeDirection: filter.direction } } : {}), }; const search = filter.search?.trim(); @@ -2032,6 +2033,22 @@ export class WarehouseInventoryService { const isTruckLeaving = dto.grossWeight !== undefined && Boolean(dto.gateOutTime); if (isTruckLeaving) { await this.invoices.assertClearanceAllowed(id); + + if (item.bookingId) { + const [truckInfo]: Array<{ customerTruckAssignedAt: string | null }> = + await this.dataSource.query( + `SELECT customer_truck_assigned_at AS "customerTruckAssignedAt" + FROM freight.bookings + WHERE id = $1 AND deleted_at IS NULL`, + [item.bookingId], + ); + const usesCustomerTruck = Boolean(truckInfo?.customerTruckAssignedAt); + if (usesCustomerTruck && !this.extractCustomerDeliveryApproval(item.notes)) { + throw new BadRequestException( + 'Customer must approve delivery (sign the handover) before the exit paper can be generated', + ); + } + } } const releaseDate = isTruckLeaving ? dto.releaseDate ? new Date(dto.releaseDate) : new Date() diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index 1e7d1e280..e3beb67ce 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -121,6 +121,7 @@ import WarehouseInvoicesPage from "./pages/warehouses/WarehouseInvoicesPage"; import WarehouseListPage from "./pages/warehouses/WarehouseListPage"; import WarehouseRulesPage from "./pages/warehouses/WarehouseRulesPage"; import { HealthCheck } from "./features/health/HealthCheck"; +import FaydaCallbackPage from "./pages/FaydaCallbackPage"; const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ { @@ -330,7 +331,7 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ }, { label: "Terminal Inventory", - href: "/dashboard/warehouse-inventory", + href: "/dashboard/warehouse-inventory?direction=IMPORT", icon: , }, { @@ -377,7 +378,7 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ }, { label: "Terminal Inventory", - href: "/dashboard/warehouse-inventory", + href: "/dashboard/warehouse-inventory?direction=EXPORT", icon: , }, ], @@ -569,6 +570,7 @@ const App = () => { } /> } /> + } /> } /> ); @@ -578,6 +580,7 @@ const App = () => { } /> } /> + } /> } /> ) => void; + /** + * Show a "Verify with Fayda" step: opens the eSignet popup and prefills + * firstName/lastName/email/phoneNumber/dateOfBirth from the verified + * identity, stamping faydaVerified + faydaSub on the payload. + */ + verifyWithFayda?: boolean; } +// Fayda returns gender as "Male"/"Female"; snap it onto the form's uppercase +// option values (MALE/FEMALE/OTHER) so the Select prefills instead of rendering +// blank. Unknown/empty values fall through to undefined (field left untouched). +const normalizeGender = (raw?: string): string | undefined => { + const up = (raw ?? "").trim().toUpperCase(); + if (up === "MALE" || up === "M") return "MALE"; + if (up === "FEMALE" || up === "F") return "FEMALE"; + return up ? "OTHER" : undefined; +}; + +// Fayda may return the birthdate as "2001/12/01" (slashes), but the date input +// and validator expect ISO "2001-12-01". Normalize separators + trim to 10 chars +// so the DOB field prefills instead of silently staying blank. +const normalizeBirthdate = (raw?: string): string | undefined => { + const iso = (raw ?? "").trim().replace(/\//g, "-").slice(0, 10); + return /^\d{4}-\d{2}-\d{2}$/.test(iso) ? iso : undefined; +}; + const buildInitialValues = ( fields: FleetFormFieldDef[], emptyValues: Record, @@ -72,9 +101,12 @@ const FleetFormDialog = ({ isSubmitting, selectOptionsLoading, onSubmit, + verifyWithFayda, }: FleetFormDialogProps) => { const [values, setValues] = useState>({}); const [errors, setErrors] = useState>({}); + const [faydaLoading, setFaydaLoading] = useState(false); + const [faydaError, setFaydaError] = useState(null); // Seed the form ONLY when the dialog opens or the edited record changes — NOT // when `fields`/`emptyValues` get new object refs (they're rebuilt whenever the @@ -87,10 +119,88 @@ const FleetFormDialog = ({ if (open) { setValues(buildInitialValues(fields, emptyValues, initialRecord)); setErrors({}); + setFaydaError(null); + setFaydaLoading(false); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [open, recordId]); + // Receive the ?code&state relayed by the /callback popup, exchange it for + // the verified identity, and prefill the matching form fields. + useEffect(() => { + if (!open || !verifyWithFayda) return; + const onMessage = async (event: MessageEvent) => { + if (event.origin !== window.location.origin) return; + if (event.data?.type !== "fayda-callback") return; + + if (event.data.error) { + setFaydaLoading(false); + setFaydaError(event.data.errorDescription ?? event.data.error); + return; + } + if (!event.data.code || !event.data.state) return; + + try { + const result = await verifaydaService.complete(event.data.code, event.data.state); + if (!result.verified) { + setFaydaError("Identity could not be verified"); + return; + } + const nameParts = (result.fullName ?? "").trim().split(/\s+/).filter(Boolean); + const [firstName, ...rest] = nameParts; + const gender = normalizeGender(result.gender); + const dateOfBirth = normalizeBirthdate(result.birthdate); + setValues((current) => ({ + ...current, + ...(firstName ? { firstName } : {}), + ...(rest.length ? { lastName: rest.join(" ") } : {}), + ...(result.email ? { email: result.email } : {}), + ...(result.phoneNumber ? { phoneNumber: result.phoneNumber } : {}), + ...(dateOfBirth ? { dateOfBirth } : {}), + ...(gender ? { gender } : {}), + faydaVerified: true, + ...(result.iamUserId ? { faydaSub: result.iamUserId } : {}), + })); + setFaydaError(null); + } catch (err) { + const message = + (err as { response?: { data?: { message?: string } } })?.response?.data?.message ?? + (err instanceof Error ? err.message : "Verification failed"); + setFaydaError(message); + } finally { + setFaydaLoading(false); + } + }; + window.addEventListener("message", onMessage); + return () => window.removeEventListener("message", onMessage); + }, [open, verifyWithFayda]); + + const handleFaydaVerify = async () => { + setFaydaError(null); + setFaydaLoading(true); + try { + const { authorizationUrl } = await verifaydaService.start(); + const popup = window.open( + authorizationUrl, + "fayda-verify", + "width=480,height=760,noopener=no", + ); + if (!popup) { + setFaydaLoading(false); + setFaydaError("Pop-up blocked — allow pop-ups for this site and retry."); + } + // Loading stays on until the popup posts back; reopening the dialog resets it. + } catch (err) { + setFaydaLoading(false); + const message = + (err as { response?: { data?: { message?: string } } })?.response?.data?.message ?? + (err instanceof Error ? err.message : "Could not start verification"); + setFaydaError(message); + } + }; + + const faydaVerified = values.faydaVerified === true; + const shortFields = useMemo( () => fields.filter((f) => f.type !== "textarea"), [fields], @@ -127,6 +237,12 @@ const FleetFormDialog = ({ const date = new Date(stringValue + "T00:00:00Z"); if (isNaN(date.getTime())) { next[field.name] = `${field.label} is not a valid date`; + } else if (field.dateBound === "future") { + const startOfToday = new Date(); + startOfToday.setUTCHours(0, 0, 0, 0); + if (date <= startOfToday) { + next[field.name] = `${field.label} must be in the future`; + } } else if (date > new Date()) { next[field.name] = `${field.label} cannot be in the future`; } @@ -147,6 +263,12 @@ const FleetFormDialog = ({ }, [fields]); const handleSubmit = () => { + // Hard gate: a driver record cannot be saved until its identity is verified + // with Fayda. Mirrored server-side in DriversService. + if (verifyWithFayda && !faydaVerified) { + setFaydaError("Verify the driver's identity with Fayda before saving."); + return; + } if (!validate()) return; const payload = Object.fromEntries( Object.entries(values) @@ -167,6 +289,9 @@ const FleetFormDialog = ({ const renderField = (field: FleetFormFieldDef) => { const value = values[field.name]; const error = errors[field.name]; + // Fayda-owned identity fields (name/email/phone/DOB/gender) are populated + // only by verification and never hand-edited. + const isDisabled = Boolean(field.disabled || field.faydaLocked); if (field.type === "select") { return ( @@ -186,7 +311,7 @@ const FleetFormDialog = ({ } error={error} searchable - disabled={selectOptionsLoading} + disabled={selectOptionsLoading || isDisabled} rightSection={ selectOptionsLoading ? ( @@ -216,7 +341,7 @@ const FleetFormDialog = ({ error={error} searchable clearable - disabled={selectOptionsLoading} + disabled={selectOptionsLoading || isDisabled} rightSection={ selectOptionsLoading ? ( @@ -240,7 +365,7 @@ const FleetFormDialog = ({ })) } error={error} - disabled={field.disabled} + disabled={isDisabled} /> ); } @@ -260,7 +385,7 @@ const FleetFormDialog = ({ } error={error} minRows={3} - disabled={field.disabled} + disabled={isDisabled} /> ); } @@ -280,13 +405,8 @@ const FleetFormDialog = ({ })) } error={error} - disabled={field.disabled} + disabled={isDisabled} description={field.description || "Select a date"} - rightSection={ - - - - } size="sm" radius="md" styles={{ @@ -318,7 +438,7 @@ const FleetFormDialog = ({ })) } error={error} - disabled={field.disabled} + disabled={isDisabled} /> ); }; @@ -333,6 +453,40 @@ const FleetFormDialog = ({ centered > + {verifyWithFayda && ( + + {faydaVerified ? ( + } + > + Identity verified with Fayda + + ) : ( + + Identity must be verified with Fayda before this driver can be + saved. + + )} + + + )} + {verifyWithFayda && faydaError && ( + + {faydaError} + + )} {shortFields.map(renderField)} @@ -345,6 +499,7 @@ const FleetFormDialog = ({ color="edr-green" loading={isSubmitting} onClick={handleSubmit} + disabled={verifyWithFayda && !faydaVerified} > Save diff --git a/apps/edr-freight-web/backoffice/src/components/fleet/FleetHistoryModal.tsx b/apps/edr-freight-web/backoffice/src/components/fleet/FleetHistoryModal.tsx new file mode 100644 index 000000000..43f2bce4f --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/fleet/FleetHistoryModal.tsx @@ -0,0 +1,207 @@ +import { Center, Loader, Modal, Text, Timeline } from "@mantine/core"; +import { useQuery } from "@tanstack/react-query"; +import { + Activity, + CircleDot, + Route, + Truck, + UserCheck, + UserMinus, + UserPlus, +} from "lucide-react"; + +import { + fleetHistoryService, + type FleetHistoryEvent, +} from "@/services/fleet-history.service"; +import type { FleetRecord } from "@/services/fleet/fleet.service"; + +export interface FleetHistoryModalProps { + opened: boolean; + onClose: () => void; + entity: "driver" | "vehicle"; + record: FleetRecord | null; +} + +const asObj = (r: FleetRecord | null) => (r ?? {}) as Record; + +const titleFor = (entity: "driver" | "vehicle", record: FleetRecord | null) => { + const r = asObj(record); + if (entity === "vehicle") { + return `Vehicle history — ${r.plateNumber ?? r.code ?? ""}`.trim(); + } + return `Driver history — ${[r.firstName, r.lastName] + .filter(Boolean) + .join(" ")}`.trim(); +}; + +const mileLabel = (e: FleetHistoryEvent) => + e.metadata?.mile === "LAST" ? "Last-mile" : "First-mile"; + +const arrow = (from?: string | null, to?: string | null) => + `${from ?? "—"} → ${to ?? "—"}`; + +const metaStr = (e: FleetHistoryEvent, key: string) => { + const v = e.metadata?.[key]; + return typeof v === "string" && v ? v : null; +}; + +function describe(e: FleetHistoryEvent, entity: "driver" | "vehicle") { + const vehiclePlate = metaStr(e, "vehiclePlate"); + const driverName = metaStr(e, "driverName") ?? (e.label || null); + const bookingRef = metaStr(e, "bookingRef"); + + // Compose the detail line with whatever the current view doesn't already + // know: on a driver's timeline show which vehicle; always show the booking. + const detail = (extra?: string) => + [ + entity === "driver" && vehiclePlate ? `Vehicle ${vehiclePlate}` : "", + bookingRef ? `Booking ${bookingRef}` : "", + extra ?? "", + ] + .filter(Boolean) + .join(" · "); + + switch (e.eventType) { + case "DRIVER_REGISTERED": + return { + icon: , + title: "Driver registered", + text: e.toValue ? `Status: ${e.toValue}` : "", + }; + case "VEHICLE_REGISTERED": + return { + icon: , + title: "Vehicle registered", + text: e.toValue ? `Availability: ${e.toValue}` : "", + }; + case "DRIVER_ASSIGNED": + return { + icon: , + title: entity === "vehicle" ? "Driver assigned" : "Assigned to vehicle", + text: + entity === "vehicle" + ? driverName + ? `Driver ${driverName}` + : "" + : vehiclePlate + ? `Vehicle ${vehiclePlate}` + : "", + }; + case "DRIVER_UNASSIGNED": + return { + icon: , + title: + entity === "vehicle" + ? "Driver unassigned" + : "Unassigned from vehicle", + text: + entity === "vehicle" + ? driverName + ? `Driver ${driverName}` + : "" + : vehiclePlate + ? `Vehicle ${vehiclePlate}` + : "", + }; + case "VEHICLE_STATUS_CHANGED": + return { + icon: , + title: "Status changed", + text: arrow(e.fromValue, e.toValue), + }; + case "VEHICLE_AVAILABILITY_CHANGED": + return { + icon: , + title: `Marked ${e.toValue ?? ""}`.trim(), + text: e.fromValue ? arrow(e.fromValue, e.toValue) : "", + }; + case "MILE_VEHICLE_ASSIGNED": + return { + icon: , + title: `${mileLabel(e)}: vehicle assigned`, + text: detail(e.label ? `Status: ${e.label}` : ""), + }; + case "MILE_VEHICLE_RELEASED": + return { + icon: , + title: `${mileLabel(e)}: vehicle released`, + text: detail(), + }; + case "MILE_STATUS_CHANGED": + return { + icon: , + title: `${mileLabel(e)} status`, + text: detail(arrow(e.fromValue, e.toValue)), + }; + default: + return { icon: , title: e.eventType, text: "" }; + } +} + +const fmt = (iso: string) => { + const d = new Date(iso); + return Number.isNaN(d.getTime()) ? iso : d.toLocaleString(); +}; + +const FleetHistoryModal = ({ + opened, + onClose, + entity, + record, +}: FleetHistoryModalProps) => { + const id = asObj(record).id ? String(asObj(record).id) : ""; + + const { data, isLoading } = useQuery({ + queryKey: ["fleet-history", entity, id], + queryFn: () => + entity === "vehicle" + ? fleetHistoryService.vehicle(id) + : fleetHistoryService.driver(id), + enabled: opened && Boolean(id), + }); + + const events = data ?? []; + + return ( + {titleFor(entity, record)}} + radius="lg" + size="lg" + centered + > + {isLoading ? ( +
+ +
+ ) : events.length === 0 ? ( + + No history recorded yet. Activity appears here as this{" "} + {entity} is assigned, reassigned, or its status changes. + + ) : ( + + {events.map((e) => { + const d = describe(e, entity); + return ( + + {d.text && ( + + {d.text} + + )} + + {fmt(e.createdAt)} + + + ); + })} + + )} +
+ ); +}; + +export default FleetHistoryModal; diff --git a/apps/edr-freight-web/backoffice/src/components/fleet/FleetRecordActions.tsx b/apps/edr-freight-web/backoffice/src/components/fleet/FleetRecordActions.tsx index 9096f52a1..d499d030c 100644 --- a/apps/edr-freight-web/backoffice/src/components/fleet/FleetRecordActions.tsx +++ b/apps/edr-freight-web/backoffice/src/components/fleet/FleetRecordActions.tsx @@ -1,4 +1,4 @@ -import { Edit2, Trash2, Eye, Users, MoreVertical } from "lucide-react"; +import { Edit2, Trash2, Eye, Users, MoreVertical, History } from "lucide-react"; import { ActionIcon, Menu, MenuItem, Tooltip } from "@mantine/core"; import { useNavigate } from "react-router-dom"; @@ -11,6 +11,7 @@ export interface FleetRecordActionsProps { onEdit: (record: FleetRecord) => void; onRemove: (record: FleetRecord) => void; onAssignDriver?: (record: FleetRecord) => void; + onHistory?: (record: FleetRecord) => void; layout?: "row" | "compact"; } @@ -20,12 +21,16 @@ const FleetRecordActions = ({ onEdit, onRemove, onAssignDriver, + onHistory, layout = "row", }: FleetRecordActionsProps) => { const navigate = useNavigate(); const removeLabel = config.removeActionLabel ?? "Delete"; const showDetail = Boolean(config.detailPath && "id" in record); const isVehicle = config.slug === "vehicles"; + const showHistory = + Boolean(onHistory) && + (config.slug === "drivers" || config.slug === "vehicles"); const handleDetail = () => { if (!config.detailPath || !("id" in record)) return; @@ -57,6 +62,14 @@ const FleetRecordActions = ({ > Edit + {showHistory ? ( + onHistory?.(record)} + leftSection={} + > + History + + ) : null} {showDetail ? ( >(); @@ -20,6 +20,16 @@ export const formatFleetCell = ( format?: FleetColumnFormat, accessorKey?: string, ): ReactNode => { + if (format === "verifiedBadge") { + return value === true ? ( + + Verified + + ) : ( + + ); + } + if (format === "statusBadge") { const status = value == null || value === "" ? "—" : String(value); const getStatusColor = (st: string): string => { diff --git a/apps/edr-freight-web/backoffice/src/components/operations/LastMileSteps.tsx b/apps/edr-freight-web/backoffice/src/components/operations/LastMileSteps.tsx new file mode 100644 index 000000000..d4860a414 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/operations/LastMileSteps.tsx @@ -0,0 +1,93 @@ +import { Group, Stack, Text, Timeline, Tooltip } from "@mantine/core"; +import { Check } from "lucide-react"; + +/** One stage of the last-mile delivery workflow. */ +export interface LastMileStepState { + label: string; + done: boolean; + active: boolean; + /** Optional stamp/value shown next to the step (plate, time, distance…). */ + detail?: string | null; +} + +/** + * Compact 6-dot progress bar for a table row — filled = done, ringed = current, + * hollow = pending. Hover a dot for its label + stamp. + */ +export function LastMileStepBar({ steps }: { steps: LastMileStepState[] }) { + return ( + + {steps.map((s, i) => { + const color = s.done + ? "var(--mantine-color-green-6)" + : s.active + ? "var(--mantine-color-blue-5)" + : "var(--mantine-color-gray-4)"; + return ( + + + + ); + })} + + ); +} + +/** + * Vertical stepper for the detail view — completed steps bulleted + green, the + * current step highlighted, each showing its stamp/value when known. + */ +export function LastMileStepper({ steps }: { steps: LastMileStepState[] }) { + const activeIndex = steps.findIndex((s) => s.active); + // Timeline highlights items with index < `active`; count of done steps drives it. + const doneCount = steps.filter((s) => s.done).length; + return ( + + {steps.map((s, i) => ( + : undefined} + title={ + + {s.label} + + } + lineVariant={s.done ? "solid" : "dashed"} + > + + + {s.done ? "Done" : s.active ? "Current step" : "Pending"} + + {s.detail && ( + + {s.detail} + + )} + + + ))} + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/ImportLoadingConfirmationPanel.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/ImportLoadingConfirmationPanel.tsx new file mode 100644 index 000000000..ab1d907e6 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/ImportLoadingConfirmationPanel.tsx @@ -0,0 +1,172 @@ +import { useMemo, useState } from "react"; +import { PackageCheck } from "lucide-react"; +import { Badge, Button, Checkbox, Group, Loader, Paper, Stack, Text } from "@mantine/core"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import toast from "react-hot-toast"; + +import { api } from "@/services/api"; +import type { + ImportLoadingBooking, + ImportLoadingBookingsResponse, + LoadingStatus, +} from "@/types/trainScheduling"; + +function ImportLoadingBookingRow({ + booking, + selected, + onToggle, +}: { + booking: ImportLoadingBooking; + selected: boolean; + onToggle: () => void; +}) { + return ( + + + + + + + {booking.reference ?? booking.id} + + + {booking.loadingStatus} + + + + {booking.customer ?? "Unknown customer"} + + + {booking.weightTons}T + + + + ); +} + +export function ImportLoadingConfirmationPanel({ + scheduleId, + items, + isLoading, +}: { + scheduleId: string; + items: ImportLoadingBooking[]; + isLoading?: boolean; +}) { + const [selectedIds, setSelectedIds] = useState([]); + const queryClient = useQueryClient(); + + const updateStatus = useMutation< + ImportLoadingBookingsResponse, + Error, + { id: string; bookingIds: string[]; loadingStatus: LoadingStatus } + >({ + ...api.trainScheduling.updateImportLoadingStatus.mutationOptions(), + onSuccess: () => { + setSelectedIds([]); + queryClient.invalidateQueries({ + queryKey: api.trainScheduling.importLoadingBookings.queryKey({ id: scheduleId }), + }); + }, + onError: (error) => { + toast.error(error instanceof Error ? error.message : "Could not update loading status"); + }, + }); + + const toggle = (id: string) => { + setSelectedIds((prev) => + prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id], + ); + }; + + const allIds = useMemo(() => items.map((b) => b.id), [items]); + + if (isLoading) { + return ( + + + + Loading import bookings… + + + ); + } + + if (!items.length) { + return ( + + + No paid import bookings with wagons allocated on this schedule + + + ); + } + + return ( + + + + Import bookings ({items.length}) + + + + + + + + + {items.map((booking) => ( + toggle(booking.id)} + /> + ))} + + + + + + + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/CreateWarehouseModal.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/CreateWarehouseModal.tsx index ad8b4109d..6494906b4 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/CreateWarehouseModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/CreateWarehouseModal.tsx @@ -14,7 +14,7 @@ import { useMutation, useQuery } from '@tanstack/react-query'; import { api } from '@/services/api'; import { useToast } from '@/hooks/use-toast'; import type { SaveWarehousePayload, Warehouse, WarehouseType } from '@/types/warehouse'; -import { extractErrorMessage, statusOptions, warehouseTypeOptions } from './options'; +import { extractErrorMessage, lettersOnly, statusOptions, warehouseTypeOptions } from './options'; interface CreateWarehouseModalProps { opened: boolean; @@ -120,7 +120,7 @@ export function CreateWarehouseModal({ opened, onClose, warehouse }: CreateWareh placeholder="Modjo Open Warehouse" required value={form.name} - onChange={(e) => { const v = e.currentTarget.value; setForm((f) => ({ ...f, name: v })); }} + onChange={(e) => { const v = lettersOnly(e.currentTarget.value); setForm((f) => ({ ...f, name: v })); }} /> { }); }; +// Name fields (warehouse / fee rule / allocation rule) accept letters and spaces only — no numbers. +export const lettersOnly = (value: string) => value.replace(/[^A-Za-z\s]/g, ''); + export const extractErrorMessage = (error: unknown, fallback = 'Something went wrong') => { const responseData = (error as { response?: { data?: unknown } })?.response?.data; const data = responseData && typeof responseData === 'object' ? (responseData as Record) : undefined; diff --git a/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts b/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts index 70a134396..3275ef662 100644 --- a/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts @@ -109,6 +109,8 @@ export const QUERY_KEYS = { ["train-scheduling", "unassigned", id] as const, compositionRemovals: (id: string) => ["train-scheduling", "removals", id] as const, + importLoadingBookings: (id: string) => + ["train-scheduling", "import-loading-bookings", id] as const, }, FLEET: { diff --git a/apps/edr-freight-web/backoffice/src/constants/URLS.ts b/apps/edr-freight-web/backoffice/src/constants/URLS.ts index 5bb9e3f7d..351c95e7e 100644 --- a/apps/edr-freight-web/backoffice/src/constants/URLS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/URLS.ts @@ -324,6 +324,10 @@ export const URL_CONSTANTS = { PIN_WAGONS: (id: string) => `/train-scheduling/schedules/${id}/pin-wagons`, FINALIZE: (id: string) => `/train-scheduling/schedules/${id}/finalize`, DISPATCH: (id: string) => `/train-scheduling/schedules/${id}/dispatch`, + IMPORT_LOADING_BOOKINGS: (id: string) => + `/train-scheduling/schedules/${id}/import-loading-bookings`, + IMPORT_LOADING_STATUS: (id: string) => + `/train-scheduling/schedules/${id}/import-loading-status`, IMPORT_DJIBOUTI: (id: string) => `/train-scheduling/schedules/${id}/import-djibouti`, IMPORT_DJIBOUTI_DOCUMENTS: (id: string) => diff --git a/apps/edr-freight-web/backoffice/src/pages/FaydaCallbackPage.tsx b/apps/edr-freight-web/backoffice/src/pages/FaydaCallbackPage.tsx new file mode 100644 index 000000000..5febb664a --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/FaydaCallbackPage.tsx @@ -0,0 +1,56 @@ +import { useEffect, useState } from "react"; +import { Center, Loader, Stack, Text } from "@mantine/core"; + +import type { FaydaCallbackMessage } from "@/services/verifayda.service"; + +/** + * Landing page for the eSignet redirect_uri (FAYDA_WEB_REDIRECT_URI → + * http://localhost:5183/callback). Runs inside the verification popup: + * relays ?code&state (or ?error) to the window that opened it via + * postMessage, then closes itself. The opener performs the /complete call + * so the single-use session is only consumed once, in one place. + */ +const FaydaCallbackPage = () => { + const [standalone, setStandalone] = useState(false); + + useEffect(() => { + const params = new URLSearchParams(window.location.search); + const message: FaydaCallbackMessage = { + type: "fayda-callback", + code: params.get("code") ?? undefined, + state: params.get("state") ?? undefined, + error: params.get("error") ?? undefined, + errorDescription: params.get("error_description") ?? undefined, + }; + + if (window.opener && window.opener !== window) { + (window.opener as Window).postMessage(message, window.location.origin); + window.close(); + } else { + // Opened as a full-page redirect instead of a popup — nothing to relay to. + setStandalone(true); + } + }, []); + + return ( +
+ + {standalone ? ( + <> + Verification window lost its parent page + + Close this tab and restart the verification from the form. + + + ) : ( + <> + + Completing Fayda verification… + + )} + +
+ ); +}; + +export default FaydaCallbackPage; diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/FleetResourcePage.tsx b/apps/edr-freight-web/backoffice/src/pages/fleet/FleetResourcePage.tsx index 13be9ef71..ff7caa9d2 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/FleetResourcePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/FleetResourcePage.tsx @@ -10,6 +10,7 @@ import { Navigate, useLocation } from "react-router-dom"; import FleetCardGrid from "@/components/fleet/FleetCardGrid"; import FleetFormDialog from "@/components/fleet/FleetFormDialog"; +import FleetHistoryModal from "@/components/fleet/FleetHistoryModal"; import FleetRecordActions from "@/components/fleet/FleetRecordActions"; import FleetToolbar from "@/components/fleet/FleetToolbar"; import { formatFleetCell, registerFleetOptionLabels } from "@/components/fleet/fleetFormat"; @@ -42,6 +43,7 @@ const FleetResourcePage = () => { const [editing, setEditing] = useState(null); const [removeTarget, setRemoveTarget] = useState(null); const [assigningDriver, setAssigningDriver] = useState(null); + const [historyTarget, setHistoryTarget] = useState(null); const [selectedDriver, setSelectedDriver] = useState(""); const { viewMode, setViewMode } = useFleetViewMode(slug); @@ -273,6 +275,7 @@ const FleetResourcePage = () => { }} onRemove={setRemoveTarget} onAssignDriver={setAssigningDriver} + onHistory={setHistoryTarget} /> ), @@ -510,6 +513,7 @@ const FleetResourcePage = () => { isSubmitting={create.isPending || update.isPending} selectOptionsLoading={selectOptionsLoading} onSubmit={handleFormSubmit} + verifyWithFayda={Boolean(config.faydaVerification)} /> {
+ + setHistoryTarget(null)} + entity={slug === "vehicles" ? "vehicle" : "driver"} + record={historyTarget} + /> ); }; diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/config/drivers.ts b/apps/edr-freight-web/backoffice/src/pages/fleet/config/drivers.ts index 1274b0972..f240bda09 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/config/drivers.ts +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/config/drivers.ts @@ -8,6 +8,12 @@ const DRIVER_STATUS_OPTIONS = [ { label: "On leave", value: "ON_LEAVE" }, ]; +const DRIVER_GENDER_OPTIONS = [ + { label: "Male", value: "MALE" }, + { label: "Female", value: "FEMALE" }, + { label: "Other", value: "OTHER" }, +]; + export const driversConfig: FleetResourceConfig = { slug: "drivers", label: "Drivers", @@ -29,24 +35,28 @@ export const driversConfig: FleetResourceConfig = { options: DRIVER_STATUS_OPTIONS, }, ], + faydaVerification: true, searchKeys: ["firstName", "lastName", "email", "phoneNumber", "licenseNumber", "status"], columns: [ - { id: "licenseNumber", header: "License Number", accessorKey: "licenseNumber", format: "code", size: 140 }, + { id: "licenseNumber", header: "Driver's License Number", accessorKey: "licenseNumber", format: "code", size: 180 }, { id: "firstName", header: "First Name", accessorKey: "firstName", format: "code", size: 120 }, { id: "lastName", header: "Last Name", accessorKey: "lastName", format: "code", size: 120 }, { id: "email", header: "Email", accessorKey: "email", format: "code", size: 180 }, { id: "phoneNumber", header: "Phone", accessorKey: "phoneNumber", format: "code", size: 120 }, + { id: "gender", header: "Gender", accessorKey: "gender", format: "code", size: 90 }, { id: "licenseExpiryDate", header: "License Expiry", accessorKey: "licenseExpiryDate", format: "code", size: 130 }, { id: "status", header: "Status", accessorKey: "status", format: "statusBadge", size: 100 }, + { id: "faydaVerified", header: "Fayda", accessorKey: "faydaVerified", format: "verifiedBadge", size: 90 }, ], formFields: [ - { name: "licenseNumber", label: "License Number", type: "text", required: true }, - { name: "firstName", label: "First Name", type: "text", required: true }, - { name: "lastName", label: "Last Name", type: "text", required: true }, - { name: "email", label: "Email", type: "email", required: true }, - { name: "phoneNumber", label: "Phone Number", type: "text", required: true }, - { name: "dateOfBirth", label: "Date of Birth", type: "date", required: true }, - { name: "licenseExpiryDate", label: "License Expiry Date", type: "date", required: true }, + { name: "licenseNumber", label: "Driver's License Number", type: "text", required: true }, + { name: "firstName", label: "First Name", type: "text", required: true, faydaLocked: true }, + { name: "lastName", label: "Last Name", type: "text", required: true, faydaLocked: true }, + { name: "email", label: "Email", type: "email", required: true, faydaLocked: true }, + { name: "phoneNumber", label: "Phone Number", type: "text", required: true, faydaLocked: true }, + { name: "dateOfBirth", label: "Date of Birth", type: "date", required: true, faydaLocked: true }, + { name: "gender", label: "Gender", type: "select", options: DRIVER_GENDER_OPTIONS, faydaLocked: true }, + { name: "licenseExpiryDate", label: "License Expiry Date", type: "date", required: true, dateBound: "future" }, { name: "status", label: "Status", type: "select", required: true, options: DRIVER_STATUS_OPTIONS }, { name: "vehicleTypesAuthorized", label: "Authorized Vehicle Types", type: "multiselect", options: VEHICLE_TYPE_OPTIONS }, { name: "address", label: "Address", type: "textarea" }, @@ -60,6 +70,7 @@ export const driversConfig: FleetResourceConfig = { email: "", phoneNumber: "", dateOfBirth: "", + gender: "", licenseExpiryDate: "", status: "ACTIVE", vehicleTypesAuthorized: [], diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/config/resources.ts b/apps/edr-freight-web/backoffice/src/pages/fleet/config/resources.ts index 41ef390ca..0e7c9934d 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/config/resources.ts +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/config/resources.ts @@ -33,13 +33,23 @@ export interface FleetResourceColumn { id: string; header: string; accessorKey: string; - format?: ColumnFormat | "statusBadge"; + format?: ColumnFormat | "statusBadge" | "verifiedBadge"; size?: number; } export interface FleetFormFieldDef extends FormFieldDef { dynamicOptions?: FleetDynamicOptions; noneOption?: boolean; + /** + * Field is owned by the Fayda identity — populated only by verification and + * never hand-edited. Rendered disabled in the form. + */ + faydaLocked?: boolean; + /** + * Direction a `date` field is constrained to. "future" = must be after today + * (e.g. a license expiry); "past" (default) = cannot be in the future. + */ + dateBound?: "past" | "future"; } export interface FleetListFilterDef { @@ -73,6 +83,8 @@ export interface FleetResourceConfig { cardCodeKey?: string; cardSubtitleKey?: string; searchKeys: string[]; + /** Offer Fayda identity verification in the add/edit form (drivers). */ + faydaVerification?: boolean; } export const FLEET_BASE_PATH_BY_SLUG: Record = { diff --git a/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx b/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx index cec4d4351..2ec99f496 100644 --- a/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx @@ -6,6 +6,7 @@ import { MoreHorizontal, PackageCheck, Printer, + Receipt, RefreshCw, Ruler, Trash, @@ -879,10 +880,14 @@ const FirstMilePage = () => { } - disabled={!nextStatus} + disabled={!nextStatus || (nextStatus === "IN_TRANSIT" && !assigned)} onClick={() => handleAdvanceStatus(row.original)} > - {nextStatus ? `Mark ${STATUS_META[nextStatus].label}` : STATUS_META[row.original.status].label} + {nextStatus === "IN_TRANSIT" && !assigned + ? "Assign a vehicle first" + : nextStatus + ? `Mark ${STATUS_META[nextStatus].label}` + : STATUS_META[row.original.status].label} { > Add distance + } + disabled={!(row.original.exactKm != null && row.original.exactKm > 0)} + onClick={() => openInvoice(row.original)} + > + Generate Invoice + {canPrint && ( } @@ -954,7 +966,7 @@ const FirstMilePage = () => { }, [vehicleOptions]); return ( - + diff --git a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx index 7ec9d1b33..2fb05b0de 100644 --- a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx @@ -4,6 +4,7 @@ import { Eye, MoreHorizontal, Printer, + Receipt, RefreshCw, Ruler, Trash, @@ -49,6 +50,7 @@ import { driversService, type Driver } from "@/services/drivers.service"; import { ratesService } from "@/services/rates.service"; import { LastMileContainerAllocationTable, type LastMileContainerRow } from "@/components/LastMileContainerAllocationTable"; import { ReleaseOrderModal, type ReleaseOrderTruckPrefill } from "@/components/warehouses/ReleaseOrderModal"; +import { LastMileStepper, type LastMileStepState } from "@/components/operations/LastMileSteps"; import { api } from "@/auth/http"; const formatPrice = (amount: number) => @@ -92,6 +94,58 @@ const vehicleLabel = (record: LastMileRecord) => { const isAssigned = (record: LastMileRecord) => Boolean(record.vehicleId); +const fmtStamp = (iso?: string | null) => { + if (!iso) return null; + const d = new Date(iso); + return Number.isNaN(d.getTime()) ? null : d.toLocaleString(); +}; + +/** + * Derive the 6-step last-mile workflow state for a record. Step completion is + * read from the record + its pickup-ready (warehouse release) row: + * assign→vehicleId, arrived→release order issued, leave→releaseDate, + * in-transit/delivered→status, distance→exactKm. + */ +const computeLastMileSteps = ( + record: LastMileRecord, + releaseRow?: ImportUnloadedItem, +): LastMileStepState[] => { + const exactKm = (record as { exactKm?: number | null }).exactKm; + // Truck arrival/leave live in the transient warehouse pickup-ready queue and + // vanish once the item is released. So once the leg is IN_TRANSIT/DELIVERED, + // treat both as done (the truck must have arrived + left to get there). + const past = record.status === "IN_TRANSIT" || record.status === "DELIVERED"; + const flags = [ + record.status !== "PAYMENT_PENDING", + Boolean(record.vehicleId), + past || Boolean(releaseRow?.releaseOrderReference), + past || Boolean(releaseRow?.releaseDate), + past, + exactKm != null, + exactKm != null, // Generate Invoice — auto-generated when distance is saved + record.status === "DELIVERED", + ]; + // Current step = earliest incomplete one. + const activeIdx = flags.findIndex((f) => !f); + const labels = ["Ready to Transit", "Assign vehicle", "Truck arrived", "Truck leave", "In transit", "Add distance", "Generate Invoice", "Delivered"]; + const details: (string | null)[] = [ + null, + record.vehicle?.plateNumber ?? null, + releaseRow?.releaseOrderReference ?? null, + fmtStamp(releaseRow?.releaseDate), + null, + exactKm != null ? `${exactKm} KM` : null, + exactKm != null ? "Invoice ready" : null, + fmtStamp(releaseRow?.deliveredAt), + ]; + return labels.map((label, i) => ({ + label, + done: flags[i], + active: i === activeIdx, + detail: details[i], + })); +}; + const bookingRef = (r: LastMileRecord) => r.booking?.reference ?? r.bookingId; const customerName = (r: LastMileRecord) => r.booking?.company?.name ?? "—"; const deliveryLocation = (r: LastMileRecord) => r.booking?.lastMileDeliveryAddress ?? "—"; @@ -955,7 +1009,24 @@ const LastMilePage = () => { const delivered = row.original.status === "DELIVERED"; const releaseRow = pickupReadyByBooking.get(row.original.bookingId) ?? pickupReadyByBooking.get(bookingRef(row.original)); - const truckArrivalLabel = releaseRow?.releaseOrderReference ? "Truck Leaving" : "Truck Arrival"; + // Gate on PERSISTENT state (status/vehicle/distance), not the truck + // arrival/leave signals — those live in the warehouse queue and vanish + // once the item is released, so they can't gate the status advance. + const status = row.original.status; + const hasDistance = row.original.exactKm != null; + // Advance: PAYMENT_PENDING→Ready, READY_TO_TRANSIT→In-transit (needs a + // vehicle), IN_TRANSIT→Delivered (needs distance/invoice). + const canAdvance = + status === "PAYMENT_PENDING" || + (status === "READY_TO_TRANSIT" && assigned) || + (status === "IN_TRANSIT" && hasDistance); + const canAssignStep = !assigned && status !== "DELIVERED"; + const canDistance = status === "IN_TRANSIT"; + // Truck arrival/leaving are independent — each driven only by its own + // warehouse state: arrive once assigned & not arrived, leave once + // arrived & not departed. + const canArrive = assigned && !releaseRow?.releaseOrderReference; + const canLeave = Boolean(releaseRow?.releaseOrderReference) && !releaseRow?.releaseDate; return ( @@ -967,15 +1038,17 @@ const LastMilePage = () => { } - disabled={!nextStatus} + disabled={!nextStatus || !canAdvance} onClick={() => handleAdvanceStatus(row.original)} > - {nextStatus ? `Mark ${STATUS_META[nextStatus].label}` : STATUS_META[row.original.status].label} + {nextStatus + ? `Mark ${STATUS_META[nextStatus].label}` + : STATUS_META[row.original.status].label} } - disabled={assigned || delivered} + disabled={!canAssignStep} onClick={() => openAssign(row.original.id)} > Assign @@ -989,10 +1062,17 @@ const LastMilePage = () => { } - disabled={!assigned} + disabled={!canArrive} onClick={() => openTruckArrival(row.original)} > - {truckArrivalLabel} + Truck Arrival + + } + disabled={!canLeave} + onClick={() => openTruckArrival(row.original)} + > + Truck Leaving { } - disabled={delivered} + disabled={!canDistance} onClick={() => openDistance(row.original.id)} > Add distance + } + disabled={!(row.original.exactKm != null && row.original.exactKm > 0)} + onClick={() => openInvoice(row.original)} + > + Generate Invoice + {canPrint && ( } @@ -1044,7 +1131,7 @@ const LastMilePage = () => { }, [vehicleOptions, pickupReadyByBooking]); return ( - + @@ -1320,6 +1407,18 @@ const LastMilePage = () => { > {activeRecord && } + {activeRecord && ( + + Delivery steps + + + )} diff --git a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx index 8ff192ef6..958049a63 100644 --- a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx @@ -45,6 +45,7 @@ import { } from "@/components/trainScheduling/containerPlacement.util"; import { ContainerPlacementGrid } from "@/components/trainScheduling/ContainerPlacementGrid"; import { FleetAvailabilitySummary } from "@/components/trainScheduling/FleetAvailabilitySummary"; +import { ImportLoadingConfirmationPanel } from "@/components/trainScheduling/ImportLoadingConfirmationPanel"; import { RescheduleTrainDialog } from "@/components/trainScheduling/RescheduleTrainDialog"; import { ScheduleBatchPanel } from "@/components/trainScheduling/ScheduleBatchPanel"; import { ScheduleBookingsStep } from "@/components/trainScheduling/ScheduleBookingsStep"; @@ -145,6 +146,13 @@ export default function TrainScheduleV2DetailPage() { }, }); + const importLoadingQuery = useQuery( + api.trainScheduling.importLoadingBookings.queryOptions({ + input: { id: scheduleId ?? "" }, + enabled: Boolean(scheduleId && schedule?.direction === "IMPORT"), + }), + ); + const eligibleFilters = useMemo( () => schedule @@ -955,6 +963,23 @@ export default function TrainScheduleV2DetailPage() { ]} /> + {schedule?.direction === "IMPORT" ? ( + + + Import loading confirmation + + Paid import bookings with a wagon allocated on this schedule. Marking loaded/unloaded + is tracking only — it does not block dispatch. + + + + + ) : null} + {gatepassApplies ? ( diff --git a/apps/edr-freight-web/backoffice/src/pages/warehouses/ExportDjiboutiUnloadingQueuePage.tsx b/apps/edr-freight-web/backoffice/src/pages/warehouses/ExportDjiboutiUnloadingQueuePage.tsx index 31ee5ea36..609995e3e 100644 --- a/apps/edr-freight-web/backoffice/src/pages/warehouses/ExportDjiboutiUnloadingQueuePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/warehouses/ExportDjiboutiUnloadingQueuePage.tsx @@ -14,7 +14,17 @@ import { Text, } from '@mantine/core'; import { useNavigate } from 'react-router-dom'; -import { ChevronDown, ChevronRight, Eye, FileText, History, PackageOpen, Truck } from 'lucide-react'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { + ChevronDown, + ChevronRight, + Eye, + FileText, + History, + PackageOpen, + ShieldCheck, + Truck, +} from 'lucide-react'; import { PageHeader } from '@/components/page'; import Breadcrumbs from '@/components/ui/Breadcrumbs'; @@ -36,6 +46,7 @@ import { useInterchangeDocuments, } from '@/hooks/useInterchangeDocuments'; import { useToast } from '@/hooks/use-toast'; +import { trainSchedulingService } from '@/services/trainScheduling.service'; import type { AutoUnloadExportDjiboutiResult, ExportTrain, @@ -179,6 +190,14 @@ export default function ExportDjiboutiUnloadingQueuePage() { const { data: interchangeDocuments = [] } = useInterchangeDocuments({ direction: 'EXPORT' }); const autoUnload = useAutoUnloadExportAtDjibouti(); const generateInterchange = useGenerateInterchangeDocument(); + const qc = useQueryClient(); + const secureGatePass = useMutation({ + mutationFn: (scheduleId: string) => trainSchedulingService.grantImportDjiboutiGatepass(scheduleId), + onSuccess: () => + qc.invalidateQueries({ + queryKey: ['warehouse-inventory', 'export-djibouti-arrival-queue'], + }), + }); const [openScheduleId, setOpenScheduleId] = useState(null); const [busyScheduleId, setBusyScheduleId] = useState(null); const [historyInventoryId, setHistoryInventoryId] = useState(null); @@ -189,6 +208,25 @@ export default function ExportDjiboutiUnloadingQueuePage() { .map((doc) => [doc.scheduleId as string, doc]), ); + const secureGate = async (train: ExportTrain) => { + setBusyScheduleId(train.scheduleId); + try { + await secureGatePass.mutateAsync(train.scheduleId); + toast({ + title: 'Gate pass secured', + description: `Djibouti Port entry allowed for ${train.trainNumber ?? 'the train'}. You can now auto unload.`, + }); + } catch (error) { + toast({ + variant: 'destructive', + title: 'Could not secure gate pass', + description: getErrorMessage(error), + }); + } finally { + setBusyScheduleId(null); + } + }; + const unloadTrain = async (train: ExportTrain) => { setBusyScheduleId(train.scheduleId); try { @@ -346,6 +384,16 @@ export default function ExportDjiboutiUnloadingQueuePage() { > Open + - - - ) : phase === "role" ? ( - + + What does your company do?(multiple) + )} - - + ); diff --git a/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx b/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx index bfde5c43b..85af9bfdd 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx @@ -4,7 +4,6 @@ import { Divider, Group, Loader, - PinInput, SimpleGrid, Stack, Text, @@ -12,15 +11,7 @@ import { } from "@mantine/core"; import { zodResolver } from "@hookform/resolvers/zod"; import { useQuery } from "@tanstack/react-query"; -import { - AlertCircle, - ArrowLeft, - ArrowRight, - CheckCircle2, - RotateCw, - Smartphone, - UserCheck, -} from "lucide-react"; +import { AlertCircle, ArrowLeft, ArrowRight, UserCheck } from "lucide-react"; import { useEffect, useRef, useState } from "react"; import { useForm } from "react-hook-form"; @@ -35,7 +26,6 @@ import RoleLicenseStep, { type RoleLicenseProfile, } from "@/components/onboarding/RoleLicenseStep"; import ETradeInfo from "@/components/onboarding/ETradeInfo"; -import { extractApiError } from "@/utils/result"; import { type CompanyStep, type FormData, @@ -44,8 +34,6 @@ import { } from "./companyProfileForm/schema"; import { buildPayload, - maskPhone, - samePhone, stepPayload, toFormValues, } from "./companyProfileForm/helpers"; @@ -295,6 +283,7 @@ export default function CompanyProfileForm({ const useOwnerAsManager = () => { if (!etradeOwner) return; setValue("generalManagerName", etradeOwner.name); + setValue("generalManagerEmail", user.email); setValue("generalManagerPhone", etradeOwner.phone ?? "", { shouldValidate: true, }); @@ -350,85 +339,6 @@ export default function CompanyProfileForm({ } }; - // --- Contact-phone SMS OTP verification ----------------------------------- - // The phone we verify is the contact-person phone, normalised to E.164 so it - // matches what the backend persists as `contactVerifiedPhone`. - const contactPhoneE164 = toEthiopianE164(watch("contactPersonPhone") ?? ""); - // Source of truth for "already verified" comes from the onboarding/profile - // info (rehydrate) — so a refresh resumes the verify step's "done" state. - const [verifiedPhone, setVerifiedPhone] = useState( - rehydrate?.contactVerifiedPhone ?? null, - ); - useEffect(() => { - if (rehydrate?.contactVerifiedPhone) { - setVerifiedPhone(rehydrate.contactVerifiedPhone); - } - }, [rehydrate?.contactVerifiedPhone]); - const phoneVerified = samePhone(verifiedPhone, contactPhoneE164); - - const [otpSent, setOtpSent] = useState(false); - const [otpCode, setOtpCode] = useState(""); - const [sendingOtp, setSendingOtp] = useState(false); - const [verifyingOtp, setVerifyingOtp] = useState(false); - const [otpError, setOtpError] = useState(null); - const [resendIn, setResendIn] = useState(0); - - // Resend cooldown countdown (no Date.now needed — pure setTimeout ticks). - useEffect(() => { - if (resendIn <= 0) return; - const t = setTimeout(() => setResendIn((s) => s - 1), 1000); - return () => clearTimeout(t); - }, [resendIn]); - - // A changed contact phone invalidates any in-flight code entry (the previous - // code was for a different number). Verified state is handled separately via - // the phone comparison, so this only resets the send/enter UI. - useEffect(() => { - setOtpSent(false); - setOtpCode(""); - setOtpError(null); - }, [contactPhoneE164]); - - const sendContactOtp = async () => { - setOtpError(null); - if (!contactPhoneE164) { - setOtpError("Enter a valid contact phone number first."); - return; - } - setSendingOtp(true); - try { - await api.auth.sendOTP.call({ phone: contactPhoneE164 }); - setOtpSent(true); - setOtpCode(""); - setResendIn(60); - } catch (err) { - setOtpError(extractApiError(err).message); - } finally { - setSendingOtp(false); - } - }; - - const verifyContactOtp = async () => { - setOtpError(null); - if (otpCode.length !== 6) { - setOtpError("Enter the 6-digit code we sent you."); - return; - } - setVerifyingOtp(true); - try { - await api.auth.verifyOTP.call({ phone: contactPhoneE164, otp: otpCode }); - setVerifiedPhone(contactPhoneE164); - setOtpSent(false); - // Persist the verified phone so the step resumes as "done" after a refresh - // (best-effort — the OTP itself already succeeded server-side). - onSaveStep?.({ contactVerifiedPhone: contactPhoneE164 }).catch(() => { }); - } catch (err) { - setOtpError(extractApiError(err).message); - } finally { - setVerifyingOtp(false); - } - }; - const hasDocuments = Boolean(uploadSetting?.fields?.length); // The registration/license details come straight from the eTrade lookup and @@ -451,10 +361,8 @@ export default function CompanyProfileForm({ "company", "personnel", "contact", - "verify", "poa", "documents", - "additional", ]; const currentIdx = stepOrder.indexOf(step); @@ -485,30 +393,6 @@ export default function CompanyProfileForm({ const nextStep = async () => { userNavigatedRef.current = true; - if (step === "additional") { - if (!licenseComplete) { - setSaveError( - "Please upload a business license for each of your operational profiles.", - ); - return; - } - handleSubmit((data) => onSubmit(buildPayload(data, user)))(); - return; - } - // Contact-phone verification gates advancing past the verify step. The - // verified phone is already persisted (on verify success), so there's - // nothing extra to save here. - if (step === "verify") { - if (!phoneVerified) { - setSaveError( - "Please verify the contact person's phone number to continue.", - ); - return; - } - setSaveError(null); - setStep(stepOrder[currentIdx + 1]); - return; - } // The documents step auto-uploads whatever the user selected as they // continue (partial uploads are allowed — required-doc completeness is // re-checked on resume). A failed upload holds them on the step. @@ -525,8 +409,15 @@ export default function CompanyProfileForm({ setSaving(false); } } + + if (!licenseComplete) { + setSaveError( + "Please upload a business license for each of your operational profiles.", + ); + return; + } setSaveError(null); - setStep(stepOrder[currentIdx + 1]); + handleSubmit((data) => onSubmit(buildPayload(data, user)))(); return; } // Field steps validate + save before advancing. @@ -551,10 +442,7 @@ export default function CompanyProfileForm({
e.preventDefault()}> {step === "company" && ( - <> - - Enter your TIN to auto-fill company information from eTrade - + - + )} {step === "personnel" && ( @@ -783,107 +671,6 @@ export default function CompanyProfileForm({ )} - {step === "verify" && ( - - - We'll text a one-time code to the contact person's phone to - confirm it's reachable. This is required before you continue. - - - {!contactPhoneE164 ? ( - } - > - Add a valid contact phone number on the previous step first. - - ) : phoneVerified ? ( - } - title="Phone verified" - > - {maskPhone(contactPhoneE164)} has been verified. - - ) : ( - - - - - {maskPhone(contactPhoneE164)} - - - - {!otpSent ? ( - - ) : ( - - - - - - - - )} - - {otpError && ( - } - > - {otpError} - - )} - - )} - - )} - {step === "poa" && ( <> @@ -954,15 +741,13 @@ export default function CompanyProfileForm({ onChange={setDocumentFiles} /> )} - - )} - {step === "additional" && ( - { })} - /> + { })} + /> + )} {saveError && ( @@ -970,11 +755,7 @@ export default function CompanyProfileForm({ color="red" variant="light" icon={} - title={ - step === "additional" - ? "Business license required" - : "Couldn't save this step" - } + title={"Couldn't save this step"} > {saveError} @@ -998,7 +779,7 @@ export default function CompanyProfileForm({ onClick={prevStep} leftSection={} > - {step === "additional" ? "Back to Documents" : "Back"} + Back ) : ( @@ -1009,17 +790,14 @@ export default function CompanyProfileForm({ disabled={ isPending || saving || - (step === "documents" && !hasDocuments && loadingDocuments) || - (step === "verify" && !phoneVerified) + (step === "documents" && !hasDocuments && loadingDocuments) } loading={isPending || saving} rightSection={ - !isPending && !saving && step !== "additional" ? ( - - ) : undefined + !isPending && !saving ? : undefined } > - {step === "additional" ? "Submit for review" : "Continue"} + {step === "documents" ? "Submit for review" : "Continue"} diff --git a/apps/edr-freight-web/portal/src/pages/accounts/SignupPage.tsx b/apps/edr-freight-web/portal/src/pages/accounts/SignupPage.tsx index 723591597..50320f699 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/SignupPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/SignupPage.tsx @@ -1,18 +1,38 @@ -import { useState } from "react"; +import { useEffect, useState } from "react"; import { zodResolver } from "@hookform/resolvers/zod"; -import { ArrowRight, Check, Eye, EyeOff, X } from "lucide-react"; -import { Controller, useForm } from "react-hook-form"; +import { + Alert, + Button, + PasswordInput, + PinInput, + SegmentedControl, + SimpleGrid, + Stack, + Text, + TextInput, +} from "@mantine/core"; +import { + AlertCircle, + ArrowLeft, + ArrowRight, + Check, + Mail, + RotateCw, + ShieldCheck, + Smartphone, + X, +} from "lucide-react"; +import { useForm } from "react-hook-form"; import { useNavigate } from "react-router-dom"; import { z } from "zod"; -import RPNInput from "react-phone-number-input"; -import "react-phone-number-input/style.css"; import { userType } from "@/enums/userType"; import useAuth from "@/hooks/useAuth"; import type { SignupPayload } from "@/types/auth"; -import AuthShell, { fieldClass, primaryButtonClass } from "@/components/auth/AuthShell"; -import { isValidPhone } from "@/components/PhoneField"; -import "@/components/phone-field.css"; +import AuthShell from "@/components/auth/AuthShell"; +import { ControlledPhoneField, isValidPhone } from "@/components/PhoneField"; +import { api } from "@/services/api"; +import { extractApiError } from "@/utils/result"; const EDR_LOGO = "/assets/edr-logo.png"; @@ -50,16 +70,46 @@ const userSchema = z type FormData = z.infer; -const errorText = (msg?: string) => - msg ?

{msg}

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

- Create account -

-

- Register to access EDR Freight services. -

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

+ Create account +

+

+ Register to access EDR Freight services. +

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

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

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

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

+

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

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

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

-
- + Verify & create account + + +
+ + +
+ + )} +
); } diff --git a/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/schema.ts b/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/schema.ts index 31ee21ced..9a123e255 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/schema.ts +++ b/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/schema.ts @@ -6,7 +6,6 @@ export type CompanyStep = | "company" | "personnel" | "contact" - | "verify" | "poa" | "documents" | "additional"; @@ -103,7 +102,6 @@ export const stepFields: Record = { "contactPersonEmail", "contactPersonPhone", ], - verify: [], poa: [], documents: [], additional: [], diff --git a/apps/edr-freight-web/portal/src/pages/contracts/ContractViewPage.tsx b/apps/edr-freight-web/portal/src/pages/contracts/ContractViewPage.tsx index 29bdef371..f1e5da228 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/ContractViewPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/ContractViewPage.tsx @@ -11,17 +11,27 @@ import { Loader, Modal, Paper, + PinInput, Stack, Text, TextInput, } from "@mantine/core"; -import { ArrowLeft, Download, FileSignature, Printer } from "lucide-react"; +import { + ArrowLeft, + Download, + FileSignature, + Printer, + RotateCw, + ShieldCheck, +} from "lucide-react"; import toast from "react-hot-toast"; import { ContractSignSuccessModal } from "@/components/contracts/ContractSignSuccessModal"; import { ContractSignaturePad } from "@/components/bookings/ContractSignaturePad"; import { contractsService } from "@/services/contracts.service"; import { api } from "@/services/api"; +import useAuth from "@/hooks/useAuth"; +import { extractApiError } from "@/utils/result"; const CONSENT_TEXT = "I have read the entire contract and agree to its terms."; @@ -34,9 +44,13 @@ export default function ContractViewPage() { const { id } = useParams<{ id: string }>(); const navigate = useNavigate(); const qc = useQueryClient(); + const { user } = useAuth(); const iframeRef = useRef(null); const [signOpen, setSignOpen] = useState(false); + const [otpOpen, setOtpOpen] = useState(false); + const [otpCode, setOtpCode] = useState(""); + const [otpError, setOtpError] = useState(null); const [successOpen, setSuccessOpen] = useState(false); const [signerName, setSignerName] = useState(""); const [signatureData, setSignatureData] = useState(null); @@ -44,6 +58,15 @@ export default function ContractViewPage() { const [hasScrolledToBottom, setHasScrolledToBottom] = useState(false); const [agreedToTerms, setAgreedToTerms] = useState(false); + // The signed-in customer's registered phone — where the sudo-mode OTP is sent. + const customerPhone = user?.phoneNumber ?? ""; + const maskedPhone = + customerPhone.length > 4 + ? `${customerPhone.slice(0, 4)}${"*".repeat( + Math.max(customerPhone.length - 6, 0), + )}${customerPhone.slice(-2)}` + : customerPhone; + const { data, isLoading, isError, refetch } = useQuery({ queryKey: ["contract-view", id], queryFn: () => contractsService.getContractView(id!), @@ -95,6 +118,18 @@ export default function ContractViewPage() { }; }, [checkScrollBottom]); + // Send (or resend) the fresh OTP challenge to the customer's phone. On success + // we swap the signature modal for the OTP entry modal. + const sendOtpMutation = useMutation({ + mutationFn: () => api.auth.sendOTP.call({ phone: customerPhone }), + onSuccess: () => { + setSignOpen(false); + setOtpError(null); + setOtpOpen(true); + }, + onError: () => toast.error("Failed to send verification code"), + }); + const signMutation = useMutation({ mutationFn: () => contractsService.signContract(id!, { @@ -104,16 +139,22 @@ export default function ContractViewPage() { : (signatureData as string), signerDisplayName: signerName.trim(), consentText: CONSENT_TEXT, + otp: otpCode.trim(), + otpPhone: customerPhone, }), onSuccess: () => { - setSignOpen(false); + setOtpOpen(false); + setOtpCode(""); setSuccessOpen(true); void refetch(); void qc.invalidateQueries({ queryKey: api.contracts.get.queryKey({ id: id! }), }); }, - onError: () => toast.error("Failed to sign contract"), + onError: (err) => + setOtpError( + extractApiError(err).message ?? "Failed to verify code and sign", + ), }); const openSign = () => { @@ -128,6 +169,17 @@ export default function ContractViewPage() { if (!signerName.trim()) return; const image = usingSaved ? savedSignatureImage : signatureData; if (!image) return; + if (!customerPhone) { + toast.error("No phone number on file to verify your signature."); + return; + } + setOtpCode(""); + sendOtpMutation.mutate(); + }; + + const confirmOtp = () => { + if (otpCode.trim().length !== 6) return; + setOtpError(null); signMutation.mutate(); }; @@ -315,20 +367,114 @@ export default function ContractViewPage() { + setOtpOpen(false)} + title="Verify it's you" + centered + radius="lg" + > + + + + + + + For security, enter the 6-digit code we sent by SMS to{" "} + + {maskedPhone} + {" "} + to confirm and apply your signature. + + + + {otpError && ( + + {otpError} + + )} + + + + Verification code + + + + + + + + + + + + + + value) + // Incoming value is an ISO date string (YYYY-MM-DD); transform to a Date so + // @MaxDate (which requires an actual Date instance) evaluates correctly. + @Transform(({ value }) => (value ? new Date(value) : value)) + @IsDate() @MaxDate(() => new Date(), { message: 'Date of birth cannot be in the future' }) - dateOfBirth: string; + dateOfBirth: Date; @ApiProperty({ example: 'NATIONAL_ID', enum: IdDocumentType, description: 'NATIONAL_ID for Ethiopians (Verifayda verified), PASSPORT for others' }) @IsEnum(IdDocumentType) idDocumentType: IdDocumentType; @ApiPropertyOptional({ example: 'ET123456789', description: 'Ethiopian national ID - verified via Verifayda 2.0 (NOT stored in database)' }) @IsOptional() @IsString() idDocumentNumber?: string; @ApiPropertyOptional({ example: 'P1234567', description: 'Passport number for non-Ethiopian passengers (no verification)' }) @IsOptional() @IsString() passportNumber?: string; @@ -44,10 +46,12 @@ export class RoundTripPassengerDto { example: '1990-05-15', description: 'Date of birth (YYYY-MM-DD). Must not be a future date.' }) - @IsDateString() - @Transform(({ value }) => value) + // Incoming value is an ISO date string (YYYY-MM-DD); transform to a Date so + // @MaxDate (which requires an actual Date instance) evaluates correctly. + @Transform(({ value }) => (value ? new Date(value) : value)) + @IsDate() @MaxDate(() => new Date(), { message: 'Date of birth cannot be in the future' }) - dateOfBirth: string; + dateOfBirth: Date; @ApiProperty({ example: 'NATIONAL_ID', diff --git a/apps/edr-passenger-web/portal/src/app/booking/results/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/results/page.tsx index 810e0689a..d1952da23 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/results/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/results/page.tsx @@ -1,4 +1,4 @@ -'use client'; +'use client'; import { useSearchParams, useRouter } from 'next/navigation'; import { useQuery } from '@tanstack/react-query'; @@ -147,10 +147,16 @@ export default function ResultsPage() { // For one-way, check if outbound has results // For round-trip, check if BOTH outbound and inbound have results - const hasResults = isRoundTrip + const hasResults = isRoundTrip ? (outboundSchedules.length > 0 && inboundSchedules.length > 0) : outboundSchedules.length > 0; + // One-way searches that come back with an empty outbound list may still include + // date-shifted alternatives from the API — surface those instead of a dead end. + const isOneWayNoOutbound = !isRoundTrip && !!results && outboundSchedules.length === 0; + const alternativeOutbound: Schedule[] = isOneWayNoOutbound ? (results.alternativeOutbound || []) : []; + const requestedDate: string = (results && results.requestedDate) || searchData.date; + const handleSelectCoachType = (scheduleId: string, coachTypeId: string, coachTypeCode: string, coachTypeName: string, seatClassName: string) => { setSelectedCoachTypes(prev => ({ ...prev, [scheduleId]: { id: coachTypeId, code: coachTypeCode, name: coachTypeName, seatClassName } })); }; @@ -217,7 +223,190 @@ export default function ResultsPage() { router.push('/booking/auth-check'); }; - const renderScheduleCard = (schedule: Schedule, isOutbound: boolean = false) => { + // Shared "Choose Your Coach" drawer — used by both the normal results view and the + // Alternative Travel Options fallback, so selecting an alternative opens the exact + // same coach-type picker as a normal schedule. + const renderClassModal = () => { + if (!classModal) return null; + + const scheduleId = classModal.scheduleId || classModal.id || ''; + const selectedCoachType = selectedCoachTypes[scheduleId]; + const isOutbound = (classModal as any).isOutbound; + const coachTypes = classModal.coachTypes || []; + + const getCoachIcon = (typeName: string) => { + const lower = typeName.toLowerCase(); + if (lower.includes('soft') || lower.includes('vip')) return Star; + if (lower.includes('bed')) return Bed; + return Armchair; + }; + + return ( + <> +
setClassModal(null)} /> +
+
+
+

Choose Your Coach

+

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

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

No coach types available for this journey

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

+ + Select a coach type to continue +

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

No trains available

+

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

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

Alternative Travel Options

+

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

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

Choose Your Coach

-

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

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

No coach types available for this journey

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

- - Select a coach type to continue -

- )} -
-
-
- - - ); - })()} + {renderClassModal()} {promoData && (
diff --git a/packages/types/src/freight/index.ts b/packages/types/src/freight/index.ts index 261f22d0b..3afc21a43 100644 --- a/packages/types/src/freight/index.ts +++ b/packages/types/src/freight/index.ts @@ -198,6 +198,11 @@ export enum TrainSetWagonStatus { Departed = "DEPARTED", } +export enum LoadingStatus { + Loaded = "LOADED", + Unloaded = "UNLOADED", +} + export enum WagonStatus { Available = "AVAILABLE", Assigned = "ASSIGNED", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9fcc8ce59..a610ea30a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -117,6 +117,9 @@ importers: handlebars: specifier: ^4.7.9 version: 4.7.9 + jose: + specifier: ^5.10.0 + version: 5.10.0 libphonenumber-js: specifier: ^1.13.6 version: 1.13.6